fix: recover paragraph editor index errors

This commit is contained in:
lucaronin
2026-06-07 02:49:04 +02:00
parent 3d28e21a5a
commit 48eac1e40d
4 changed files with 54 additions and 10 deletions

View File

@@ -41,6 +41,20 @@ describe('blockNoteRenderRecovery', () => {
expect(isRecoverableBlockNoteRenderError(error)).toBe(true)
})
it('recognizes production paragraph index render errors from slash input', () => {
const error = new RangeError('Index 1 out of range for <paragraph("/")>')
expect(blockNoteRenderRecoveryReason(error)).toBe('paragraph_index_out_of_range')
expect(isRecoverableBlockNoteRenderError(error)).toBe(true)
})
it('recognizes production paragraph index render errors that are plain Error instances', () => {
const error = new Error('Index 1 out of range for <paragraph("/")>')
expect(blockNoteRenderRecoveryReason(error)).toBe('paragraph_index_out_of_range')
expect(isRecoverableBlockNoteRenderError(error)).toBe(true)
})
it('recognizes recovered BlockNote block type mismatch render errors', () => {
const error = new Error('Block type does not match')

View File

@@ -3,10 +3,12 @@ const BLOCKNOTE_BLOCK_TYPE_MISMATCH_ERROR = 'Block type does not match'
const BLOCKNOTE_RECOVERY_BOUNDARY_NAME = 'BlockNoteRenderRecoveryBoundary'
const RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK = '__tolariaRecoveredBlockNoteRenderError'
const BLOCKNOTE_TABLE_ROW_INDEX_ERROR = /^Index \d+ out of range for <tableRow\(/
const BLOCKNOTE_PARAGRAPH_INDEX_ERROR = /^Index \d+ out of range for <paragraph\(/
export type BlockNoteRenderRecoveryReason =
| 'block_type_mismatch'
| 'block_missing_id'
| 'paragraph_index_out_of_range'
| 'table_row_index_out_of_range'
type MarkedRecoveredBlockNoteRenderError = Error & {
@@ -29,6 +31,9 @@ export function blockNoteRenderRecoveryReason(error: unknown): BlockNoteRenderRe
if (BLOCKNOTE_TABLE_ROW_INDEX_ERROR.test(error.message)) {
return 'table_row_index_out_of_range'
}
if (BLOCKNOTE_PARAGRAPH_INDEX_ERROR.test(error.message)) {
return 'paragraph_index_out_of_range'
}
return null
}

View File

@@ -95,6 +95,12 @@ describe('isRecoverableEditorTransformError', () => {
expect(isRecoverableEditorTransformError(new Error(
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new RangeError(
'Index 1 out of range for <paragraph("/")>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error(
'Index 1 out of range for <paragraph("/")>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error(
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
))).toBe(true)
@@ -106,9 +112,6 @@ describe('isRecoverableEditorTransformError', () => {
expect(isRecoverableEditorTransformError(new TypeError(
"Cannot read properties of null (reading 'append')",
))).toBe(false)
expect(isRecoverableEditorTransformError(new RangeError(
'Index 1 out of range for <paragraph("A")>',
))).toBe(false)
expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false)
})
})
@@ -218,6 +221,13 @@ describe('installRichEditorTransformErrorRecovery', () => {
)
})
it('recovers production paragraph index transactions from stale slash input', () => {
expectDocumentRepairRecovery(
new RangeError('Index 1 out of range for <paragraph("/")>'),
'paragraph_position_out_of_range',
)
})
it('recovers invalid insertion-depth transactions after note switching and saves', () => {
expectDocumentRepairRecovery(
new RangeError('Inserted content deeper than insertion position'),

View File

@@ -49,11 +49,17 @@ type RecoveryReason =
| 'invalid_insertion_depth'
| 'mismatched_transaction'
| 'null_fragment_append'
| 'paragraph_position_out_of_range'
| 'stale_block_reference'
| 'stale_transaction'
| 'table_position_out_of_range'
| 'transform_error'
interface RecoveryReasonMatcher {
matches: (error: unknown) => boolean
reason: RecoveryReason
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}
@@ -92,6 +98,10 @@ function isTablePositionOutOfRangeError(error: unknown): boolean {
return error instanceof Error && /^Index \d+ out of range for <tableRow\(/.test(error.message)
}
function isParagraphPositionOutOfRangeError(error: unknown): boolean {
return error instanceof Error && /^Index \d+ out of range for <paragraph\(/.test(error.message)
}
function isInvalidBlockJoinError(error: unknown): boolean {
return isTransformError(error) && /^Cannot join (blockGroup|tableCell) onto blockContainer$/.test(error.message)
}
@@ -114,6 +124,7 @@ function isTransformError(error: unknown): error is Error {
function isRecoverableRangeError(error: unknown): boolean {
return isInvalidContentTransactionError(error)
|| isInvalidInsertionDepthError(error)
|| isParagraphPositionOutOfRangeError(error)
|| isTablePositionOutOfRangeError(error)
}
@@ -125,6 +136,16 @@ const RECOVERABLE_EDITOR_ERROR_PREDICATES = [
isStaleBlockReferenceError,
]
const RECOVERY_REASON_MATCHERS: RecoveryReasonMatcher[] = [
{ matches: isMismatchedTransactionError, reason: 'mismatched_transaction' },
{ matches: isStaleBlockReferenceError, reason: 'stale_block_reference' },
{ matches: isInvalidBlockJoinError, reason: 'invalid_block_join' },
{ matches: isInvalidInsertionDepthError, reason: 'invalid_insertion_depth' },
{ matches: isNullFragmentAppendError, reason: 'null_fragment_append' },
{ matches: isParagraphPositionOutOfRangeError, reason: 'paragraph_position_out_of_range' },
{ matches: isTablePositionOutOfRangeError, reason: 'table_position_out_of_range' },
]
export function isRecoverableEditorTransformError(error: unknown): boolean {
return RECOVERABLE_EDITOR_ERROR_PREDICATES.some((predicate) => predicate(error))
}
@@ -135,13 +156,7 @@ function recoveryReason(
view: RichEditorDispatchView,
): RecoveryReason {
if (transactionDocIsStale(transaction, view)) return 'stale_transaction'
if (isMismatchedTransactionError(error)) return 'mismatched_transaction'
if (isStaleBlockReferenceError(error)) return 'stale_block_reference'
if (isInvalidBlockJoinError(error)) return 'invalid_block_join'
if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth'
if (isNullFragmentAppendError(error)) return 'null_fragment_append'
if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range'
return 'transform_error'
return RECOVERY_REASON_MATCHERS.find(({ matches }) => matches(error))?.reason ?? 'transform_error'
}
function shouldRepairEditorDocument(error: unknown): boolean {