fix: recover stale editor block references
This commit is contained in:
@@ -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'))
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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": "Сховішча",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user