fix(editor): guard stale whiteboard block writes
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
88
src/components/tldrawBlockProps.test.ts
Normal file
88
src/components/tldrawBlockProps.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
81
src/components/tldrawBlockProps.ts
Normal file
81
src/components/tldrawBlockProps.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user