diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index 1078a8500..bcb159416 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -3,13 +3,17 @@ ref="tiptapInstanceRef" class="tiptap" > +
diff --git a/frontend/tests/e2e/task/tiptap-editor-save.spec.ts b/frontend/tests/e2e/task/tiptap-editor-save.spec.ts new file mode 100644 index 000000000..919eda8c5 --- /dev/null +++ b/frontend/tests/e2e/task/tiptap-editor-save.spec.ts @@ -0,0 +1,164 @@ +import {test, expect} from '../../support/fixtures' +import {TaskFactory} from '../../factories/task' +import {ProjectFactory} from '../../factories/project' + +test.describe('TipTap Editor Save', () => { + test.beforeEach(async ({authenticatedPage: page}) => { + await ProjectFactory.create(1) + await TaskFactory.truncate() + }) + + /** + * Regression test for https://github.com/go-vikunja/vikunja/issues/1770 + * + * When saving the description editor, a race condition between Vue's DOM + * reconciliation and tiptap's internal DOM manipulation during unmount + * caused "Cannot read properties of null (reading 'insertBefore')" error. + * + * The fix uses v-show instead of v-if for EditorToolbar and BubbleMenu + * to avoid unmounting them during edit mode transitions. + */ + test('Should not crash when saving description (issue #1770)', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: 'Initial description', + }) + + // Collect any page errors and console errors that occur + const pageErrors: Error[] = [] + const consoleErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(error) + }) + + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()) + } + }) + + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + // Click edit button to enter edit mode + const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit') + await expect(editButton).toBeVisible({timeout: 10000}) + await editButton.click() + + // Wait for editor to be visible and editable + const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror') + await expect(editor).toBeVisible() + + // Make an edit + await editor.fill('Updated description text') + + // Save the description - this triggers the mode transition that could crash + const saveButton = page.locator('[data-cy="saveEditor"]').filter({hasText: 'Save'}) + await expect(saveButton).toBeVisible() + await saveButton.click() + + // Wait for save confirmation + await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!') + + // Give time for mode transition and any async errors to surface + await page.waitForTimeout(2000) + + // Check for errors - either the DOM crashes or the edit button should appear + const insertBeforeErrors = [ + ...pageErrors.filter(e => + e.message.includes('insertBefore') || + e.message.includes("Cannot read properties of null") + ), + ...consoleErrors.filter(msg => + msg.includes('insertBefore') || + msg.includes("Cannot read properties of null") + ), + ] + + // If there are DOM manipulation errors, fail the test + if (insertBeforeErrors.length > 0) { + throw new Error(`DOM manipulation errors detected (issue #1770): ${JSON.stringify(insertBeforeErrors)}`) + } + + // If no errors, the edit button should be visible (mode transition completed) + await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).toBeVisible({timeout: 5000}) + }) + + test('Should not crash when rapidly toggling edit mode (issue #1770)', async ({authenticatedPage: page}) => { + const tasks = await TaskFactory.create(1, { + id: 1, + description: 'Test description for rapid toggle', + }) + + // Collect any page errors and console errors that occur + const pageErrors: Error[] = [] + const consoleErrors: string[] = [] + + page.on('pageerror', (error) => { + pageErrors.push(error) + }) + + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()) + } + }) + + await page.goto(`/tasks/${tasks[0].id}`) + await page.waitForLoadState('networkidle') + + // Perform multiple edit/save cycles to stress test the mode transitions + for (let i = 0; i < 3; i++) { + // Enter edit mode - click edit button or double-click the editor + const editButton = page.locator('.task-view .details.content.description .tiptap button.done-edit') + const isEditButtonVisible = await editButton.isVisible().catch(() => false) + + if (isEditButtonVisible) { + await editButton.click() + } else { + // Already in edit mode or need to double-click to enter + const editorArea = page.locator('.task-view .details.content.description .tiptap__editor') + await editorArea.dblclick() + } + + // Wait for editor to be editable + const editor = page.locator('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror') + await expect(editor).toBeVisible() + + // Make a small edit + await editor.fill(`Cycle ${i + 1} description`) + + // Save + const saveButton = page.locator('[data-cy="saveEditor"]').filter({hasText: 'Save'}) + await expect(saveButton).toBeVisible() + await saveButton.click() + + // Wait for save confirmation + await expect(page.locator('.task-view .details.content.description h3 span.is-small.has-text-success')).toContainText('Saved!') + + // Give time for mode transition + await page.waitForTimeout(2000) + + // Check for errors after each cycle + const domErrors = [ + ...pageErrors.filter(e => + e.message.includes('insertBefore') || + e.message.includes("Cannot read properties of null") + ), + ...consoleErrors.filter(msg => + msg.includes('insertBefore') || + msg.includes("Cannot read properties of null") + ), + ] + + // If there are DOM manipulation errors, fail the test + if (domErrors.length > 0) { + throw new Error(`DOM manipulation errors detected in cycle ${i + 1} (issue #1770): ${JSON.stringify(domErrors)}`) + } + + // Verify mode transition completed (edit button should be visible) + await expect(page.locator('.task-view .details.content.description .tiptap button.done-edit')).toBeVisible({timeout: 5000}) + } + }) +})