fix: recover table row render errors
This commit is contained in:
@@ -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()]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user