diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 0a696882..8ee9f3ae 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -589,6 +589,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola - The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model. - The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references. - BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Checklist checkbox handlers also re-resolve the live block before updating `checked`, making delayed clicks after note reloads a no-op instead of a stale block mutation. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation. +- `SingleEditorView` wraps the BlockNote surface in a narrow render-recovery boundary for BlockNote's transient `Block doesn't have id` node-view failure. The boundary retries the BlockNote view once, records `editor_render_recovered`, and marks the recovered error so the React root handler does not send that handled case back to Sentry. Other render errors still propagate through the normal root error path. - `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags. - `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader. - `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features. diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 3b2c4224..f7e2be00 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -13,6 +13,7 @@ const state = vi.hoisted(() => ({ capturedImageDropArgs: null as null | Record, capturedBlockNoteOnChange: null as null | (() => void), capturedMantineGetStyleNonce: null as null | (() => string), + blockNoteViewError: null as Error | null, hoverGuardMock: vi.fn(), imageDropState: { isDragOver: false }, linkActivationMock: vi.fn(), @@ -36,6 +37,12 @@ vi.mock('@blocknote/react', () => ({ onChange?: () => void theme?: string }) => { + if (state.blockNoteViewError) { + const error = state.blockNoteViewError + state.blockNoteViewError = null + throw error + } + const { children, editable, @@ -422,6 +429,7 @@ describe('SingleEditorView', () => { state.capturedImageDropArgs = null state.capturedBlockNoteOnChange = null state.capturedMantineGetStyleNonce = null + state.blockNoteViewError = null state.imageDropState.isDragOver = false state.personMentionCandidates = [] state.wikilinkEntriesRef.current = [] @@ -433,6 +441,29 @@ describe('SingleEditorView', () => { delete window.__laputaTest }) + it('remounts the editor view when a stale BlockNote node view has no block id', async () => { + state.blockNoteViewError = new Error("Block doesn't have id") + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + + try { + render( + , + { wrapper: TooltipProvider, onRecoverableError: () => {} }, + ) + + await waitFor(() => { + expect(screen.getByTestId('blocknote-view')).toBeInTheDocument() + }) + expect(screen.getByTestId('blocknote-view')).toHaveAttribute('data-editable', 'true') + } 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 4745534d..dd4a9d15 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, useMemo, useRef, useContext, useState } from 'react' +import { Component, useEffect, useCallback, useMemo, useRef, useContext, useState, type ReactNode } from 'react' import { invoke } from '@tauri-apps/api/core' import { trackEvent } from '../lib/telemetry' import { @@ -55,6 +55,10 @@ import { registerPlainTextPasteTarget, type PlainTextPasteTarget, } from '../utils/plainTextPaste' +import { + isRecoverableBlockNoteRenderError, + markRecoveredBlockNoteRenderError, +} from './blockNoteRenderRecovery' const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 | | --- | --- | --- | @@ -79,6 +83,7 @@ const TOOLBAR_MOUSE_DOWN_ALLOW_SELECTOR = [ 'textarea', '[contenteditable="true"]', ].join(', ') +const MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES = 1 type TestTableBlock = { type?: string @@ -86,6 +91,55 @@ type TestTableBlock = { } type SuggestionAction = () => void type SuggestionItemWithClick = { onItemClick?: SuggestionAction } +type BlockNoteRenderRecoveryState = { + error: unknown + recoveryKey: number + retries: number +} + +class BlockNoteRenderRecoveryBoundary extends Component< + { children: (recoveryKey: number) => ReactNode }, + BlockNoteRenderRecoveryState +> { + state: BlockNoteRenderRecoveryState = { + error: null, + recoveryKey: 0, + retries: 0, + } + + static getDerivedStateFromError(error: unknown): Partial { + return { error } + } + + componentDidCatch(error: unknown) { + if (!isRecoverableBlockNoteRenderError(error)) 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 }) + this.setState(({ recoveryKey, retries }) => ({ + error: null, + recoveryKey: recoveryKey + 1, + retries: retries + 1, + })) + } + + render() { + if (this.state.error) { + if ( + !isRecoverableBlockNoteRenderError(this.state.error) + || this.state.retries >= MAX_BLOCKNOTE_RENDER_RECOVERY_RETRIES + ) { + throw this.state.error + } + + return null + } + + return this.props.children(this.state.recoveryKey) + } +} function isEditorReadyForSuggestionAction( editor: ReturnType, @@ -1350,22 +1404,27 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
Drop image here
)} - - - + + {(recoveryKey) => ( + + + + )} + {copyTarget && } diff --git a/src/components/blockNoteRenderRecovery.test.ts b/src/components/blockNoteRenderRecovery.test.ts new file mode 100644 index 00000000..7a4c0800 --- /dev/null +++ b/src/components/blockNoteRenderRecovery.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + isRecoverableBlockNoteRenderError, + isRecoveredBlockNoteRenderError, + markRecoveredBlockNoteRenderError, +} from './blockNoteRenderRecovery' + +describe('blockNoteRenderRecovery', () => { + it('marks only recovered BlockNote missing-id render errors for root suppression', () => { + const error = new Error("Block doesn't have id") + + expect(isRecoverableBlockNoteRenderError(error)).toBe(true) + expect(isRecoveredBlockNoteRenderError(error, '')).toBe(false) + + markRecoveredBlockNoteRenderError(error) + + expect(isRecoveredBlockNoteRenderError(error, '')).toBe(true) + expect(isRecoveredBlockNoteRenderError(new Error('Other render failure'), '')).toBe(false) + }) + + it('recognizes recovered BlockNote errors from the React component stack fallback', () => { + expect(isRecoveredBlockNoteRenderError( + new Error("Block doesn't have id"), + '\n in MermaidBlock\n in BlockNoteRenderRecoveryBoundary', + )).toBe(true) + }) +}) diff --git a/src/components/blockNoteRenderRecovery.ts b/src/components/blockNoteRenderRecovery.ts new file mode 100644 index 00000000..e902ec26 --- /dev/null +++ b/src/components/blockNoteRenderRecovery.ts @@ -0,0 +1,33 @@ +const BLOCKNOTE_MISSING_ID_ERROR = "Block doesn't have id" +const BLOCKNOTE_RECOVERY_BOUNDARY_NAME = 'BlockNoteRenderRecoveryBoundary' +const RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK = '__tolariaRecoveredBlockNoteRenderError' + +type MarkedRecoveredBlockNoteRenderError = Error & { + [RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK]?: true +} + +function hasRecoveredRenderErrorMark(error: unknown): boolean { + if (!(error instanceof Error)) return false + return (error as MarkedRecoveredBlockNoteRenderError)[RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK] === true +} + +export function isRecoverableBlockNoteRenderError(error: unknown): boolean { + return error instanceof Error && error.message.includes(BLOCKNOTE_MISSING_ID_ERROR) +} + +export function markRecoveredBlockNoteRenderError(error: unknown): void { + if (!isRecoverableBlockNoteRenderError(error)) return + const markedError = error as MarkedRecoveredBlockNoteRenderError + markedError[RECOVERED_BLOCKNOTE_RENDER_ERROR_MARK] = true +} + +export function isRecoveredBlockNoteRenderError( + error: unknown, + componentStack: string, +): boolean { + return isRecoverableBlockNoteRenderError(error) + && ( + hasRecoveredRenderErrorMark(error) + || componentStack.includes(BLOCKNOTE_RECOVERY_BOUNDARY_NAME) + ) +} diff --git a/src/main.test.ts b/src/main.test.ts index abb49773..17095dfb 100644 --- a/src/main.test.ts +++ b/src/main.test.ts @@ -136,6 +136,20 @@ describe('main entrypoint', () => { expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack: '' }) }) + it('suppresses recovered BlockNote missing-id render errors from Sentry', async () => { + await importEntrypoint() + + const error = new Error("Block doesn't have id") + const componentStack = '\n in MermaidBlock\n in BlockNoteRenderRecoveryBoundary' + window.__tolariaFrontendReady = true + + rootOptions().onCaughtError?.(error, { componentStack }) + expect(mocks.sentryHandler).not.toHaveBeenCalled() + + rootOptions().onUncaughtError?.(error, { componentStack }) + expect(mocks.sentryHandler).toHaveBeenCalledWith(error, { componentStack }) + }) + it('mounts a frontend readiness marker after the app shell', async () => { await importEntrypoint() diff --git a/src/main.tsx b/src/main.tsx index 8d9a291b..311e852d 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -17,6 +17,7 @@ import { type AppCommandShortcutEventInit, type AppCommandShortcutEventOptions, } from './hooks/appCommandCatalog' +import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery' import { shouldUseLinuxWindowChrome } from './utils/platform' import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady' @@ -114,14 +115,25 @@ function captureReactRootError( error: unknown, errorInfo: { componentStack?: string }, ): void { - sentryReactErrorHandler(error, { componentStack: errorInfo.componentStack ?? '' }) + const componentStack = errorInfo.componentStack ?? '' + sentryReactErrorHandler(error, { componentStack }) reloadFrontendOnceIfStartupFailed() } +function captureRecoverableReactRootError( + error: unknown, + errorInfo: { componentStack?: string }, +): void { + const componentStack = errorInfo.componentStack ?? '' + if (isRecoveredBlockNoteRenderError(error, componentStack)) return + + captureReactRootError(error, { componentStack }) +} + createRoot(document.getElementById('root')!, { - onCaughtError: captureReactRootError, + onCaughtError: captureRecoverableReactRootError, onUncaughtError: captureReactRootError, - onRecoverableError: captureReactRootError, + onRecoverableError: captureRecoverableReactRootError, }).render(