Merge branch 'main' into pr-728

This commit is contained in:
github-actions[bot]
2026-05-25 15:18:46 +00:00
committed by GitHub
2 changed files with 152 additions and 16 deletions

View File

@@ -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 <tableRow(tableCell(tableParagraph("A")))>'),
@@ -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: <paragraph("Procedures are long-running"), blockGroup(blockContainer(bulletListItem("Step")))>',

View File

@@ -5,9 +5,16 @@ import { repairMalformedEditorBlocks } from '../hooks/editorBlockRepair'
const DISPATCH_RECOVERY_STATE_KEY = '__tolariaRichEditorTransformErrorRecovery'
type RichEditorDispatch = (transaction: unknown) => unknown
type RichEditorPropRunner<T> = (prop: T) => unknown
type RichEditorSomeProp = <T>(propName: string, run?: RichEditorPropRunner<T>) => 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<T>(
view: RichEditorRecoveryView,
recoveryState: DispatchRecoveryState,
run: RichEditorPropRunner<T>,
): RichEditorPropRunner<T> {
return (prop: T) => {
try {
return run(prop)
} catch (error) {
recoverAfterEditorTransformError(error, undefined, view, recoveryState)
return true
}
}
}
function callSomeProp<T>(
view: RichEditorRecoveryView,
someProp: RichEditorSomeProp,
propName: string,
run?: RichEditorPropRunner<T>,
): unknown {
const boundSomeProp = someProp as (
this: RichEditorRecoveryView,
propName: string,
run?: RichEditorPropRunner<T>,
) => unknown
return boundSomeProp.call(view, propName, run)
}
function createRecoveringSomeProp(
view: RichEditorRecoveryView,
recoveryState: DispatchRecoveryState,
): RichEditorSomeProp {
return <T>(propName: string, run?: RichEditorPropRunner<T>) => {
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 })