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
This commit is contained in:
kolaente
2026-01-27 13:54:24 +01:00
parent f216fea2b3
commit e5da54e58a
2 changed files with 170 additions and 2 deletions

View File

@@ -3,13 +3,17 @@
ref="tiptapInstanceRef"
class="tiptap"
>
<!-- Using v-show instead of v-if to avoid unmounting which causes race condition
with tiptap's DOM manipulation. See: https://github.com/ueberdosis/tiptap/issues/7342 -->
<EditorToolbar
v-if="editor && isEditing"
v-if="editor"
v-show="isEditing"
:editor="editor"
@imageUploadClicked="triggerImageInput"
/>
<BubbleMenu
v-if="editor && isEditing"
v-if="editor"
v-show="isEditing"
:editor="editor"
>
<div class="editor-bubble__wrapper">

View File

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