fix: recover table row render errors

This commit is contained in:
lucaronin
2026-05-29 11:16:23 +02:00
parent 9be7049bfd
commit 9a2c025d07
5 changed files with 120 additions and 3 deletions

View File

@@ -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 <tableRow(tableCell(tableParagraph("A")))>',
)
state.blockNoteViewErrorOnce = true
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
const editor = createEditor()
try {
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
{ 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()]

View File

@@ -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,

View File

@@ -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 <tableRow(tableCell(tableParagraph("A")))>',
)
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"),

View File

@@ -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 <tableRow\(/
export type BlockNoteRenderRecoveryReason =
| 'block_missing_id'
| 'table_row_index_out_of_range'
type MarkedRecoveredBlockNoteRenderError = Error & {
[RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK]?: true
@@ -12,7 +17,17 @@ function hasRecoveredRenderErrorMark(error: unknown): boolean {
}
export function isRecoverableBlockNoteRenderError(error: unknown): boolean {
return error instanceof Error && error.message.includes(BLOCKNOTE_MISSING_ID_ERROR)
return blockNoteRenderRecoveryReason(error) !== null
}
export function blockNoteRenderRecoveryReason(error: unknown): BlockNoteRenderRecoveryReason | null {
if (!(error instanceof Error)) return null
if (error.message.includes(BLOCKNOTE_MISSING_ID_ERROR)) return 'block_missing_id'
if (error instanceof RangeError && BLOCKNOTE_TABLE_ROW_INDEX_ERROR.test(error.message)) {
return 'table_row_index_out_of_range'
}
return null
}
export function markRecoveredBlockNoteRenderError(error: unknown): void {

View File

@@ -7,6 +7,8 @@ import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
const TABLE_RELOAD_NOTE_PATH = '/project/table-reload-regression.md'
const TABLE_RELOAD_NOTE_TITLE = 'Table Reload Regression'
const ONE_CELL_TABLE_NOTE_PATH = '/project/one-cell-table-click-regression.md'
const ONE_CELL_TABLE_NOTE_TITLE = 'One Cell Table Click Regression'
const TABLE_WIKILINK_NOTE_PATH = '/table-wikilink-regression.md'
const TABLE_WIKILINK_NOTE_TITLE = 'Table Wikilink Regression'
const TABLE_WIKILINK_TARGET = 'application-design-and-build'
@@ -36,6 +38,25 @@ status: draft
)
}
function writeOneCellTableNote(vaultDir: string): void {
fs.writeFileSync(
path.join(vaultDir, ONE_CELL_TABLE_NOTE_PATH.slice(1)),
`---
title: ${ONE_CELL_TABLE_NOTE_TITLE}
isA: Project
status: draft
---
# ${ONE_CELL_TABLE_NOTE_TITLE}
| Requirement |
| --- |
| M shall include the Minimum Data Set (MDS) 3.0. |
After table.
`,
)
}
function writeTableWikilinkNotes(vaultDir: string): void {
fs.writeFileSync(
path.join(vaultDir, `${TABLE_WIKILINK_TARGET}.md`),
@@ -337,6 +358,38 @@ test.describe('table hover crash regression', () => {
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)