fix(editor): guard stale whiteboard block writes

This commit is contained in:
lucaronin
2026-05-05 19:54:09 +02:00
parent 634975e505
commit 7a274d3a83
4 changed files with 183 additions and 13 deletions

View File

@@ -569,6 +569,7 @@ Defined in `src/utils/durableMarkdownBlocks.ts`, `src/utils/editorDurableMarkdow
- Fenced `tldraw` blocks become `tldrawBlock` schema nodes before BlockNote sees the Markdown body.
- Each `tldrawBlock` stores a stable `boardId` plus the tldraw document snapshot JSON. Session state such as camera, selected tool, and current selection is not persisted into the note.
- The rich editor renders the block with the `tldraw` package and saves debounced document snapshot changes back into the block props, so normal Tolaria autosave writes the board into the `.md` file.
- Whiteboard prop writes re-resolve the live BlockNote block by id before mutating it, and disappear as no-ops if a note reload or mode switch has already removed that block.
- Mermaid and tldraw both register small codecs with the shared durable fenced-block pipeline; scanner, token, block injection, and mixed serialization mechanics live in one owner.
- The `/whiteboard` slash command inserts an empty tldraw block using the same Markdown-durable storage path. Preview images are intentionally omitted; thumbnails can be added later as derived cache artifacts.

View File

@@ -16,6 +16,7 @@ import { createTolariaCodeBlockOptions } from './codeBlockOptions'
import { NoteTitleIcon } from './NoteTitleIcon'
import { MermaidDiagram } from './MermaidDiagram'
import { SafeHtmlSpan } from './SafeMarkup'
import { updateTldrawBlockPropsSafely } from './tldrawBlockProps'
const TldrawWhiteboard = lazy(() => import('./TldrawWhiteboard').then(module => ({
default: module.TldrawWhiteboard,
@@ -186,25 +187,24 @@ const TldrawBlock = createReactBlockSpec(
snapshot={props.block.props.snapshot}
width={props.block.props.width}
onSnapshotChange={(snapshot) => {
props.editor.updateBlock(props.block, {
type: TLDRAW_BLOCK_TYPE,
props: {
boardId: props.block.props.boardId,
height: props.block.props.height,
updateTldrawBlockPropsSafely({
blockId: props.block.id,
editor: props.editor,
nextProps: (currentProps) => ({
...currentProps,
snapshot,
width: props.block.props.width,
},
}),
})
}}
onSizeChange={(size) => {
props.editor.updateBlock(props.block, {
type: TLDRAW_BLOCK_TYPE,
props: {
boardId: props.block.props.boardId,
updateTldrawBlockPropsSafely({
blockId: props.block.id,
editor: props.editor,
nextProps: (currentProps) => ({
...currentProps,
height: size.height,
snapshot: props.block.props.snapshot,
width: size.width,
},
}),
})
}}
/>

View File

@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import { TLDRAW_BLOCK_TYPE } from '../utils/tldrawMarkdown'
import {
updateTldrawBlockPropsSafely,
type TldrawBlockMutationEditor,
} from './tldrawBlockProps'
function tldrawBlock(props: {
boardId?: string
height?: string
id?: string
snapshot?: string
width?: string
}) {
return {
id: props.id ?? 'whiteboard-block',
props: {
boardId: props.boardId ?? 'planning-map',
height: props.height ?? '520',
snapshot: props.snapshot ?? '{}',
width: props.width ?? '',
},
type: TLDRAW_BLOCK_TYPE,
}
}
describe('tldraw block prop updates', () => {
it('ignores whiteboard callbacks after the owning BlockNote block disappears', () => {
const editor: TldrawBlockMutationEditor = {
getBlock: vi.fn(() => undefined),
updateBlock: vi.fn(),
}
expect(updateTldrawBlockPropsSafely({
blockId: 'whiteboard-block',
editor,
nextProps: (props) => ({ ...props, snapshot: '{ "store": {} }' }),
})).toBe(false)
expect(editor.updateBlock).not.toHaveBeenCalled()
})
it('resolves live whiteboard props before writing a debounced snapshot', () => {
const editor: TldrawBlockMutationEditor = {
getBlock: vi.fn(() => tldrawBlock({
boardId: 'fresh-board',
height: '640',
snapshot: '{ "store": "old" }',
width: '900',
})),
updateBlock: vi.fn(),
}
expect(updateTldrawBlockPropsSafely({
blockId: 'whiteboard-block',
editor,
nextProps: (props) => ({ ...props, snapshot: '{ "store": "next" }' }),
})).toBe(true)
expect(editor.updateBlock).toHaveBeenCalledWith('whiteboard-block', {
props: {
boardId: 'fresh-board',
height: '640',
snapshot: '{ "store": "next" }',
width: '900',
},
type: TLDRAW_BLOCK_TYPE,
})
})
it('turns a final missing-block race into a no-op', () => {
const missingBlockError = new Error('Block with ID whiteboard-block not found')
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
const editor: TldrawBlockMutationEditor = {
getBlock: vi.fn(() => tldrawBlock({})),
updateBlock: vi.fn(() => {
throw missingBlockError
}),
}
expect(updateTldrawBlockPropsSafely({
blockId: 'whiteboard-block',
editor,
nextProps: (props) => ({ ...props, height: '720' }),
})).toBe(false)
expect(warn).toHaveBeenCalledWith('[editor] Ignored stale whiteboard block update:', missingBlockError)
warn.mockRestore()
})
})

View File

@@ -0,0 +1,81 @@
import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown'
export interface TldrawBlockProps {
boardId: string
height: string
snapshot: string
width: string
}
export interface TldrawBlockMutationEditor {
getBlock: (blockId: string) => unknown
updateBlock: (blockId: string, update: TldrawBlockUpdate) => unknown
}
interface TldrawBlockUpdate {
props: TldrawBlockProps
type: typeof TLDRAW_BLOCK_TYPE
}
interface LiveTldrawBlock {
id: string
props: TldrawBlockProps
}
interface TldrawBlockMutation {
blockId: string
editor: TldrawBlockMutationEditor
nextProps: (props: TldrawBlockProps) => TldrawBlockProps
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function stringProp(value: unknown, fallback: string) {
return typeof value === 'string' ? value : fallback
}
function tldrawBlockProps(value: unknown): TldrawBlockProps | null {
if (!isRecord(value)) return null
if (typeof value.boardId !== 'string' || typeof value.snapshot !== 'string') return null
return {
boardId: value.boardId,
height: stringProp(value.height, TLDRAW_DEFAULT_HEIGHT),
snapshot: value.snapshot,
width: stringProp(value.width, ''),
}
}
function liveTldrawBlock(value: unknown): LiveTldrawBlock | null {
if (!isRecord(value)) return null
if (value.type !== TLDRAW_BLOCK_TYPE || typeof value.id !== 'string') return null
const props = tldrawBlockProps(value.props)
return props ? { id: value.id, props } : null
}
function isMissingBlockError(error: unknown) {
return error instanceof Error
&& error.message.includes('Block with ID')
&& error.message.includes('not found')
}
export function updateTldrawBlockPropsSafely({ blockId, editor, nextProps }: TldrawBlockMutation) {
const liveBlock = liveTldrawBlock(editor.getBlock(blockId))
if (!liveBlock) return false
try {
editor.updateBlock(liveBlock.id, {
props: nextProps(liveBlock.props),
type: TLDRAW_BLOCK_TYPE,
})
return true
} catch (error) {
if (!isMissingBlockError(error)) throw error
console.warn('[editor] Ignored stale whiteboard block update:', error)
return false
}
}