mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 22:47:40 +00:00
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:
@@ -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">
|
||||
|
||||
164
frontend/tests/e2e/task/tiptap-editor-save.spec.ts
Normal file
164
frontend/tests/e2e/task/tiptap-editor-save.spec.ts
Normal 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})
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user