From 9a2c025d073547f8a9f1f37fb2995e9d179bf04f Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 29 May 2026 11:16:23 +0200 Subject: [PATCH] fix: recover table row render errors --- src/components/SingleEditorView.test.tsx | 34 ++++++++++++ src/components/SingleEditorView.tsx | 6 ++- .../blockNoteRenderRecovery.test.ts | 13 +++++ src/components/blockNoteRenderRecovery.ts | 17 +++++- tests/smoke/table-hover-crash.spec.ts | 53 +++++++++++++++++++ 5 files changed, 120 insertions(+), 3 deletions(-) diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index fd3f34c5..0c4f392d 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -14,6 +14,7 @@ const state = vi.hoisted(() => ({ capturedBlockNoteOnChange: null as null | (() => void), capturedMantineGetStyleNonce: null as null | (() => string), blockNoteViewError: null as Error | null, + blockNoteViewErrorOnce: false, hoverGuardMock: vi.fn(), imageDropState: { isDragOver: false }, linkActivationMock: vi.fn(), @@ -40,6 +41,10 @@ vi.mock('@blocknote/react', () => ({ }) => { if (state.blockNoteViewError) { const error = state.blockNoteViewError + if (state.blockNoteViewErrorOnce) { + state.blockNoteViewError = null + state.blockNoteViewErrorOnce = false + } throw error } @@ -453,6 +458,7 @@ describe('SingleEditorView', () => { state.capturedBlockNoteOnChange = null state.capturedMantineGetStyleNonce = null state.blockNoteViewError = null + state.blockNoteViewErrorOnce = false state.imageDropState.isDragOver = false state.personMentionCandidates = [] state.wikilinkEntriesRef.current = [] @@ -513,6 +519,34 @@ describe('SingleEditorView', () => { } }) + it('remounts after a BlockNote table row index render error', async () => { + state.blockNoteViewError = new RangeError( + 'Index 1 out of range for ', + ) + state.blockNoteViewErrorOnce = true + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const editor = createEditor() + + try { + render( + , + { wrapper: TooltipProvider, onRecoverableError: () => {} }, + ) + + await waitFor(() => { + expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() + }) + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') + expect(editor.replaceBlocks).not.toHaveBeenCalled() + } finally { + consoleError.mockRestore() + } + }) + it('registers the seeded BlockNote test bridge, applies column widths, and cleans it up on unmount', async () => { const editor = createEditor() const entries = [makeEntry()] diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 9d1abb7a..5c455069 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -58,6 +58,7 @@ import { type PlainTextPasteTarget, } from '../utils/plainTextPaste' import { + blockNoteRenderRecoveryReason, isRecoverableBlockNoteRenderError, markRecoveredBlockNoteRenderError, } from './blockNoteRenderRecovery' @@ -130,12 +131,13 @@ class BlockNoteRenderRecoveryBoundary extends Component<{ } componentDidCatch(error: unknown) { - if (!isRecoverableBlockNoteRenderError(error)) return + const reason = blockNoteRenderRecoveryReason(error) + if (!reason) return if (this.state.retries >= MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES) return const attempt = this.state.retries + 1 markRecoveredBlockNoteRenderError(error) - trackEvent('editor_render_recovered', { reason: 'block_missing_id', attempt }) + trackEvent('editor_render_recovered', { reason, attempt }) this.props.onRecover?.(attempt) this.setState(({ recoveryKey, retries }) => ({ error: null, diff --git a/src/components/blockNoteRenderRecovery.test.ts b/src/components/blockNoteRenderRecovery.test.ts index 7a4c0800..2030aa7b 100644 --- a/src/components/blockNoteRenderRecovery.test.ts +++ b/src/components/blockNoteRenderRecovery.test.ts @@ -18,6 +18,19 @@ describe('blockNoteRenderRecovery', () => { expect(isRecoveredBlockNoteRenderError(new Error('Other render failure'), '')).toBe(false) }) + it('recognizes recovered BlockNote table row index render errors', () => { + const error = new RangeError( + 'Index 1 out of range for ', + ) + + expect(isRecoverableBlockNoteRenderError(error)).toBe(true) + expect(isRecoveredBlockNoteRenderError(error, '')).toBe(false) + + markRecoveredBlockNoteRenderError(error) + + expect(isRecoveredBlockNoteRenderError(error, '')).toBe(true) + }) + it('recognizes recovered BlockNote errors from the React component stack fallback', () => { expect(isRecoveredBlockNoteRenderError( new Error("Block doesn't have id"), diff --git a/src/components/blockNoteRenderRecovery.ts b/src/components/blockNoteRenderRecovery.ts index 9501ecfe..c701a1cd 100644 --- a/src/components/blockNoteRenderRecovery.ts +++ b/src/components/blockNoteRenderRecovery.ts @@ -1,6 +1,11 @@ const BLOCKNOTE_MISSING_ID_ERROR = "Block doesn't have id" 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 { expect(errors).toEqual([]) }) + test('clicking a saved one-cell table after reload keeps the editor stable', async ({ page }) => { + const errors = trackUnexpectedErrors(page) + + writeOneCellTableNote(tempVaultDir) + await openFixtureVaultTauri(page, tempVaultDir) + const tableReloadNote = page + .getByTestId('note-list-container') + .locator('[data-note-path]') + .filter({ hasText: ONE_CELL_TABLE_NOTE_TITLE }) + .first() + await expect(tableReloadNote).toBeVisible({ timeout: 5_000 }) + await tableReloadNote.click() + + await expect(page.locator('table tr')).toHaveCount(2, { timeout: 5_000 }) + await expect(tableCell(page, 1, 0)).toContainText('M shall include the Minimum Data Set (MDS) 3.0.') + await triggerMenuCommand(page, 'file-save') + await triggerMenuCommand(page, 'vault-reload') + + await expect(page.locator('div.tableWrapper')).toBeVisible({ timeout: 5_000 }) + await moveAcrossElement(page, 'div.tableWrapper') + await tableCell(page, 1, 0).click() + + const trailingParagraph = page.locator('.bn-editor [data-content-type="paragraph"]').last() + await trailingParagraph.click() + await page.keyboard.type('stable after one-cell table reload') + + const editor = page.getByRole('textbox').last() + await expect(editor).toContainText('stable after one-cell table reload') + await expect(page.locator('table')).toHaveCount(1) + expect(errors).toEqual([]) + }) + test('dragging table row and column handles completes without editor errors', async ({ page }) => { const errors = trackUnexpectedErrors(page)