fix: recover stale editor block references

This commit is contained in:
lucaronin
2026-05-23 16:53:43 +02:00
parent f0fb7b7418
commit 12646d4e98
8 changed files with 157 additions and 18 deletions

View File

@@ -67,6 +67,9 @@ describe('isRecoverableEditorTransformError', () => {
expect(isRecoverableEditorTransformError(new RangeError(
'Index 1 out of range for <tableRow(tableCell(tableParagraph("A")))>',
))).toBe(true)
expect(isRecoverableEditorTransformError(new Error(
'Block with ID 6c1c3bb4-e218-4f00-aaf5-40606852d286 not found',
))).toBe(true)
expect(isRecoverableEditorTransformError(new RangeError(
'Index 1 out of range for <paragraph("A")>',
))).toBe(false)
@@ -122,6 +125,21 @@ describe('installRichEditorTransformErrorRecovery', () => {
)
})
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',
})
})
it('keeps non-ProseMirror dispatch failures visible', () => {
const { view } = createView(new Error('plugin failed'))

View File

@@ -38,6 +38,7 @@ interface RepairableBlockNoteEditor {
type RecoveryReason =
| 'invalid_insertion_depth'
| 'mismatched_transaction'
| 'stale_block_reference'
| 'stale_transaction'
| 'table_position_out_of_range'
| 'transform_error'
@@ -80,6 +81,10 @@ function isTablePositionOutOfRangeError(error: unknown): boolean {
return error instanceof RangeError && /^Index \d+ out of range for <tableRow\(/.test(error.message)
}
export function isStaleBlockReferenceError(error: unknown): boolean {
return error instanceof Error && /^Block with ID .+ not found$/.test(error.message)
}
function isTransformError(error: unknown): boolean {
return error instanceof Error && error.name === 'TransformError'
}
@@ -90,8 +95,15 @@ function isRecoverableRangeError(error: unknown): boolean {
|| isTablePositionOutOfRangeError(error)
}
const RECOVERABLE_EDITOR_ERROR_PREDICATES = [
isTransformError,
isMismatchedTransactionError,
isRecoverableRangeError,
isStaleBlockReferenceError,
]
export function isRecoverableEditorTransformError(error: unknown): boolean {
return isTransformError(error) || isMismatchedTransactionError(error) || isRecoverableRangeError(error)
return RECOVERABLE_EDITOR_ERROR_PREDICATES.some((predicate) => predicate(error))
}
function recoveryReason(
@@ -101,6 +113,7 @@ function recoveryReason(
): RecoveryReason {
if (transactionDocIsStale(transaction, view)) return 'stale_transaction'
if (isMismatchedTransactionError(error)) return 'mismatched_transaction'
if (isStaleBlockReferenceError(error)) return 'stale_block_reference'
if (isInvalidInsertionDepthError(error)) return 'invalid_insertion_depth'
if (isTablePositionOutOfRangeError(error)) return 'table_position_out_of_range'
return 'transform_error'

View File

@@ -367,6 +367,20 @@ describe('TolariaSideMenu', () => {
})
})
it('hides table header actions when the live block lookup throws after reload churn', () => {
const staleTable = {
id: 'table-block',
type: 'table',
content: { type: 'tableContent', rows: [], headerRows: undefined },
}
mockEditor.getBlock.mockImplementation(() => {
throw staleBlockError(staleTable)
})
expect(() => renderSideMenuWithBlock(staleTable)).not.toThrow()
expect(screen.queryByText('Header row')).not.toBeInTheDocument()
})
it('ignores stale drag starts after reload churn', () => {
renderSideMenuWithBlock(sideMenuBlock)
@@ -386,6 +400,18 @@ describe('TolariaSideMenu', () => {
expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before')
})
it('ignores pointer reorders when a target block lookup throws after reload churn', () => {
const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture()
mockEditor.getBlock.mockImplementation((id: string) => {
if (id === targetBlock.id) throw staleBlockError(id)
return id === draggedBlock.id ? draggedBlock : undefined
})
expect(() => dispatchHandlePointerReorder(dragHandle)).not.toThrow()
expect(mockEditor.removeBlocks).not.toHaveBeenCalled()
expect(mockEditor.insertBlocks).not.toHaveBeenCalled()
})
it('shows and clears pointer reorder affordances while dragging', () => {
const { draggedElement, dragHandle } = renderPointerReorderFixture()

View File

@@ -25,6 +25,7 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react'
import { isStaleBlockReferenceError } from './richEditorTransformErrorRecoveryExtension'
type TolariaBlockNoteEditor = BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
type TolariaBlock = NonNullable<ReturnType<TolariaBlockNoteEditor['getBlock']>>
@@ -83,14 +84,26 @@ const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8
function liveSideMenuBlock(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
if (!block) return undefined
return editor.getBlock(block.id)
try {
return editor.getBlock(block.id)
} catch (error) {
if (isStaleBlockReferenceError(error)) {
console.warn('[editor] Ignored stale block side-menu lookup:', error)
return undefined
}
throw error
}
}
function runSideMenuAction(action: () => void) {
try {
action()
} catch (error) {
console.warn('[editor] Ignored stale block side-menu action:', error)
if (isStaleBlockReferenceError(error)) {
console.warn('[editor] Ignored stale block side-menu action:', error)
return
}
throw error
}
}
@@ -457,8 +470,8 @@ function validDropTarget({
const blockId = blockIdFromElement(targetElement)
if (!blockId || blockId === state.draggedBlockId) return null
const draggedBlock = editor.getBlock(state.draggedBlockId)
const targetBlock = editor.getBlock(blockId)
const draggedBlock = liveSideMenuBlock(editor, { id: state.draggedBlockId, type: '' })
const targetBlock = liveSideMenuBlock(editor, { id: blockId, type: '' })
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, blockId)) return null
return {
@@ -481,15 +494,15 @@ function moveBlockByPointerDrop({
}): boolean {
if (draggedBlockId === targetBlockId) return false
const draggedBlock = editor.getBlock(draggedBlockId)
const targetBlock = editor.getBlock(targetBlockId)
const draggedBlock = liveSideMenuBlock(editor, { id: draggedBlockId, type: '' })
const targetBlock = liveSideMenuBlock(editor, { id: targetBlockId, type: '' })
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, targetBlockId)) return false
let moved = false
editor.focus()
editor.transact(() => {
const currentDraggedBlock = editor.getBlock(draggedBlockId)
const currentTargetBlock = editor.getBlock(targetBlockId)
const currentDraggedBlock = liveSideMenuBlock(editor, { id: draggedBlockId, type: '' })
const currentTargetBlock = liveSideMenuBlock(editor, { id: targetBlockId, type: '' })
if (!currentDraggedBlock || !currentTargetBlock) return
if (hasChildBlock(currentDraggedBlock, targetBlockId)) return

View File

@@ -79,6 +79,7 @@ vi.mock('@blocknote/react', () => ({
vi.mock('@blocknote/core', () => ({
blockHasType: blockHasTypeMock,
createExtension: (factory: unknown) => factory,
defaultProps: { textAlignment: 'left' },
editorHasBlockWithType: editorHasBlockWithTypeMock,
}))
@@ -160,6 +161,7 @@ function createMockEditor(blockType = 'image', props: Record<string, unknown> =
domElement,
focus: vi.fn(),
getActiveStyles: () => ({ bold: true }),
getBlock: vi.fn((id: string) => (id === selectedBlock.id ? selectedBlock : undefined)),
getSelection: () => ({ blocks: [selectedBlock] }),
getTextCursorPosition: () => ({ block: selectedBlock }),
toggleStyles: vi.fn(),
@@ -192,11 +194,27 @@ describe('tolariaEditorFormatting behavior', () => {
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
expect(editor.transact).toHaveBeenCalledTimes(1)
expect(editor.updateBlock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'file-block' }),
'file-block',
{ type: 'heading', props: { level: 1 } },
)
})
it('ignores stale block-type clicks when the selected block disappeared before the action', () => {
const editor = createMockEditor('paragraph')
editor.getBlock.mockImplementation(() => {
throw new Error('Block with ID file-block not found')
})
useBlockNoteEditorMock.mockReturnValue(editor)
render(<TolariaFormattingToolbar />)
expect(() => {
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
}).not.toThrow()
expect(editor.transact).not.toHaveBeenCalled()
expect(editor.updateBlock).not.toHaveBeenCalled()
})
it('opens selected file blocks through the active vault path', () => {
const editor = createMockEditor('file', {
url: 'asset://localhost/%2Fvault%2Fattachments%2Freport.pdf',

View File

@@ -59,6 +59,10 @@ import {
} from './tolariaEditorFormattingConfig'
import { useBlockNoteFormattingToolbarHoverGuard } from './blockNoteFormattingToolbarHoverGuard'
import { openEditorAttachmentOrUrl } from './editorAttachmentActions'
import {
isStaleBlockReferenceError,
reportRecoveredEditorTransformError,
} from './richEditorTransformErrorRecoveryExtension'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
@@ -359,6 +363,40 @@ function getSelectedFileBlockState(
: null
}
function reportStaleFormattingToolbarBlockReference(error: unknown) {
reportRecoveredEditorTransformError('stale_block_reference', error)
}
function liveSelectedBlock(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
block: TolariaSelectedBlock,
) {
try {
return editor.getBlock(block.id) as TolariaSelectedBlock | undefined
} catch (error) {
if (isStaleBlockReferenceError(error)) {
reportStaleFormattingToolbarBlockReference(error)
return undefined
}
throw error
}
}
function liveSelectedBlocks(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
) {
const liveBlocks: TolariaSelectedBlock[] = []
for (const block of selectedBlocks) {
const liveBlock = liveSelectedBlock(editor, block)
if (!liveBlock) return []
liveBlocks.push(liveBlock)
}
return liveBlocks
}
function fileDownloadTooltip(dict: unknown, blockType: string): string {
const tooltip = (dict as {
formatting_toolbar?: {
@@ -383,15 +421,26 @@ function updateSelectedBlocksToType(
selectedBlocks: TolariaSelectedBlock[],
item: ReturnType<typeof getTolariaBlockTypeSelectItems>[number],
) {
editor.focus()
editor.transact(() => {
for (const block of selectedBlocks) {
editor.updateBlock(block, {
type: item.type as never,
props: item.props as never,
})
const blocks = liveSelectedBlocks(editor, selectedBlocks)
if (!blocks.length) return
try {
editor.focus()
editor.transact(() => {
for (const block of blocks) {
editor.updateBlock(block.id, {
type: item.type as never,
props: item.props as never,
})
}
})
} catch (error) {
if (isStaleBlockReferenceError(error)) {
reportStaleFormattingToolbarBlockReference(error)
return
}
})
throw error
}
}
function TolariaBasicTextStyleButton({

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Закаміціць і адправіць (Commit & Push)",
"command.git.addRemote": "Дадаць аддалены сервер (Remote) да сховішча",
"command.git.pull": "Атрымаць змены (Pull)",
"command.git.pullRepository": "Атрымаць з аддаленага рэпазіторыя: {repository}",
"command.git.resolveConflicts": "Вырашыць канфлікты",
"command.git.viewChanges": "Прагледзець чакаючыя змены",
"git.repository.select": "Сховішча",

View File

@@ -57,6 +57,7 @@
"command.git.commitPush": "Zakamicić i adpravić (Commit & Push)",
"command.git.addRemote": "Dadać addalieny siervier (Remote) da schovišča",
"command.git.pull": "Atrymać zmieny (Pull)",
"command.git.pullRepository": "Atrymać z addalienaha repazitoryja: {repository}",
"command.git.resolveConflicts": "Vyrašyć kanflikty",
"command.git.viewChanges": "Prahliedzieć čakajučyja zmieny",
"git.repository.select": "Schovišča",