fix: repair rich editor invalid list content

This commit is contained in:
lucaronin
2026-05-17 03:06:49 +02:00
parent 28e2072f9b
commit 247c3eefb9
2 changed files with 93 additions and 8 deletions

View File

@@ -82,10 +82,12 @@ describe('installRichEditorTransformErrorRecovery', () => {
'Invalid content for node blockContainer: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',
)
const { currentDoc, view } = createView(schemaError)
const recoverDocument = vi.fn()
installRichEditorTransformErrorRecovery(view)
installRichEditorTransformErrorRecovery(view, { recoverDocument })
expect(() => view.dispatch({ before: currentDoc })).not.toThrow()
expect(recoverDocument).toHaveBeenCalledTimes(1)
expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', {
reason: 'transform_error',
})
@@ -138,4 +140,48 @@ describe('createRichEditorTransformErrorRecoveryExtension', () => {
expect(view.dispatch).toBe(dispatch)
})
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()
})
})

View File

@@ -1,9 +1,11 @@
import { createExtension } from '@blocknote/core'
import { trackEvent } from '../lib/telemetry'
import { repairMalformedEditorBlocks } from '../hooks/editorBlockRepair'
const DISPATCH_RECOVERY_STATE_KEY = '__tolariaRichEditorTransformErrorRecovery'
type RichEditorDispatch = (transaction: unknown) => unknown
type RecoverEditorDocument = () => void
interface RichEditorDispatchView {
dispatch: RichEditorDispatch
@@ -16,9 +18,19 @@ interface RichEditorDispatchView {
interface DispatchRecoveryState {
originalDispatch: RichEditorDispatch
recoverDocument?: RecoverEditorDocument
refCount: number
}
interface InstallRecoveryOptions {
recoverDocument?: RecoverEditorDocument
}
interface RepairableBlockNoteEditor {
document?: unknown[]
replaceBlocks?: (currentBlocks: unknown[], nextBlocks: unknown[]) => unknown
}
type RecoveryReason = 'mismatched_transaction' | 'stale_transaction' | 'transform_error'
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -92,21 +104,26 @@ function releaseRecoveryState(
function retainRecoveryState(
view: RichEditorDispatchView,
recoveryState: DispatchRecoveryState,
recoverDocument?: RecoverEditorDocument,
): () => void {
recoveryState.refCount += 1
if (recoverDocument) recoveryState.recoverDocument = recoverDocument
return () => releaseRecoveryState(view, recoveryState, recoveryState.originalDispatch)
}
function createRecoveringDispatch(
view: RichEditorDispatchView,
originalDispatch: RichEditorDispatch,
recoveryState: DispatchRecoveryState,
): RichEditorDispatch {
return (transaction: unknown) => {
try {
return originalDispatch.call(view, transaction)
return recoveryState.originalDispatch.call(view, transaction)
} catch (error) {
if (!isRecoverableEditorTransformError(error)) throw error
if (isInvalidContentTransactionError(error)) {
recoveryState.recoverDocument?.()
}
reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error)
return undefined
}
@@ -116,25 +133,44 @@ function createRecoveringDispatch(
function installRecoveryState(
view: RichEditorDispatchView,
originalDispatch: RichEditorDispatch,
recoverDocument?: RecoverEditorDocument,
): DispatchRecoveryState {
const recoveryState: DispatchRecoveryState = {
originalDispatch,
recoverDocument,
refCount: 1,
}
view.dispatch = createRecoveringDispatch(view, originalDispatch)
view.dispatch = createRecoveringDispatch(view, recoveryState)
Reflect.set(view, DISPATCH_RECOVERY_STATE_KEY, recoveryState)
return recoveryState
}
export function installRichEditorTransformErrorRecovery(view: RichEditorDispatchView): () => void {
function repairEditorDocumentAfterInvalidContentError(editor: RepairableBlockNoteEditor): void {
if (!Array.isArray(editor.document) || typeof editor.replaceBlocks !== 'function') return
const currentBlocks = editor.document
const safeBlocks = repairMalformedEditorBlocks(currentBlocks)
if (safeBlocks === currentBlocks) return
try {
editor.replaceBlocks(currentBlocks, safeBlocks)
} catch (error) {
console.warn('[editor] Failed to repair rich-editor document after transform error:', error)
}
}
export function installRichEditorTransformErrorRecovery(
view: RichEditorDispatchView,
options: InstallRecoveryOptions = {},
): () => void {
const currentState = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY)
if (isDispatchRecoveryState(currentState)) {
return retainRecoveryState(view, currentState)
return retainRecoveryState(view, currentState, options.recoverDocument)
}
const originalDispatch = view.dispatch
const recoveryState = installRecoveryState(view, originalDispatch)
const recoveryState = installRecoveryState(view, originalDispatch, options.recoverDocument)
return () => releaseRecoveryState(view, recoveryState, originalDispatch)
}
@@ -145,7 +181,10 @@ export const createRichEditorTransformErrorRecoveryExtension = createExtension((
const view = editor._tiptapEditor?.view ?? editor.prosemirrorView
if (!view || typeof view.dispatch !== 'function') return
const uninstall = installRichEditorTransformErrorRecovery(view as unknown as RichEditorDispatchView)
const uninstall = installRichEditorTransformErrorRecovery(
view as unknown as RichEditorDispatchView,
{ recoverDocument: () => repairEditorDocumentAfterInvalidContentError(editor as RepairableBlockNoteEditor) },
)
signal.addEventListener('abort', uninstall, { once: true })
},
} as const))