2026-05-13 07:21:37 +02:00
|
|
|
import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest'
|
|
|
|
|
import {
|
|
|
|
|
createRichEditorTransformErrorRecoveryExtension,
|
|
|
|
|
installRichEditorTransformErrorRecovery,
|
|
|
|
|
isRecoverableEditorTransformError,
|
|
|
|
|
} from './richEditorTransformErrorRecoveryExtension'
|
|
|
|
|
import { trackEvent } from '../lib/telemetry'
|
|
|
|
|
|
|
|
|
|
vi.mock('../lib/telemetry', () => ({
|
|
|
|
|
trackEvent: vi.fn(),
|
|
|
|
|
}))
|
|
|
|
|
|
|
|
|
|
function transformError(message = 'Invalid transform') {
|
|
|
|
|
const error = new Error(message)
|
|
|
|
|
error.name = 'TransformError'
|
|
|
|
|
return error
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 03:57:21 +02:00
|
|
|
function nullFragmentAppendError(message = "null is not an object (evaluating 'o.fillBefore(e).append')") {
|
|
|
|
|
return new TypeError(message)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 07:21:37 +02:00
|
|
|
function createView(error?: Error) {
|
|
|
|
|
const currentDoc = {
|
|
|
|
|
eq: vi.fn((candidate: unknown) => candidate === currentDoc),
|
|
|
|
|
}
|
|
|
|
|
const dispatch = vi.fn(() => {
|
|
|
|
|
if (error) throw error
|
|
|
|
|
return 'dispatched'
|
|
|
|
|
})
|
|
|
|
|
const view = {
|
|
|
|
|
dispatch,
|
|
|
|
|
state: { doc: currentDoc },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { currentDoc, dispatch, view }
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 16:59:56 +02:00
|
|
|
function createViewWithSomeProp(handleKeyDown: () => boolean) {
|
|
|
|
|
const { currentDoc, dispatch, view } = createView()
|
|
|
|
|
const keyDownPlugin = vi.fn(handleKeyDown)
|
|
|
|
|
const someProp = vi.fn((_propName: string, run?: (prop: typeof keyDownPlugin) => unknown) => (
|
|
|
|
|
run?.(keyDownPlugin)
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
currentDoc,
|
|
|
|
|
dispatch,
|
|
|
|
|
keyDownPlugin,
|
|
|
|
|
view: {
|
|
|
|
|
...view,
|
|
|
|
|
someProp,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-23 16:11:20 +02:00
|
|
|
function expectDocumentRepairRecovery(error: Error, reason: string) {
|
|
|
|
|
const { currentDoc, view } = createView(error)
|
|
|
|
|
const recoverDocument = vi.fn()
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view, { recoverDocument })
|
|
|
|
|
|
|
|
|
|
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
|
|
|
|
|
expect(recoverDocument).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
|
|
|
|
reason,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 07:21:37 +02:00
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
vi.clearAllMocks()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('isRecoverableEditorTransformError', () => {
|
|
|
|
|
it('recognizes ProseMirror transform and mismatched transaction failures', () => {
|
|
|
|
|
expect(isRecoverableEditorTransformError(transformError())).toBe(true)
|
|
|
|
|
expect(isRecoverableEditorTransformError(new RangeError('Applying a mismatched transaction'))).toBe(true)
|
2026-05-14 09:55:20 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new RangeError(
|
|
|
|
|
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
|
|
|
|
))).toBe(true)
|
2026-05-23 17:36:55 +02:00
|
|
|
expect(isRecoverableEditorTransformError(transformError(
|
|
|
|
|
'Cannot join blockGroup onto blockContainer',
|
|
|
|
|
))).toBe(true)
|
2026-05-23 16:11:20 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new RangeError(
|
|
|
|
|
'Inserted content deeper than insertion position',
|
|
|
|
|
))).toBe(true)
|
2026-05-23 15:28:35 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new RangeError(
|
|
|
|
|
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
|
|
|
|
|
))).toBe(true)
|
2026-06-05 22:58:28 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new Error(
|
|
|
|
|
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
|
|
|
|
|
))).toBe(true)
|
2026-05-23 16:53:43 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new Error(
|
|
|
|
|
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
|
|
|
|
|
))).toBe(true)
|
2026-06-06 03:57:21 +02:00
|
|
|
expect(isRecoverableEditorTransformError(nullFragmentAppendError())).toBe(true)
|
2026-06-06 04:24:52 +02:00
|
|
|
const stackOnlyAppendError = nullFragmentAppendError("Cannot read properties of null (reading 'append')")
|
|
|
|
|
stackOnlyAppendError.stack =
|
|
|
|
|
"TypeError: Cannot read properties of null (reading 'append')\n at o.fillBefore(e).append (App-CrXlNLOq.js:1:1)"
|
|
|
|
|
expect(isRecoverableEditorTransformError(stackOnlyAppendError)).toBe(true)
|
2026-06-06 03:57:21 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new TypeError(
|
|
|
|
|
"Cannot read properties of null (reading 'append')",
|
|
|
|
|
))).toBe(false)
|
2026-05-23 15:28:35 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new RangeError(
|
|
|
|
|
'Index 1 out of range for <paragraph("A")>',
|
|
|
|
|
))).toBe(false)
|
2026-05-13 07:21:37 +02:00
|
|
|
expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('installRichEditorTransformErrorRecovery', () => {
|
|
|
|
|
it('recovers stale transform errors without rethrowing from editor dispatch', () => {
|
|
|
|
|
const { dispatch, view } = createView(transformError())
|
|
|
|
|
const previousDoc = { stale: true }
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
|
|
|
|
|
expect(() => view.dispatch({ before: previousDoc })).not.toThrow()
|
|
|
|
|
expect(dispatch).toHaveBeenCalledWith({ before: previousDoc })
|
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
|
|
|
|
reason: 'stale_transaction',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('recovers ProseMirror mismatched transactions from active key handling', () => {
|
|
|
|
|
const { currentDoc, view } = createView(new RangeError('Applying a mismatched transaction'))
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
|
|
|
|
|
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
|
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
|
|
|
|
reason: 'mismatched_transaction',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-14 09:55:20 +02:00
|
|
|
it('recovers invalid-content schema transactions from mixed paragraph and list editing', () => {
|
2026-05-23 16:11:20 +02:00
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
new RangeError(
|
|
|
|
|
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
|
|
|
|
),
|
|
|
|
|
'transform_error',
|
2026-05-14 09:55:20 +02:00
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-23 17:36:55 +02:00
|
|
|
it('repairs invalid block joins after pull refreshes editor state', () => {
|
|
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
transformError('Cannot join blockGroup onto blockContainer'),
|
|
|
|
|
'invalid_block_join',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-27 19:50:16 +02:00
|
|
|
it('repairs invalid table-cell joins while editing table contents', () => {
|
|
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
transformError('Cannot join tableCell onto blockContainer'),
|
|
|
|
|
'invalid_block_join',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-25 16:59:56 +02:00
|
|
|
it('recovers invalid block joins thrown during keydown handling before dispatch', () => {
|
|
|
|
|
const { view, keyDownPlugin } = createViewWithSomeProp(() => {
|
|
|
|
|
throw transformError('Cannot join blockGroup onto blockContainer')
|
|
|
|
|
})
|
|
|
|
|
const recoverDocument = vi.fn()
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view, { recoverDocument })
|
2026-05-27 19:50:16 +02:00
|
|
|
|
|
|
|
|
expect(view.someProp('handleKeyDown', (handler) => handler())).toBe(true)
|
|
|
|
|
expect(keyDownPlugin).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(recoverDocument).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
|
|
|
|
reason: 'invalid_block_join',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('recovers invalid table-cell joins thrown during keydown handling', () => {
|
|
|
|
|
const { view, keyDownPlugin } = createViewWithSomeProp(() => {
|
|
|
|
|
throw transformError('Cannot join tableCell onto blockContainer')
|
|
|
|
|
})
|
|
|
|
|
const recoverDocument = vi.fn()
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view, { recoverDocument })
|
2026-05-25 16:59:56 +02:00
|
|
|
|
|
|
|
|
expect(view.someProp('handleKeyDown', (handler) => handler())).toBe(true)
|
|
|
|
|
expect(keyDownPlugin).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(recoverDocument).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
|
|
|
|
reason: 'invalid_block_join',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('keeps unrelated keydown handler failures visible', () => {
|
|
|
|
|
const { view } = createViewWithSomeProp(() => {
|
|
|
|
|
throw new Error('keyboard plugin failed')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
|
|
|
|
|
expect(() => view.someProp('handleKeyDown', (handler) => handler())).toThrow('keyboard plugin failed')
|
|
|
|
|
expect(trackEvent).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-23 15:28:35 +02:00
|
|
|
it('recovers table selection transactions whose target row changed underneath BlockNote', () => {
|
2026-05-23 16:11:20 +02:00
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
new RangeError('Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>'),
|
|
|
|
|
'table_position_out_of_range',
|
2026-05-23 15:28:35 +02:00
|
|
|
)
|
2026-06-05 22:58:28 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('recovers production table row index transactions reported as plain errors', () => {
|
|
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
new Error('Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>'),
|
|
|
|
|
'table_position_out_of_range',
|
|
|
|
|
)
|
2026-05-23 16:11:20 +02:00
|
|
|
})
|
2026-05-23 15:28:35 +02:00
|
|
|
|
2026-05-23 16:11:20 +02:00
|
|
|
it('recovers invalid insertion-depth transactions after note switching and saves', () => {
|
|
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
new RangeError('Inserted content deeper than insertion position'),
|
|
|
|
|
'invalid_insertion_depth',
|
|
|
|
|
)
|
2026-05-23 15:28:35 +02:00
|
|
|
})
|
|
|
|
|
|
2026-06-06 03:57:21 +02:00
|
|
|
it('repairs null fragment append failures from invalid document model fills', () => {
|
|
|
|
|
expectDocumentRepairRecovery(
|
|
|
|
|
nullFragmentAppendError(),
|
|
|
|
|
'null_fragment_append',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-23 16:53:43 +02:00
|
|
|
it('recovers stale block-reference transactions from toolbar actions', () => {
|
|
|
|
|
const { currentDoc, view } = createView(new Error(
|
|
|
|
|
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
|
|
|
|
|
))
|
|
|
|
|
const recoverDocument = vi.fn()
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view, { recoverDocument })
|
|
|
|
|
|
|
|
|
|
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
|
|
|
|
|
expect(recoverDocument).not.toHaveBeenCalled()
|
|
|
|
|
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
|
|
|
|
|
reason: 'stale_block_reference',
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-13 07:21:37 +02:00
|
|
|
it('keeps non-ProseMirror dispatch failures visible', () => {
|
|
|
|
|
const { view } = createView(new Error('plugin failed'))
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
|
|
|
|
|
expect(() => view.dispatch({})).toThrow('plugin failed')
|
|
|
|
|
expect(trackEvent).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('restores the original dispatch after all installs are cleaned up', () => {
|
|
|
|
|
const { dispatch, view } = createView()
|
|
|
|
|
|
|
|
|
|
const firstUninstall = installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
const secondUninstall = installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
const wrappedDispatch = view.dispatch
|
|
|
|
|
|
|
|
|
|
expect(wrappedDispatch).not.toBe(dispatch)
|
|
|
|
|
|
|
|
|
|
firstUninstall()
|
|
|
|
|
expect(view.dispatch).toBe(wrappedDispatch)
|
|
|
|
|
|
|
|
|
|
secondUninstall()
|
|
|
|
|
expect(view.dispatch).toBe(dispatch)
|
|
|
|
|
})
|
2026-05-17 19:15:55 +02:00
|
|
|
|
2026-05-25 16:59:56 +02:00
|
|
|
it('restores the original keydown prop lookup after all installs are cleaned up', () => {
|
|
|
|
|
const { view } = createViewWithSomeProp(() => false)
|
|
|
|
|
const originalSomeProp = view.someProp
|
|
|
|
|
|
|
|
|
|
const firstUninstall = installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
const secondUninstall = installRichEditorTransformErrorRecovery(view)
|
|
|
|
|
const wrappedSomeProp = view.someProp
|
|
|
|
|
|
|
|
|
|
expect(wrappedSomeProp).not.toBe(originalSomeProp)
|
|
|
|
|
|
|
|
|
|
firstUninstall()
|
|
|
|
|
expect(view.someProp).toBe(wrappedSomeProp)
|
|
|
|
|
|
|
|
|
|
secondUninstall()
|
|
|
|
|
expect(view.someProp).toBe(originalSomeProp)
|
|
|
|
|
})
|
|
|
|
|
|
2026-05-17 19:15:55 +02:00
|
|
|
it('restores the previous recoverDocument callback when a later install unmounts', () => {
|
|
|
|
|
const schemaError = new RangeError(
|
|
|
|
|
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
|
|
|
|
)
|
|
|
|
|
const { currentDoc, view } = createView(schemaError)
|
|
|
|
|
const firstRecoverDocument = vi.fn()
|
|
|
|
|
const secondRecoverDocument = vi.fn()
|
|
|
|
|
|
|
|
|
|
installRichEditorTransformErrorRecovery(view, { recoverDocument: firstRecoverDocument })
|
|
|
|
|
const secondUninstall = installRichEditorTransformErrorRecovery(view, { recoverDocument: secondRecoverDocument })
|
|
|
|
|
secondUninstall()
|
|
|
|
|
|
|
|
|
|
view.dispatch({ before: currentDoc })
|
|
|
|
|
|
|
|
|
|
expect(firstRecoverDocument).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(secondRecoverDocument).not.toHaveBeenCalled()
|
|
|
|
|
})
|
2026-05-13 07:21:37 +02:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('createRichEditorTransformErrorRecoveryExtension', () => {
|
|
|
|
|
it('installs and removes dispatch recovery with the BlockNote mount signal', () => {
|
|
|
|
|
const { dispatch, view } = createView()
|
|
|
|
|
const editor = {
|
|
|
|
|
_tiptapEditor: { view },
|
|
|
|
|
prosemirrorView: view,
|
|
|
|
|
}
|
|
|
|
|
const extension = createRichEditorTransformErrorRecoveryExtension()({ editor: editor as never })
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
|
|
|
|
extension.mount?.({
|
|
|
|
|
dom: document.createElement('div'),
|
|
|
|
|
root: document,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
expect(view.dispatch).not.toBe(dispatch)
|
|
|
|
|
|
|
|
|
|
controller.abort()
|
|
|
|
|
|
|
|
|
|
expect(view.dispatch).toBe(dispatch)
|
|
|
|
|
})
|
2026-05-17 03:06:49 +02:00
|
|
|
|
|
|
|
|
it('repairs malformed list-heavy editor documents when invalid-content dispatch fails', () => {
|
|
|
|
|
const schemaError = new RangeError(
|
|
|
|
|
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
|
|
|
|
|
)
|
|
|
|
|
const { currentDoc, view } = createView(schemaError)
|
|
|
|
|
const childListItem = {
|
|
|
|
|
id: 'list-child',
|
|
|
|
|
type: 'bulletListItem',
|
|
|
|
|
content: [{ type: 'text', text: 'Step', styles: {} }],
|
|
|
|
|
children: [],
|
|
|
|
|
}
|
|
|
|
|
const paragraph = {
|
|
|
|
|
id: 'paragraph-parent',
|
|
|
|
|
type: 'paragraph',
|
|
|
|
|
content: [{ type: 'text', text: 'Procedures are long-running', styles: {} }],
|
|
|
|
|
children: [childListItem],
|
|
|
|
|
}
|
|
|
|
|
const editor = {
|
|
|
|
|
document: [paragraph],
|
|
|
|
|
replaceBlocks: vi.fn(),
|
|
|
|
|
_tiptapEditor: { view },
|
|
|
|
|
prosemirrorView: view,
|
|
|
|
|
}
|
|
|
|
|
const extension = createRichEditorTransformErrorRecoveryExtension()({ editor: editor as never })
|
|
|
|
|
const controller = new AbortController()
|
|
|
|
|
|
|
|
|
|
extension.mount?.({
|
|
|
|
|
dom: document.createElement('div'),
|
|
|
|
|
root: document,
|
|
|
|
|
signal: controller.signal,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
|
|
|
|
|
expect(editor.replaceBlocks).toHaveBeenCalledWith(
|
|
|
|
|
[paragraph],
|
|
|
|
|
[
|
|
|
|
|
{ ...paragraph, children: [] },
|
|
|
|
|
childListItem,
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
controller.abort()
|
|
|
|
|
})
|
2026-05-13 07:21:37 +02:00
|
|
|
})
|