Files
vikunja/frontend/tests/e2e/task/tiptap-editor-save.spec.ts
kolaente e5da54e58a fix(editor): prevent crash when exiting edit mode in tiptap
Use v-show instead of v-if for EditorToolbar and BubbleMenu to avoid
a race condition between Vue's DOM reconciliation and tiptap's internal
DOM manipulation during unmount. This fixes the "Cannot read properties
of null (reading 'insertBefore')" error that occurred when saving a
task description.

Adds regression test to verify the fix.

Upstream issue: https://github.com/ueberdosis/tiptap/issues/7342
Fixes: https://github.com/go-vikunja/vikunja/issues/1770
2026-01-27 14:03:02 +01:00

165 lines
5.7 KiB
TypeScript

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})
}
})
})