From 553ed4de07bd928b4242d8a9b92830196db02394 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 13 May 2026 07:21:37 +0200 Subject: [PATCH] fix: recover stale rich-editor transforms --- src/components/Editor.tsx | 2 + src/components/mathInputExtension.test.ts | 38 ++++- src/components/mathInputExtension.ts | 40 ++++- ...torTransformErrorRecoveryExtension.test.ts | 124 +++++++++++++++ ...chEditorTransformErrorRecoveryExtension.ts | 146 ++++++++++++++++++ tests/smoke/rich-editor-reload-typing.spec.ts | 80 ++++++++++ 6 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/components/richEditorTransformErrorRecoveryExtension.test.ts create mode 100644 src/components/richEditorTransformErrorRecoveryExtension.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index a9d635fb..e20648b4 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -34,6 +34,7 @@ import { useRawModeWithFlush } from './useRawModeWithFlush' import { createArrowLigaturesExtension } from './arrowLigaturesExtension' import { createImeCompositionKeyGuardExtension } from './imeCompositionKeyGuardExtension' import { createMathInputExtension } from './mathInputExtension' +import { createRichEditorTransformErrorRecoveryExtension } from './richEditorTransformErrorRecoveryExtension' import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard' import './Editor.css' import './EditorTheme.css' @@ -208,6 +209,7 @@ function useEditorSetup({ uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current), _tiptapOptions: { injectNonce: RUNTIME_STYLE_NONCE }, extensions: [ + createRichEditorTransformErrorRecoveryExtension(), createImeCompositionKeyGuardExtension(), createArrowLigaturesExtension(), createMathInputExtension(), diff --git a/src/components/mathInputExtension.test.ts b/src/components/mathInputExtension.test.ts index a928496a..c4967c6b 100644 --- a/src/components/mathInputExtension.test.ts +++ b/src/components/mathInputExtension.test.ts @@ -1,5 +1,16 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' import { createMathInputExtension } from './mathInputExtension' +import { trackEvent } from '../lib/telemetry' + +vi.mock('../lib/telemetry', () => ({ + trackEvent: vi.fn(), +})) + +function transformError(message = 'Invalid math transform') { + const error = new Error(message) + error.name = 'TransformError' + return error +} function createTransaction() { const transaction = { @@ -98,6 +109,15 @@ function createFixture(beforeText = 'Inline $x^2$') { } } +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + describe('createMathInputExtension', () => { it('registers a beforeinput listener when the editor mounts', () => { const fixture = createFixture() @@ -162,4 +182,20 @@ describe('createMathInputExtension', () => { expect(fixture.view.dispatch).not.toHaveBeenCalled() expect(event.preventDefault).not.toHaveBeenCalled() }) + + it('falls back to native input when an inline math transform is stale', () => { + const fixture = createFixture() + fixture.transaction.replaceWith.mockImplementation(() => { + throw transformError() + }) + fixture.mount() + + const event = fixture.fireInput() + + expect(fixture.view.dispatch).not.toHaveBeenCalled() + expect(event.preventDefault).not.toHaveBeenCalled() + expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', { + reason: 'transform_error', + }) + }) }) diff --git a/src/components/mathInputExtension.ts b/src/components/mathInputExtension.ts index e5364f30..304dc392 100644 --- a/src/components/mathInputExtension.ts +++ b/src/components/mathInputExtension.ts @@ -1,6 +1,10 @@ import { createExtension } from '@blocknote/core' import type { useCreateBlockNote } from '@blocknote/react' import { MATH_INLINE_TYPE, readCompletedInlineMathAtEnd } from '../utils/mathMarkdown' +import { + isRecoverableEditorTransformError, + reportRecoveredEditorTransformError, +} from './richEditorTransformErrorRecoveryExtension' const INLINE_WHITESPACE_RE = /^[^\S\r\n]$/ const NEWLINE_INPUT_TYPES = new Set(['insertParagraph', 'insertLineBreak']) @@ -83,6 +87,38 @@ function replaceCompletedInlineMath( return transaction.scrollIntoView() } +function recoverTransformError(error: unknown): boolean { + if (!isRecoverableEditorTransformError(error)) return false + + reportRecoveredEditorTransformError('transform_error', error) + return true +} + +function readMathInputTransaction( + view: EditorViewLike, + trailingText?: string, +): EditorViewLike['state']['tr'] | null { + try { + return replaceCompletedInlineMath(view, trailingText) + } catch (error) { + if (!recoverTransformError(error)) throw error + return null + } +} + +function dispatchMathInputTransaction( + view: EditorViewLike, + transaction: EditorViewLike['state']['tr'], +): boolean { + try { + view.dispatch(transaction) + return true + } catch (error) { + if (!recoverTransformError(error)) throw error + return false + } +} + export const createMathInputExtension = createExtension(({ editor }) => { const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView @@ -94,10 +130,10 @@ export const createMathInputExtension = createExtension(({ editor }) => { if (!view || shouldSkipInput(event, view)) return const trailingText = isInsertedInlineWhitespace(event) ? event.data : undefined - const transaction = replaceCompletedInlineMath(view, trailingText) + const transaction = readMathInputTransaction(view, trailingText) if (!transaction) return - view.dispatch(transaction) + if (!dispatchMathInputTransaction(view, transaction)) return if (trailingText !== undefined) { event.preventDefault() } diff --git a/src/components/richEditorTransformErrorRecoveryExtension.test.ts b/src/components/richEditorTransformErrorRecoveryExtension.test.ts new file mode 100644 index 00000000..ea622414 --- /dev/null +++ b/src/components/richEditorTransformErrorRecoveryExtension.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi, afterEach, beforeEach } from 'vitest' +import { + createRichEditorTransformErrorRecoveryExtension, + installRichEditorTransformErrorRecovery, + isRecoverableEditorTransformError, +} from './richEditorTransformErrorRecoveryExtension' +import { trackEvent } from '../lib/telemetry' + +vi.mock('../lib/telemetry', () => ({ + trackEvent: vi.fn(), +})) + +function transformError(message = 'Invalid transform') { + const error = new Error(message) + error.name = 'TransformError' + return error +} + +function createView(error?: Error) { + const currentDoc = { + eq: vi.fn((candidate: unknown) => candidate === currentDoc), + } + const dispatch = vi.fn(() => { + if (error) throw error + return 'dispatched' + }) + const view = { + dispatch, + state: { doc: currentDoc }, + } + + return { currentDoc, dispatch, view } +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +describe('isRecoverableEditorTransformError', () => { + it('recognizes ProseMirror transform and mismatched transaction failures', () => { + expect(isRecoverableEditorTransformError(transformError())).toBe(true) + expect(isRecoverableEditorTransformError(new RangeError('Applying a mismatched transaction'))).toBe(true) + expect(isRecoverableEditorTransformError(new Error('unrelated'))).toBe(false) + }) +}) + +describe('installRichEditorTransformErrorRecovery', () => { + it('recovers stale transform errors without rethrowing from editor dispatch', () => { + const { dispatch, view } = createView(transformError()) + const previousDoc = { stale: true } + + installRichEditorTransformErrorRecovery(view) + + expect(() => view.dispatch({ before: previousDoc })).not.toThrow() + expect(dispatch).toHaveBeenCalledWith({ before: previousDoc }) + expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', { + reason: 'stale_transaction', + }) + }) + + it('recovers ProseMirror mismatched transactions from active key handling', () => { + const { currentDoc, view } = createView(new RangeError('Applying a mismatched transaction')) + + installRichEditorTransformErrorRecovery(view) + + expect(() => view.dispatch({ before: currentDoc })).not.toThrow() + expect(trackEvent).toHaveBeenCalledWith('rich_editor_transform_error_recovered', { + reason: 'mismatched_transaction', + }) + }) + + it('keeps non-ProseMirror dispatch failures visible', () => { + const { view } = createView(new Error('plugin failed')) + + installRichEditorTransformErrorRecovery(view) + + expect(() => view.dispatch({})).toThrow('plugin failed') + expect(trackEvent).not.toHaveBeenCalled() + }) + + it('restores the original dispatch after all installs are cleaned up', () => { + const { dispatch, view } = createView() + + const firstUninstall = installRichEditorTransformErrorRecovery(view) + const secondUninstall = installRichEditorTransformErrorRecovery(view) + const wrappedDispatch = view.dispatch + + expect(wrappedDispatch).not.toBe(dispatch) + + firstUninstall() + expect(view.dispatch).toBe(wrappedDispatch) + + secondUninstall() + expect(view.dispatch).toBe(dispatch) + }) +}) + +describe('createRichEditorTransformErrorRecoveryExtension', () => { + it('installs and removes dispatch recovery with the BlockNote mount signal', () => { + const { dispatch, view } = createView() + const editor = { + _tiptapEditor: { view }, + prosemirrorView: view, + } + const extension = createRichEditorTransformErrorRecoveryExtension()({ editor: editor as never }) + const controller = new AbortController() + + extension.mount?.({ + dom: document.createElement('div'), + root: document, + signal: controller.signal, + }) + expect(view.dispatch).not.toBe(dispatch) + + controller.abort() + + expect(view.dispatch).toBe(dispatch) + }) +}) diff --git a/src/components/richEditorTransformErrorRecoveryExtension.ts b/src/components/richEditorTransformErrorRecoveryExtension.ts new file mode 100644 index 00000000..e1315df7 --- /dev/null +++ b/src/components/richEditorTransformErrorRecoveryExtension.ts @@ -0,0 +1,146 @@ +import { createExtension } from '@blocknote/core' +import { trackEvent } from '../lib/telemetry' + +const DISPATCH_RECOVERY_STATE_KEY = '__tolariaRichEditorTransformErrorRecovery' + +type RichEditorDispatch = (transaction: unknown) => unknown + +interface RichEditorDispatchView { + dispatch: RichEditorDispatch + state?: { + doc?: { + eq?: (other: unknown) => boolean + } + } +} + +interface DispatchRecoveryState { + originalDispatch: RichEditorDispatch + refCount: number +} + +type RecoveryReason = 'mismatched_transaction' | 'stale_transaction' | 'transform_error' + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isDispatchRecoveryState(value: unknown): value is DispatchRecoveryState { + return isRecord(value) + && typeof value.originalDispatch === 'function' + && typeof value.refCount === 'number' +} + +function transactionBefore(transaction: unknown): unknown { + return isRecord(transaction) ? transaction.before : undefined +} + +function transactionDocIsStale(transaction: unknown, view: RichEditorDispatchView): boolean { + const before = transactionBefore(transaction) + const currentDoc = view.state?.doc + if (!before || !currentDoc || typeof currentDoc.eq !== 'function') return false + + return !currentDoc.eq(before) +} + +function isMismatchedTransactionError(error: unknown): boolean { + return error instanceof Error && error.message.includes('Applying a mismatched transaction') +} + +export function isRecoverableEditorTransformError(error: unknown): boolean { + return error instanceof Error && ( + error.name === 'TransformError' + || isMismatchedTransactionError(error) + ) +} + +function recoveryReason( + error: unknown, + transaction: unknown, + view: RichEditorDispatchView, +): RecoveryReason { + if (transactionDocIsStale(transaction, view)) return 'stale_transaction' + if (isMismatchedTransactionError(error)) return 'mismatched_transaction' + return 'transform_error' +} + +export function reportRecoveredEditorTransformError(reason: RecoveryReason, error: unknown): void { + console.warn('[editor] Recovered rich-editor transform error:', error) + trackEvent('rich_editor_transform_error_recovered', { reason }) +} + +function releaseRecoveryState( + view: RichEditorDispatchView, + recoveryState: DispatchRecoveryState, + originalDispatch: RichEditorDispatch, +): void { + const state = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY) + if (!isDispatchRecoveryState(state) || state.originalDispatch !== originalDispatch) return + + state.refCount -= 1 + if (state.refCount > 0) return + + view.dispatch = recoveryState.originalDispatch + Reflect.deleteProperty(view, DISPATCH_RECOVERY_STATE_KEY) +} + +function retainRecoveryState( + view: RichEditorDispatchView, + recoveryState: DispatchRecoveryState, +): () => void { + recoveryState.refCount += 1 + return () => releaseRecoveryState(view, recoveryState, recoveryState.originalDispatch) +} + +function createRecoveringDispatch( + view: RichEditorDispatchView, + originalDispatch: RichEditorDispatch, +): RichEditorDispatch { + return (transaction: unknown) => { + try { + return originalDispatch.call(view, transaction) + } catch (error) { + if (!isRecoverableEditorTransformError(error)) throw error + + reportRecoveredEditorTransformError(recoveryReason(error, transaction, view), error) + return undefined + } + } +} + +function installRecoveryState( + view: RichEditorDispatchView, + originalDispatch: RichEditorDispatch, +): DispatchRecoveryState { + const recoveryState: DispatchRecoveryState = { + originalDispatch, + refCount: 1, + } + + view.dispatch = createRecoveringDispatch(view, originalDispatch) + Reflect.set(view, DISPATCH_RECOVERY_STATE_KEY, recoveryState) + return recoveryState +} + +export function installRichEditorTransformErrorRecovery(view: RichEditorDispatchView): () => void { + const currentState = Reflect.get(view, DISPATCH_RECOVERY_STATE_KEY) + if (isDispatchRecoveryState(currentState)) { + return retainRecoveryState(view, currentState) + } + + const originalDispatch = view.dispatch + const recoveryState = installRecoveryState(view, originalDispatch) + + return () => releaseRecoveryState(view, recoveryState, originalDispatch) +} + +export const createRichEditorTransformErrorRecoveryExtension = createExtension(({ editor }) => ({ + key: 'richEditorTransformErrorRecovery', + mount: ({ signal }) => { + const view = editor._tiptapEditor?.view ?? editor.prosemirrorView + if (!view || typeof view.dispatch !== 'function') return + + const uninstall = installRichEditorTransformErrorRecovery(view) + signal.addEventListener('abort', uninstall, { once: true }) + }, +} as const)) diff --git a/tests/smoke/rich-editor-reload-typing.spec.ts b/tests/smoke/rich-editor-reload-typing.spec.ts index b437ec0c..721025b7 100644 --- a/tests/smoke/rich-editor-reload-typing.spec.ts +++ b/tests/smoke/rich-editor-reload-typing.spec.ts @@ -19,6 +19,13 @@ type TextBlockTarget = { text: string } type NoteTitleTarget = { title: string } type NotePathTarget = { notePath: string } type MediaBlockquoteFile = { filePath: string } +type MockHandler = (args?: Record) => unknown +type SaveProbe = Array<{ content: string; path: string }> +type SaveCountExpectation = { expectedCount: number; page: Page } +type RichEditorSaveProbeWindow = Window & typeof globalThis & { + __mockHandlers?: Record + __richEditorTransformSaveProbe?: SaveProbe +} function isEditorTypingCrash(message: string): boolean { return ( @@ -94,6 +101,52 @@ async function expectNoteFileToContain(filePath: string, marker: string): Promis await expect.poll(() => fs.readFileSync(filePath, 'utf8'), { timeout: 10_000 }).toContain(marker) } +async function installRichEditorSaveProbe(page: Page): Promise { + await page.evaluate(() => { + const probeWindow = window as RichEditorSaveProbeWindow + const saves: SaveProbe = [] + const patchHandlers = (handlers?: Record | null) => { + if (!handlers || Reflect.get(handlers, '__richEditorTransformProbePatched') === true) { + return handlers ?? null + } + + const originalSaveNoteContent = handlers.save_note_content + handlers.save_note_content = (args?: Record) => { + saves.push({ + content: typeof args?.content === 'string' ? args.content : '', + path: typeof args?.path === 'string' ? args.path : '', + }) + return originalSaveNoteContent?.(args) + } + Object.defineProperty(handlers, '__richEditorTransformProbePatched', { + configurable: true, + enumerable: false, + value: true, + }) + return handlers + } + + let ref = patchHandlers(probeWindow.__mockHandlers) ?? null + probeWindow.__richEditorTransformSaveProbe = saves + Object.defineProperty(probeWindow, '__mockHandlers', { + configurable: true, + get() { + return patchHandlers(ref) ?? ref + }, + set(value) { + ref = patchHandlers(value) ?? null + }, + }) + }) +} + +async function expectSaveCount({ expectedCount, page }: SaveCountExpectation): Promise { + await expect.poll(() => page.evaluate(() => { + const probeWindow = window as RichEditorSaveProbeWindow + return probeWindow.__richEditorTransformSaveProbe?.length ?? 0 + }), { timeout: 10_000 }).toBeGreaterThanOrEqual(expectedCount) +} + function writeChecklistNote(filePath: string, marker: string, checked = false): void { fs.writeFileSync(filePath, `--- Is A: Note @@ -243,6 +296,33 @@ test('@smoke typing after a rich-editor reload and note switch stays usable', as expect(crashes).toEqual([]) }) +test('@smoke rich-editor typing stays usable through repeated saves and transforms', async ({ page }) => { + const crashes = trackEditorTypingCrashes(page) + const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md') + const firstMarker = `repeated save transform first ${Date.now()}` + const secondMarker = `repeated save transform second ${Date.now()}` + const finalMarker = `repeated save transform final ${Date.now()}` + + await installRichEditorSaveProbe(page) + await openNote(page, 'Note B') + await placeCaretAtEndOfBlock(page, 1) + + await page.keyboard.type(` ${firstMarker} $x^2$`, { delay: 10 }) + await triggerMenuCommand(page, 'file-save') + await expectSaveCount({ expectedCount: 1, page }) + + await page.keyboard.type(` ${secondMarker} ->`, { delay: 10 }) + await triggerMenuCommand(page, 'file-save') + await expectSaveCount({ expectedCount: 2, page }) + + await page.keyboard.type(` ${finalMarker}`, { delay: 10 }) + await triggerMenuCommand(page, 'file-save') + await expectSaveCount({ expectedCount: 3, page }) + + await expectNoteFileToContain(noteBPath, finalMarker) + expect(crashes).toEqual([]) +}) + test('typing after current-note filesystem refresh stays usable', async ({ page }) => { const crashes = trackEditorTypingCrashes(page) const noteBPath = path.join(tempVaultDir, 'note', 'note-b.md')