From 780e33bff212ce093e76b50e6bc698e379dcdb0c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 25 May 2026 16:59:56 +0200 Subject: [PATCH] fix: recover rich editor keydown join errors --- ...torTransformErrorRecoveryExtension.test.ts | 62 ++++++++++ ...chEditorTransformErrorRecoveryExtension.ts | 106 +++++++++++++++--- 2 files changed, 152 insertions(+), 16 deletions(-) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts index 64ad9ced..19b6cc5d 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.test.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -32,6 +32,24 @@ function createView(error?: Error) { return { currentDoc, dispatch, view } } +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, + }, + } +} + function expectDocumentRepairRecovery(error: Error, reason: string) { const { currentDoc, view } = createView(error) const recoverDocument = vi.fn() @@ -121,6 +139,33 @@ describe('installRichEditorTransformErrorRecovery', () => { ) }) + 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 }) + + 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() + }) + it('recovers table selection transactions whose target row changed underneath BlockNote', () => { expectDocumentRepairRecovery( new RangeError('Index 1 out of range for '), @@ -175,6 +220,23 @@ describe('installRichEditorTransformErrorRecovery', () => { expect(view.dispatch).toBe(dispatch) }) + 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) + }) + it('restores the previous recoverDocument callback when a later install unmounts', () => { const schemaError = new RangeError( 'Invalid content for node blockContainer: ', diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts index 2ac264fb..8a1f0362 100644 --- a/src/components/richEditorTransformErrorRecoveryExtension.ts +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -5,9 +5,16 @@ import { repairMalformedEditorBlocks } from '../hooks/editorBlockRepair' const DISPATCH_RECOVERY_STATE_KEY = '__tolariaRichEditorTransformErrorRecovery' type RichEditorDispatch = (transaction: unknown) => unknown +type RichEditorPropRunner = (prop: T) => unknown +type RichEditorSomeProp = (propName: string, run?: RichEditorPropRunner) => unknown type RecoverEditorDocument = () => void type RecoveryToken = symbol +interface RecoveryDocumentEntry { + recoverDocument: RecoverEditorDocument + token: RecoveryToken +} + interface RichEditorDispatchView { dispatch: RichEditorDispatch state?: { @@ -17,12 +24,14 @@ interface RichEditorDispatchView { } } +interface RichEditorRecoveryView extends RichEditorDispatchView { + someProp?: RichEditorSomeProp +} + interface DispatchRecoveryState { originalDispatch: RichEditorDispatch - recoverDocuments: Array<{ - recoverDocument: RecoverEditorDocument - token: RecoveryToken - }> + originalSomeProp?: RichEditorSomeProp + recoverDocuments: RecoveryDocumentEntry[] refCount: number } @@ -135,7 +144,7 @@ export const reportRecoveredEditorTransformError = (reason: RecoveryReason, erro } function releaseRecoveryState( - view: RichEditorDispatchView, + view: RichEditorRecoveryView, recoveryState: DispatchRecoveryState, originalDispatch: RichEditorDispatch, token: RecoveryToken, @@ -148,11 +157,12 @@ function releaseRecoveryState( if (state.refCount > 0) return view.dispatch = recoveryState.originalDispatch + if (recoveryState.originalSomeProp) view.someProp = recoveryState.originalSomeProp Reflect.deleteProperty(view, DISPATCH_RECOVERY_STATE_KEY) } function retainRecoveryState( - view: RichEditorDispatchView, + view: RichEditorRecoveryView, recoveryState: DispatchRecoveryState, token: RecoveryToken, recoverDocument?: RecoverEditorDocument, @@ -162,10 +172,24 @@ function retainRecoveryState( return () => releaseRecoveryState(view, recoveryState, recoveryState.originalDispatch, token) } -function activeRecoverDocument(recoveryState: DispatchRecoveryState): RecoverEditorDocument | undefined { +function activeRecoverDocument(recoveryState: { recoverDocuments: RecoveryDocumentEntry[] }): RecoverEditorDocument | undefined { return recoveryState.recoverDocuments.at(-1)?.recoverDocument } +function recoverAfterEditorTransformError( + error: unknown, + transaction: unknown, + view: RichEditorDispatchView, + recoveryState: { recoverDocuments: RecoveryDocumentEntry[] }, +): void { + if (!isRecoverableEditorTransformError(error)) throw error + + if (shouldRepairEditorDocument(error)) { + activeRecoverDocument(recoveryState)?.() + } + reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error) +} + function createRecoveringDispatch( view: RichEditorDispatchView, recoveryState: DispatchRecoveryState, @@ -174,30 +198,79 @@ function createRecoveringDispatch( try { return recoveryState.originalDispatch.call(view, transaction) } catch (error) { - if (!isRecoverableEditorTransformError(error)) throw error - - if (shouldRepairEditorDocument(error)) { - activeRecoverDocument(recoveryState)?.() - } - reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error) + recoverAfterEditorTransformError(error, transaction, view, recoveryState) return undefined } } } +function createRecoveringKeydownRunner( + view: RichEditorRecoveryView, + recoveryState: DispatchRecoveryState, + run: RichEditorPropRunner, +): RichEditorPropRunner { + return (prop: T) => { + try { + return run(prop) + } catch (error) { + recoverAfterEditorTransformError(error, undefined, view, recoveryState) + return true + } + } +} + +function callSomeProp( + view: RichEditorRecoveryView, + someProp: RichEditorSomeProp, + propName: string, + run?: RichEditorPropRunner, +): unknown { + const boundSomeProp = someProp as ( + this: RichEditorRecoveryView, + propName: string, + run?: RichEditorPropRunner, + ) => unknown + + return boundSomeProp.call(view, propName, run) +} + +function createRecoveringSomeProp( + view: RichEditorRecoveryView, + recoveryState: DispatchRecoveryState, +): RichEditorSomeProp { + return (propName: string, run?: RichEditorPropRunner) => { + const originalSomeProp = recoveryState.originalSomeProp + if (!originalSomeProp) return undefined + + if (propName !== 'handleKeyDown' || typeof run !== 'function') { + return callSomeProp(view, originalSomeProp, propName, run) + } + + return callSomeProp( + view, + originalSomeProp, + propName, + createRecoveringKeydownRunner(view, recoveryState, run), + ) + } +} + function installRecoveryState( - view: RichEditorDispatchView, + view: RichEditorRecoveryView, originalDispatch: RichEditorDispatch, token: RecoveryToken, recoverDocument?: RecoverEditorDocument, ): DispatchRecoveryState { + const originalSomeProp = typeof view.someProp === 'function' ? view.someProp : undefined const recoveryState: DispatchRecoveryState = { originalDispatch, + originalSomeProp, recoverDocuments: recoverDocument ? [{ recoverDocument, token }] : [], refCount: 1, } view.dispatch = createRecoveringDispatch(view, recoveryState) + if (originalSomeProp) view.someProp = createRecoveringSomeProp(view, recoveryState) Reflect.set(view, DISPATCH_RECOVERY_STATE_KEY, recoveryState) return recoveryState } @@ -217,11 +290,12 @@ function repairEditorDocumentAfterInvalidContentError(editor: RepairableBlockNot } export function installRichEditorTransformErrorRecovery( - view: RichEditorDispatchView, + view: RichEditorRecoveryView, options: InstallRecoveryOptions = {}, ): () => void { const token = Symbol('rich-editor-transform-error-recovery') const currentState = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY) + if (isDispatchRecoveryState(currentState)) { return retainRecoveryState(view, currentState, token, options.recoverDocument) } @@ -239,7 +313,7 @@ export const createRichEditorTransformErrorRecoveryExtension = createExtension(( if (!view || typeof view.dispatch !== 'function') return const uninstall = installRichEditorTransformErrorRecovery( - view as unknown as RichEditorDispatchView, + view as unknown as RichEditorRecoveryView, { recoverDocument: () => repairEditorDocumentAfterInvalidContentError(editor as RepairableBlockNoteEditor) }, ) signal.addEventListener('abort', uninstall, { once: true })