From f14e2b5dcb7880c818b24e09a1e00a618faacbb1 Mon Sep 17 00:00:00 2001 From: Anish Lakhwara Date: Mon, 10 Aug 2026 13:39:18 -0700 Subject: [PATCH] fix(editor): optimize low-end typing performance --- package.json | 1 + src/editor/CM6Editor.tsx | 95 ++- src/editor/FormattingToolbar.tsx | 27 +- ...-editor-remote-change-suppression.test.tsx | 10 +- .../__tests__/list-bullet-widget.test.ts | 373 +++++++++++- src/editor/extensions/callout-decoration.ts | 9 +- src/editor/extensions/checkbox-widget.ts | 12 +- .../extensions/code-fence-decoration.ts | 7 +- .../extensions/code-marker-decoration.ts | 7 +- .../extensions/fenced-code-block-widget.ts | 7 +- src/editor/extensions/footnote-decoration.ts | 13 +- src/editor/extensions/frontmatter-widget.ts | 7 +- src/editor/extensions/heading-decoration.ts | 9 +- src/editor/extensions/hr-decoration.ts | 7 +- src/editor/extensions/image-embed-widget.ts | 10 +- src/editor/extensions/image-gallery-widget.ts | 10 +- .../extensions/inline-format-decoration.ts | 9 +- src/editor/extensions/list-bullet-widget.ts | 565 ++++++++++++++---- .../extensions/obsidian-syntax-decoration.ts | 7 +- src/editor/extensions/table-grid.ts | 7 +- src/editor/extensions/tag-decoration.ts | 7 +- src/editor/extensions/wikilink-widget.ts | 11 +- src/lib/perf-profile.ts | 121 ++++ .../background-indexer-coverage.test.ts | 5 + ...ent-sync-derived-index.integration.test.ts | 14 +- .../markdown-reconciliation-worker.test.ts | 134 +++++ src/services/background-indexer.ts | 46 +- src/services/document-manager.ts | 132 +++- .../markdown-reconciliation-worker-client.ts | 74 +++ .../markdown-reconciliation-worker-core.ts | 32 + ...markdown-reconciliation-worker-protocol.ts | 42 ++ .../markdown-reconciliation.worker.ts | 39 ++ ...document-sync-changeat.integration.test.ts | 6 +- .../document-sync-controller.test.ts | 247 ++++++-- .../reactive-attach.integration.test.ts | 2 +- src/sync/__tests__/use-document-sync.test.ts | 20 +- src/sync/document-sync-controller.ts | 142 ++++- src/sync/use-document-sync.ts | 5 +- .../invariants/reams-invariant-diagnostics.ts | 6 + .../__tests__/reams-perf-diagnostics.test.ts | 36 ++ src/testing/perf/reams-perf-diagnostics.ts | 57 +- .../fixtures/chainlink-546-core-allowlist.ts | 5 +- tests/invariants/bombadil/invariants.test.ts | 59 ++ tests/invariants/bombadil/invariants.ts | 44 ++ .../invariants/bombadil/structural-actions.ts | 89 +++ tests/invariants/bombadil/structural.spec.ts | 25 +- tests/perf/instrumentation.perf.test.ts | 124 ++++ ...ted-character-browser-low-end.perf.test.ts | 132 ++++ .../repeated-character-browser.perf.test.ts | 432 +++++++++++++ tests/perf/support/artifacts.ts | 70 +++ tests/perf/support/fixtures.ts | 67 +++ tests/perf/support/perf-runner.ts | 7 +- vite.config.ts | 4 + 53 files changed, 3121 insertions(+), 307 deletions(-) create mode 100644 src/services/__tests__/markdown-reconciliation-worker.test.ts create mode 100644 src/services/markdown-reconciliation-worker-client.ts create mode 100644 src/services/markdown-reconciliation-worker-core.ts create mode 100644 src/services/markdown-reconciliation-worker-protocol.ts create mode 100644 src/services/markdown-reconciliation.worker.ts create mode 100644 tests/perf/instrumentation.perf.test.ts create mode 100644 tests/perf/repeated-character-browser-low-end.perf.test.ts create mode 100644 tests/perf/repeated-character-browser.perf.test.ts diff --git a/package.json b/package.json index a98911f8..2e8653af 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:type-tag-parser:long": "REAMS_TYPE_TAG_PARSER_LONG=1 REAMS_TEST_SEED=516 vitest run --config vitest.config.property.ts src/types-records/__tests__/type-tag-parser.property.test.ts", "test:invariants": "node scripts/require-browser-test-env.mjs && vitest run --config vitest.config.invariants.ts --maxWorkers=1 src/testing/invariants tests/invariants", "test:perf": "node scripts/require-perf-test-env.mjs && vitest run --config vitest.config.perf.ts --maxWorkers=1", + "test:perf:low-end": "RUN_LOW_END_PERF_TESTS=1 node scripts/require-perf-test-env.mjs && RUN_LOW_END_PERF_TESTS=1 vitest run --config vitest.config.perf.ts --maxWorkers=1 tests/perf/repeated-character-browser-low-end.perf.test.ts", "ratchet:chainlink19": "node scripts/chainlink19-ratchet.mjs", "ratchet:document-boundary": "node scripts/document-boundary-ratchet.mjs", "test:bombadil": "node scripts/require-browser-test-env.mjs && vitest run --config vitest.config.invariants.ts --maxWorkers=1 tests/invariants/bombadil/reams.bombadil.test.ts tests/invariants/plugins/dataview-live-preview-geometry.test.ts", diff --git a/src/editor/CM6Editor.tsx b/src/editor/CM6Editor.tsx index 6497983c..e70026a9 100644 --- a/src/editor/CM6Editor.tsx +++ b/src/editor/CM6Editor.tsx @@ -6,7 +6,7 @@ * with minimal changes to the SyncedEditor integration. */ import { useRef, useEffect, useLayoutEffect, useState, useSyncExternalStore, useCallback } from 'react' -import { EditorState, Compartment, Prec, type Extension } from '@codemirror/state' +import { EditorState, Compartment, Prec, type Extension, type TransactionSpec } from '@codemirror/state' import { EditorView, drawSelection, keymap, placeholder } from '@codemirror/view' import { markdown } from '@codemirror/lang-markdown' import { GFM } from '@lezer/markdown' @@ -43,6 +43,7 @@ import { markdownLinkClickHandler } from './extensions/markdown-link-click-handl import { editorSuggestDispatcher } from './extensions/editor-suggest-dispatcher' import { domSelectionLayer } from './extensions/dom-selection-layer' import { safeVerticalMotion } from './extensions/safe-vertical-motion' +import { beginPerfEdit, recordPerfSample, isProfilingEnabled } from '../lib/perf-profile' import { tableKeybindings } from './extensions/table-keybindings' import { workspaceEventBridge } from './extensions/workspace-event-bridge' import './extensions/editor-suggest-dispatcher.css' @@ -90,9 +91,44 @@ import './cm6-editor.css' declare global { interface Window { __cm6_editor_view__?: EditorView + __cm6_editor_ownership__?: EditorBindingOwnership } } +function insertedLength(value: unknown): number { + if (typeof value === 'string') return value.length + if (value && typeof value === 'object' && 'length' in value && typeof value.length === 'number') { + return value.length + } + return 0 +} + +function changeSpecChangesDocument(changes: unknown): boolean { + if (changes === undefined || changes === null) return false + if (Array.isArray(changes)) return changes.some(changeSpecChangesDocument) + if (typeof changes !== 'object') return false + if ('empty' in changes && typeof changes.empty === 'boolean') return !changes.empty + if ('from' in changes && typeof changes.from === 'number') { + const to = 'to' in changes && typeof changes.to === 'number' ? changes.to : changes.from + return to > changes.from || insertedLength('insert' in changes ? changes.insert : undefined) > 0 + } + return false +} + +/** Side-effect-free dispatch inspection used by the opt-in profiler. */ +export function isDocChangingDispatchInput(input: readonly unknown[]): boolean { + return input.some(spec => { + if (Array.isArray(spec)) { + return spec.some(transaction => Boolean( + transaction && typeof transaction === 'object' && 'docChanged' in transaction && transaction.docChanged, + )) + } + if (spec && typeof spec === 'object' && 'docChanged' in spec) return Boolean(spec.docChanged) + if (spec && typeof spec === 'object' && 'changes' in spec) return changeSpecChangesDocument(spec.changes) + return false + }) +} + function isExpectedSemanticSaveCancellation(error: unknown): boolean { return error instanceof Error && (error.name === 'AbortError' || error.name === 'DocumentSyncCancellationError') @@ -309,9 +345,9 @@ export function CM6Editor({ onChange: () => {}, }) - // Bumps on every CM6 selectionSet so the FormattingToolbar's - // cursor-position-dependent `isVisible(editor)` predicates re-evaluate. - const [selectionVersion, setSelectionVersion] = useState(0) + // Selection invalidation is consumed by the toolbar itself. Keeping it out + // of this component prevents the editor wrapper from rerendering per key. + const selectionUpdateRef = useRef<(() => void) | null>(null) // Mirror FormattingActionManager toolbar visibility onto a data attribute // so cm6-editor.css can add mobile bottom padding via a selector-only @@ -362,7 +398,13 @@ export function CM6Editor({ editorRef.current = adapter setEditorVersion((version) => version + 1) + let dispatchDocChanged = false + let dispatchLocalDocChanged = false const updateListener = EditorView.updateListener.of((update) => { + if (update.transactions.some(transaction => transaction.docChanged)) { + dispatchDocChanged = true + dispatchLocalDocChanged = !ownership.isIgnoringTransactions() + } if (update.docChanged) { if (!ownership.isIgnoringTransactions()) { const localDocChangingTransactions = update.transactions.filter(tr => tr.docChanged) @@ -388,9 +430,10 @@ export function CM6Editor({ if (update.focusChanged) { setIsFocused(update.view.hasFocus) } - // Bump selectionVersion so toolbar re-evaluates isVisible predicates. + // Re-evaluate cursor-position-dependent toolbar predicates without + // rerendering the CM6 editor wrapper. if (update.selectionSet) { - setSelectionVersion(v => v + 1) + selectionUpdateRef.current?.() } }) @@ -524,6 +567,38 @@ export function CM6Editor({ parent: containerRef.current, }) + // EditorView.dispatch is the narrowest boundary around CM6's complete + // transaction/update pipeline, including view-plugin and decoration work. + // Install this only for an opted-in diagnostics session so normal typing + // keeps the original method and allocation profile. + if (isProfilingEnabled()) { + const originalDispatch = view.dispatch.bind(view) + view.dispatch = (...specs) => { + dispatchDocChanged = false + dispatchLocalDocChanged = false + if (ownership.isIgnoringTransactions()) { + originalDispatch(...(specs as TransactionSpec[])) + return + } + const predictedDocChanged = isDocChangingDispatchInput(specs) + if (!predictedDocChanged) { + originalDispatch(...(specs as TransactionSpec[])) + return + } + const editId = beginPerfEdit() + const started = performance.now() + originalDispatch(...(specs as TransactionSpec[])) + const duration = performance.now() - started + if (dispatchDocChanged && dispatchLocalDocChanged) { + recordPerfSample('cm6.transaction', duration, { + editId, + label: 'EditorView.dispatch', + detail: { specCount: specs.length, docLength: view.state.doc.length }, + }) + } + } + } + viewRef.current = view adapter.setView(view) // The layout effects run before this mount effect, so their initial @@ -589,6 +664,7 @@ export function CM6Editor({ // assign without a cast. if (import.meta.env.DEV) { window.__cm6_editor_view__ = view + window.__cm6_editor_ownership__ = ownership } return () => { @@ -599,6 +675,7 @@ export function CM6Editor({ editorRef.current = null if (import.meta.env.DEV) { delete window.__cm6_editor_view__ + delete window.__cm6_editor_ownership__ } view.destroy() viewRef.current = null @@ -751,9 +828,9 @@ export function CM6Editor({ editorRef={editorRef} editorVersion={editorVersion} mode={mode} - isFocused={isFocused} - selectionVersion={selectionVersion} - /> + isFocused={isFocused} + selectionUpdateRef={selectionUpdateRef} + /> ) } diff --git a/src/editor/FormattingToolbar.tsx b/src/editor/FormattingToolbar.tsx index 10d23bc6..346afdf6 100644 --- a/src/editor/FormattingToolbar.tsx +++ b/src/editor/FormattingToolbar.tsx @@ -13,7 +13,7 @@ * - FormattingActionButton subcomponent with setIcon fallback * - data-visible, data-mode, data-show-desktop, data-empty attributes */ -import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import type { EditorMode } from '../layout/layout-state' import type { FormattingActionManager, OrderEntry } from '../plugin/ui/formatting-action-manager' import type { FormattingAction } from '../plugin/ui/formatting-action-types' @@ -42,18 +42,25 @@ interface FormattingToolbarProps { editorVersion: number mode: EditorMode isFocused: boolean - /** - * Bumps on every CM6 selectionSet so cursor-position-dependent - * `isVisible(editor)` predicates re-evaluate as the user moves the - * cursor. The toolbar's `useMemo` over the entries adds this to its - * dep array. - */ - selectionVersion: number + /** CM6 selection invalidations are routed here without rerendering its wrapper. */ + selectionUpdateRef?: React.MutableRefObject<(() => void) | null> + /** Test seam for forcing predicate evaluation without a CM6 view. */ + selectionVersion?: number } -export function FormattingToolbar({ manager, toolbarChrome, editorRef, editorVersion, mode, isFocused, selectionVersion }: FormattingToolbarProps) { +export function FormattingToolbar({ manager, toolbarChrome, editorRef, editorVersion, mode, isFocused, selectionUpdateRef, selectionVersion = 0 }: FormattingToolbarProps) { const toolbarRef = useRef(null) const scrollerRef = useRef(null) + const [selectionTick, setSelectionTick] = useState(0) + + useLayoutEffect(() => { + const updateSelection = () => setSelectionTick(version => version + 1) + if (!selectionUpdateRef) return + selectionUpdateRef.current = updateSelection + return () => { + if (selectionUpdateRef.current === updateSelection) selectionUpdateRef.current = null + } + }, [selectionUpdateRef]) // Subscribe to manager changes for action list const subscribe = useCallback((cb: () => void) => manager.subscribe(cb), [manager]) @@ -98,7 +105,7 @@ export function FormattingToolbar({ manager, toolbarChrome, editorRef, editorVer } return cleaned // eslint-disable-next-line react-hooks/exhaustive-deps - }, [entries, editorVersion, selectionVersion]) + }, [entries, editorVersion, selectionTick, selectionVersion]) useEffect(() => { const toolbar = toolbarRef.current diff --git a/src/editor/__tests__/cm6-editor-remote-change-suppression.test.tsx b/src/editor/__tests__/cm6-editor-remote-change-suppression.test.tsx index ccf49acd..e4b02226 100644 --- a/src/editor/__tests__/cm6-editor-remote-change-suppression.test.tsx +++ b/src/editor/__tests__/cm6-editor-remote-change-suppression.test.tsx @@ -5,7 +5,8 @@ import { describe, it, expect, vi } from 'vitest' import { render, act } from '@testing-library/react' import { EditorView } from '@codemirror/view' -import { CM6Editor } from '../CM6Editor' +import { CM6Editor, isDocChangingDispatchInput } from '../CM6Editor' +import { EditorState } from '@codemirror/state' import { createTestProps } from './CM6Editor.test-helpers' import type { CM6EditorAdapter } from '../../plugin/cm6-editor' import type { EditorBindingOwnership } from '../editor-binding-ownership' @@ -16,6 +17,13 @@ type EditorInstance = { } describe('CM6Editor transaction ownership', () => { + it('detects equal-length replacements without running EditorState.update', () => { + expect(isDocChangingDispatchInput([{ changes: { from: 1, to: 2, insert: 'z' } }])).toBe(true) + expect(isDocChangingDispatchInput([{ selection: { anchor: 1 } }])).toBe(false) + const transaction = EditorState.create({ doc: 'ab' }).update({ changes: { from: 0, to: 1, insert: 'z' } }) + expect(isDocChangingDispatchInput([transaction])).toBe(true) + }) + it('does NOT call onChange for a controller projection inside ownership', () => { const onChange = vi.fn() let capturedView: EditorView | undefined diff --git a/src/editor/__tests__/list-bullet-widget.test.ts b/src/editor/__tests__/list-bullet-widget.test.ts index 4d229609..daffd790 100644 --- a/src/editor/__tests__/list-bullet-widget.test.ts +++ b/src/editor/__tests__/list-bullet-widget.test.ts @@ -1,6 +1,7 @@ // src/editor/__tests__/list-bullet-widget.test.ts import { describe, it, expect, afterEach, vi } from 'vitest' -import { EditorSelection, EditorState } from '@codemirror/state' +import fc from 'fast-check' +import { EditorSelection, EditorState, StateEffect } from '@codemirror/state' import { EditorView } from '@codemirror/view' import { markdown, markdownLanguage } from '@codemirror/lang-markdown' import { GFM } from '@lezer/markdown' @@ -62,6 +63,16 @@ function expectDepthClass(deco: { class: string | null }, className: string): vo expect(deco.class?.split(' ')).toContain(className) } +async function waitForListChrome( + requestMeasure: ReturnType, + assertion: () => void, +): Promise { + await vi.waitFor(() => { + expect(requestMeasure).toHaveBeenCalled() + }, { timeout: 1000 }) + await vi.waitFor(assertion, { timeout: 1000 }) +} + afterEach(() => { document.body.innerHTML = '' }) @@ -874,6 +885,229 @@ describe('listBulletWidget — DOM rendering', () => { view.destroy() }) + it('reuses unaffected descendant glyphs after inserting a nested list item', async () => { + const doc = '- root\n - branch\n - deep one\n - deep two\n - sibling\n- outside' + const view = createMountedView(doc) + await new Promise(resolve => setTimeout(resolve, 100)) + + const glyphForLine = (lineText: string): HTMLElement => { + const lineFrom = view.state.doc.toString().indexOf(lineText) + const line = view.state.doc.lineAt(lineFrom) + const markerFrom = line.from + line.text.indexOf('-') + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${markerFrom}"]`, + ) + expect(glyph).not.toBeNull() + return glyph! + } + + const deepOneGlyph = glyphForLine(' - deep one') + const deepTwoGlyph = glyphForLine(' - deep two') + const insertion = view.state.doc.toString().indexOf(' - deep one') + view.dispatch({ changes: { from: insertion, insert: ' - inserted\n' } }) + await new Promise(resolve => setTimeout(resolve, 30)) + + expect(glyphForLine(' - deep one')).toBe(deepOneGlyph) + expect(glyphForLine(' - deep two')).toBe(deepTwoGlyph) + expect(deepOneGlyph.dataset.listMarkerFrom).toBe(String(view.state.doc.toString().indexOf(' - deep one') + 4)) + expect(deepTwoGlyph.dataset.listMarkerFrom).toBe(String(view.state.doc.toString().indexOf(' - deep two') + 4)) + view.destroy() + }) + + it('property: insertion preserves glyph identity while remapping descendant positions', async () => { + await fc.assert(fc.asyncProperty( + fc.integer({ min: 3, max: 7 }), + fc.integer({ min: 0, max: 100 }), + async (depth, insertionSeed) => { + const lines = ['- item-0'] + for (let level = 1; level < depth; level++) { + lines.push(`${' '.repeat(level)}- item-${level}`) + } + const doc = lines.join('\n') + const insertionLevel = 1 + (insertionSeed % (depth - 1)) + const view = createMountedView(doc) + try { + await new Promise(resolve => setTimeout(resolve, 30)) + const glyphs = new Map() + for (const lineText of lines.slice(insertionLevel)) { + const lineFrom = view.state.doc.toString().indexOf(lineText) + const line = view.state.doc.lineAt(lineFrom) + const markerFrom = line.from + line.text.indexOf('-') + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${markerFrom}"]`, + ) + expect(glyph).not.toBeNull() + glyphs.set(lineText, glyph!) + } + + const insertion = view.state.doc.toString().indexOf(lines[insertionLevel]!) + view.dispatch({ + changes: { + from: insertion, + insert: `${' '.repeat(insertionLevel)}- inserted\n`, + }, + }) + await new Promise(resolve => setTimeout(resolve, 30)) + + for (const [lineText, oldGlyph] of glyphs) { + const lineFrom = view.state.doc.toString().indexOf(lineText) + const line = view.state.doc.lineAt(lineFrom) + const markerFrom = line.from + line.text.indexOf('-') + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${markerFrom}"]`, + ) + expect(glyph).toBe(oldGlyph) + expect(glyph?.dataset.listMarkerFrom).toBe(String(markerFrom)) + } + } finally { + view.destroy() + } + }, + ), { numRuns: 12 }) + }) + + it('refreshes mapped marker metadata after a task-body edit before later markers', async () => { + const doc = '- [ ] first\n- [ ] second\n- [ ] third' + const view = createMountedView(doc) + await new Promise(resolve => setTimeout(resolve, 30)) + const glyphFor = (text: string): HTMLElement => { + const lineFrom = view.state.doc.toString().indexOf(text) + const markerFrom = view.state.doc.lineAt(lineFrom).from + return view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${markerFrom}"]`, + )! + } + const second = glyphFor('- [ ] second') + const third = glyphFor('- [ ] third') + const insertion = doc.indexOf('first') + 'first'.length + view.dispatch({ changes: { from: insertion, insert: ' edited' } }) + await new Promise(resolve => setTimeout(resolve, 30)) + + const secondNow = glyphFor('- [ ] second') + const thirdNow = glyphFor('- [ ] third') + expect(secondNow).toBe(second) + expect(thirdNow).toBe(third) + expect(secondNow.dataset.listMarkerFrom).toBe(String(view.state.doc.toString().indexOf('- [ ] second'))) + expect(thirdNow.dataset.listMarkerFrom).toBe(String(view.state.doc.toString().indexOf('- [ ] third'))) + const secondMarkerFrom = view.state.doc.toString().indexOf('- [ ] second') + const marker = view.contentDOM.querySelector( + `.cm-list-bullet[data-list-marker-from="${secondMarkerFrom}"]`, + ) + expect(marker?.dataset.listGlyphIdentity).toBe(secondNow.dataset.listGlyphIdentity) + view.destroy() + }) + + it('reuses a glyph when indentation changes its visual style', async () => { + const doc = '- root\n - parent\n - child' + const view = createMountedView(doc) + await new Promise(resolve => setTimeout(resolve, 30)) + const childFrom = doc.indexOf(' - child') + 4 + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childFrom}"]`, + )! + const initialClass = glyph.className + view.dispatch({ changes: { from: doc.indexOf(' - child'), to: childFrom, insert: ' ' } }) + await new Promise(resolve => setTimeout(resolve, 30)) + + const childNow = view.state.doc.toString().indexOf(' - child') + 2 + const remapped = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childNow}"]`, + )! + expect(remapped).toBe(glyph) + expect(remapped.className).not.toBe(initialClass) + view.destroy() + }) + + it('preserves identities through insertion-equivalent whole-document replacement', async () => { + const before = '- root\n - child\n- outside' + const after = '- root\n - inserted\n - child\n- outside' + const view = createMountedView(before) + await new Promise(resolve => setTimeout(resolve, 30)) + const childFrom = before.indexOf(' - child') + 2 + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childFrom}"]`, + )! + view.dispatch({ changes: { from: 0, to: before.length, insert: after } }) + await new Promise(resolve => setTimeout(resolve, 30)) + const childNow = after.indexOf(' - child') + 2 + expect(view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childNow}"]`, + )).toBe(glyph) + view.destroy() + }) + + it('preserves descendant glyph identity through whole-document insertion inside task text', async () => { + const before = '- [ ] task text\n - child\n- outside' + const after = '- [ ] task text edited\n - child\n- outside' + const view = createMountedView(before) + await new Promise(resolve => setTimeout(resolve, 30)) + const childFrom = before.indexOf(' - child') + 2 + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childFrom}"]`, + )! + view.dispatch({ changes: { from: 0, to: before.length, insert: after } }) + await new Promise(resolve => setTimeout(resolve, 30)) + const childNow = after.indexOf(' - child') + 2 + expect(view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childNow}"]`, + )).toBe(glyph) + view.destroy() + }) + + it('does not transfer identities through a true whole-document replacement', async () => { + const before = '- old root\n - old child' + const after = '- new root\n - new child' + const view = createMountedView(before) + await new Promise(resolve => setTimeout(resolve, 30)) + const childFrom = before.indexOf(' - old child') + 2 + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childFrom}"]`, + )! + view.dispatch({ changes: { from: 0, to: before.length, insert: after } }) + await new Promise(resolve => setTimeout(resolve, 30)) + const childNow = after.indexOf(' - new child') + 2 + expect(view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childNow}"]`, + )).not.toBe(glyph) + view.destroy() + }) + + it('preserves later descendant identity after deleting text before it', async () => { + const doc = '- root text\n - parent\n - child\n- outside' + const view = createMountedView(doc) + await new Promise(resolve => setTimeout(resolve, 30)) + const childFrom = doc.indexOf(' - child') + 4 + const glyph = view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childFrom}"]`, + )! + view.dispatch({ changes: { from: doc.indexOf('root'), to: doc.indexOf('root') + 5 } }) + await new Promise(resolve => setTimeout(resolve, 30)) + const childNow = view.state.doc.toString().indexOf(' - child') + 4 + expect(view.scrollDOM.querySelector( + `.cm-list-bullet-glyph[data-list-marker-from="${childNow}"]`, + )).toBe(glyph) + view.destroy() + }) + + it('keeps a collapsed branch closed through whole-document insertion and deletion', async () => { + const before = '- parent\n - child\n- outside' + const inserted = '- parent\n - inserted\n - child\n- outside' + const view = createMountedView(before) + await new Promise(resolve => setTimeout(resolve, 30)) + view.scrollDOM.querySelector('.cm-list-fold-control[data-list-marker-from="0"]')!.click() + const isCollapsed = () => view.dom.querySelectorAll('.cm-list-folded-child').length > 0 + expect(isCollapsed()).toBe(true) + + view.dispatch({ changes: { from: 0, to: before.length, insert: inserted } }) + await new Promise(resolve => setTimeout(resolve, 30)) + expect(isCollapsed()).toBe(true) + + view.dispatch({ changes: { from: 0, to: inserted.length, insert: before } }) + await new Promise(resolve => setTimeout(resolve, 30)) + expect(isCollapsed()).toBe(true) + view.destroy() + }) + it('renders a fold chevron for a foldable parent row', async () => { const view = createMountedView('- parent\n - child\n\nfar cursor', 4) await new Promise(resolve => setTimeout(resolve, 30)) @@ -881,6 +1115,57 @@ describe('listBulletWidget — DOM rendering', () => { view.destroy() }) + it('reuses a fold control and refreshes its semantic position after an earlier edit', async () => { + const doc = '- outside\n- parent\n - child' + const view = createMountedView(doc, doc.length) + await new Promise(resolve => setTimeout(resolve, 30)) + const oldMarkerFrom = doc.indexOf('- parent') + const oldControl = view.scrollDOM.querySelector( + `.cm-list-fold-control[data-list-marker-from="${oldMarkerFrom}"]`, + ) + expect(oldControl).not.toBeNull() + const insertion = doc.indexOf('outside') + 'outside'.length + view.dispatch({ changes: { from: insertion, insert: ' edited' } }) + await new Promise(resolve => setTimeout(resolve, 30)) + + const nextDoc = view.state.doc.toString() + const nextMarkerFrom = nextDoc.indexOf('- parent') + const nextControl = view.scrollDOM.querySelector( + `.cm-list-fold-control[data-list-marker-from="${nextMarkerFrom}"]`, + ) + expect(nextControl).toBe(oldControl) + expect(nextControl?.dataset.listMarkerFrom).toBe(String(nextMarkerFrom)) + expect(nextControl?.dataset.listGlyphIdentity).toBeTruthy() + view.destroy() + }) + + it('restores folded-line interactivity and prior tabindex values on plugin destroy', async () => { + const view = createMountedView('- parent\n - child') + const childLine = Array.from(view.contentDOM.querySelectorAll('.cm-line')) + .find(line => line.textContent?.includes('child')) + expect(childLine).not.toBeUndefined() + const focusable = document.createElement('button') + focusable.type = 'button' + focusable.tabIndex = 7 + childLine!.appendChild(focusable) + await new Promise(resolve => setTimeout(resolve, 60)) + + view.scrollDOM.querySelector('.cm-list-fold-control[data-list-marker-from="0"]')!.click() + await vi.waitFor(() => { + expect(childLine).toHaveAttribute('inert') + }, { timeout: 1000 }) + // jsdom can redraw the CM line after the fold write, so pin the exact + // state produced by setFoldedInteractivity before testing teardown. + childLine!.appendChild(focusable) + focusable.dataset.foldedTabIndex = '7' + focusable.tabIndex = -1 + + view.destroy() + expect(childLine).not.toHaveAttribute('inert') + expect(focusable.tabIndex).toBe(7) + expect(focusable.dataset.foldedTabIndex).toBeUndefined() + }) + it('exposes a typed list Record glyph to assistive technology', async () => { const doc = '- typed #task\n\nfar cursor' const parent = document.createElement('div') @@ -909,6 +1194,92 @@ describe('listBulletWidget — DOM rendering', () => { view.destroy() }) + it('rebuilds list chrome when ordinary task-body edits change typed semantics', async () => { + const doc = '- [ ] task #one\n- [ ] other' + const parent = document.createElement('div') + document.body.appendChild(parent) + const view = new EditorView({ + parent, + state: EditorState.create({ + doc, + extensions: [ + markdown({ base: markdownLanguage, extensions: [GFM], addKeymap: false }), + markdownFrontmatterField, + typedRecordNodes.of([]), + listBulletWidget, + ], + selection: { anchor: doc.length }, + }), + }) + const requestMeasure = vi.spyOn(view, 'requestMeasure') + const placement = (nodeId: string, typeMarker: string) => ({ + nodeId, + kind: 'list-item' as const, + title: 'task', + from: 0, + to: view.state.doc.length, + hasChildren: false, + typeMarkers: [typeMarker], + records: [], + }) + const setPlacement = (value: ReturnType | null) => { + view.dispatch({ effects: StateEffect.appendConfig.of(typedRecordNodes.of(value ? [value] : [])) }) + } + const editBody = (text: string) => { + const from = view.state.doc.toString().indexOf('task') + 'task'.length + view.dispatch({ changes: { from, insert: text } }) + } + + try { + expect(view.scrollDOM.querySelector('.cm-list-bullet-glyph-record')).toBeNull() + + editBody(' added') + requestMeasure.mockClear() + setPlacement(placement('node-one', '#one')) + expect(view.state.facet(typedRecordNodes)[0]?.nodeId).toBe('node-one') + await waitForListChrome(requestMeasure, () => { + expect(view.scrollDOM.querySelector('.cm-list-bullet-glyph-record')).not.toBeNull() + }) + let glyph = view.scrollDOM.querySelector('.cm-list-bullet-glyph-record') + expect(glyph).not.toBeNull() + expect(glyph).toHaveAttribute('data-record-node-id', 'node-one') + expect(glyph).not.toHaveAttribute('aria-hidden') + const dispatch = vi.spyOn(view, 'dispatch') + glyph!.click() + expect(dispatch.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ + effects: expect.objectContaining({ value: { nodeId: 'node-one', collapsed: true } }), + })) + + editBody(' removed') + requestMeasure.mockClear() + setPlacement(null) + await waitForListChrome(requestMeasure, () => { + expect(view.scrollDOM.querySelector('.cm-list-bullet-glyph-record')).toBeNull() + }) + expect(view.scrollDOM.querySelector('.cm-list-bullet-glyph-record')).toBeNull() + const plainGlyph = view.scrollDOM.querySelector('.cm-list-bullet-glyph') + expect(plainGlyph).not.toBeNull() + expect(plainGlyph).not.toHaveAttribute('data-record-node-id') + expect(plainGlyph).toHaveAttribute('aria-hidden', 'true') + + editBody(' changed') + requestMeasure.mockClear() + setPlacement(placement('node-two', '#two')) + await waitForListChrome(requestMeasure, () => { + expect(view.scrollDOM.querySelector('.cm-list-bullet-glyph-record')).not.toBeNull() + }) + glyph = view.scrollDOM.querySelector('.cm-list-bullet-glyph-record') + expect(glyph).not.toBeNull() + expect(glyph).toHaveAttribute('data-record-node-id', 'node-two') + glyph!.click() + expect(dispatch.mock.calls[dispatch.mock.calls.length - 1]?.[0]).toEqual(expect.objectContaining({ + effects: expect.objectContaining({ value: { nodeId: 'node-two', collapsed: true } }), + })) + } finally { + view.destroy() + } + }) + it('honors the fresh-mount initiallyCollapsed live-preview preference', async () => { const { livePreview } = await import('../extensions/live-preview') const doc = '- parent\n - child\n- outside' diff --git a/src/editor/extensions/callout-decoration.ts b/src/editor/extensions/callout-decoration.ts index 37998d44..54a4595e 100644 --- a/src/editor/extensions/callout-decoration.ts +++ b/src/editor/extensions/callout-decoration.ts @@ -40,6 +40,7 @@ import { getFrontmatterRange, shouldSkipBlockMarkdownEnhancement } from './markd import { QUOTE_LINE_RE, countQuoteDepth } from '../quote-syntax' import { buildCalloutIconSvg } from './callout-icons' import { BLOCKQUOTE_FORMATTING_HIDDEN } from './hidden-text-classes' +import { measurePerfSample } from '../../lib/perf-profile' /** * Inline SVG icon emitted at the start of a callout header line, in both @@ -376,7 +377,7 @@ function bootstrapCalloutState( */ type BootstrapCache = Map> -function buildDecorations(view: EditorView, cache: BootstrapCache): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView, cache: BootstrapCache): DecorationSet { const doc = view.state.doc const frontmatter = getFrontmatterRange(view.state) // In read mode there is no editing caret to reveal source for. The cursor @@ -553,6 +554,12 @@ function buildDecorations(view: EditorView, cache: BootstrapCache): DecorationSe return builder.finish() } +function buildDecorations(view: EditorView, cache: BootstrapCache): DecorationSet { + return measurePerfSample('live-preview.decoration', 'callout', () => buildDecorationsUnprofiled(view, cache), { + docLength: view.state.doc.length, + }) +} + export const calloutDecoration = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/checkbox-widget.ts b/src/editor/extensions/checkbox-widget.ts index 84c9f9fd..30fcc493 100644 --- a/src/editor/extensions/checkbox-widget.ts +++ b/src/editor/extensions/checkbox-widget.ts @@ -38,6 +38,7 @@ import { type WidgetMarkerRange, } from './widget-pointer-select-guard' import { getSafeSelectionForRestore } from './selection-validation' +import { measurePerfSample } from '../../lib/perf-profile' const PROXIMITY_CHARS = 2 @@ -195,7 +196,7 @@ interface BuildResult { markers: WidgetMarkerRange[] } -function buildDecorations( +function buildDecorationsUnprofiled( view: EditorView, pointerSelectAtCheckbox: WidgetMarkerRange | null, ): BuildResult { @@ -286,6 +287,15 @@ function buildDecorations( return { decorations: builder.finish(), markers } } +function buildDecorations( + view: EditorView, + pointerSelectAtCheckbox: WidgetMarkerRange | null, +): BuildResult { + return measurePerfSample('live-preview.decoration', 'checkbox', () => buildDecorationsUnprofiled(view, pointerSelectAtCheckbox), { + docLength: view.state.doc.length, + }) +} + export const checkboxWidget = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/code-fence-decoration.ts b/src/editor/extensions/code-fence-decoration.ts index 82c64ce0..26b098bb 100644 --- a/src/editor/extensions/code-fence-decoration.ts +++ b/src/editor/extensions/code-fence-decoration.ts @@ -24,6 +24,7 @@ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' import { RangeSetBuilder } from '@codemirror/state' import { syntaxTree } from '@codemirror/language' +import { measurePerfSample } from '../../lib/perf-profile' const CODEBLOCK_LINE_DECO = Decoration.line({ class: 'cm-codeblock' }) @@ -31,7 +32,7 @@ const CODEBLOCK_LINE_DECO = Decoration.line({ class: 'cm-codeblock' }) // than constructing a Set when N is this tiny. const FENCED_NODE_NAMES = ['FencedCode', 'CodeBlock'] -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const doc = view.state.doc const ranges = view.visibleRanges.length > 0 ? view.visibleRanges @@ -76,6 +77,10 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'code-fence', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) +} + export const codeFenceDecoration = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/code-marker-decoration.ts b/src/editor/extensions/code-marker-decoration.ts index 45b0b6c5..b622e69f 100644 --- a/src/editor/extensions/code-marker-decoration.ts +++ b/src/editor/extensions/code-marker-decoration.ts @@ -46,6 +46,7 @@ import { import { RangeSetBuilder, type Extension } from '@codemirror/state' import { getFrontmatterRange, isRangeInFrontmatter, isInsideTableCell } from './markdown-context' import { INLINE_FORMATTING_HIDDEN } from './hidden-text-classes' +import { measurePerfSample } from '../../lib/perf-profile' // Mirrors `PROXIMITY_CHARS` in `inline-format-decoration.ts`. const INLINE_PROXIMITY_CHARS = 2 @@ -98,7 +99,7 @@ function isCursorOnBlockLine(view: EditorView, blockFrom: number, blockTo: numbe return cursorLine >= blockStartLine && cursorLine <= blockEndLine } -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const decos: Array<{ from: number; to: number; deco: Decoration }> = [] // Dedupe across overlapping visible ranges. A FencedCode node that // straddles two visible ranges would otherwise be entered twice, pushing @@ -191,6 +192,10 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'code-marker', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) +} + /** * The underlying ViewPlugin. Exported so tests can resolve the plugin * instance via `view.plugin(codeMarkerDecorationPlugin)`. Production diff --git a/src/editor/extensions/fenced-code-block-widget.ts b/src/editor/extensions/fenced-code-block-widget.ts index 99bc7de2..567b0538 100644 --- a/src/editor/extensions/fenced-code-block-widget.ts +++ b/src/editor/extensions/fenced-code-block-widget.ts @@ -17,6 +17,7 @@ import { import { Component } from '../../plugin/component' import { isOffsetRangeInTableCell } from './markdown-context' import { isSafeUrl } from './markdown-link-click-handler' +import { measurePerfSample } from '../../lib/perf-profile' import { getPostProcessorRevision, hasRegisteredCodeBlockProcessor, @@ -264,7 +265,7 @@ class FencedProcessorWidget extends WidgetType { } } -function buildDecorations(state: EditorState, config: FencedCodeBlockProcessorConfig): DecorationSet { +function buildDecorationsUnprofiled(state: EditorState, config: FencedCodeBlockProcessorConfig): DecorationSet { const builder = new RangeSetBuilder() const documentSource = state.doc.toString() for (const range of findFencedRanges(state, documentSource)) { @@ -285,6 +286,10 @@ function buildDecorations(state: EditorState, config: FencedCodeBlockProcessorCo return builder.finish() } +function buildDecorations(state: EditorState, config: FencedCodeBlockProcessorConfig): DecorationSet { + return measurePerfSample('live-preview.decoration', 'fenced-code-block', () => buildDecorationsUnprofiled(state, config), { docLength: state.doc.length }) +} + function createField(config: FencedCodeBlockProcessorConfig): StateField { return StateField.define({ create: (state) => buildDecorations(state, config), diff --git a/src/editor/extensions/footnote-decoration.ts b/src/editor/extensions/footnote-decoration.ts index 0431f413..1401975d 100644 --- a/src/editor/extensions/footnote-decoration.ts +++ b/src/editor/extensions/footnote-decoration.ts @@ -57,6 +57,7 @@ import { isCursorNearSameLine, } from './markdown-context' import { detectPointerSelectOnVisibleWidget } from './widget-pointer-select-guard' +import { measurePerfSample } from '../../lib/perf-profile' import { FOOTNOTE_REF_RE, FOOTNOTE_DEF_LINE_RE, @@ -304,7 +305,7 @@ const footnoteDefMarkerDeco = Decoration.mark({ class: 'cm-md-footnote-def-marke * - The cached refs list (passed in) is doc-wide; widget emission * filters it to the visible ranges itself. */ -function buildDecorations( +function buildDecorationsUnprofiled( view: EditorView, defs: DefRecord[], refs: RefRecord[], @@ -423,6 +424,16 @@ function buildDecorations( return builder.finish() } +function buildDecorations( + view: EditorView, + defs: DefRecord[], + refs: RefRecord[], + defIndex: Map, + pointerSelectAtChip: { from: number; to: number } | null, +): DecorationSet { + return measurePerfSample('live-preview.decoration', 'footnote', () => buildDecorationsUnprofiled(view, defs, refs, defIndex, pointerSelectAtChip), { docLength: view.state.doc.length }) +} + const footnotePlugin = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/frontmatter-widget.ts b/src/editor/extensions/frontmatter-widget.ts index 7bbb2d95..2a96d23b 100644 --- a/src/editor/extensions/frontmatter-widget.ts +++ b/src/editor/extensions/frontmatter-widget.ts @@ -62,6 +62,7 @@ import { isMap, isScalar, parseDocument } from 'yaml' import { parseFrontmatter } from '../../services/metadata/extract-metadata' import { getFrontmatterRange } from '../../markdown/frontmatter' import { inferType, type FrontmatterPropertyType } from './frontmatter-types' +import { measurePerfSample } from '../../lib/perf-profile' import { editorInfoField } from '../../plugin/obsidian-cm6-stubs' // --------------------------------------------------------------------------- @@ -1382,7 +1383,7 @@ function typeIconGlyph(t: FrontmatterPropertyType): string { * Otherwise returns a single `Decoration.replace({block: true})` over * `[0, endOffset)`. */ -function buildDecorations(state: EditorState): DecorationSet { +function buildDecorationsUnprofiled(state: EditorState): DecorationSet { if (state.doc.line(1).text !== '---') return Decoration.none const fm = parseFrontmatter(state.doc.toString()) if (!fm) return Decoration.none @@ -1433,6 +1434,10 @@ function buildDecorations(state: EditorState): DecorationSet { ]) } +function buildDecorations(state: EditorState): DecorationSet { + return measurePerfSample('live-preview.decoration', 'frontmatter', () => buildDecorationsUnprofiled(state), { docLength: state.doc.length }) +} + /** * The frontmatter-widget StateField. Rebuilds when: * - The document changes (frontmatter content / boundaries shift) diff --git a/src/editor/extensions/heading-decoration.ts b/src/editor/extensions/heading-decoration.ts index 2fe6026f..3da59ba5 100644 --- a/src/editor/extensions/heading-decoration.ts +++ b/src/editor/extensions/heading-decoration.ts @@ -10,6 +10,7 @@ import { EditorSelection, RangeSetBuilder } from '@codemirror/state' import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' import { getFrontmatterRange, isRangeInFrontmatter, isInsideTableCell } from './markdown-context' import { FORMATTING_HIDDEN } from './hidden-text-classes' +import { measurePerfSample } from '../../lib/perf-profile' /** Pre-allocated heading line decorations (1-6) to avoid object allocation per call. */ const HEADING_LINE_DECOS = [ @@ -26,7 +27,7 @@ const hiddenMark = Decoration.mark({ class: FORMATTING_HIDDEN }) /** Module-level regex — avoids re-compilation on every tree iteration. */ const ATX_HEADING_RE = /^ATXHeading(\d)$/ -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const builder = new RangeSetBuilder() // TODO(#252): in read mode (`view.state.readOnly`), substitute `-1` for // `cursorLine` so the heading hash chrome stays hidden regardless of @@ -92,6 +93,12 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'heading', () => buildDecorationsUnprofiled(view), { + docLength: view.state.doc.length, + }) +} + export const headingDecoration = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/hr-decoration.ts b/src/editor/extensions/hr-decoration.ts index f3e280e2..0f484191 100644 --- a/src/editor/extensions/hr-decoration.ts +++ b/src/editor/extensions/hr-decoration.ts @@ -44,6 +44,7 @@ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate import { RangeSetBuilder } from '@codemirror/state' import { getFrontmatterRange, shouldSkipBlockMarkdownEnhancement } from './markdown-context' import { HR_LINE_HIDDEN } from './hidden-text-classes' +import { measurePerfSample } from '../../lib/perf-profile' export const HR_LINE_CLASS = 'cm-md-hr-line' // Backward-compat re-export — at least one external test imports @@ -54,7 +55,7 @@ export const HR_HIDDEN_CLASS = HR_LINE_HIDDEN const hrLineDeco = Decoration.line({ class: HR_LINE_CLASS }) const hrHiddenMark = Decoration.mark({ class: HR_LINE_HIDDEN }) -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const builder = new RangeSetBuilder() const cursorLine = view.state.doc.lineAt(view.state.selection.main.head).number const frontmatter = getFrontmatterRange(view.state) @@ -96,6 +97,10 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'hr', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) +} + export const hrDecoration = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/image-embed-widget.ts b/src/editor/extensions/image-embed-widget.ts index 3883991c..cba9e017 100644 --- a/src/editor/extensions/image-embed-widget.ts +++ b/src/editor/extensions/image-embed-widget.ts @@ -62,6 +62,7 @@ import { isOffsetRangeInTableCell, } from './markdown-context' import { EMBED_RE, isSizingSpec, collectGalleryImages, hasImageEmbedSyntax } from './image-embed-utils' +import { measurePerfSample } from '../../lib/perf-profile' export interface ImageResolverLike { resolveImageUrl(reference: string): Promise @@ -175,7 +176,7 @@ class ImageEmbedWidget extends WidgetType { } } -function buildDecorations( +function buildDecorationsUnprofiled( state: EditorState, config: ImageEmbedConfig, ): DecorationSet { @@ -302,6 +303,13 @@ function buildDecorations( return builder.finish() } +function buildDecorations( + state: EditorState, + config: ImageEmbedConfig, +): DecorationSet { + return measurePerfSample('live-preview.decoration', 'image-embed', () => buildDecorationsUnprofiled(state, config), { docLength: state.doc.length }) +} + export function imageEmbedWidget(config: ImageEmbedConfig): Extension { // Block decorations cannot be exposed via a ViewPlugin (CM6 throws // "Block decorations may not be specified via plugins"). We use a diff --git a/src/editor/extensions/image-gallery-widget.ts b/src/editor/extensions/image-gallery-widget.ts index 772d87ee..d9df8b0b 100644 --- a/src/editor/extensions/image-gallery-widget.ts +++ b/src/editor/extensions/image-gallery-widget.ts @@ -29,6 +29,7 @@ import { type Extension, } from '@codemirror/state' import { isSizingSpec, collectGalleryImages, hasImageEmbedSyntax } from './image-embed-utils' +import { measurePerfSample } from '../../lib/perf-profile' export interface ImageResolverLike { resolveImageUrl(reference: string): Promise @@ -212,7 +213,7 @@ class GalleryWidget extends WidgetType { } } -function buildDecorations( +function buildDecorationsUnprofiled( state: EditorState, config: ImageGalleryConfig, ): DecorationSet { @@ -270,6 +271,13 @@ function buildDecorations( return builder.finish() } +function buildDecorations( + state: EditorState, + config: ImageGalleryConfig, +): DecorationSet { + return measurePerfSample('live-preview.decoration', 'image-gallery', () => buildDecorationsUnprofiled(state, config), { docLength: state.doc.length }) +} + export function imageGalleryWidget(config: ImageGalleryConfig): Extension { // Block decorations cannot be exposed via a ViewPlugin (CM6 throws // "Block decorations may not be specified via plugins"). We use a diff --git a/src/editor/extensions/inline-format-decoration.ts b/src/editor/extensions/inline-format-decoration.ts index 89656adf..70d2008c 100644 --- a/src/editor/extensions/inline-format-decoration.ts +++ b/src/editor/extensions/inline-format-decoration.ts @@ -16,6 +16,7 @@ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate import { RangeSetBuilder, type Extension } from '@codemirror/state' import { getFrontmatterRange, isRangeInFrontmatter, isInsideTableCell, isCursorNearSameLine } from './markdown-context' import { INLINE_FORMATTING_HIDDEN } from './hidden-text-classes' +import { measurePerfSample } from '../../lib/perf-profile' const PROXIMITY_CHARS = 2 @@ -60,7 +61,7 @@ interface DecorationOutput { hidden: DecorationSet } -function buildDecorations(view: EditorView): DecorationOutput { +function buildDecorationsUnprofiled(view: EditorView): DecorationOutput { const decos: Array<{ from: number; to: number; deco: Decoration }> = [] const hiddenRanges: Array<{ from: number; to: number; deco: Decoration }> = [] const emitted = new Set() @@ -140,6 +141,12 @@ function buildDecorations(view: EditorView): DecorationOutput { return { decorations: builder.finish(), hidden: hiddenBuilder.finish() } } +function buildDecorations(view: EditorView): DecorationOutput { + return measurePerfSample('live-preview.decoration', 'inline-format', () => buildDecorationsUnprofiled(view), { + docLength: view.state.doc.length, + }) +} + /** * The underlying ViewPlugin. Exported so tests can resolve the plugin * instance via `view.plugin(inlineFormatDecorationPlugin)`. Production diff --git a/src/editor/extensions/list-bullet-widget.ts b/src/editor/extensions/list-bullet-widget.ts index 7b47098f..760d7bb7 100644 --- a/src/editor/extensions/list-bullet-widget.ts +++ b/src/editor/extensions/list-bullet-widget.ts @@ -38,6 +38,7 @@ import type { SyntaxNode } from '@lezer/common' import { editorLivePreviewField } from '../../plugin/obsidian-cm6-stubs' import { recordDisclosureState, setRecordDisclosure, typedRecordNodes } from './block-record-widget' import { listFoldDefaultClosed } from './list-fold-config' +import { isProfilingEnabled, measurePerfSample, recordPerfSample } from '../../lib/perf-profile' const PROXIMITY_CHARS = 2 const toggleListFold = StateEffect.define() @@ -120,6 +121,7 @@ function leadingIndentWidth(line: string): number { } interface ListMarker { + identity?: number from: number to: number bulletDepth: number @@ -135,6 +137,30 @@ function typedNodeIdForMarker(view: EditorView, marker: ListMarker): string | un return view.state.facet(typedRecordNodes).find(node => node.kind === 'list-item' && node.from === marker.item.from)?.nodeId } +function typedRecordSemanticForMarker(view: EditorView, marker: ListMarker): string { + const placement = view.state.facet(typedRecordNodes).find(node => + node.kind === 'list-item' && node.from === marker.item.from) + return placement ? JSON.stringify(placement) : '' +} + +function typedRecordSemantics( + view: EditorView, + markers: readonly ListMarker[], +): Map { + return new Map(markers.map(marker => [ + marker.identity!, + typedRecordSemanticForMarker(view, marker), + ])) +} + +function sameTypedRecordSemantics( + before: ReadonlyMap, + after: ReadonlyMap, +): boolean { + return before.size === after.size + && [...before].every(([identity, semantic]) => after.get(identity) === semantic) +} + export interface ListFoldSnapshot { parents: Array<{ markerFrom: number @@ -279,6 +305,124 @@ function mapListMarkers(update: ViewUpdate, markers: readonly ListMarker[]): Lis })) } +interface NormalizedDocumentChange { + from: number + to: number + delta: number +} + +interface WholeDocumentNormalization { + whole: boolean + change: NormalizedDocumentChange | null +} + +function longestCommonPrefix(before: string, after: string): number { + let prefix = 0 + while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix++ + return prefix +} + +function normalizeWholeDocumentChange(update: ViewUpdate): WholeDocumentNormalization { + let whole = false + update.changes.iterChangedRanges((fromA, toA, _fromB, _toB) => { + whole = whole || (fromA === 0 && toA === update.startState.doc.length + && _fromB === 0 && _toB === update.state.doc.length) + }) + if (!whole) return { whole: false, change: null } + + const before = update.startState.doc.toString() + const after = update.state.doc.toString() + const prefix = longestCommonPrefix(before, after) + let beforeEnd = before.length + let afterEnd = after.length + while (beforeEnd > prefix && afterEnd > prefix && before[beforeEnd - 1] === after[afterEnd - 1]) { + beforeEnd-- + afterEnd-- + } + + // Prefer a line boundary when the replacement is an entire inserted/deleted + // line, because the shared list prefix is not the semantic edit boundary. + const boundaries = (text: string) => { + const result = [0] + for (let index = 0; index < text.length; index++) { + if (text[index] === '\n') result.push(index + 1) + } + return result + } + const boundary = before.length <= after.length + ? boundaries(before).find(from => after.startsWith(before.slice(0, from)) + && after.endsWith(before.slice(from)) && after.length > before.length) + : boundaries(after).find(from => before.startsWith(after.slice(0, from)) + && before.endsWith(after.slice(from)) && before.length > after.length) + if (boundary !== undefined) { + const delta = after.length - before.length + return { + whole: true, + change: delta > 0 + ? { from: boundary, to: boundary, delta } + : { from: boundary, to: boundary - delta, delta }, + } + } + + // Ordinary LCP/LCS normalization handles replacements represented as a + // whole-document change, including edits inside a task's text. + if (beforeEnd === prefix || afterEnd === prefix) { + return { + whole: true, + change: { from: prefix, to: beforeEnd, delta: afterEnd - beforeEnd }, + } + } + return { whole: true, change: null } +} + +function mapPositionThroughNormalizedChange( + position: number, + assoc: -1 | 1, + change: NormalizedDocumentChange, +): number { + if (position < change.from || (position === change.from && assoc < 0)) return position + if (position > change.to || (position === change.to && assoc > 0)) return position + change.delta + return change.from +} + +function reconcileListMarkerIdentity( + update: ViewUpdate, + previous: readonly ListMarker[], + next: ListMarker[], + allocateIdentity: () => number, +): ListMarker[] { + const ranges: Array<{ from: number; to: number }> = [] + const normalization = normalizeWholeDocumentChange(update) + const normalizedChange = normalization.change + update.changes.iterChangedRanges((fromA, toA, _fromB, _toB) => { + if (normalization.whole) return + ranges.push({ from: fromA, to: toA }) + }) + if (normalization.whole) { + ranges.push(normalizedChange + ? { from: normalizedChange.from, to: normalizedChange.to } + : { from: 0, to: update.startState.doc.length }) + } + const mapIdentityPosition = (position: number, assoc: -1 | 1): number => normalizedChange + ? mapPositionThroughNormalizedChange(position, assoc, normalizedChange) + : update.changes.mapPos(position, assoc) + const mapped = new Map() + for (const marker of previous) { + let unaffected = true + for (const range of ranges) { + if (range.from < marker.to && range.to > marker.from) unaffected = false + } + if (!unaffected) continue + const from = mapIdentityPosition(marker.from, 1) + const to = mapIdentityPosition(marker.to, -1) + mapped.set(`${from}:${to}`, marker) + } + return next.map(marker => ({ + ...marker, + identity: mapped.get(`${marker.from}:${marker.to}`)?.identity ?? allocateIdentity(), + })) +} + function getActiveBulletMarkers(view: EditorView, markers: readonly ListMarker[]): ListMarker[] { const cursor = view.state.readOnly ? -1 : view.state.selection.main.head return markers.filter(marker => marker.bulletDepth > 0 @@ -314,6 +458,17 @@ interface TrailPoint { from: number } +interface MarkerDom { + line: HTMLElement + sourceMarker: HTMLElement +} + +interface FoldControlMeasurement extends TrailPoint { + identity: number + typedNodeId: string | undefined + collapsed: boolean +} + const TRAIL_DURATION_MS = 280 const MAX_BRIDGE_CAP_PX = 64 const EXIT_FADE_DURATION_MS = 180 @@ -392,7 +547,15 @@ function finishDecorations(decorations: Array<{ from: number; to: number; deco: return builder.finish() } -function buildDecorations( +function setAttributeIfChanged(element: Element, name: string, value: string): void { + if (element.getAttribute(name) !== value) element.setAttribute(name, value) +} + +function setStyleIfChanged(element: { style: CSSStyleDeclaration }, property: string, value: string): void { + if (element.style.getPropertyValue(property) !== value) element.style.setProperty(property, value) +} + +function buildDecorationsUnprofiled( view: EditorView, markers: readonly ListMarker[], collapsedMarkers: ReadonlySet, @@ -466,7 +629,10 @@ function buildDecorations( to: marker.to, deco: Decoration.mark({ class: 'cm-list-bullet-source', - attributes: { 'data-list-marker-from': String(marker.from) }, + attributes: { + 'data-list-marker-from': String(marker.from), + 'data-list-glyph-identity': String(marker.identity), + }, }), }) continue @@ -481,9 +647,12 @@ function buildDecorations( bullets.push({ from: marker.from, to: marker.to, - deco: Decoration.mark({ - class: bulletClass, - attributes: { 'data-list-marker-from': String(marker.from) }, + deco: Decoration.mark({ + class: bulletClass, + attributes: { + 'data-list-marker-from': String(marker.from), + 'data-list-glyph-identity': String(marker.identity), + }, }), }) } @@ -494,6 +663,14 @@ function buildDecorations( } } +function buildDecorations( + view: EditorView, + markers: readonly ListMarker[], + collapsedMarkers: ReadonlySet, +): BuiltDecorations { + return measurePerfSample('live-preview.decoration', 'list-bullet', () => buildDecorationsUnprofiled(view, markers, collapsedMarkers), { docLength: view.state.doc.length }) +} + export const listBulletWidget = ViewPlugin.fromClass( class { decorations: DecorationSet @@ -501,6 +678,7 @@ export const listBulletWidget = ViewPlugin.fromClass( private readonly glyphLayer: HTMLDivElement private readonly recordGlyphButtons = new Map() private readonly glyphElements = new Map() + private readonly foldControls = new Map() private readonly foldLayer: HTMLDivElement private readonly path: SVGPathElement private readonly bridgePath: SVGPathElement @@ -537,6 +715,8 @@ export const listBulletWidget = ViewPlugin.fromClass( private foldGeneration = 0 private foldFollowerFrame: number | null = null private layoutFrame: number | null = null + private measurePending = false + private suppressMetadataGeometryMeasure = false private foldFollowerTracking: FoldFollowerTracking | null = null private foldScrollLock: { top: number; overflowAnchor: string } | null = null private readonly onFoldScroll = () => { @@ -547,6 +727,8 @@ export const listBulletWidget = ViewPlugin.fromClass( } private foldTransitions: ListFoldSnapshot['transitions'] = [] private markers: ListMarker[] + private typedSemantics: Map + private nextMarkerIdentity = 1 private destroyed = false private readonly onReducedMotionChange = (event: MediaQueryListEvent) => { if (event.matches && this.animationTarget !== null) { @@ -556,9 +738,13 @@ export const listBulletWidget = ViewPlugin.fromClass( } if (event.matches) this.cancelFoldAnimations() } - private readonly onExternalLayoutChange = () => this.scheduleThread(this.view) + private readonly onExternalLayoutChange = () => this.scheduleThread(this.view, true) constructor(private readonly view: EditorView) { - this.markers = getListMarkers(view) + this.markers = getListMarkers(view).map(marker => ({ + ...marker, + identity: this.nextMarkerIdentity++, + })) + this.typedSemantics = typedRecordSemantics(view, this.markers) if (view.state.facet(listFoldDefaultClosed)) { this.collapsedMarkers = new Set(this.markers .filter(marker => marker.childList !== null) @@ -610,21 +796,24 @@ export const listBulletWidget = ViewPlugin.fromClass( } } if (!changed) return - this.scheduleThread(this.view) + this.scheduleThread(this.view, true) }) : null this.resizeObserver?.observe(view.scrollDOM) this.resizeObserver?.observe(view.contentDOM) - this.contentMutationObserver = typeof MutationObserver === 'function' - ? new MutationObserver(() => this.scheduleThreadAfterLayout(view)) + this.contentMutationObserver = typeof MutationObserver === 'function' + ? new MutationObserver(() => { + if (this.suppressMetadataGeometryMeasure) return + this.scheduleThreadAfterLayout(view) + }) : null this.contentMutationObserver?.observe(view.contentDOM, { childList: true, subtree: true }) this.fontSet = view.dom.ownerDocument.fonts ?? null this.fontSet?.addEventListener('loadingdone', this.onExternalLayoutChange) - void this.fontSet?.ready.then(() => { - if (!this.destroyed) this.scheduleThread(view) - }) - this.scheduleThread(view) + void this.fontSet?.ready.then(() => { + if (!this.destroyed) this.scheduleThread(view, true) + }) + this.scheduleThread(view) } update(update: ViewUpdate) { if (update.docChanged) { @@ -635,35 +824,29 @@ export const listBulletWidget = ViewPlugin.fromClass( // the branch identity boundary and invalidate only folds whose marker // was replaced; the parse/filter below handles topology changes. const invalidated = new Set() - let isWholeDocumentReplacement = false - update.changes.iterChangedRanges((fromA, toA, fromB, toB) => { - isWholeDocumentReplacement = isWholeDocumentReplacement - || (fromA === 0 && toA === update.startState.doc.length - && fromB === 0 && toB === update.state.doc.length) + const normalization = normalizeWholeDocumentChange(update) + update.changes.iterChangedRanges((fromA, toA, _fromB, _toB) => { + if (normalization.whole) return for (const markerFrom of this.collapsedMarkers) { if (fromA < markerFrom + 1 && markerFrom <= toA) invalidated.add(markerFrom) } }) - if (isWholeDocumentReplacement) { - // Diagnostics and remote reconciliation may represent a small - // insertion as a whole-document replacement. Preserve folds for - // those edits, but do not let a true replacement inherit a fold by - // source offset. - const before = update.startState.doc.toString() - const after = update.state.doc.toString() - let prefix = 0 - while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) prefix += 1 - let beforeEnd = before.length - let afterEnd = after.length - while (beforeEnd > prefix && afterEnd > prefix && before[beforeEnd - 1] === after[afterEnd - 1]) { - beforeEnd -= 1 - afterEnd -= 1 + if (normalization.whole && !normalization.change) { + // A true replacement cannot inherit fold state by source offset. + for (const markerFrom of this.collapsedMarkers) invalidated.add(markerFrom) + } + if (normalization.whole && normalization.change) { + const { from, to } = normalization.change + for (const markerFrom of this.collapsedMarkers) { + if (from < markerFrom + 1 && markerFrom <= to) invalidated.add(markerFrom) } - if (!(beforeEnd > prefix && afterEnd > prefix)) invalidated.clear() } + const mapCollapsedMarker = (position: number) => normalization.change + ? mapPositionThroughNormalizedChange(position, 1, normalization.change) + : update.changes.mapPos(position, 1) this.collapsedMarkers = new Set([...this.collapsedMarkers] .filter(from => !invalidated.has(from)) - .map(from => update.changes.mapPos(from, 1))) + .map(mapCollapsedMarker)) this.cancelFoldAnimations() } const foldToggles = update.transactions.flatMap(transaction => transaction.effects) @@ -681,8 +864,12 @@ export const listBulletWidget = ViewPlugin.fromClass( }) } const syntaxTreeChanged = syntaxTree(update.startState) !== syntaxTree(update.state) + const mappedMarkers = update.docChanged ? mapListMarkers(update, this.markers) : this.markers + const nextTypedSemantics = typedRecordSemantics(update.view, mappedMarkers) + const typedSemanticsChanged = !sameTypedRecordSemantics(this.typedSemantics, nextTypedSemantics) const canMapDecorations = update.docChanged && foldToggles.length === 0 + && !typedSemanticsChanged && canMapTaskBodyChanges(update, this.markers) if ( update.docChanged || @@ -691,13 +878,51 @@ export const listBulletWidget = ViewPlugin.fromClass( syntaxTreeChanged || foldToggles.length > 0 || disclosureChanged + || typedSemanticsChanged ) { if (canMapDecorations) { - this.markers = mapListMarkers(update, this.markers) - this.decorations = this.decorations.map(update.changes) - return + this.markers = mappedMarkers + this.typedSemantics = nextTypedSemantics + // The edit is after every task marker, so its decoration topology, + // visibility, and geometry are unchanged. CM can map the ranges; + // overlay metadata is refreshed by semantic identity below. + this.decorations = this.decorations.map(update.changes) + this.activeMarkerFroms = new Set([...this.activeMarkerFroms] + .map(from => update.changes.mapPos(from, 1))) + this.visualPoints = this.visualPoints.map(point => ({ + ...point, + from: update.changes.mapPos(point.from, 1), + })) + this.paintedPoints = this.paintedPoints.map(point => ({ + ...point, + from: update.changes.mapPos(point.from, 1), + })) + this.animationTarget = this.animationTarget?.map(point => ({ + ...point, + from: update.changes.mapPos(point.from, 1), + })) ?? null + if (this.durableAnchorFrom !== null) { + this.durableAnchorFrom = update.changes.mapPos(this.durableAnchorFrom, 1) + } + if (this.layoutFrame !== null) { + cancelAnimationFrame(this.layoutFrame) + this.layoutFrame = null + } + this.suppressMetadataGeometryMeasure = true + setTimeout(() => { + this.suppressMetadataGeometryMeasure = false + }, 0) + this.updateMarkerMetadata() + return + } + if (update.docChanged || syntaxTreeChanged) { + this.markers = reconcileListMarkerIdentity( + update, + this.markers, + getListMarkers(update.view), + () => this.nextMarkerIdentity++, + ) } - if (update.docChanged || syntaxTreeChanged) this.markers = getListMarkers(update.view) const markers = this.markers // Mapping a position through an edit does not prove it still denotes // the same kind of list item. Keep only current foldable parents. @@ -707,14 +932,16 @@ export const listBulletWidget = ViewPlugin.fromClass( this.collapsedMarkers = new Set( [...this.collapsedMarkers].filter(from => foldableMarkerFroms.has(from)), ) - const built = buildDecorations(update.view, markers, this.collapsedMarkers) - this.decorations = RangeSet.join([built.bullets, built.layout]) - } - if (update.docChanged || foldToggles.length > 0 || disclosureChanged) this.scheduleThreadAfterLayout(update.view) + const built = buildDecorations(update.view, markers, this.collapsedMarkers) + this.decorations = RangeSet.join([built.bullets, built.layout]) + this.typedSemantics = typedRecordSemantics(update.view, markers) + } + if ((update.docChanged && !canMapDecorations) || foldToggles.length > 0 || disclosureChanged || typedSemanticsChanged) this.scheduleThreadAfterLayout(update.view) else if (update.selectionSet) this.scheduleThread(update.view) } - docViewUpdate(view: EditorView) { - this.scheduleThread(view) + docViewUpdate(view: EditorView) { + if (this.suppressMetadataGeometryMeasure) return + this.scheduleThread(view) } destroy() { this.destroyed = true @@ -724,12 +951,21 @@ export const listBulletWidget = ViewPlugin.fromClass( this.fontSet?.removeEventListener('loadingdone', this.onExternalLayoutChange) this.cancelAnimations(true) this.cancelFoldAnimations() + // Folded lines belong to contentDOM, not the overlay. Restore their + // focusability before the plugin is torn down so source mode and a + // destroyed view never leave stale inert/tabindex state behind. + this.setFoldedInteractivity( + Array.from(this.view.contentDOM.querySelectorAll('.cm-line[inert]')), + false, + ) if (this.layoutFrame !== null) cancelAnimationFrame(this.layoutFrame) this.thread.remove() this.glyphLayer.remove() this.glyphElements.clear() this.recordGlyphButtons.clear() + for (const button of this.foldControls.values()) button.remove() this.foldLayer.remove() + this.foldControls.clear() } private scheduleThreadAfterLayout(view: EditorView) { if (this.layoutFrame !== null) cancelAnimationFrame(this.layoutFrame) @@ -741,48 +977,73 @@ export const listBulletWidget = ViewPlugin.fromClass( return } this.layoutFrame = null - this.scheduleThread(view) + this.scheduleThread(view, true) } this.layoutFrame = requestAnimationFrame(settle) } - private scheduleThread(view: EditorView) { + private scheduleThread(view: EditorView, force = false) { + if (this.measurePending && !force) return + this.measurePending = true + if (isProfilingEnabled()) { + recordPerfSample('live-preview.decoration', 0, { label: 'list-overlay.requestMeasure' }) + } view.requestMeasure({ key: this, - read: measuredView => { + read: measuredView => { + // CM's measure queue owns the actual coalescing. Release our guard + // once this request has entered its read phase so a later layout + // invalidation is not lost while the write phase is pending. + this.measurePending = false + const readStarted = performance.now() const contentRect = measuredView.contentDOM.getBoundingClientRect() const markers = this.markers + const markerDom = new Map() + for (const line of measuredView.contentDOM.querySelectorAll('.cm-line.cm-list-item')) { + if (line.classList.contains('cm-list-folded-child')) continue + for (const element of line.querySelectorAll('.cm-list-bullet, .cm-list-bullet-source')) { + const markerFrom = Number(element.dataset.listMarkerFrom) + if (Number.isFinite(markerFrom) && !markerDom.has(markerFrom)) { + markerDom.set(markerFrom, { line, sourceMarker: element }) + } + } + } const visibleMarkers = markers.filter(marker => measuredView.visibleRanges.some(range => marker.item.from <= range.to && range.from <= marker.item.to, )) const activeMarkers = getActiveBulletMarkers(measuredView, markers) - const isHiddenByCollapsedAncestor = (marker: ListMarker) => markers.some(parent => parent.childList - && this.collapsedMarkers.has(parent.from) - && parent.childList.from <= marker.from - && marker.from < parent.childList.to) + const activeMarkerSet = new Set(activeMarkers) + const collapsedRanges = markers + .filter(parent => parent.childList && this.collapsedMarkers.has(parent.from)) + .map(parent => parent.childList!) + .sort((left, right) => left.from - right.from) + const hiddenMarkerSet = new Set() + let collapsedRangeIndex = 0 + for (const marker of markers) { + while (collapsedRangeIndex < collapsedRanges.length + && collapsedRanges[collapsedRangeIndex]!.to <= marker.from) collapsedRangeIndex++ + if (collapsedRanges[collapsedRangeIndex]?.from <= marker.from) hiddenMarkerSet.add(marker) + } // The thread is visual state. It may never point at a descendant // whose row is hidden by a fold. - const visibleActiveMarkers = activeMarkers.filter(marker => !isHiddenByCollapsedAncestor(marker)) + const visibleActiveMarkers = activeMarkers.filter(marker => !hiddenMarkerSet.has(marker)) const activeMarkerFrom = visibleActiveMarkers.reduce( (deepest, marker) => Math.max(deepest, marker.from), -1, ) + const rootFontSize = parseFloat(getComputedStyle(measuredView.dom.ownerDocument.documentElement).fontSize) + const markerPoints = new Map() const markerPoint = (marker: ListMarker) => { - let sourceMarker: HTMLElement | null = null - let line: HTMLElement | null = null - for (const candidateLine of measuredView.contentDOM.querySelectorAll('.cm-line.cm-list-item')) { - if (candidateLine.classList.contains('cm-list-folded-child')) continue - const candidateMarker = Array.from(candidateLine.querySelectorAll( - `.cm-list-bullet[data-list-marker-from="${marker.from}"], .cm-list-bullet-source[data-list-marker-from="${marker.from}"]`, - )).find(element => element.closest('.cm-line') === candidateLine) - if (!candidateMarker) continue - sourceMarker = candidateMarker - line = candidateLine - break - } + if (markerPoints.has(marker.from)) return markerPoints.get(marker.from)! + const domMarker = markerDom.get(marker.from) + const sourceMarker = domMarker?.sourceMarker ?? null + let line = domMarker?.line ?? null const dom = line ? null : measuredView.domAtPos(marker.from) const element = dom?.node instanceof Element ? dom.node : dom?.node.parentElement line = line ?? element?.closest('.cm-line') ?? null - if (!line) return null + if (!line) { + markerPoints.set(marker.from, null) + return null + } const lineRect = line.getBoundingClientRect() const markerRect = sourceMarker?.getBoundingClientRect() const markerCoords = measuredView.coordsAtPos(marker.from, 1) @@ -791,7 +1052,10 @@ export const listBulletWidget = ViewPlugin.fromClass( : markerCoords ? markerCoords.left - contentRect.left : null - if (markerX === null) return null + if (markerX === null) { + markerPoints.set(marker.from, null) + return null + } const sourceLine = measuredView.state.doc.lineAt(marker.from) const prefix = sourceLine.text.match(/^\s*(?:[-+*]|\d+[.)])\s+(?:\[[ xX]\]\s+)?/)?.[0] ?? '' const textCoords = measuredView.coordsAtPos(sourceLine.from + prefix.length, 1) @@ -800,7 +1064,7 @@ export const listBulletWidget = ViewPlugin.fromClass( : textCoords ? (textCoords.top + textCoords.bottom) / 2 - contentRect.top : (lineRect.top + lineRect.bottom) / 2 - contentRect.top - return { + const point = { // The decorated source marker is the authoritative horizontal // anchor. Reading its real browser rect avoids duplicating CSS // padding, indentation, font, and zoom arithmetic in JS. @@ -808,12 +1072,14 @@ export const listBulletWidget = ViewPlugin.fromClass( y: markerY, from: marker.from, } + markerPoints.set(marker.from, point) + return point } const glyphs = visibleMarkers.flatMap(marker => { const typedNodeId = typedNodeIdForMarker(measuredView, marker) if (marker.bulletDepth === 0 && !typedNodeId) return [] - if (isHiddenByCollapsedAncestor(marker)) return [] - const isActive = visibleActiveMarkers.includes(marker) + if (hiddenMarkerSet.has(marker)) return [] + const isActive = activeMarkerSet.has(marker) const isCurrent = marker.from === activeMarkerFrom if (!typedNodeId && ((isCurrent && isNearCursor(measuredView, marker.from, marker.to)) || (!isActive && isNearCursor(measuredView, marker.from, marker.to)))) return [] @@ -821,19 +1087,20 @@ export const listBulletWidget = ViewPlugin.fromClass( return point ? [{ ...point, from: marker.from, + identity: marker.identity, style: bulletStyleForDepth(marker.bulletDepth || 1), typedNodeId, active: isActive, }] : [] }) - const foldControls = visibleMarkers.flatMap(marker => { + const foldControls: FoldControlMeasurement[] = visibleMarkers.flatMap(marker => { // Read mode shares live-preview rendering but not its editor // interactions. Folding is intentionally live-preview-only. if (measuredView.state.readOnly) return [] if (!marker.childList) return [] // Overlay controls live outside contentDOM, so CSS-collapsed // descendant rows do not hide them automatically. - if (isHiddenByCollapsedAncestor(marker)) return [] + if (hiddenMarkerSet.has(marker)) return [] const point = markerPoint(marker) if (!point) return [] const typedNodeId = typedNodeIdForMarker(measuredView, marker) @@ -842,18 +1109,17 @@ export const listBulletWidget = ViewPlugin.fromClass( // One absolute overlay column in the editor's existing side // padding. Folding belongs to the list gutter, not beside each // marker: nesting and a typed Record must never move its head. - x: -0.45 * parseFloat(getComputedStyle(measuredView.dom.ownerDocument.documentElement).fontSize), + x: -0.45 * rootFontSize, y: point.y, from: marker.from, + identity: marker.identity!, typedNodeId, collapsed: this.collapsedMarkers.has(marker.from), }] }) const points = visibleActiveMarkers .map(marker => { - const sourceMarker = measuredView.contentDOM.querySelector( - `.cm-list-bullet-source[data-list-marker-from="${marker.from}"]`, - ) + const sourceMarker = markerDom.get(marker.from)?.sourceMarker if (sourceMarker) { const range = measuredView.dom.ownerDocument.createRange() range.selectNodeContents(sourceMarker) @@ -869,7 +1135,7 @@ export const listBulletWidget = ViewPlugin.fromClass( return markerPoint(marker) }) .filter((point): point is TrailPoint => point !== null) - return { + const measurement = { points, glyphs, foldControls, @@ -878,35 +1144,52 @@ export const listBulletWidget = ViewPlugin.fromClass( offsetLeft: contentRect.left - measuredView.scrollDOM.getBoundingClientRect().left + measuredView.scrollDOM.scrollLeft, offsetTop: contentRect.top - measuredView.scrollDOM.getBoundingClientRect().top + measuredView.scrollDOM.scrollTop, } + if (isProfilingEnabled()) { + recordPerfSample('live-preview.decoration', performance.now() - readStarted, { + label: 'list-overlay.read', + detail: { visibleMarkers: visibleMarkers.length, glyphs: glyphs.length, foldControls: foldControls.length }, + }) + } + return measurement }, - write: measurement => { + write: measurement => { + const writeStarted = performance.now() + let created = 0 + let reused = 0 + let removed = 0 const activeGlyphKeys = new Set() const glyphElements = measurement.glyphs.map(glyph => { - const key = glyph.typedNodeId ? `record:${glyph.typedNodeId}` : `bullet:${glyph.from}:${glyph.style}` + const key = glyph.typedNodeId ? `record:${glyph.typedNodeId}` : `bullet:${glyph.identity}` activeGlyphKeys.add(key) - const glyphElement = this.glyphElements.get(key) ?? (glyph.typedNodeId + const existing = this.glyphElements.get(key) + const glyphElement = existing ?? (glyph.typedNodeId ? document.createElement('button') : document.createElement('span')) + if (existing) reused++ + else created++ this.glyphElements.set(key, glyphElement) - glyphElement.className = `cm-list-bullet-glyph cm-list-bullet-glyph-${glyph.typedNodeId ? 'record' : glyph.style}` - glyphElement.dataset.listMarkerFrom = String(glyph.from) + const className = `cm-list-bullet-glyph cm-list-bullet-glyph-${glyph.typedNodeId ? 'record' : glyph.style}` + if (glyphElement.className !== className) glyphElement.className = className + setAttributeIfChanged(glyphElement, 'data-list-marker-from', String(glyph.from)) + setAttributeIfChanged(glyphElement, 'data-list-glyph-identity', String(glyph.identity)) if (glyph.typedNodeId) { if (!glyphElement.firstElementChild) glyphElement.appendChild(recordGlyphSvg(this.view.dom.ownerDocument)) } else { - glyphElement.textContent = BULLET_GLYPHS[glyph.style] - glyphElement.setAttribute('aria-hidden', 'true') + const text = BULLET_GLYPHS[glyph.style] + if (glyphElement.textContent !== text) glyphElement.textContent = text + setAttributeIfChanged(glyphElement, 'aria-hidden', 'true') } - glyphElement.style.left = `${glyph.x}px` - glyphElement.style.top = `${glyph.y}px` + setStyleIfChanged(glyphElement, 'left', `${glyph.x}px`) + setStyleIfChanged(glyphElement, 'top', `${glyph.y}px`) if (glyph.typedNodeId) { const button = glyphElement as HTMLButtonElement - button.type = 'button' - button.removeAttribute('aria-hidden') - button.setAttribute('aria-label', 'Toggle Record fields') + if (button.type !== 'button') button.type = 'button' + if (button.hasAttribute('aria-hidden')) button.removeAttribute('aria-hidden') + setAttributeIfChanged(button, 'aria-label', 'Toggle Record fields') const collapsed = this.view.state.facet(recordDisclosureState).collapsedNodeIds.has(glyph.typedNodeId) - button.setAttribute('aria-expanded', String(!collapsed)) - button.dataset.active = String(this.view.state.doc.lineAt(this.view.state.selection.main.head).from === this.view.state.doc.lineAt(glyph.from).from) + setAttributeIfChanged(button, 'aria-expanded', String(!collapsed)) + setAttributeIfChanged(button, 'data-active', String(this.view.state.doc.lineAt(this.view.state.selection.main.head).from === this.view.state.doc.lineAt(glyph.from).from)) if (!this.recordGlyphButtons.has(glyph.typedNodeId)) { button.addEventListener('pointerdown', event => event.stopPropagation()) button.addEventListener('mousedown', event => { event.preventDefault(); event.stopPropagation() }) @@ -918,12 +1201,13 @@ export const listBulletWidget = ViewPlugin.fromClass( }) this.recordGlyphButtons.set(glyph.typedNodeId, button) } - button.dataset.recordNodeId = glyph.typedNodeId + setAttributeIfChanged(button, 'data-record-node-id', glyph.typedNodeId) } return glyphElement }) for (const [key, element] of this.glyphElements) { if (activeGlyphKeys.has(key)) continue + removed++ element.remove() if (key.startsWith('record:')) this.recordGlyphButtons.delete(key.slice('record:'.length)) this.glyphElements.delete(key) @@ -933,37 +1217,55 @@ export const listBulletWidget = ViewPlugin.fromClass( this.glyphLayer.insertBefore(element, this.glyphLayer.children[index] ?? null) } }) - this.foldLayer.replaceChildren(...measurement.foldControls.map(control => { - const button = document.createElement('button') - button.type = 'button' - button.className = 'cm-list-fold-control' - button.dataset.listMarkerFrom = String(control.from) - button.dataset.collapsed = String(control.collapsed) - button.setAttribute('aria-label', control.collapsed ? 'Expand list' : 'Collapse list') - button.setAttribute('aria-expanded', String(!control.collapsed)) - button.textContent = '›' - button.style.left = `${control.x}px` - button.style.top = `${control.y}px` - button.addEventListener('mousedown', event => event.preventDefault()) - button.addEventListener('click', event => { - event.preventDefault() - this.toggleFold(control.from) - }) + const activeFoldKeys = new Set() + const foldElements = measurement.foldControls.map(control => { + activeFoldKeys.add(control.identity) + let button = this.foldControls.get(control.identity) + if (!button) { + button = this.view.dom.ownerDocument.createElement('button') + button.type = 'button' + button.className = 'cm-list-fold-control' + button.textContent = '›' + button.addEventListener('mousedown', event => event.preventDefault()) + button.addEventListener('click', event => { + event.preventDefault() + const markerFrom = Number((event.currentTarget as HTMLButtonElement).dataset.listMarkerFrom) + if (Number.isFinite(markerFrom)) this.toggleFold(markerFrom) + }) + this.foldControls.set(control.identity, button) + } + setAttributeIfChanged(button, 'data-list-marker-from', String(control.from)) + setAttributeIfChanged(button, 'data-list-glyph-identity', String(control.identity)) + setAttributeIfChanged(button, 'data-collapsed', String(control.collapsed)) + setAttributeIfChanged(button, 'aria-label', control.collapsed ? 'Expand list' : 'Collapse list') + setAttributeIfChanged(button, 'aria-expanded', String(!control.collapsed)) + setStyleIfChanged(button, 'left', `${control.x}px`) + setStyleIfChanged(button, 'top', `${control.y}px`) return button - })) - this.glyphLayer.style.left = `${measurement.offsetLeft}px` - this.glyphLayer.style.top = `${measurement.offsetTop}px` - this.glyphLayer.style.width = `${measurement.width}px` - this.glyphLayer.style.height = `${measurement.height}px` - this.foldLayer.style.left = `${measurement.offsetLeft}px` - this.foldLayer.style.top = `${measurement.offsetTop}px` - this.foldLayer.style.width = `${measurement.width}px` - this.foldLayer.style.height = `${measurement.height}px` - this.thread.style.left = `${measurement.offsetLeft}px` - this.thread.style.top = `${measurement.offsetTop}px` - this.thread.setAttribute('viewBox', `0 0 ${measurement.width} ${measurement.height}`) - this.thread.setAttribute('width', String(measurement.width)) - this.thread.setAttribute('height', String(measurement.height)) + }) + for (const [identity, button] of this.foldControls) { + if (activeFoldKeys.has(identity)) continue + button.remove() + this.foldControls.delete(identity) + } + foldElements.forEach((element, index) => { + if (this.foldLayer.children[index] !== element) { + this.foldLayer.insertBefore(element, this.foldLayer.children[index] ?? null) + } + }) + setStyleIfChanged(this.glyphLayer, 'left', `${measurement.offsetLeft}px`) + setStyleIfChanged(this.glyphLayer, 'top', `${measurement.offsetTop}px`) + setStyleIfChanged(this.glyphLayer, 'width', `${measurement.width}px`) + setStyleIfChanged(this.glyphLayer, 'height', `${measurement.height}px`) + setStyleIfChanged(this.foldLayer, 'left', `${measurement.offsetLeft}px`) + setStyleIfChanged(this.foldLayer, 'top', `${measurement.offsetTop}px`) + setStyleIfChanged(this.foldLayer, 'width', `${measurement.width}px`) + setStyleIfChanged(this.foldLayer, 'height', `${measurement.height}px`) + setStyleIfChanged(this.thread, 'left', `${measurement.offsetLeft}px`) + setStyleIfChanged(this.thread, 'top', `${measurement.offsetTop}px`) + setAttributeIfChanged(this.thread, 'viewBox', `0 0 ${measurement.width} ${measurement.height}`) + setAttributeIfChanged(this.thread, 'width', String(measurement.width)) + setAttributeIfChanged(this.thread, 'height', String(measurement.height)) // CSS collapse hides the row but cannot remove its descendants from // keyboard focus. Make the rendered hidden subtree inert and restore // normal focusability as soon as it is visible again. @@ -971,6 +1273,12 @@ export const listBulletWidget = ViewPlugin.fromClass( Array.from(view.contentDOM.querySelectorAll('.cm-line[inert]')), false, ) + if (isProfilingEnabled()) { + recordPerfSample('live-preview.decoration', performance.now() - writeStarted, { + label: 'list-overlay.write', + detail: { created, reused, removed, glyphs: measurement.glyphs.length, foldControls: measurement.foldControls.length }, + }) + } this.setFoldedInteractivity( Array.from(view.contentDOM.querySelectorAll('.cm-line.cm-list-folded-child')), true, @@ -995,6 +1303,19 @@ export const listBulletWidget = ViewPlugin.fromClass( }, }) } + /** Update position-bearing overlay metadata without forcing a layout read. */ + private updateMarkerMetadata() { + const markerByIdentity = new Map(this.markers.map(marker => [marker.identity, marker])) + for (const root of [this.glyphLayer, this.foldLayer, this.view.contentDOM]) { + for (const element of root.querySelectorAll('[data-list-glyph-identity]')) { + const identity = Number(element.dataset.listGlyphIdentity) + const marker = markerByIdentity.get(identity) + if (!marker) continue + setAttributeIfChanged(element, 'data-list-marker-from', String(marker.from)) + } + } + this.setAccentedMarkers(this.activeMarkerFroms) + } private toggleFold(markerFrom: number) { const marker = this.markers.find(candidate => candidate.from === markerFrom) if (!marker?.childList) return diff --git a/src/editor/extensions/obsidian-syntax-decoration.ts b/src/editor/extensions/obsidian-syntax-decoration.ts index deda8afd..6c39f9c2 100644 --- a/src/editor/extensions/obsidian-syntax-decoration.ts +++ b/src/editor/extensions/obsidian-syntax-decoration.ts @@ -19,6 +19,7 @@ import { Decoration, type DecorationSet, EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view' import { RangeSetBuilder } from '@codemirror/state' import { getFrontmatterRange, shouldSkipMarkdownEnhancement } from './markdown-context' +import { measurePerfSample } from '../../lib/perf-profile' const wikilinkBracketMark = Decoration.mark({ class: 'cm-md-wikilink-bracket' }) const frontmatterLineDeco = Decoration.line({ class: 'cm-md-frontmatter' }) @@ -35,7 +36,7 @@ interface PendingDeco { deco: Decoration } -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const decos: PendingDeco[] = [] const doc = view.state.doc const frontmatter = getFrontmatterRange(view.state) @@ -105,6 +106,10 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'obsidian-syntax', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) +} + export const obsidianSyntaxDecoration = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/table-grid.ts b/src/editor/extensions/table-grid.ts index d5d23c62..1cbfa4e4 100644 --- a/src/editor/extensions/table-grid.ts +++ b/src/editor/extensions/table-grid.ts @@ -46,6 +46,7 @@ import { RangeSetBuilder } from '@codemirror/state' import type { SyntaxNode } from '@lezer/common' import { getFrontmatterRange, isRangeInFrontmatter, isRangeInCodeContext } from './markdown-context' import { TABLE_PIPE_HIDDEN, TABLE_SEPARATOR_ANCHOR } from './hidden-text-classes' +import { measurePerfSample } from '../../lib/perf-profile' // Idiom 2 (whole-line) — see hidden-text-classes.ts. The separator // row's `|---|---|` text stays in the DOM as editable text wrapped by @@ -233,7 +234,7 @@ function pushRowDecorations( * cm-table-separator-anchor — see #269), * - per-row pipe marks, cell marks, and empty-cell widgets. */ -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const doc = view.state.doc const frontmatter = getFrontmatterRange(view.state) const decos: Array<{ from: number; to: number; deco: Decoration }> = [] @@ -344,6 +345,10 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'table-grid', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) +} + export const tableGrid = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/tag-decoration.ts b/src/editor/extensions/tag-decoration.ts index 4b22a9bc..48ebb90b 100644 --- a/src/editor/extensions/tag-decoration.ts +++ b/src/editor/extensions/tag-decoration.ts @@ -29,6 +29,7 @@ import { shouldSkipMarkdownEnhancement, } from './markdown-context' import { editorInfoField } from '../../plugin/obsidian-cm6-stubs' +import { measurePerfSample } from '../../lib/perf-profile' // Same regex as extract-metadata.ts. Matches #tag where tag starts with // \w (alphanumeric or underscore) then continues with \w, hyphen, or slash. @@ -39,7 +40,7 @@ const HEADING_RE = /^(#{1,6})\s+.+$/ const tagMarkDeco = Decoration.mark({ class: 'cm-md-tag reams-tag-chip' }) -function buildDecorations(view: EditorView): DecorationSet { +function buildDecorationsUnprofiled(view: EditorView): DecorationSet { const state = view.state const frontmatter = getFrontmatterRange(state) const fmEndOffset = frontmatter?.endOffset ?? 0 @@ -71,6 +72,10 @@ function buildDecorations(view: EditorView): DecorationSet { return builder.finish() } +function buildDecorations(view: EditorView): DecorationSet { + return measurePerfSample('live-preview.decoration', 'tag', () => buildDecorationsUnprofiled(view), { docLength: view.state.doc.length }) +} + export const tagPlugin = ViewPlugin.fromClass( class { decorations: DecorationSet diff --git a/src/editor/extensions/wikilink-widget.ts b/src/editor/extensions/wikilink-widget.ts index a789de8e..c2401520 100644 --- a/src/editor/extensions/wikilink-widget.ts +++ b/src/editor/extensions/wikilink-widget.ts @@ -19,6 +19,7 @@ import { import { type WidgetMarkerRange, } from './widget-pointer-select-guard' +import { measurePerfSample } from '../../lib/perf-profile' const PROXIMITY_CHARS = 2 @@ -117,7 +118,7 @@ interface BuildResult { markers: WidgetMarkerRange[] } -function buildDecorations( +function buildDecorationsUnprofiled( view: EditorView, config: WikilinkConfig, pointerSelectAtWikilink: WidgetMarkerRange | null, @@ -204,6 +205,14 @@ function buildDecorations( return { decorations: builder.finish(), markers } } +function buildDecorations( + view: EditorView, + config: WikilinkConfig, + pointerSelectAtWikilink: WidgetMarkerRange | null, +): BuildResult { + return measurePerfSample('live-preview.decoration', 'wikilink', () => buildDecorationsUnprofiled(view, config, pointerSelectAtWikilink), { docLength: view.state.doc.length }) +} + export function wikilinkExtension(config: WikilinkConfig) { return ViewPlugin.fromClass( class { diff --git a/src/lib/perf-profile.ts b/src/lib/perf-profile.ts index bf16a7ac..c2e1ec18 100644 --- a/src/lib/perf-profile.ts +++ b/src/lib/perf-profile.ts @@ -73,6 +73,127 @@ export function measurePerf(label: string, startMark: string): void { } } +export type PerfMetric = + | 'cm6.transaction' + | 'live-preview.decoration' + | 'persistence.write' + | 'indexing.edit' + | 'markdown.reconcile' + +export interface PerfSample { + metric: PerfMetric + duration: number + timestamp: number + editId?: number + label?: string + detail?: Record +} + +const perfSamples: PerfSample[] = [] +const MAX_PERF_SAMPLES = 10_000 +let nextEditId = 0 +let activeEditId: number | undefined +const committedEditIds = new Map() + +function perfHeadsKey(heads: readonly string[]): string { + return [...heads].sort().join(',') +} + +function committedEditKey(documentId: string, heads: readonly string[]): string { + return `${documentId}\u0000${perfHeadsKey(heads)}` +} + +/** Start the correlation window for one local document edit. */ +export function beginPerfEdit(): number | undefined { + if (!isProfilingEnabled()) return undefined + activeEditId = ++nextEditId + return activeEditId +} + +/** Return the most recently observed local edit, if profiling is active. */ +export function getLastPerfEditId(): number | undefined { + return isProfilingEnabled() ? activeEditId : undefined +} + +export function registerPerfEdit( + documentId: string, + resultingHeads: readonly string[], + editId: number | undefined, +): void { + if (!isProfilingEnabled() || editId === undefined || resultingHeads.length === 0) return + committedEditIds.set(committedEditKey(documentId, resultingHeads), editId) +} + +export function consumePerfEdit(documentId: string, observedHeads: readonly string[]): number | undefined { + if (!isProfilingEnabled() || observedHeads.length === 0) return undefined + const key = committedEditKey(documentId, observedHeads) + const editId = committedEditIds.get(key) + if (editId !== undefined) committedEditIds.delete(key) + return editId +} + +/** Store one structured timing sample. Disabled profiling does no work. */ +export function recordPerfSample( + metric: PerfMetric, + duration: number, + options: Omit = {}, +): void { + if (!isProfilingEnabled()) return + if (perfSamples.length >= MAX_PERF_SAMPLES) perfSamples.splice(0, 1000) + perfSamples.push({ + metric, + duration, + timestamp: performance.now(), + editId: options.editId ?? activeEditId, + label: options.label, + detail: options.detail, + }) +} + +/** Measure a synchronous operation and retain the raw duration. */ +export function measurePerfSample( + metric: PerfMetric, + label: string, + fn: () => T, + detail?: Record, + editId?: number, +): T { + if (!isProfilingEnabled()) return fn() + const start = performance.now() + try { + return fn() + } finally { + recordPerfSample(metric, performance.now() - start, { label, detail, editId }) + } +} + +export async function measurePerfSampleAsync( + metric: PerfMetric, + label: string, + fn: () => Promise, + detail?: Record, + editId?: number, +): Promise { + if (!isProfilingEnabled()) return fn() + const operationEditId = editId ?? activeEditId + const start = performance.now() + try { + return await fn() + } finally { + recordPerfSample(metric, performance.now() - start, { label, detail, editId: operationEditId }) + } +} + +export function getPerfSamples(): readonly PerfSample[] { + return perfSamples +} + +export function resetPerfSamples(): void { + perfSamples.length = 0 + activeEditId = undefined + committedEditIds.clear() +} + interface ProfilerStats { count: number totalMs: number diff --git a/src/services/__tests__/background-indexer-coverage.test.ts b/src/services/__tests__/background-indexer-coverage.test.ts index 702a11a2..ecbae67d 100644 --- a/src/services/__tests__/background-indexer-coverage.test.ts +++ b/src/services/__tests__/background-indexer-coverage.test.ts @@ -17,6 +17,11 @@ vi.mock('../../lib/perf-profile', () => ({ isProfilingEnabled: isProfilingEnabledMock, markPerf: vi.fn(), measurePerf: vi.fn(), + // The production code wraps mock-indexing calls in measurePerfSample; + // invoke the wrapped callback so the underlying mocks are exercised. + measurePerfSample: vi.fn((_label: string, _name: string, fn: () => void) => { fn() }), + consumePerfEdit: vi.fn(() => 1), + recordPerfSample: vi.fn(), })) // --- Idle callback stubs --- diff --git a/src/services/__tests__/document-sync-derived-index.integration.test.ts b/src/services/__tests__/document-sync-derived-index.integration.test.ts index a5be6d85..8bbe376a 100644 --- a/src/services/__tests__/document-sync-derived-index.integration.test.ts +++ b/src/services/__tests__/document-sync-derived-index.integration.test.ts @@ -144,6 +144,14 @@ async function flushScheduledIndexerWork(): Promise { } } +async function flushPendingPlainWrite(): Promise { + await vi.advanceTimersByTimeAsync(500) + await flushPendingIdleCallbacks() + // The idle callback starts an async write; let its read/refresh chain settle + // without draining the derived-index callback queued by onLocalSaved. + for (let i = 0; i < 5; i++) await flushPromises() +} + describe('document sync and derived index cross-track invariants', () => { let manager: CrossTrackDocumentManager let controller: DocumentSyncController @@ -211,8 +219,7 @@ describe('document sync and derived index cross-track invariants', () => { expect(searchService.search('draft')).toHaveLength(0) expect(manager.updateContent).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(50) - await flushPromises() + await flushPendingPlainWrite() expect(manager.updateContent).toHaveBeenCalledWith(DOC_URL, 'draft local content with [[Target]]') expect(searchService.search('draft')).toHaveLength(0) @@ -229,8 +236,7 @@ describe('document sync and derived index cross-track invariants', () => { await flushPromises() controller.queuePlainText('fresh canonical content') - await vi.advanceTimersByTimeAsync(50) - await flushPromises() + await flushPendingPlainWrite() expect(controller.getSnapshot().savePhase).toBe('saved') expect(searchService.search('fresh')).toHaveLength(0) diff --git a/src/services/__tests__/markdown-reconciliation-worker.test.ts b/src/services/__tests__/markdown-reconciliation-worker.test.ts new file mode 100644 index 00000000..f3d4adf4 --- /dev/null +++ b/src/services/__tests__/markdown-reconciliation-worker.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import * as A from '@automerge/automerge' +import { Repo } from '@automerge/automerge-repo' +import { DocumentManager } from '../document-manager' +import { reconcileMarkdownWorkerRequest } from '../markdown-reconciliation-worker-core' +import { WorkerMarkdownReconciliationClient } from '../markdown-reconciliation-worker-client' +import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from '../markdown-reconciliation-worker-protocol' +import type { PushworkFileDocument } from '../../types/pushwork' +import { getHeads } from '@automerge/automerge' +import { visibleTextFromDoc } from '../../format-preservation' + +class FakeWorker { + onmessage: ((event: MessageEvent) => void) | null = null + onerror: ((event: ErrorEvent) => void) | null = null + terminated = false + readonly responses: MarkdownWorkerResponse[] = [] + + postMessage(request: MarkdownWorkerRequest): void { + queueMicrotask(() => { + if (this.terminated) return + try { + const response = reconcileMarkdownWorkerRequest(request) + this.responses.push(response) + this.onmessage?.({ data: response } as MessageEvent) + } catch (error) { + this.onerror?.({ message: error instanceof Error ? error.message : String(error) } as ErrorEvent) + } + }) + } + + terminate(): void { + this.terminated = true + } +} + +class DelayedWorker extends FakeWorker { + readonly requests: MarkdownWorkerRequest[] = [] + + override postMessage(request: MarkdownWorkerRequest): void { + this.requests.push(request) + } + + complete(index: number): void { + const request = this.requests[index] + if (!request) throw new Error(`Missing delayed worker request ${index}`) + const response = reconcileMarkdownWorkerRequest(request) + this.responses.push(response) + this.onmessage?.({ data: response } as MessageEvent) + } +} + +class FailingWorker extends FakeWorker { + override postMessage(_request: MarkdownWorkerRequest): void { + queueMicrotask(() => this.onerror?.({ message: 'test worker failure' } as ErrorEvent)) + } +} + +describe('Markdown reconciliation worker path', () => { + it('imports a fresh-actor incremental change through the owning repo', async () => { + const repo = new Repo() + const worker = new FakeWorker() + const manager = new DocumentManager(repo, { markdownWorkerFactory: () => worker }) + const url = manager.createFile('worker.md') + const before = await repo.find(url) + const beforeSnapshot = A.save(before.doc()) + const result = await manager.updateContent(url, '# Worker\n\n**text**') + const after = before.doc() + const response = worker.responses[0] + + expect(result.content).toBe('# Worker\n\n**text**') + if (!response || response.type !== 'success') throw new Error('Worker did not return a success response') + expect(response.changes.byteLength).toBeGreaterThan(0) + const replay = A.load(beforeSnapshot) + const replayed = A.loadIncremental(replay, response?.changes ?? new Uint8Array()) + expect(visibleTextFromDoc(replayed as unknown as A.Doc)).toBe('# Worker\n\n**text**') + expect(getHeads(after as A.Doc)).toEqual(result.heads) + expect(A.spans(after as A.Doc, ['content']).some(span => span.type === 'block')).toBe(true) + manager.dispose() + }) + + it('keeps scalar documents on the scalar schema', async () => { + const repo = new Repo() + const manager = new DocumentManager(repo, { markdownWorkerFactory: () => new FakeWorker() }) + const url = manager.createFile('worker.txt') + await expect(manager.updateContent(url, 'plain scalar')).resolves.toMatchObject({ content: 'plain scalar' }) + }) + + it('falls back synchronously when the worker fails', async () => { + const repo = new Repo() + const manager = new DocumentManager(repo, { markdownWorkerFactory: () => new FailingWorker() }) + const url = manager.createFile('fallback.md') + await expect(manager.updateContent(url, 'fallback')).resolves.toMatchObject({ content: 'fallback' }) + }) + + it('rejects a response from an older request', async () => { + let worker: FakeWorker | undefined + const client = new WorkerMarkdownReconciliationClient(worker = new FakeWorker()) + const request = { + requestId: 1, + generation: 1, + snapshot: A.save(A.from({ content: '' })), + baseHeads: getHeads(A.from({ content: '' })), + markdown: '', + schema: 'scalar' as const, + enableNativeIdentityReconciliation: false, + } + const first = client.reconcile(request) + worker.onmessage?.({ data: { type: 'success', requestId: 1, generation: 0, changes: new Uint8Array() } } as MessageEvent) + expect(worker.terminated).toBe(false) + await expect(first).resolves.toBeDefined() + client.dispose() + }) + + it('does not supersede concurrent saves for different documents', async () => { + const repo = new Repo() + const worker = new DelayedWorker() + const manager = new DocumentManager(repo, { markdownWorkerFactory: () => worker }) + const urlA = manager.createFile('a.md') + const urlB = manager.createFile('b.md') + + const saveA = manager.updateContent(urlA, '# A') + const saveB = manager.updateContent(urlB, '# B') + await Promise.resolve() + await Promise.resolve() + expect(worker.requests).toHaveLength(2) + worker.complete(0) + worker.complete(1) + + await expect(saveA).resolves.toMatchObject({ content: '# A' }) + await expect(saveB).resolves.toMatchObject({ content: '# B' }) + await expect(manager.readText(urlA)).resolves.toMatchObject({ content: '# A' }) + await expect(manager.readText(urlB)).resolves.toMatchObject({ content: '# B' }) + }) +}) diff --git a/src/services/background-indexer.ts b/src/services/background-indexer.ts index 43ffb11e..12d5284c 100644 --- a/src/services/background-indexer.ts +++ b/src/services/background-indexer.ts @@ -11,7 +11,7 @@ import { } from './derived-index-persistence' import { isFolderEntry } from '../types/pushwork' import type { TFile } from '../plugin/files' -import { isProfilingEnabled, markPerf, measurePerf } from '../lib/perf-profile' +import { consumePerfEdit, isProfilingEnabled, markPerf, measurePerf, measurePerfSample, recordPerfSample } from '../lib/perf-profile' import { headsKey, type Heads } from '../sync/heads' import { TypeCatalog, type TypeCatalogSnapshot } from '../types-records/type-catalog' import { RootFrontmatterRecords } from '../types-records/root-frontmatter-records' @@ -79,6 +79,7 @@ interface DocumentToIndex { path: string retryCount: number generation: DerivedIndexWorkGeneration + queuedAt?: number } interface DiscoveryPass { @@ -392,6 +393,7 @@ export class BackgroundIndexer { queuedDoc.path = path queuedDoc.retryCount = 0 queuedDoc.generation = generation + queuedDoc.queuedAt = isProfilingEnabled() ? performance.now() : undefined this.discoveryPass?.queuedUrls.add(url) return } @@ -402,6 +404,7 @@ export class BackgroundIndexer { path, retryCount: 0, generation, + queuedAt: isProfilingEnabled() ? performance.now() : undefined, }) this.discoveryPass?.queuedUrls.add(url) @@ -477,6 +480,7 @@ export class BackgroundIndexer { path: newPath, retryCount: 0, generation, + queuedAt: isProfilingEnabled() ? performance.now() : undefined, }) this.discoveryPass?.queuedUrls.add(url) @@ -891,6 +895,7 @@ export class BackgroundIndexer { if (!this.isLifecycleCurrent(lifecycle) || !this.isWorkGenerationCurrent(doc.url, doc.generation)) return false this.lastHeadsByUrl.set(doc.url, headsKey(searchHeads)) this.lastHeadsValueByUrl.set(doc.url, [...searchHeads]) + consumePerfEdit(String(doc.url), searchHeads) return false } } @@ -909,6 +914,14 @@ export class BackgroundIndexer { // After the guard above, observedHeads is defined and heads is non-empty. const observedHeadsKey = headsKey(observedText.heads) + const editId = consumePerfEdit(String(doc.url), observedText.heads) + if (isProfilingEnabled() && doc.queuedAt !== undefined) { + recordPerfSample('indexing.edit', performance.now() - doc.queuedAt, { + editId, + label: 'indexing.queue-wait', + detail: { documentId: String(doc.url) }, + }) + } // The heads short-circuit only fires when every derived cache reports // this URL fully indexed under the same name/path. @@ -922,6 +935,7 @@ export class BackgroundIndexer { } const content = observedText.content + const indexStarted = isProfilingEnabled() ? performance.now() : 0 const typedResult = this.typesRecordsEnabled ? await this.buildTypedContribution(doc, observedText) : undefined @@ -963,18 +977,34 @@ export class BackgroundIndexer { return false // No change in either index } - // Update search index if needed if (!status.searchUpToDate) { - this.searchService.indexDocument(doc.url, doc.name, doc.path, content, observedText.heads) + measurePerfSample('indexing.edit', 'search.indexDocument', () => { + this.searchService.indexDocument(doc.url, doc.name, doc.path, content, observedText.heads) + }, { contentLength: content.length }, editId) } - - // Always update metadata cache if not up to date if (!status.metadataUpToDate) { - this.metadataCache.updateFile(doc.url, doc.name, content, contentHash, observedText.heads) - this.metadataCache.notifyFileResolved(doc.url) + measurePerfSample('indexing.edit', 'metadata.updateFile', () => { + this.metadataCache.updateFile(doc.url, doc.name, content, contentHash, observedText.heads) + this.metadataCache.notifyFileResolved(doc.url) + }, { contentLength: content.length }, editId) } if (!status.typedUpToDate && typedContribution) { - this.typedNodeCatalog?.replaceDocument(typedContribution) + measurePerfSample('indexing.edit', 'typed.replaceDocument', () => { + this.typedNodeCatalog?.replaceDocument(typedContribution) + }, { contentLength: content.length }, editId) + } + if (isProfilingEnabled()) { + const indexDuration = performance.now() - indexStarted + recordPerfSample('indexing.edit', indexDuration, { + editId, + label: 'indexDocument', + detail: { contentLength: content.length }, + }) + recordPerfSample('markdown.reconcile', indexDuration, { + editId, + label: 'derived.index-refresh', + detail: { contentLength: content.length }, + }) } this.lastHeadsByUrl.set(doc.url, observedHeadsKey) diff --git a/src/services/document-manager.ts b/src/services/document-manager.ts index f87ef07f..2ebdc7d1 100644 --- a/src/services/document-manager.ts +++ b/src/services/document-manager.ts @@ -15,6 +15,8 @@ import type { InlineSemanticChange } from '../editor/inline-semantic-ops' import type { Heads } from '../sync/heads' import { headsEqual, normalizeHeads } from '../sync/heads' import { readBlockFactDocumentAt, writeBlockFactDraft, type BlockScalar } from '../types-records/block-record-facts' +import { createMarkdownWorkerClient, MarkdownWorkerReconciliationError, type MarkdownWorkerClient, type MarkdownWorkerFactory } from './markdown-reconciliation-worker-client' +import { isProfilingEnabled, recordPerfSample } from '../lib/perf-profile' export class DocumentManagerValidationError extends Error { constructor(readonly documentId: AutomergeUrl, readonly reason: 'not-file' | 'binary-content' | 'malformed-pushwork' | 'invalid-input') { @@ -50,6 +52,13 @@ export class DocumentManagerMarkdownReconciliationError extends Error { } } +class MarkdownWorkerRequestSupersededError extends Error { + constructor() { + super('Markdown reconciliation worker response was superseded') + this.name = 'AbortError' + } +} + function isPlainRecord(value: unknown): value is Record { return Object.prototype.toString.call(value) === '[object Object]' } @@ -137,6 +146,10 @@ interface CreateFileOptions { contentType?: string } +export interface DocumentManagerOptions { + readonly markdownWorkerFactory?: MarkdownWorkerFactory +} + /** * Compatibility-API named result for the canonical text write seam. * Conceptually the `url` field is the stable document identity; its @@ -169,7 +182,20 @@ export type MarkdownReadResult = * through to raw DocHandles except at explicitly documented low-level seams. */ export class DocumentManager { - constructor(private readonly repo: Repo) {} + private readonly markdownWorker: MarkdownWorkerClient | undefined + private markdownWorkerGeneration = 0 + private markdownWorkerRequestId = 0 + private readonly markdownWorkerLatestRequestByDocument = new Map() + + constructor(private readonly repo: Repo, options?: DocumentManagerOptions) { + this.markdownWorker = createMarkdownWorkerClient(options?.markdownWorkerFactory) + } + + dispose(): void { + this.markdownWorkerGeneration += 1 + this.markdownWorkerLatestRequestByDocument.clear() + this.markdownWorker?.dispose() + } private validateTextContentShape(url: AutomergeUrl, doc: PushworkFileDocument): 'scalar' | 'rich' { return validateTextContentShapeValue(url, doc) @@ -276,6 +302,86 @@ export class DocumentManager { } } + private async applyPlainContentWithWorker( + url: AutomergeUrl, + handle: DocHandle, + newContent: string, + baseHeads: Heads, + ownership?: WriteOwnership, + ): Promise { + if (!this.markdownWorker || !A.hasHeads(handle.doc() as Doc, [...baseHeads])) return undefined + const current = handle.doc() + const snapshotStarted = isProfilingEnabled() ? performance.now() : 0 + const view = A.view(current as Doc, [...baseHeads]) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - snapshotStarted, { label: 'main.snapshot.view' }) + const shape = this.validateTextContentShape(url, view) + const isPublicMarkdown = view.contentType === 'public.markdown' && (shape === 'rich' || (shape === 'scalar' && view.content === '')) + const saveStarted = isProfilingEnabled() ? performance.now() : 0 + const snapshot = A.save(view) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - saveStarted, { label: 'main.snapshot.save', detail: { bytes: snapshot.byteLength } }) + const generation = this.markdownWorkerGeneration + const requestId = ++this.markdownWorkerRequestId + this.markdownWorkerLatestRequestByDocument.set(url, requestId) + let response + try { + const roundTripStarted = isProfilingEnabled() ? performance.now() : 0 + response = await this.markdownWorker.reconcile({ + requestId, + generation, + snapshot, + baseHeads, + markdown: newContent, + schema: isPublicMarkdown ? 'rich' : 'scalar', + enableNativeIdentityReconciliation: isPublicMarkdown, + }) + if (isProfilingEnabled()) { + recordPerfSample('markdown.reconcile', performance.now() - roundTripStarted, { + label: 'worker.round-trip', + detail: { workerExecutionCount: response.workerExecutionCount ?? 0, resultBytes: response.changes.byteLength }, + }) + if (response.workerDurationMs !== undefined) { + recordPerfSample('markdown.reconcile', response.workerDurationMs, { + label: 'worker.execution', + detail: { workerExecutionCount: response.workerExecutionCount ?? 0 }, + }) + } + } + } catch (error) { + if (generation !== this.markdownWorkerGeneration || this.markdownWorkerLatestRequestByDocument.get(url) !== requestId) throw new MarkdownWorkerRequestSupersededError() + if (error instanceof MarkdownWorkerReconciliationError && error.failure.kind === 'reconciliation') { + throw new DocumentManagerMarkdownReconciliationError(url, 'plain', baseHeads, error.failure.issues ?? [], 'draft-error', { cause: error }) + } + // Worker construction/runtime failures are explicitly recoverable. The + // existing changeAt path remains the source of truth for fallback. + return undefined + } + if (generation !== this.markdownWorkerGeneration || this.markdownWorkerLatestRequestByDocument.get(url) !== requestId) throw new MarkdownWorkerRequestSupersededError() + // Let the editor paint the already-committed local transaction before the + // synchronous public repo.import/loadIncremental path runs. Ownership is + // still established around the import itself, and the request is rechecked + // after the yield so a newer worker result cannot be applied out of order. + await new Promise(resolve => setTimeout(resolve, 0)) + if (generation !== this.markdownWorkerGeneration || this.markdownWorkerLatestRequestByDocument.get(url) !== requestId) throw new MarkdownWorkerRequestSupersededError() + ownership?.enter() + try { + const docId = url.slice('automerge:'.length) as AutomergeRepoDocumentId + const importStarted = isProfilingEnabled() ? performance.now() : 0 + this.repo.import(response.changes, { docId }) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - importStarted, { label: 'main.repo.import', detail: { bytes: response.changes.byteLength } }) + } catch { + if (generation !== this.markdownWorkerGeneration || this.markdownWorkerLatestRequestByDocument.get(url) !== requestId) throw new MarkdownWorkerRequestSupersededError() + return undefined + } finally { + ownership?.exit() + } + const result = { + documentId: url, + heads: normalizeHeads(getHeads(handle.doc() as Doc)), + content: visibleTextFromDoc(handle.doc() as unknown as A.Doc), + } + return result + } + /** * Create a new file document. * Returns the AutomergeUrl of the created document. @@ -464,7 +570,10 @@ export class DocumentManager { if (!document) return { status: 'missing-heads', documentId: url } if (document.content instanceof Uint8Array || typeof document.content !== 'string') return { status: 'not-text', documentId: url } const heads = normalizeHeads(getHeads(document as Doc)) - return { status: 'available', documentId: url, heads, content: visibleTextFromDoc(A.clone(document) as unknown as A.Doc) } + const readStarted = isProfilingEnabled() ? performance.now() : 0 + const content = visibleTextFromDoc(A.clone(document) as unknown as A.Doc) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - readStarted, { label: 'authoritative.readText' }) + return { status: 'available', documentId: url, heads, content } } catch { return { status: 'unavailable', documentId: url } } @@ -497,7 +606,13 @@ export class DocumentManager { if (document.contentType !== 'public.markdown') return { status: 'not-markdown', documentId: url } const heads = normalizeHeads(getHeads(document as Doc)) const detached = A.clone(document) as unknown as A.Doc - return { status: 'available', documentId: url, heads, content: visibleTextFromDoc(detached), nodeIndex: buildNodeIndex(detached, { path: ['content'] }) } + const readStarted = isProfilingEnabled() ? performance.now() : 0 + const content = visibleTextFromDoc(detached) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - readStarted, { label: 'authoritative.readMarkdown' }) + const indexStarted = isProfilingEnabled() ? performance.now() : 0 + const nodeIndex = buildNodeIndex(detached, { path: ['content'] }) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - indexStarted, { label: 'authoritative.node-index' }) + return { status: 'available', documentId: url, heads, content, nodeIndex } } catch { return { status: 'unavailable', documentId: url } } @@ -614,8 +729,12 @@ export class DocumentManager { const handle = await this.repo.find(url) const doc = handle.doc() this.validateTextContentShape(url, doc) - const result = this.applyTextDraftWrite(url, handle, newContent, undefined, undefined, ownership) + const baseHeads = normalizeHeads(getHeads(handle.doc() as Doc)) + const result = await this.applyPlainContentWithWorker(url, handle, newContent, baseHeads, ownership) + ?? this.applyTextDraftWrite(url, handle, newContent, undefined, undefined, ownership) + const flushStarted = isProfilingEnabled() ? performance.now() : 0 await this.flushTextWrite(url) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - flushStarted, { label: 'main.flush' }) return result } @@ -623,8 +742,11 @@ export class DocumentManager { const handle = await this.repo.find(url) const doc = handle.doc() this.validateTextContentShape(url, doc) - const result = this.applyTextDraftWrite(url, handle, newContent, undefined, baseHeads, ownership) + const result = await this.applyPlainContentWithWorker(url, handle, newContent, normalizeHeads(baseHeads), ownership) + ?? this.applyTextDraftWrite(url, handle, newContent, undefined, baseHeads, ownership) + const flushStarted = isProfilingEnabled() ? performance.now() : 0 await this.flushTextWrite(url) + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - flushStarted, { label: 'main.flush' }) return result } diff --git a/src/services/markdown-reconciliation-worker-client.ts b/src/services/markdown-reconciliation-worker-client.ts new file mode 100644 index 00000000..a2969ccd --- /dev/null +++ b/src/services/markdown-reconciliation-worker-client.ts @@ -0,0 +1,74 @@ +import type { MarkdownWorkerRequest, MarkdownWorkerResponse, MarkdownWorkerSuccess, MarkdownWorkerSchema } from './markdown-reconciliation-worker-protocol' + +export class MarkdownWorkerReconciliationError extends Error { + constructor(readonly failure: Extract['error']) { + super(failure.message) + this.name = failure.name + } +} + +export interface MarkdownWorkerClient { + reconcile(request: Omit): Promise + dispose(): void +} + +interface WorkerLike { + onmessage: ((event: MessageEvent) => void) | null + onerror: ((event: ErrorEvent) => void) | null + postMessage(message: MarkdownWorkerRequest, transfer?: Transferable[]): void + terminate(): void +} + +export type MarkdownWorkerFactory = () => WorkerLike + +export function browserMarkdownWorkerFactory(): WorkerLike { + return new Worker(new URL('./markdown-reconciliation.worker.ts', import.meta.url), { type: 'module' }) +} + +export class WorkerMarkdownReconciliationClient implements MarkdownWorkerClient { + private disposed = false + private readonly pending = new Map void; reject: (error: Error) => void; generation: number }>() + + constructor(private readonly worker: WorkerLike) { + worker.onmessage = event => { + const response = event.data + const pending = this.pending.get(response.requestId) + if (!pending || pending.generation !== response.generation) return + this.pending.delete(response.requestId) + if (response.type === 'success') pending.resolve(response) + else pending.reject(new MarkdownWorkerReconciliationError(response.error)) + } + worker.onerror = event => { + const error = new Error(event.message || 'Markdown reconciliation worker failed') + for (const entry of this.pending.values()) entry.reject(error) + this.pending.clear() + } + } + + reconcile(request: Omit): Promise { + if (this.disposed) return Promise.reject(new Error('Markdown reconciliation worker is disposed')) + return new Promise((resolve, reject) => { + this.pending.set(request.requestId, { resolve, reject, generation: request.generation }) + const message: MarkdownWorkerRequest = { type: 'reconcile', ...request } + // The snapshot is no longer used by the caller after dispatch. Transfer + // it instead of cloning a complete Automerge history on the main thread. + this.worker.postMessage(message, [message.snapshot.buffer as ArrayBuffer]) + }) + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.worker.terminate() + for (const entry of this.pending.values()) entry.reject(new Error('Markdown reconciliation worker disposed')) + this.pending.clear() + } +} + +export function createMarkdownWorkerClient(factory: MarkdownWorkerFactory | undefined): MarkdownWorkerClient | undefined { + if (factory) return new WorkerMarkdownReconciliationClient(factory()) + if (typeof Worker === 'undefined') return undefined + return new WorkerMarkdownReconciliationClient(browserMarkdownWorkerFactory()) +} + +export type { MarkdownWorkerSchema } diff --git a/src/services/markdown-reconciliation-worker-core.ts b/src/services/markdown-reconciliation-worker-core.ts new file mode 100644 index 00000000..397107c3 --- /dev/null +++ b/src/services/markdown-reconciliation-worker-core.ts @@ -0,0 +1,32 @@ +import * as A from '@automerge/automerge' +import { applyMarkdownDraft, createMutationContext, DraftReconciliationError, getCoreSemanticRegistry, parseOnce, SCALAR_SCHEMA, analyzeMarkdown } from '../automerge-markdown' +import type { FrameworkDoc, DocConfig } from '../format-preservation/types' +import type { MarkdownWorkerRequest, MarkdownWorkerSuccess } from './markdown-reconciliation-worker-protocol' + +export function reconcileMarkdownWorkerRequest(request: MarkdownWorkerRequest): MarkdownWorkerSuccess { + const schema = request.schema === 'scalar' + ? SCALAR_SCHEMA + : { analyze: analyzeMarkdown, constructs: getCoreSemanticRegistry(), parseTree: parseOnce } + const base = A.load(request.snapshot) + const draft = A.clone(base) + const updated = A.change(draft, doc => { + const outcome = applyMarkdownDraft(doc as unknown as A.Doc, { + path: ['content'], + markdown: request.markdown, + schema, + mode: 'preserve', + docConfig: { canonicalize: 'minimal' } satisfies DocConfig, + mutationContext: createMutationContext(), + enableNativeIdentityReconciliation: request.enableNativeIdentityReconciliation, + }) + if (outcome.status === 'unresolved') { + throw new DraftReconciliationError('native-invariant', 'Markdown draft could not be reconciled', outcome.issues) + } + }) + return { + type: 'success', + requestId: request.requestId, + generation: request.generation, + changes: A.saveSince(updated, [...request.baseHeads]), + } +} diff --git a/src/services/markdown-reconciliation-worker-protocol.ts b/src/services/markdown-reconciliation-worker-protocol.ts new file mode 100644 index 00000000..73f5722f --- /dev/null +++ b/src/services/markdown-reconciliation-worker-protocol.ts @@ -0,0 +1,42 @@ +import type { Heads } from '../sync/heads' + +export type MarkdownWorkerSchema = 'scalar' | 'rich' + +export interface MarkdownWorkerRequest { + readonly type: 'reconcile' + readonly requestId: number + readonly generation: number + readonly snapshot: Uint8Array + readonly baseHeads: Heads + readonly markdown: string + readonly schema: MarkdownWorkerSchema + readonly enableNativeIdentityReconciliation: boolean +} + +export interface MarkdownWorkerSuccess { + readonly type: 'success' + readonly requestId: number + readonly generation: number + readonly changes: Uint8Array + readonly workerDurationMs?: number + readonly workerExecutionCount?: number +} + +export interface MarkdownWorkerFailure { + readonly type: 'failure' + readonly requestId: number + readonly generation: number + readonly error: { + readonly kind: 'reconciliation' | 'runtime' + readonly name: string + readonly message: string + readonly issues?: readonly { + readonly kind: 'ambiguous-correspondence' | 'malformed-marker' | 'unsupported' + readonly message: string + readonly plainIndex?: number + readonly richIndex?: number + }[] + } +} + +export type MarkdownWorkerResponse = MarkdownWorkerSuccess | MarkdownWorkerFailure diff --git a/src/services/markdown-reconciliation.worker.ts b/src/services/markdown-reconciliation.worker.ts new file mode 100644 index 00000000..2abd8e15 --- /dev/null +++ b/src/services/markdown-reconciliation.worker.ts @@ -0,0 +1,39 @@ +import { DraftReconciliationError } from '../automerge-markdown' +import type { MarkdownWorkerRequest, MarkdownWorkerResponse } from './markdown-reconciliation-worker-protocol' +import { reconcileMarkdownWorkerRequest } from './markdown-reconciliation-worker-core' + +let workerExecutionCount = 0 +const workerScope = self as unknown as { + onmessage: ((event: MessageEvent) => void) | null + postMessage(message: MarkdownWorkerResponse, transfer?: Transferable[]): void +} + +self.onmessage = (event: MessageEvent) => { + const request = event.data + if (request.type !== 'reconcile') return + try { + const started = performance.now() + const baseResponse = reconcileMarkdownWorkerRequest(request) + workerExecutionCount += 1 + const response = { + ...baseResponse, + workerDurationMs: performance.now() - started, + workerExecutionCount, + } + workerScope.postMessage(response, [response.changes.buffer as ArrayBuffer]) + } catch (error) { + const reconciliation = error instanceof DraftReconciliationError + const response: MarkdownWorkerResponse = { + type: 'failure', + requestId: request.requestId, + generation: request.generation, + error: { + kind: reconciliation ? 'reconciliation' : 'runtime', + name: error instanceof Error ? error.name : 'UnknownError', + message: error instanceof Error ? error.message : String(error), + ...(reconciliation ? { issues: error.issues } : {}), + }, + } + workerScope.postMessage(response) + } +} diff --git a/src/sync/__tests__/document-sync-changeat.integration.test.ts b/src/sync/__tests__/document-sync-changeat.integration.test.ts index 59deb1c3..d55f1a48 100644 --- a/src/sync/__tests__/document-sync-changeat.integration.test.ts +++ b/src/sync/__tests__/document-sync-changeat.integration.test.ts @@ -157,7 +157,7 @@ describe('semantic CM6 changeAt integration', () => { heads: result!.heads, content: result!.content, }) - await new Promise((resolve) => setTimeout(resolve, 100)) + await new Promise((resolve) => setTimeout(resolve, 100)) expect(semanticWrite).toHaveBeenCalledTimes(1) expect(updateContentAt).not.toHaveBeenCalled() } finally { @@ -278,8 +278,8 @@ describe('semantic CM6 changeAt integration', () => { view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: firstEnter } }) controller.queuePlainText(firstEnter) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(updateContentAt).toHaveBeenCalledTimes(1) + await new Promise((resolve) => setTimeout(resolve, 550)) + expect(updateContentAt).toHaveBeenCalledTimes(1) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: secondEnter } }) controller.queuePlainText(secondEnter) diff --git a/src/sync/__tests__/document-sync-controller.test.ts b/src/sync/__tests__/document-sync-controller.test.ts index 76a6fa0a..c776e48c 100644 --- a/src/sync/__tests__/document-sync-controller.test.ts +++ b/src/sync/__tests__/document-sync-controller.test.ts @@ -1,7 +1,11 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest' import { EditorState } from '@codemirror/state' import { EditorView } from '@codemirror/view' -import { DocumentSyncCancellationError, DocumentSyncController } from '../document-sync-controller' +import { + DocumentSyncCancellationError, + DocumentSyncController, + type DocumentSyncIdleScheduler, +} from '../document-sync-controller' import { DocumentManagerMarkdownReconciliationError } from '../../services/document-manager' import type { DocumentManager, TextReadResult } from '../../services/document-manager' import type { SubscribeReactiveDocSyncOptions } from '../reactive-doc-sync' @@ -28,6 +32,30 @@ function flushPromises(): Promise { return Promise.resolve().then(() => undefined) } +function createIdleScheduler() { + let nextId = 0 + const callbacks = new Map void>() + const scheduler: DocumentSyncIdleScheduler = { + request: vi.fn((callback) => { + const id = ++nextId + callbacks.set(id, callback) + return id + }), + cancel: vi.fn((id) => { + callbacks.delete(id as number) + }), + } + return { + scheduler, + run(id = 1) { + callbacks.get(id)?.() + }, + callback(id = 1) { + return callbacks.get(id) + }, + } +} + function createHandle() { return { on: vi.fn(), @@ -204,7 +232,7 @@ describe('DocumentSyncController', () => { controller.queuePlainText('pending same-doc work') controller.load(manager as unknown as DocumentManager, urlA) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect(manager.updateContentAt).toHaveBeenCalledWith( @@ -565,69 +593,178 @@ describe('DocumentSyncController', () => { await controller.dispose() }) - it('bounds remote-storm starvation for pending plain writes', async () => { + it('coalesces 120ms typing and writes once after quiet', async () => { + const manager = new FakeDocumentManager() + const controller = new DocumentSyncController({ + documentManager: manager as unknown as DocumentManager, + docUrl: urlA, + }) + controller.start() + await flushPromises() + + for (let i = 0; i < 6; i += 1) { + controller.queuePlainText(`typed ${i}`) + await vi.advanceTimersByTimeAsync(120) + } + + expect(manager.updateContentAt).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(379) + expect(manager.updateContentAt).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(121) + await flushPromises() + expect(manager.updateContentAt).toHaveBeenCalledTimes(1) + expect(manager.updateContentAt.mock.calls[0]?.[2]).toBe('typed 5') + await controller.dispose() + }) + + it('does not notify status subscribers for coalesced saving snapshots', async () => { + const manager = new FakeDocumentManager() + const controller = new DocumentSyncController({ + documentManager: manager as unknown as DocumentManager, + docUrl: urlA, + }) + controller.start() + await flushPromises() + + const statuses: string[] = [] + controller.subscribe(() => statuses.push(controller.getSnapshot().saveStatus.state)) + for (let i = 0; i < 6; i += 1) controller.queuePlainText(`typed ${i}`) + + expect(statuses).toEqual(['saving']) + expect(controller.getSnapshot().localGeneration).toBe(6) + + await vi.advanceTimersByTimeAsync(30_500) + await flushPromises() + expect(statuses).toContain('saved') + await controller.dispose() + }) + + it('forces a pending short-document write at the thirty-second starvation cap', async () => { + const manager = new FakeDocumentManager() + const controller = new DocumentSyncController({ + documentManager: manager as unknown as DocumentManager, + docUrl: urlA, + }) + controller.start() + await flushPromises() + + for (let elapsed = 0; elapsed < 30_000; elapsed += 120) { + controller.queuePlainText(`starved ${elapsed}`) + await vi.advanceTimersByTimeAsync(120) + } + expect(manager.updateContentAt).toHaveBeenCalledTimes(1) + await flushPromises() + + expect(manager.updateContentAt).toHaveBeenCalledTimes(1) + expect(manager.updateContentAt.mock.calls[0]?.[2]).toBe('starved 29880') + await controller.dispose() + }) + + it('debounces long documents but enforces the five-second write cap', async () => { const manager = new FakeDocumentManager() - const boundedWrite = deferred<{ documentId: DocumentId; heads: string[] }>() - manager.updateContent.mockReturnValueOnce(boundedWrite.promise) - let afterApply: (() => void) | undefined - const subscribeReactive = vi.fn((opts: SubscribeReactiveDocSyncOptions<{ content: unknown }>) => { - afterApply = () => opts.onRemoteChange?.({ documentId: urlA, heads: ['remote'] as any, content: view.state.doc.toString() }) - return vi.fn() - }) const controller = new DocumentSyncController({ documentManager: manager as unknown as DocumentManager, docUrl: urlA, - subscribeReactive, }) controller.start() - const view = new EditorView({ state: EditorState.create({ doc: 'initial A' }) }) - - await flushPromises() - controller.bindView(view) await flushPromises() - await flushPromises() - controller.queuePlainText('local') - for (let i = 0; i < 6; i += 1) { - view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: `merged ${i}` } }) - afterApply?.() - await vi.advanceTimersByTimeAsync(40) + const longText = 'x'.repeat(16_000) + controller.queuePlainText(longText) + for (let edit = 1; edit <= 7; edit += 1) { + await vi.advanceTimersByTimeAsync(700) + expect(manager.updateContentAt).not.toHaveBeenCalled() + controller.queuePlainText(`${longText}${edit}`) } - expect(manager.updateContentAt).toHaveBeenCalled() - const calls = manager.updateContentAt.mock.calls - expect(calls[calls.length - 1]?.[2]).toBe('local') - boundedWrite.resolve({ documentId: urlA, heads: ['bounded'] }) + await vi.advanceTimersByTimeAsync(99) + expect(manager.updateContentAt).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(manager.updateContentAt).toHaveBeenCalledTimes(1) + expect(manager.updateContentAt.mock.calls[0]?.[2]).toBe(`${longText}7`) + await controller.dispose() + }) + + it('defers plain persistence until the injected idle scheduler runs', async () => { + const manager = new FakeDocumentManager() + const idle = createIdleScheduler() + const controller = new DocumentSyncController({ + documentManager: manager as unknown as DocumentManager, + docUrl: urlA, + idleScheduler: idle.scheduler, + }) + controller.start() await flushPromises() - await vi.advanceTimersByTimeAsync(50) + + controller.queuePlainText('idle write') + await vi.advanceTimersByTimeAsync(500) + expect(manager.updateContentAt).not.toHaveBeenCalled() + expect(idle.scheduler.request).toHaveBeenCalledWith(expect.any(Function), 29500) + + idle.run() await flushPromises() - expect(calls[calls.length - 1]?.[2]).toBe('local') - view.destroy() + expect(manager.updateContentAt).toHaveBeenCalledTimes(1) await controller.dispose() }) - it('debounces long documents but enforces the five-second write cap', async () => { + it('cancels a stale idle callback and coalesces the newest plain text', async () => { const manager = new FakeDocumentManager() + const idle = createIdleScheduler() const controller = new DocumentSyncController({ documentManager: manager as unknown as DocumentManager, docUrl: urlA, + idleScheduler: idle.scheduler, }) controller.start() await flushPromises() - const longText = 'x'.repeat(16_000) - controller.queuePlainText(longText) - for (let edit = 1; edit <= 7; edit += 1) { - await vi.advanceTimersByTimeAsync(700) - expect(manager.updateContentAt).not.toHaveBeenCalled() - controller.queuePlainText(`${longText}${edit}`) - } - - await vi.advanceTimersByTimeAsync(99) + controller.queuePlainText('stale') + await vi.advanceTimersByTimeAsync(500) + const staleCallback = idle.callback() + controller.queuePlainText('latest') + await vi.advanceTimersByTimeAsync(500) + staleCallback?.() expect(manager.updateContentAt).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(1) + idle.run(2) + await flushPromises() + expect(manager.updateContentAt.mock.calls[0]?.[2]).toBe('latest') + await controller.dispose() + }) + + it('cancels an idle save on dispose without losing the pending text', async () => { + const manager = new FakeDocumentManager() + const idle = createIdleScheduler() + const controller = new DocumentSyncController({ + documentManager: manager as unknown as DocumentManager, + docUrl: urlA, + idleScheduler: idle.scheduler, + }) + controller.start() + await flushPromises() + controller.queuePlainText('must persist') + await vi.advanceTimersByTimeAsync(500) + const staleCallback = idle.callback() + + await controller.dispose() + staleCallback?.() + await flushPromises() + expect(manager.updateContentAt).toHaveBeenCalledTimes(1) + expect(manager.updateContentAt.mock.calls[0]?.[2]).toBe('must persist') + expect(idle.scheduler.cancel).toHaveBeenCalled() + }) + + it('falls back to a task when requestIdleCallback is unavailable', async () => { + const manager = new FakeDocumentManager() + const controller = new DocumentSyncController({ + documentManager: manager as unknown as DocumentManager, + docUrl: urlA, + }) + controller.start() + await flushPromises() + controller.queuePlainText('fallback write') + + await vi.advanceTimersByTimeAsync(500) expect(manager.updateContentAt).toHaveBeenCalledTimes(1) - expect(manager.updateContentAt.mock.calls[0]?.[2]).toBe(`${longText}7`) await controller.dispose() }) @@ -653,7 +790,7 @@ describe('DocumentSyncController', () => { await flushPromises() await flushPromises() controller.queuePlainText('stale local snapshot') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) expect(manager.updateContentAt).toHaveBeenCalledWith( urlA, expect.any(Array), 'stale local snapshot', expect.any(Object), ) @@ -662,7 +799,7 @@ describe('DocumentSyncController', () => { afterApply?.() firstWrite.resolve({ documentId: urlA, heads: ['first'] }) await flushPromises() - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() const calls = manager.updateContentAt.mock.calls @@ -693,7 +830,7 @@ describe('DocumentSyncController', () => { await flushPromises() await flushPromises() controller.queuePlainText('stale plain snapshot') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) expect(manager.updateContentAt).toHaveBeenCalledWith( urlA, expect.any(Array), 'stale plain snapshot', expect.any(Object), ) @@ -704,7 +841,7 @@ describe('DocumentSyncController', () => { firstPlainWrite.resolve({ documentId: urlA, heads: ['plain'] }) await semantic await flushPromises() - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect(manager.updateContentWithInlineSemanticOps).toHaveBeenCalledWith( @@ -749,7 +886,7 @@ describe('DocumentSyncController', () => { semanticWrite.resolve({ documentId: urlA, heads: ['semantic'] }) await semantic await flushPromises() - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect(manager.updateContentAt).not.toHaveBeenCalled() @@ -906,7 +1043,7 @@ describe('DocumentSyncController', () => { await flushPromises() controller.queuePlainText('stale plain') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) const firstSemantic = controller.queueSemanticChange('queued semantic 1', { ops: [] }) const secondSemantic = controller.queueSemanticChange('queued semantic latest', { ops: [] }) const firstRejected = expect(firstSemantic).rejects.toBeInstanceOf(DocumentSyncCancellationError) @@ -944,7 +1081,7 @@ describe('DocumentSyncController', () => { await flushPromises() controller.queuePlainText('stale plain') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) const semantic = controller.queueSemanticChange('queued semantic latest', { ops: [] }) const rejected = expect(semantic).rejects.toBeInstanceOf(DocumentSyncCancellationError) @@ -1035,7 +1172,7 @@ describe('DocumentSyncController', () => { await flushPromises() await flushPromises() controller.queuePlainText('stale plain before dispose') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: 'latest visible before dispose' } }) afterApply?.() @@ -1085,11 +1222,11 @@ describe('DocumentSyncController', () => { await flushPromises() controller.queuePlainText('retry me') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect(controller.getSnapshot().saveStatus.state).toBe('error') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) expect(manager.updateContentAt).toHaveBeenCalledTimes(2) expect(manager.updateContentAt.mock.calls[0]?.slice(1, 3)).toEqual( [manager.updateContentAt.mock.calls[1]?.[1], 'retry me'], @@ -1120,7 +1257,7 @@ describe('DocumentSyncController', () => { await flushPromises() controller.queuePlainText('rejected structural replacement') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() await vi.advanceTimersByTimeAsync(100) @@ -1180,7 +1317,7 @@ describe('DocumentSyncController', () => { controller.start() await flushPromises() controller.queuePlainText('blocked plain') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect(manager.updateContentAt).toHaveBeenCalledTimes(1) @@ -1236,7 +1373,7 @@ describe('DocumentSyncController', () => { controller.start() await flushPromises() controller.queuePlainText('blocked until remote') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect(manager.updateContentAt).toHaveBeenCalledTimes(1) @@ -1245,7 +1382,7 @@ describe('DocumentSyncController', () => { heads: ['heads:remote'], content: 'remote content', }, 0, {}) - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await vi.waitFor(() => expect(manager.updateContentAt).toHaveBeenCalledTimes(2)) expect(manager.updateContentAt.mock.calls[1]?.slice(1, 3)).toEqual([ ['heads:remote'], @@ -1271,7 +1408,7 @@ describe('DocumentSyncController', () => { controller.start() await flushPromises() controller.queuePlainText('blocked A') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await flushPromises() expect((controller as any).blockedPlain).not.toBeNull() diff --git a/src/sync/__tests__/reactive-attach.integration.test.ts b/src/sync/__tests__/reactive-attach.integration.test.ts index e9ab396e..495e49d2 100644 --- a/src/sync/__tests__/reactive-attach.integration.test.ts +++ b/src/sync/__tests__/reactive-attach.integration.test.ts @@ -81,7 +81,7 @@ describe('reactive attach frontier integration', () => { controller.queuePlainText('local while resolving') await vi.waitFor(() => expect(controller.getSnapshot().subscriptionPhase).toBe('attached')) - await new Promise(resolve => setTimeout(resolve, 75)) + await new Promise(resolve => setTimeout(resolve, 550)) expect(documentEvents).toBe(1) expect(listenersAtEvent).toBe(1) diff --git a/src/sync/__tests__/use-document-sync.test.ts b/src/sync/__tests__/use-document-sync.test.ts index b2f728dc..af9b7741 100644 --- a/src/sync/__tests__/use-document-sync.test.ts +++ b/src/sync/__tests__/use-document-sync.test.ts @@ -107,7 +107,7 @@ describe('useDocumentSync', () => { result.current.onChange('strict saved') }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -138,7 +138,7 @@ describe('useDocumentSync', () => { // Advance past debounce await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -177,7 +177,7 @@ describe('useDocumentSync', () => { }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -218,7 +218,7 @@ describe('useDocumentSync', () => { }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -264,7 +264,7 @@ describe('useDocumentSync', () => { }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -486,7 +486,7 @@ describe('useDocumentSync', () => { rerender({ docUrl: url2 }) staleOnChange('delayed old-doc text') - await vi.advanceTimersByTimeAsync(50) + await vi.advanceTimersByTimeAsync(500) await expect(staleOnSemanticChange('delayed old-doc semantic', { ops: [] })) .rejects.toBeInstanceOf(DocumentSyncCancellationError) @@ -568,7 +568,7 @@ describe('useDocumentSync', () => { await act(async () => { releaseSemanticSave?.() await semanticSave - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -595,7 +595,7 @@ describe('useDocumentSync', () => { }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -685,7 +685,7 @@ describe('useDocumentSync', () => { }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) @@ -698,7 +698,7 @@ describe('useDocumentSync', () => { }) await act(async () => { - vi.advanceTimersByTime(50) + vi.advanceTimersByTime(500) await vi.advanceTimersByTimeAsync(0) }) diff --git a/src/sync/document-sync-controller.ts b/src/sync/document-sync-controller.ts index bc6280a2..bf56f29f 100644 --- a/src/sync/document-sync-controller.ts +++ b/src/sync/document-sync-controller.ts @@ -12,7 +12,7 @@ import { } from './reactive-doc-sync' import type { Heads } from './heads' import { headsEqual, normalizeHeads } from './heads' -import { isProfilingEnabled, markPerf, measurePerf } from '../lib/perf-profile' +import { isProfilingEnabled, markPerf, measurePerf, measurePerfSampleAsync, recordPerfSample, registerPerfEdit } from '../lib/perf-profile' import { DocumentManagerMarkdownReconciliationError, type DocumentManager, @@ -30,13 +30,40 @@ export type SaveStatus = | { state: 'saved' } | { state: 'error'; message: string } -const DEBOUNCE_MS = 50 +// Plain markdown reconciliation is expensive on older CPUs. Keep ordinary +// typing out of the worker until the user has paused meaningfully; semantic +// edits still bypass this scheduler. During uninterrupted typing, the +// crash-only unsaved window can therefore reach this 30-second cap; a normal +// pause still persists after 500ms. +const DEBOUNCE_MS = 500 const SAVED_DISPLAY_MS = 3000 -const STARVATION_MAX_MS = DEBOUNCE_MS * 4 +const STARVATION_MAX_MS = 30_000 const LONG_DOCUMENT_THRESHOLD = 16_000 const LONG_DOCUMENT_DEBOUNCE_MS = 800 const LONG_DOCUMENT_STARVATION_MAX_MS = 5_000 +export interface DocumentSyncIdleScheduler { + request(callback: () => void, timeoutMs: number): unknown + cancel(handle: unknown): void +} + +function createDefaultIdleScheduler(): DocumentSyncIdleScheduler { + if (typeof globalThis.requestIdleCallback === 'function') { + return { + request: (callback, timeoutMs) => globalThis.requestIdleCallback(callback, { timeout: timeoutMs }), + cancel: (handle) => globalThis.cancelIdleCallback(handle as number), + } + } + return { + // Without idle callbacks, the debounce is the only available yield point. + request: (callback, _timeoutMs) => { + callback() + return null + }, + cancel: () => undefined, + } +} + function plainWriteTiming(textLength: number): { debounceMs: number; starvationMaxMs: number } { return textLength < LONG_DOCUMENT_THRESHOLD ? { debounceMs: DEBOUNCE_MS, starvationMaxMs: STARVATION_MAX_MS } @@ -94,12 +121,14 @@ interface PendingPlainWrite { generation: number firstQueuedAt: number baseHeads: Heads | null + editId?: number } interface DeferredPlainInput { text: string generation: number baseHeads: Heads | null + editId?: number } type BufferedSyncTextState = SyncTextState @@ -116,6 +145,7 @@ interface QueuedSemanticWrite { baseHeads: Heads | null resolve: () => void reject: (err: unknown) => void + editId?: number } interface SemanticWriteInFlight { @@ -129,6 +159,7 @@ interface BlockedSemanticWrite { semantic: InlineSemanticChange generation: number baseHeads: Heads | null + editId?: number } interface ViewBinding { @@ -169,6 +200,8 @@ export interface DocumentSyncControllerOptions { onRemoteApplied?: () => void /** @internal test seam; production uses subscribeReactiveDocSync. */ subscribeReactive?: typeof subscribeReactiveDocSync + /** @internal test seam; production uses requestIdleCallback when available. */ + idleScheduler?: DocumentSyncIdleScheduler } export class DocumentSyncCancellationError extends Error { @@ -226,6 +259,9 @@ export class DocumentSyncController { private lastSaveOutcome: DocumentSyncSnapshot['lastSaveOutcome'] = null private markdownNodeIndex: NodeIndexResult | null = null private readonly subscribeReactive: typeof subscribeReactiveDocSync + private readonly idleScheduler: DocumentSyncIdleScheduler + private plainIdleHandle: unknown = null + private plainIdleToken = 0 constructor(options: DocumentSyncControllerOptions) { this.documentManager = options.documentManager @@ -233,6 +269,7 @@ export class DocumentSyncController { this.onLocalSaved = options.onLocalSaved this.onRemoteApplied = options.onRemoteApplied this.subscribeReactive = options.subscribeReactive ?? subscribeReactiveDocSync + this.idleScheduler = options.idleScheduler ?? createDefaultIdleScheduler() } start(): void { @@ -513,7 +550,7 @@ export class DocumentSyncController { this.emit() } - queuePlainText(markdown: string): void { + queuePlainText(markdown: string, editId?: number): void { if (this.disposed) return this.blockedPlain = null this.blockedSemantic = null @@ -527,18 +564,17 @@ export class DocumentSyncController { text: markdown, generation: this.localGeneration, baseHeads: this.deferredPlain?.baseHeads ?? this.attachCleanBaseHeads, + editId, } if (this.view && this.viewOwner) { dispatchProjection(this.view, this.viewOwner.ownership, markdown) } - this.saveStatus = { state: 'saving' } - this.emit() + this.publishSavingStatus() return } if (!this.checkpoint) { - this.deferredPlain = { text: markdown, generation: this.localGeneration, baseHeads: null } - this.saveStatus = { state: 'saving' } - this.emit() + this.deferredPlain = { text: markdown, generation: this.localGeneration, baseHeads: null, editId } + this.publishSavingStatus() return } this.pendingPlain = { @@ -546,14 +582,14 @@ export class DocumentSyncController { generation: this.localGeneration, firstQueuedAt: this.pendingPlain?.firstQueuedAt ?? Date.now(), baseHeads: this.pendingPlain?.baseHeads ?? this.checkpoint?.heads ?? null, + editId, } - this.saveStatus = { state: 'saving' } - this.emit() + this.publishSavingStatus() if (this.semanticInFlight || this.semanticQueue.length > 0) return this.schedulePendingPlainRespectingStarvation() } - queueSemanticChange(markdown: string, semantic: InlineSemanticChange): Promise { + queueSemanticChange(markdown: string, semantic: InlineSemanticChange, editId?: number): Promise { if (this.disposed) return Promise.reject(new DocumentSyncCancellationError()) this.blockedPlain = null this.blockedSemantic = null @@ -571,7 +607,7 @@ export class DocumentSyncController { this.saveStatus = { state: 'saving' } this.emit() const promise = new Promise((resolve, reject) => { - this.semanticQueue.push({ text: markdown, semantic, generation, baseHeads, resolve, reject }) + this.semanticQueue.push({ text: markdown, semantic, generation, baseHeads, resolve, reject, editId }) }) if (baseHeads !== null) this.pumpSemanticQueue() return promise @@ -627,11 +663,31 @@ export class DocumentSyncController { for (const listener of this.listeners) listener() } + /** + * Coalesced typing updates are internally significant, but while the public + * status is already `saving` they do not change user-visible sync state. + * Refresh the cached snapshot for imperative consumers without waking React. + */ + private publishSavingStatus(): void { + const wasSaving = this.saveStatus.state === 'saving' + this.saveStatus = { state: 'saving' } + if (wasSaving) { + this.snapshot = this.createSnapshot() + } else { + this.emit() + } + } + private clearPlainTimer(): void { if (this.plainTimer !== null) { clearTimeout(this.plainTimer) this.plainTimer = null } + this.plainIdleToken += 1 + if (this.plainIdleHandle !== null) { + this.idleScheduler.cancel(this.plainIdleHandle) + this.plainIdleHandle = null + } } private clearSavedResetTimer(): void { @@ -656,17 +712,30 @@ export class DocumentSyncController { this.clearPlainTimer() this.plainTimer = setTimeout(() => { this.plainTimer = null - this.startPlainWrite() + this.schedulePlainIdle() }, delayMs) } + private schedulePlainIdle(): void { + if (!this.pendingPlain || this.disposed || this.plainInFlight || this.semanticInFlight || this.semanticQueue.length > 0) return + const timing = plainWriteTiming(this.pendingPlain.text.length) + const elapsed = Date.now() - this.pendingPlain.firstQueuedAt + const timeoutMs = Math.max(0, timing.starvationMaxMs - elapsed) + const token = ++this.plainIdleToken + this.plainIdleHandle = this.idleScheduler.request(() => { + if (token !== this.plainIdleToken) return + this.plainIdleHandle = null + this.startPlainWrite() + }, timeoutMs) + } + private schedulePendingPlainRespectingStarvation(): void { if (!this.pendingPlain || this.plainInFlight || this.semanticInFlight || this.semanticQueue.length > 0) return const timing = plainWriteTiming(this.pendingPlain.text.length) const elapsed = Date.now() - this.pendingPlain.firstQueuedAt if (elapsed >= timing.starvationMaxMs) { this.clearPlainTimer() - this.startPlainWrite() + this.schedulePlainIdle() return } this.schedulePlain(Math.min(timing.debounceMs, timing.starvationMaxMs - elapsed)) @@ -683,6 +752,7 @@ export class DocumentSyncController { generation: deferred.generation, firstQueuedAt: Date.now(), baseHeads: deferred.baseHeads ?? normalizeHeads(this.checkpoint.heads), + editId: deferred.editId, } this.emit() if (!this.semanticInFlight && this.semanticQueue.length === 0) { @@ -756,9 +826,13 @@ export class DocumentSyncController { editorOwnership: EditorBindingOwnership, ): Promise { try { - const result = pending.baseHeads !== null - ? await manager.updateContentAt(url, pending.baseHeads, pending.text, writeOwnership) - : await manager.updateContent(url, pending.text, writeOwnership) + const result = await measurePerfSampleAsync('persistence.write', 'writePlain', () => pending.baseHeads !== null + ? manager.updateContentAt(url, pending.baseHeads, pending.text, writeOwnership) + : manager.updateContent(url, pending.text, writeOwnership), { + documentId: String(url), + contentLength: pending.text.length, + }, pending.editId) + registerPerfEdit(String(url), result.heads, pending.editId) const state = await this.readAuthoritativeState(manager, url, result) this.bufferedRemote = null if (!mutateCurrentState || !this.isCurrentSession(session)) return @@ -841,14 +915,17 @@ export class DocumentSyncController { const bindingGeneration = this.boundViewGeneration const editorOwnership = this.viewOwner?.ownership ?? createEditorBindingOwnership() this.saveAttemptCount += 1 - const promise = manager.updateContentWithInlineSemanticOps( + const baseHeads = entry.baseHeads + if (baseHeads === null) return + const promise = measurePerfSampleAsync('persistence.write', 'writeSemantic', () => manager.updateContentWithInlineSemanticOps( url, - entry.baseHeads, + baseHeads, entry.text, entry.semantic, writeOwnership, - ).then( + ), { documentId: String(url), contentLength: entry.text.length }, entry.editId).then( async (result) => { + registerPerfEdit(String(url), result.heads, entry.editId) const state = await this.readAuthoritativeState(manager, url, result) this.bufferedRemote = null if (this.isCurrentSession(session) @@ -973,18 +1050,23 @@ export class DocumentSyncController { } private async refreshMarkdownNodeIndex(url: DocumentId, heads: Heads): Promise { + const refreshStarted = isProfilingEnabled() ? performance.now() : 0 const refreshGeneration = ++this.markdownRefreshGeneration - const observed = await this.readMarkdownAt(this.documentManager, url, heads) - if (this.docUrl !== url || this.disposed || refreshGeneration !== this.markdownRefreshGeneration) return - if (observed.status !== 'available' || !headsEqual(observed.heads, heads)) { - this.markdownNodeIndex = null + try { + const observed = await this.readMarkdownAt(this.documentManager, url, heads) + if (this.docUrl !== url || this.disposed || refreshGeneration !== this.markdownRefreshGeneration) return + if (observed.status !== 'available' || !headsEqual(observed.heads, heads)) { + this.markdownNodeIndex = null + this.emit() + return + } + if (!(await this.documentManager.isCurrent(url, observed.heads))) return + if (this.docUrl !== url || this.disposed || refreshGeneration !== this.markdownRefreshGeneration) return + this.markdownNodeIndex = observed.nodeIndex this.emit() - return + } finally { + if (isProfilingEnabled()) recordPerfSample('markdown.reconcile', performance.now() - refreshStarted, { label: 'controller.node-index-refresh' }) } - if (!(await this.documentManager.isCurrent(url, observed.heads))) return - if (this.docUrl !== url || this.disposed || refreshGeneration !== this.markdownRefreshGeneration) return - this.markdownNodeIndex = observed.nodeIndex - this.emit() } private async readMarkdownAt(manager: DocumentManager, url: DocumentId, heads: Heads) { diff --git a/src/sync/use-document-sync.ts b/src/sync/use-document-sync.ts index 40752c8f..afbf0fdf 100644 --- a/src/sync/use-document-sync.ts +++ b/src/sync/use-document-sync.ts @@ -5,6 +5,7 @@ import type { SaveStatus } from './document-sync-controller' import type { InlineSemanticChange } from '../editor/inline-semantic-ops' import type { EditorBindingOwnership } from '../editor/editor-binding-ownership' import { DocumentSyncCancellationError, DocumentSyncController } from './document-sync-controller' +import { getLastPerfEditId } from '../lib/perf-profile' type DocumentId = Parameters[0] @@ -120,14 +121,14 @@ export function useDocumentSync({ const onChange = useCallback((markdown: string) => { if (!controller.canAcceptLocalInput(documentManager, docUrl)) return - controller.queuePlainText(markdown) + controller.queuePlainText(markdown, getLastPerfEditId()) }, [controller, documentManager, docUrl]) const onSemanticChange = useCallback((markdown: string, semantic: InlineSemanticChange) => { if (!controller.canAcceptSemanticInput(documentManager, docUrl)) { return Promise.reject(new DocumentSyncCancellationError()) } - return controller.queueSemanticChange(markdown, semantic) + return controller.queueSemanticChange(markdown, semantic, getLastPerfEditId()) }, [controller, documentManager, docUrl]) const bindView = useCallback(( diff --git a/src/testing/invariants/reams-invariant-diagnostics.ts b/src/testing/invariants/reams-invariant-diagnostics.ts index 55825b84..12b0427c 100644 --- a/src/testing/invariants/reams-invariant-diagnostics.ts +++ b/src/testing/invariants/reams-invariant-diagnostics.ts @@ -85,6 +85,9 @@ export interface EditorInvariantSnapshot { centerY: number }> listMarkers?: Array<{ + identity?: string + lineText?: string + markerFrom?: number depth: number offsetFromLineLeft: number drawnCenterOffsetFromLineLeft: number | null @@ -751,6 +754,9 @@ function getListMarkerGeometry(view: EditorView): { ? (textRect.top + textRect.bottom) / 2 : (lineRect.top + lineRect.bottom) / 2 markers.push({ + identity: glyph?.dataset.listGlyphIdentity ?? marker.dataset.listGlyphIdentity, + lineText: sourceLine.text, + markerFrom: Number(markerFrom), depth, offsetFromLineLeft: markerRect.left - lineRect.left, drawnCenterOffsetFromLineLeft: glyphRect && glyphRect.width > 0 && glyphRect.height > 0 diff --git a/src/testing/perf/__tests__/reams-perf-diagnostics.test.ts b/src/testing/perf/__tests__/reams-perf-diagnostics.test.ts index 87e814d6..b0b6e374 100644 --- a/src/testing/perf/__tests__/reams-perf-diagnostics.test.ts +++ b/src/testing/perf/__tests__/reams-perf-diagnostics.test.ts @@ -146,4 +146,40 @@ describe('reams perf diagnostics bridge', () => { unregister() }) + + it('exposes structured operation samples and resets them with collection state', async () => { + const perfModule = await import('../../../lib/perf-profile') + const module = await import('../reams-perf-diagnostics') + const unregister = module.registerPerfDiagnostics() + const diag = window.__reamsPerfDiagnostics__! + + perfModule.measurePerfSample('cm6.transaction', 'test', () => undefined, { docLength: 12 }) + expect(diag.getPerfSamples()).toHaveLength(1) + expect(diag.getPerfSamples()[0]).toMatchObject({ + metric: 'cm6.transaction', + label: 'test', + detail: { docLength: 12 }, + }) + expect(diag.getSummary().perfSamples).toHaveLength(1) + diag.reset() + expect(diag.getPerfSamples()).toHaveLength(0) + unregister() + }) + + it('does not recycle edit IDs when samples are reset', async () => { + const perfModule = await import('../../../lib/perf-profile') + const first = perfModule.beginPerfEdit() + perfModule.resetPerfSamples() + const second = perfModule.beginPerfEdit() + expect(second).toBeGreaterThan(first ?? 0) + }) + + it('correlates committed edits only to exact normalized heads', async () => { + const perfModule = await import('../../../lib/perf-profile') + const editId = perfModule.beginPerfEdit() + perfModule.registerPerfEdit('automerge:doc', ['head-b', 'head-a'], editId) + expect(perfModule.consumePerfEdit('automerge:doc', ['head-a', 'wrong'])).toBeUndefined() + expect(perfModule.consumePerfEdit('automerge:doc', ['head-a', 'head-b'])).toBe(editId) + expect(perfModule.consumePerfEdit('automerge:doc', ['head-a', 'head-b'])).toBeUndefined() + }) }) diff --git a/src/testing/perf/reams-perf-diagnostics.ts b/src/testing/perf/reams-perf-diagnostics.ts index 5de81fb6..0c9ac050 100644 --- a/src/testing/perf/reams-perf-diagnostics.ts +++ b/src/testing/perf/reams-perf-diagnostics.ts @@ -10,11 +10,14 @@ import { getPerfEnvironment, + getPerfSamples, getProfilerSamples, isProfilingEnabled, resetProfilerSamples, resetProfilerStats, + resetPerfSamples, type PerfEnvironment, + type PerfSample, type ProfilerSample, } from '../../lib/perf-profile' @@ -45,6 +48,7 @@ export interface PerfDiagnosticsSummary { eventTimingEntries: PerfEntry[] userTimingEntries: PerfEntry[] unsupportedEntryTypes: string[] + perfSamples: readonly PerfSample[] environment: PerfEnvironment collectedAt: string } @@ -58,6 +62,7 @@ export interface ReamsPerfDiagnostics { getSummary(): PerfDiagnosticsSummary reset(): void getEnvironment(): PerfEnvironment + getPerfSamples(): readonly PerfSample[] } // Display name -> actual PerformanceObserver entry type. @@ -83,12 +88,33 @@ let perfEntries: PerfEntry[] = [] let observers: PerformanceObserver[] = [] let collectionActive = false +function collectEntries(entries: readonly PerformanceEntry[]): void { + for (const perfEntry of entries) { + const pe: PerfEntry = { + entryType: perfEntry.entryType, + name: perfEntry.name, + startTime: perfEntry.startTime, + duration: perfEntry.duration, + } + const eventEntry = perfEntry as PerformanceEventTiming + if (eventEntry.processingStart !== undefined) { + pe.processingStart = eventEntry.processingStart + pe.processingEnd = eventEntry.processingEnd + } + const layoutEntry = perfEntry as PerformanceEntry & { hadRecentInput?: boolean; value?: number } + if (layoutEntry.hadRecentInput !== undefined) pe.hadRecentInput = layoutEntry.hadRecentInput + if (layoutEntry.value !== undefined) pe.value = layoutEntry.value + perfEntries.push(pe) + } +} + function startCollection(): void { if (!isProfilingEnabled()) return if (collectionActive) stopCollection() resetProfilerSamples() resetProfilerStats() + resetPerfSamples() perfEntries = [] if (typeof PerformanceObserver === 'undefined') { @@ -100,32 +126,7 @@ function startCollection(): void { if (!PerformanceObserver.supportedEntryTypes.includes(entry.type)) continue const observer = new PerformanceObserver((list) => { - for (const perfEntry of list.getEntries()) { - const pe: PerfEntry = { - entryType: perfEntry.entryType, - name: perfEntry.name, - startTime: perfEntry.startTime, - duration: perfEntry.duration, - } - // Copy event-timing-specific fields - const eventEntry = perfEntry as PerformanceEventTiming - if (eventEntry.processingStart !== undefined) { - pe.processingStart = eventEntry.processingStart - pe.processingEnd = eventEntry.processingEnd - } - // Copy layout-shift-specific fields - const layoutEntry = perfEntry as PerformanceEntry & { - hadRecentInput?: boolean - value?: number - } - if (layoutEntry.hadRecentInput !== undefined) { - pe.hadRecentInput = layoutEntry.hadRecentInput - } - if (layoutEntry.value !== undefined) { - pe.value = layoutEntry.value - } - perfEntries.push(pe) - } + collectEntries(list.getEntries()) }) try { @@ -148,6 +149,7 @@ function startCollection(): void { function stopCollection(): void { for (const observer of observers) { + collectEntries(observer.takeRecords()) observer.disconnect() } observers = [] @@ -186,6 +188,7 @@ function getSummary(): PerfDiagnosticsSummary { eventTimingEntries: eventTiming, userTimingEntries: userTiming, unsupportedEntryTypes: getUnsupportedEntryTypes(), + perfSamples: getPerfSamples(), environment: getPerfEnvironment(), collectedAt: new Date().toISOString(), } @@ -196,6 +199,7 @@ function reset(): void { perfEntries = [] resetProfilerSamples() resetProfilerStats() + resetPerfSamples() } function getEnvironment(): PerfEnvironment { @@ -225,6 +229,7 @@ function installPerfDiagnostics(): void { getSummary, reset, getEnvironment, + getPerfSamples, } } diff --git a/tests/format-preservation/fixtures/chainlink-546-core-allowlist.ts b/tests/format-preservation/fixtures/chainlink-546-core-allowlist.ts index cdef67dc..710bf202 100644 --- a/tests/format-preservation/fixtures/chainlink-546-core-allowlist.ts +++ b/tests/format-preservation/fixtures/chainlink-546-core-allowlist.ts @@ -34,8 +34,11 @@ export const FINAL_CORE_TYPE_ONLY_FILES = [ ] as const export const FINAL_MANAGER_RUNTIME_FILES = [ - ...FINAL_CORE_RUNTIME_FILES, + ...FINAL_CORE_RUNTIME_FILES.slice(0, -1), + 'src/lib/perf-profile.ts', + 'src/markdown/frontmatter.ts', 'src/services/document-manager.ts', + 'src/services/markdown-reconciliation-worker-client.ts', 'src/sync/heads.ts', 'src/types-records/block-record-facts.ts', 'src/types-records/type-name.ts', diff --git a/tests/invariants/bombadil/invariants.test.ts b/tests/invariants/bombadil/invariants.test.ts index 104186e8..a3a8e9dd 100644 --- a/tests/invariants/bombadil/invariants.test.ts +++ b/tests/invariants/bombadil/invariants.test.ts @@ -10,6 +10,7 @@ import { rejectedSaveIsQuiescent, structuralActionAccountingAllowsInvariant, structuralActionAccountingIsComplete, + listGlyphInsertionPreservesDescendants, type DocumentSyncInvariantSnapshotLike, } from './invariants' @@ -62,6 +63,7 @@ describe('document sync browser invariants', () => { identityViolations: 0, caseCoverage: { 'native-empty-list-enter': 1, + 'nested-list-glyph-insertion': 1, 'paragraph-enter': 1, 'heading-enter': 1, 'list-continuation-enter': 1, @@ -222,6 +224,63 @@ describe('document sync browser invariants', () => { )).toBe(false) }) + it('requires recreated descendant glyph identities to fail the insertion invariant', () => { + const before = { + docText: '- root\n - child', + glyphs: [{ identity: 'glyph-1', lineText: ' - child', markerFrom: 9 }], + } + const after = { + docText: '- root\n - inserted\n - child', + glyphs: [{ identity: 'glyph-new', lineText: ' - child', markerFrom: 22 }], + } + expect(listGlyphInsertionPreservesDescendants({ + before, + after, + insertionAt: 9, + delta: 13, + })).toBe(false) + }) + + it('accepts stable descendant identities with positions remapped by insertion delta', () => { + const before = { + docText: '- root\n - child', + glyphs: [{ identity: 'glyph-1', lineText: ' - child', markerFrom: 9 }], + } + const after = { + docText: '- root\n - inserted\n - child', + glyphs: [{ identity: 'glyph-1', lineText: ' - child', markerFrom: 22 }], + } + expect(listGlyphInsertionPreservesDescendants({ + before, + after, + insertionAt: 9, + delta: 13, + })).toBe(true) + }) + + it('matches duplicate semantic line text by remapped position, not a text map', () => { + const before = { + docText: '- root\n - same\n - same', + glyphs: [ + { identity: 'first', lineText: ' - same', markerFrom: 9 }, + { identity: 'second', lineText: ' - same', markerFrom: 18 }, + ], + } + const after = { + docText: '- root\n - inserted\n - same\n - same', + glyphs: [ + { identity: 'first', lineText: ' - same', markerFrom: 22 }, + { identity: 'second', lineText: ' - same', markerFrom: 31 }, + ], + } + expect(listGlyphInsertionPreservesDescendants({ + before, + after, + insertionAt: 9, + delta: 13, + })).toBe(true) + }) + it('requires a rejected save attempt count to remain stable', () => { const rejected = snapshot({ savePhase: 'error', lastSaveOutcome: 'rejected', markdownNodeIndex: null }) expect(rejectedSaveIsQuiescent(rejected, null)).toBe(true) diff --git a/tests/invariants/bombadil/invariants.ts b/tests/invariants/bombadil/invariants.ts index 180ed241..c4b396ff 100644 --- a/tests/invariants/bombadil/invariants.ts +++ b/tests/invariants/bombadil/invariants.ts @@ -66,6 +66,7 @@ export interface StructuralActionAccountingLike { export const REQUIRED_STRUCTURAL_CASES = [ 'native-empty-list-enter', + 'nested-list-glyph-insertion', 'paragraph-enter', 'heading-enter', 'list-continuation-enter', @@ -77,6 +78,49 @@ export const REQUIRED_STRUCTURAL_CASES = [ 'in-flight-enter-race', ] as const +export interface ListGlyphInsertionSnapshotLike { + docText: string + glyphs: ReadonlyArray<{ + identity?: string + lineText?: string + markerFrom?: number + }> +} + +export interface ListGlyphInsertionProbeLike { + before: ListGlyphInsertionSnapshotLike + after: ListGlyphInsertionSnapshotLike + insertionAt: number + delta: number +} + +/** + * An insertion moves source positions, but must not change the semantic glyph + * identity or its association with an unaffected source line. + */ +export function listGlyphInsertionPreservesDescendants( + probe: ListGlyphInsertionProbeLike | null, +): boolean { + if (!probe) return true + if (probe.delta <= 0 || probe.after.docText.length - probe.before.docText.length !== probe.delta) return false + const before = probe.before.glyphs.filter(glyph => + typeof glyph.identity === 'string' + && typeof glyph.lineText === 'string' + && typeof glyph.markerFrom === 'number', + ) + const affected = before.filter(glyph => glyph.markerFrom! >= probe.insertionAt) + if (affected.length === 0) return false + return affected.every(glyph => { + const expectedMarkerFrom = glyph.markerFrom! + probe.delta + const next = probe.after.glyphs.find(candidate => + candidate.lineText === glyph.lineText + && candidate.markerFrom === expectedMarkerFrom, + ) + return next?.identity === glyph.identity + && next?.markerFrom === expectedMarkerFrom + }) +} + export function structuralActionAccountingIsComplete( accounting: StructuralActionAccountingLike | null, ): boolean { diff --git a/tests/invariants/bombadil/structural-actions.ts b/tests/invariants/bombadil/structural-actions.ts index a53501bb..f3da39ea 100644 --- a/tests/invariants/bombadil/structural-actions.ts +++ b/tests/invariants/bombadil/structural-actions.ts @@ -10,6 +10,7 @@ import { REQUIRED_STRUCTURAL_CASES, structuralNodeLineageIsValid, unaffectedNodeIdsArePreserved, + type ListGlyphInsertionProbeLike, type StructuralLineageExpectation, } from './invariants.ts' import { @@ -38,6 +39,7 @@ type StructuralActionAccounting = { type StructuralAccountingWindow = Window & { __reamsStructuralActionAccounting__?: StructuralActionAccounting __reamsStructuralArmedCase__?: ArmedStructuralCase + __reamsListGlyphInsertionProbe__?: ListGlyphInsertionProbeLike | null } type ArmedStructuralCase = { @@ -77,6 +79,7 @@ const accounting: StructuralActionAccounting = { let structuralCaseIndex = 0 let structuralPrefixIndex = 0 let structuralKeyboardScenarioIndex = 0 +let nestedListInsertionIndex = 0 function publishAccounting(): void { ;(window as StructuralAccountingWindow).__reamsStructuralActionAccounting__ = { ...accounting } @@ -132,6 +135,8 @@ const KEYBOARD_FIXTURE = '* parent\n * nested child' const EMPTY_LIST_FIXTURE = '- ' const EMPTY_LIST_CURSOR = EMPTY_LIST_FIXTURE.length const EMPTY_LIST_TARGET = '' +const NESTED_LIST_GLYPH_FIXTURE = '- root\n - branch\n - deep-one\n - deep-two\n - sibling\n- outside' +const NESTED_LIST_INSERTION_TARGETS = [' - deep-one', ' - branch', ' - deep-two'] as const class StructuralActionTimeoutError extends Error {} @@ -481,6 +486,82 @@ async function dispatchEmptyListEnter(): Promise<'accepted' | 'rejected'> { return settleStructuralEdit(target, beforeGeneration) } +function listGlyphInsertionSnapshot(): ListGlyphInsertionProbeLike['before'] | null { + const snapshot = window.__reamsInvariantDiagnostics__?.getEditorSnapshot() + if (!snapshot?.listMarkers) return null + return { + docText: snapshot.docText, + glyphs: snapshot.listMarkers.map(marker => ({ + identity: marker.identity, + lineText: marker.lineText, + markerFrom: marker.markerFrom, + })), + } +} + +async function waitForListGlyphInsertionSnapshot(actionName: string): Promise>> { + for (let attempt = 0; attempt < 100; attempt += 1) { + const snapshot = listGlyphInsertionSnapshot() + if (snapshot && snapshot.glyphs.length >= 4 && snapshot.glyphs.every(glyph => glyph.identity && glyph.lineText !== undefined)) { + return snapshot + } + await sleep(20) + } + throw new StructuralActionTimeoutError(`${actionName}: nested list glyph snapshot did not settle`) +} + +async function dispatchNestedListGlyphInsertion(): Promise<'accepted' | 'rejected'> { + const actionName = 'nested-list-glyph-insertion' + const api = window.__reamsInvariantDiagnostics__ + if (!api) throw new Error(`${actionName}: diagnostics are unavailable`) + if (!api.setEditorMode('live-preview')) throw new Error(`${actionName}: could not enter live-preview`) + for (let attempt = 0; attempt < 100; attempt += 1) { + if (api.getEditorSnapshot()?.mode === 'live-preview') break + await sleep(20) + } + const setupOutcome = await replaceEditorDocument(NESTED_LIST_GLYPH_FIXTURE, `${actionName}:setup`) + if (setupOutcome === 'rejected') return setupOutcome + if (!api.setEditorMode('live-preview')) throw new Error(`${actionName}: could not restore live-preview`) + let livePreviewMounted = false + for (let attempt = 0; attempt < 100; attempt += 1) { + if (api.getEditorSnapshot()?.mode === 'live-preview') { + livePreviewMounted = true + break + } + await sleep(20) + } + if (!livePreviewMounted) throw new StructuralActionTimeoutError(`${actionName}: live-preview did not settle`) + const before = await waitForListGlyphInsertionSnapshot(`${actionName}:before`) + const beforeGeneration = api?.getDocumentSyncSnapshot()?.localGeneration ?? 0 + const target = NESTED_LIST_INSERTION_TARGETS[nestedListInsertionIndex++ % NESTED_LIST_INSERTION_TARGETS.length] + const insertionAt = before.docText.indexOf(target) + if (insertionAt < 0 || !api.setSelection(insertionAt, insertionAt)) { + throw new Error(`${actionName}: could not place insertion cursor`) + } + const inserted = `${target.match(/^\s*/)?.[0] ?? ''}- inserted-${nestedListInsertionIndex}\n` + const probeWindow = window as StructuralAccountingWindow + probeWindow.__reamsListGlyphInsertionProbe__ = { + before, + after: before, + insertionAt, + delta: inserted.length, + } + if (!api.insertText(inserted)) throw new Error(`${actionName}: diagnostics could not insert nested list item`) + const targetText = `${before.docText.slice(0, insertionAt)}${inserted}${before.docText.slice(insertionAt)}` + await waitForEditorText(targetText, actionName) + const outcome = await settleStructuralEdit(targetText, beforeGeneration) + if (outcome === 'accepted') { + const after = await waitForListGlyphInsertionSnapshot(`${actionName}:after`) + probeWindow.__reamsListGlyphInsertionProbe__ = { + before, + after, + insertionAt, + delta: inserted.length, + } + } + return outcome +} + async function prepareNativeEmptyListEnter(): Promise { const actionName = 'structural_prepare_empty_list_enter' accounting.sampled += 1 @@ -657,6 +738,10 @@ registerCustomAction('structural_empty_list_enter', async () => { await runStructuralAction(dispatchEmptyListEnter) }) +registerCustomAction('structural_nested_list_glyph_insertion', async () => { + await runStructuralAction(dispatchNestedListGlyphInsertion, 'nested-list-glyph-insertion') +}) + registerCustomAction('structural_prepare_empty_list_enter', prepareNativeEmptyListEnter) registerCustomAction('structural_finish_empty_list_enter', finishNativeEmptyListEnter) @@ -696,6 +781,9 @@ export const structuralActions = actions(() => { id => (campaign.accounting?.caseCoverage[id] ?? 0) === 0, ) if (missingRequiredCase) { + if (missingRequiredCase === 'nested-list-glyph-insertion') { + return [{ Custom: { name: 'structural_nested_list_glyph_insertion' } }] + } return [{ Custom: { name: `structural_scenario_${missingRequiredCase.replace(/-/g, '_')}` } }] } @@ -711,6 +799,7 @@ export const structuralActions = actions(() => { Custom: { name: `structural_regression_${name}` }, })), { Custom: { name: 'structural_empty_list_enter' } }, + { Custom: { name: 'structural_nested_list_glyph_insertion' } }, ...structuralKeyboardActions().map(action => ({ Custom: { name: `structural_key_${action.replace('-', '_')}` }, })), diff --git a/tests/invariants/bombadil/structural.spec.ts b/tests/invariants/bombadil/structural.spec.ts index 805e01f4..9ed6b076 100644 --- a/tests/invariants/bombadil/structural.spec.ts +++ b/tests/invariants/bombadil/structural.spec.ts @@ -5,8 +5,13 @@ import { structuralActions } from './structural-actions.ts' import { settledStructuralStateIsCoherent, structuralActionAccountingAllowsInvariant, + listGlyphInsertionPreservesDescendants, +} from './invariants.ts' +import type { + DocumentSyncInvariantSnapshotLike, + ListGlyphInsertionProbeLike, + StructuralActionAccountingLike, } from './invariants.ts' -import type { DocumentSyncInvariantSnapshotLike, StructuralActionAccountingLike } from './invariants.ts' export * from '@antithesishq/bombadil/browser/defaults' @@ -31,6 +36,13 @@ const structuralActionAccounting = extract( }, ) +const listGlyphInsertionProbe = extract(state => { + const pageWindow = (state as unknown as { window: Window }).window as Window & { + __reamsListGlyphInsertionProbe__?: ListGlyphInsertionProbeLike | null + } + return (pageWindow.__reamsListGlyphInsertionProbe__ ?? null) as never +}) + function editorSurfaceIsAlive(): boolean { const surface = browserSurfaceCell.current as { shellConnected?: unknown @@ -75,4 +87,15 @@ export const structural_edits_are_accepted = always(() => { return settledStructuralStateIsCoherent(snapshot) }) +export const nested_list_glyph_identities_survive_insertion = always(() => { + return listGlyphInsertionPreservesDescendants( + listGlyphInsertionProbe.current as ListGlyphInsertionProbeLike | null, + ) +}) + +export const nested_list_glyph_insertion_is_exercised = eventually(() => { + const accounting = structuralActionAccounting.current as StructuralActionAccountingLike | null + return (accounting?.caseCoverage?.['nested-list-glyph-insertion'] ?? 0) > 0 +}) + export const defaultActions = structuralActions diff --git a/tests/perf/instrumentation.perf.test.ts b/tests/perf/instrumentation.perf.test.ts new file mode 100644 index 00000000..8791cd8e --- /dev/null +++ b/tests/perf/instrumentation.perf.test.ts @@ -0,0 +1,124 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { assertDevServerAvailable, getInvariantTestConfig } from '../invariants/support/env' +import { readEditorSnapshot } from '../invariants/support/playwright' +import { navigateToPerfApp, openPerfSession, runPerfScenario } from './support/perf-runner' +import { PERF_FIXTURES } from './support/fixtures' + +const RUN_PERF = process.env.RUN_PERF_TESTS === '1' +const config = getInvariantTestConfig() + +describe.skipIf(!RUN_PERF)('profiled edit instrumentation', () => { + beforeAll(async () => { + await assertDevServerAvailable(config.devServerUrl) + }) + + it('reports correlated samples for multiple deterministic edits', { timeout: 120000 }, async () => { + const session = await openPerfSession(config, 'perf-profiled-edit') + try { + await navigateToPerfApp(session.page, config) + const fixture = PERF_FIXTURES.realWorldActionListDoc + const result = await runPerfScenario(session.page, { + name: 'profiled-edit-real-world-action-list', + artifactDir: '.perf-artifacts/instrumentation', + setup: async page => { + await page.evaluate((content: string) => { + if (!window.__reamsInvariantDiagnostics__?.replaceDocumentText(content)) { + throw new Error('Could not install the performance corpus') + } + }, fixture.content) + await page.waitForFunction( + (length: number) => window.__reamsInvariantDiagnostics__?.getEditorSnapshot()?.docText.length === length, + fixture.content.length, + { timeout: 30000 }, + ) + // Setup is deliberately outside the measured window. Let any + // initial persistence/index notification drain before collection. + await page.waitForFunction( + () => document.querySelector('.save-status-indicator')?.getAttribute('data-state') !== 'saving', + { timeout: 10000 }, + ) + await page.waitForTimeout(250) + await page.click('.cm6-editor-container') + await page.keyboard.press('Control+End') + }, + preGate: async page => (await readEditorSnapshot(page)).docText === fixture.content, + interaction: async page => { + await page.evaluate(() => { + const view = window.__cm6_editor_view__ + if (!view) throw new Error('CM6 view is not exposed') + const from = view.state.doc.toString().indexOf('web haptics') + if (from < 0) throw new Error('equal-length replacement target is missing') + view.dispatch({ changes: { from, to: from + 3, insert: 'WEB' } }) + }) + await page.keyboard.type('x') + await page.evaluate(() => { + const view = window.__cm6_editor_view__ + if (!view) throw new Error('CM6 view is not exposed') + const markerFrom = view.state.doc.toString().lastIndexOf('- [ ]') + if (markerFrom < 0) throw new Error('nested action marker is missing') + const lineFrom = view.state.doc.lineAt(markerFrom).from + view.dispatch({ changes: { from: lineFrom, insert: ' - [ ] Inserted nested action\n' } }) + }) + await page.waitForFunction( + () => window.__reamsPerfDiagnostics__!.getPerfSamples().filter(sample => sample.metric === 'cm6.transaction').length >= 2, + { timeout: 15000 }, + ) + await page.waitForFunction( + () => window.__reamsPerfDiagnostics__!.getPerfSamples().some(sample => sample.metric === 'persistence.write') + && window.__reamsPerfDiagnostics__!.getPerfSamples().some(sample => sample.label === 'indexDocument'), + { timeout: 15000 }, + ) + const beforeRemote = await page.evaluate(() => window.__reamsPerfDiagnostics__!.getPerfSamples() + .filter(sample => sample.metric === 'cm6.transaction').map(sample => sample.editId)) + await page.evaluate(() => { + const view = window.__cm6_editor_view__ + const ownership = window.__cm6_editor_ownership__ + if (!view || !ownership) throw new Error('CM6 diagnostics handles are missing') + const original = view.state.doc.sliceString(0, 1) + ownership.runIgnoringTransactions(() => { + view.dispatch({ changes: { from: 0, to: 1, insert: original === '-' ? '!' : '-' } }) + view.dispatch({ changes: { from: 0, to: 1, insert: original } }) + }) + }) + const afterRemote = await page.evaluate(() => window.__reamsPerfDiagnostics__!.getPerfSamples() + .filter(sample => sample.metric === 'cm6.transaction').map(sample => sample.editId)) + expect(afterRemote).toEqual(beforeRemote) + }, + postGate: async page => { + const text = (await readEditorSnapshot(page)).docText + return text.endsWith('x') && text.includes('Inserted nested action') + }, + }) + + const samples = result.artifact.perfSamples + const labels = new Set(samples.map(sample => sample.label)) + for (const label of [ + 'frontmatter', 'checkbox', 'list-bullet', 'wikilink', 'image-embed', + 'indexDocument', 'search.indexDocument', 'metadata.updateFile', + ]) expect(labels).toContain(label) + // Typed-record indexing is an optional app capability; when enabled, + // its phase must have its own sample rather than being folded into the + // metadata timing. + if (samples.some(sample => sample.label === 'typed.replaceDocument')) { + expect(labels).toContain('typed.replaceDocument') + } + const transactions = samples.filter(sample => sample.metric === 'cm6.transaction') + expect(transactions.length).toBeGreaterThanOrEqual(2) + const firstEditId = transactions[0]?.editId + const finalEditId = transactions[transactions.length - 1]?.editId + expect(firstEditId).not.toBe(finalEditId) + expect(samples.some(sample => sample.metric === 'persistence.write' && sample.editId === finalEditId), 'persistence.write').toBe(true) + for (const label of ['indexDocument', 'search.indexDocument', 'metadata.updateFile']) { + expect(samples.some(sample => sample.label === label && sample.editId === finalEditId), label).toBe(true) + } + if (samples.some(sample => sample.label === 'indexing.queue-wait')) { + expect(samples.some(sample => sample.label === 'indexing.queue-wait' && sample.editId === finalEditId)).toBe(true) + } + if (samples.some(sample => sample.label === 'typed.replaceDocument')) { + expect(samples.some(sample => sample.label === 'typed.replaceDocument' && sample.editId === finalEditId)).toBe(true) + } + } finally { + await session.close() + } + }) +}) diff --git a/tests/perf/repeated-character-browser-low-end.perf.test.ts b/tests/perf/repeated-character-browser-low-end.perf.test.ts new file mode 100644 index 00000000..2dfea8b2 --- /dev/null +++ b/tests/perf/repeated-character-browser-low-end.perf.test.ts @@ -0,0 +1,132 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { assertDevServerAvailable, getInvariantTestConfig } from '../invariants/support/env' +import { readEditorSnapshot } from '../invariants/support/playwright' +import { navigateToPerfApp, openPerfSession, runPerfScenario } from './support/perf-runner' +import { PERF_FIXTURES } from './support/fixtures' +import { getBuildHash, writePerfArtifact, type KeyboardBenchmarkArtifact } from './support/artifacts' +import { CHARACTER_COUNT, replaceCorpus, runKeys } from './repeated-character-browser.perf.test' + +const RUN_PERF = process.env.RUN_PERF_TESTS === '1' +const config = getInvariantTestConfig() +const ARTIFACT_DIR = '.perf-artifacts/repeated-character-low-end' + +async function setCpuThrottle(page: import('playwright').Page, rate: number): Promise { + const client = await page.context().newCDPSession(page) + try { + await client.send('Emulation.setCPUThrottlingRate', { rate }) + } finally { + await client.detach() + } +} + +describe.skipIf(!RUN_PERF)('exact-corpus repeated-character low-end laptop lane', () => { + beforeAll(async () => { + await assertDevServerAvailable('http://localhost:7002') + }) + + it('runs 4x and 6x CPU throttling at 30ms and 120ms cadence', { timeout: 900000 }, async () => { + const fixture = PERF_FIXTURES.realWorldActionListDoc + const failures: string[] = [] + const session = await openPerfSession(config, 'perf-repeated-character-browser-low-end') + try { + await navigateToPerfApp(session.page, config) + for (const throttle of [4, 6]) { + await setCpuThrottle(session.page, throttle) + for (const cadenceMs of [30, 120]) { + for (const scenario of [ + { name: 'nested-task-body', needle: 'how to link to block types?', insertListItem: false }, + { name: 'new-list-item', needle: 'list create is difficult (needs a button?)', insertListItem: true }, + ]) { + const name = `repeated-character-${throttle}x-${cadenceMs}ms-${scenario.name}` + const measured: KeyboardBenchmarkArtifact['scenarios'][number][] = [] + const result = await runPerfScenario(session.page, { + name, + artifactDir: ARTIFACT_DIR, + throttling: `${throttle}x CPU`, + setup: async page => { + await replaceCorpus(page) + await page.evaluate(() => window.__cm6_editor_view__?.focus()) + }, + preGate: async page => (await readEditorSnapshot(page)).docText === fixture.content, + interaction: async page => { + measured.push(await runKeys(page, scenario.name, scenario.needle, scenario.insertListItem, cadenceMs)) + }, + postGate: async page => { + const editor = await readEditorSnapshot(page) + if (!editor.docText.includes('a'.repeat(CHARACTER_COUNT))) return false + const persisted = await page.evaluate(() => window.__reamsInvariantDiagnostics__?.getPersistedTextState?.()) + return persisted?.content === editor.docText + }, + }) + const sample = measured[0] + const benchmark: KeyboardBenchmarkArtifact = { + corpus: fixture.name, + characterCount: CHARACTER_COUNT, + scenarios: measured, + targetMs: 16, + assertions: [ + { + scenario: name, + metric: 'transaction.p95', + p95: sample.transaction.p95, + targetMs: 16, + enforced: true, + passed: sample.transaction.p95 < 16, + note: 'Low-end regression gate.', + }, + { + scenario: name, + metric: 'keydownToDomMutation.p95', + p95: sample.keydownToDomMutation.p95, + targetMs: 20, + enforced: true, + passed: sample.keydownToDomMutation.p95 < 20, + note: 'Low-end regression gate.', + }, + { + scenario: name, + metric: 'keydownToPostUpdateFrame.p95', + p95: sample.keydownToPostUpdateFrame.p95, + targetMs: 40, + enforced: true, + passed: sample.keydownToPostUpdateFrame.p95 < 40, + note: 'Low-end regression gate.', + }, + ], + } + result.artifact.keyboardBenchmark = benchmark + result.artifact.revision = getBuildHash() + writePerfArtifact(result.artifact, ARTIFACT_DIR) + const workerExecutionCount = await session.page.evaluate(() => + Math.max(0, ...((window.__reamsPerfDiagnostics__?.getPerfSamples() ?? []) + .filter(sample => sample.metric === 'markdown.reconcile' && sample.label === 'worker.execution') + .map(sample => Number(sample.detail?.workerExecutionCount ?? 0)))), + ) + expect(workerExecutionCount, `${name} worker execution count`).toBeGreaterThan(0) + const gate = (condition: boolean, message: string): void => { + if (!condition) failures.push(`${name}: ${message}`) + } + gate(sample.transaction.count === CHARACTER_COUNT, `transaction sample count ${sample.transaction.count}/${CHARACTER_COUNT}`) + gate(sample.perKey.length === CHARACTER_COUNT, `per-key sample count ${sample.perKey.length}/${CHARACTER_COUNT}`) + gate(sample.missingDomMutationSamples === 0, `missing DOM samples ${sample.missingDomMutationSamples}`) + gate(sample.missingNextFrameSamples === 0, `missing frame samples ${sample.missingNextFrameSamples}`) + gate(Number.isFinite(sample.transaction.p95) && sample.transaction.p95 < 16, `transaction p95 ${sample.transaction.p95.toFixed(2)}ms >= 16ms`) + gate(Number.isFinite(sample.keydownToDomMutation.p95) && sample.keydownToDomMutation.p95 < 20, `DOM p95 ${sample.keydownToDomMutation.p95.toFixed(2)}ms >= 20ms`) + gate(Number.isFinite(sample.keydownToPostUpdateFrame.p95) && sample.keydownToPostUpdateFrame.p95 < 40, `frame p95 ${sample.keydownToPostUpdateFrame.p95.toFixed(2)}ms >= 40ms`) + gate(Number.isFinite(sample.keydownToDomMutation.max) && sample.keydownToDomMutation.max < 100, `DOM max ${sample.keydownToDomMutation.max.toFixed(2)}ms >= 100ms`) + gate(Number.isFinite(sample.keydownToPostUpdateFrame.max) && sample.keydownToPostUpdateFrame.max < 100, `frame max ${sample.keydownToPostUpdateFrame.max.toFixed(2)}ms >= 100ms`) + if (scenario.insertListItem) { + gate(sample.listOverlayRead.count > 0 && Number.isFinite(sample.listOverlayRead.median) && Number.isFinite(sample.listOverlayRead.p95) && Number.isFinite(sample.listOverlayRead.max), 'list overlay read statistics are not finite') + gate(sample.listOverlayWrite.count > 0 && Number.isFinite(sample.listOverlayWrite.median) && Number.isFinite(sample.listOverlayWrite.p95) && Number.isFinite(sample.listOverlayWrite.max), 'list overlay write statistics are not finite') + } + gate(sample.persistenceOverlapSamples === 0, `persistence overlap ${sample.persistenceOverlapSamples}/${CHARACTER_COUNT} under quiet-save policy`) + console.info(`[low-end] ${name} | transaction p50/p95/max ${sample.transaction.median.toFixed(2)}/${sample.transaction.p95.toFixed(2)}/${sample.transaction.max.toFixed(2)}ms | DOM ${sample.keydownToDomMutation.median.toFixed(2)}/${sample.keydownToDomMutation.p95.toFixed(2)}/${sample.keydownToDomMutation.max.toFixed(2)}ms | frame ${sample.keydownToPostUpdateFrame.median.toFixed(2)}/${sample.keydownToPostUpdateFrame.p95.toFixed(2)}/${sample.keydownToPostUpdateFrame.max.toFixed(2)}ms | list-read ${sample.listOverlayRead.median.toFixed(2)}/${sample.listOverlayRead.p95.toFixed(2)}/${sample.listOverlayRead.max.toFixed(2)}ms | list-write ${sample.listOverlayWrite.median.toFixed(2)}/${sample.listOverlayWrite.p95.toFixed(2)}/${sample.listOverlayWrite.max.toFixed(2)}ms | long-task ${sample.longTasks.median.toFixed(2)}/${sample.longTasks.p95.toFixed(2)}/${sample.longTasks.max.toFixed(2)}ms | persistence-overlap ${sample.persistenceOverlapSamples}/${CHARACTER_COUNT}`) + } + } + } + } finally { + await session.close() + } + expect(failures, 'low-end regression gates').toEqual([]) + }) +}) diff --git a/tests/perf/repeated-character-browser.perf.test.ts b/tests/perf/repeated-character-browser.perf.test.ts new file mode 100644 index 00000000..ee9c5795 --- /dev/null +++ b/tests/perf/repeated-character-browser.perf.test.ts @@ -0,0 +1,432 @@ +import { beforeAll, describe, expect, it } from 'vitest' +import { assertDevServerAvailable, getInvariantTestConfig } from '../invariants/support/env' +import { readEditorSnapshot } from '../invariants/support/playwright' +import { navigateToPerfApp, openPerfSession, runPerfScenario } from './support/perf-runner' +import { PERF_FIXTURES } from './support/fixtures' +import { getBuildHash, writePerfArtifact, type KeyboardBenchmarkArtifact, type KeyboardBenchmarkAttribution, type KeyboardBenchmarkKeySample, type KeyboardBenchmarkStats } from './support/artifacts' + +const RUN_PERF = process.env.RUN_PERF_TESTS === '1' +const RUN_LOW_END_LANE = process.env.RUN_LOW_END_PERF_TESTS === '1' +const config = getInvariantTestConfig() +export const CHARACTER_COUNT = 80 +const TARGET_MS = 16 + +interface BrowserKeySample { + keydown: number + dom?: number + nextFrame?: number +} + +interface BrowserBenchmarkWindow extends Window { + __repeatedCharacterSamples__?: BrowserKeySample[] + __repeatedCharacterCleanup__?: () => void +} + +function percentile(values: number[], fraction: number): number { + const sorted = [...values].sort((a, b) => a - b) + if (sorted.length === 0) return Number.NaN + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)] +} + +function stats(values: number[]): KeyboardBenchmarkStats { + const maximum = values.length === 0 ? Number.NaN : Math.max(...values) + return { + count: values.length, + median: percentile(values, 0.5), + p95: percentile(values, 0.95), + max: maximum, + } +} + +function worst(values: Array<{ value: number; timestamp?: number }>, keyWindows: Array<{ start: number; end: number }>): KeyboardBenchmarkAttribution | undefined { + const sample = values.reduce((best, current) => !best || current.value > best.value ? current : best, undefined) + if (!sample) return undefined + const keyIndex = sample.timestamp === undefined + ? -1 + : keyWindows.reduce((nearest, window, index) => { + const distance = sample.timestamp! < window.start + ? window.start - sample.timestamp! + : sample.timestamp! > window.end + ? sample.timestamp! - window.end + : 0 + return distance < nearest.distance ? { index, distance } : nearest + }, { index: -1, distance: Number.POSITIVE_INFINITY }).index + return { keyIndex: keyIndex < 0 ? -1 : keyIndex, value: sample.value, timestamp: sample.timestamp } +} + +async function installKeyInstrumentation(page: import('playwright').Page): Promise { + await page.evaluate(() => { + const benchmarkWindow = window as BrowserBenchmarkWindow + benchmarkWindow.__repeatedCharacterCleanup__?.() + const content = document.querySelector('.cm-content') + if (!content) throw new Error('Missing CodeMirror content DOM') + const samples: BrowserKeySample[] = [] + const pendingKeydowns: BrowserKeySample[] = [] + const awaitingMutationConfirmation: BrowserKeySample[] = [] + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key.length !== 1 || !(event.target instanceof Node) || !content.contains(event.target)) return + const sample: BrowserKeySample = { keydown: performance.now() } + samples.push(sample) + pendingKeydowns.push(sample) + } + document.addEventListener('keydown', onKeyDown, { capture: true }) + + const onInput = (event: Event) => { + if (!(event.target instanceof Node) || !content.contains(event.target)) return + const sample = pendingKeydowns.shift() + if (sample) awaitingMutationConfirmation.push(sample) + } + document.addEventListener('input', onInput, { capture: true }) + + // Input ownership comes from the real input event, not aggregate text + // length. The observer independently confirms that CM6 subsequently + // mutated the editor DOM, so structural newline/list redraws are valid. + // A callback drains every input queued before it; each sample is assigned + // once and never reassigned, including when cadence keys are coalesced. + const observer = new MutationObserver(() => { + const observedAt = performance.now() + while (awaitingMutationConfirmation.length > 0) { + const sample = awaitingMutationConfirmation.shift()! + sample.dom = observedAt - sample.keydown + requestAnimationFrame(() => { + if (sample.nextFrame === undefined) sample.nextFrame = performance.now() - sample.keydown + }) + } + }) + observer.observe(content, { subtree: true, childList: true, characterData: true }) + + benchmarkWindow.__repeatedCharacterSamples__ = samples + benchmarkWindow.__repeatedCharacterCleanup__ = () => { + document.removeEventListener('keydown', onKeyDown, { capture: true }) + document.removeEventListener('input', onInput, { capture: true }) + observer.disconnect() + } + }) +} + +async function waitForSavedIndexQuiescence(page: import('playwright').Page): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + await page.waitForFunction(() => { + const indicator = document.querySelector('.save-status-indicator') + return indicator === null || indicator.getAttribute('data-state') === 'saved' + }, { timeout: 15000 }) + const before = await page.evaluate(() => { + const samples = window.__reamsPerfDiagnostics__?.getPerfSamples() ?? [] + return { + persistenceWrites: samples.filter(sample => sample.metric === 'persistence.write').length, + completedIndexes: samples.filter(sample => sample.label === 'indexDocument').length, + } + }) + await page.waitForTimeout(300) + const after = await page.evaluate(() => { + const samples = window.__reamsPerfDiagnostics__?.getPerfSamples() ?? [] + return { + persistenceWrites: samples.filter(sample => sample.metric === 'persistence.write').length, + completedIndexes: samples.filter(sample => sample.label === 'indexDocument').length, + } + }) + if (before.persistenceWrites === after.persistenceWrites && before.completedIndexes === after.completedIndexes) return + } + throw new Error('Persistence/indexing did not reach a stable quiescent state before benchmark setup') +} + +export async function replaceCorpus(page: import('playwright').Page): Promise { + const fixture = PERF_FIXTURES.realWorldActionListDoc + await waitForSavedIndexQuiescence(page) + const beforeReset = await page.evaluate(() => { + const samples = window.__reamsPerfDiagnostics__?.getPerfSamples() ?? [] + return { + persistenceWrites: samples.filter(sample => sample.metric === 'persistence.write').length, + completedIndexes: samples.filter(sample => sample.label === 'indexDocument').length, + } + }) + await page.evaluate((content: string) => { + if (!window.__reamsInvariantDiagnostics__?.replaceDocumentText(content)) { + throw new Error('Could not install the exact reams-features corpus') + } + }, fixture.content) + await page.waitForFunction( + (length: number) => window.__reamsInvariantDiagnostics__?.getEditorSnapshot()?.docText.length === length, + fixture.content.length, + { timeout: 30000 }, + ) + await page.waitForSelector('.save-status-indicator[data-state="saved"]', { timeout: 15000 }) + // Setup is outside collection. Wait for this reset's own persistence and + // indexing work to finish so neither it nor the prior scenario can bleed + // into the next measured window. Persistence remains fully enabled later. + await page.waitForFunction( + (baseline: { persistenceWrites: number; completedIndexes: number }) => { + const samples = window.__reamsPerfDiagnostics__?.getPerfSamples() ?? [] + return samples.filter(sample => sample.metric === 'persistence.write').length > baseline.persistenceWrites + && samples.filter(sample => sample.label === 'indexDocument').length > baseline.completedIndexes + }, + beforeReset, + { timeout: 30000 }, + ) + await waitForSavedIndexQuiescence(page) +} + +export async function runKeys( + page: import('playwright').Page, + scenario: string, + cursorNeedle: string, + insertListItem: boolean, + cadenceMs?: number, +): Promise { + await page.evaluate(({ cursorNeedle: needle, insertListItem: shouldInsert }) => { + const view = window.__cm6_editor_view__ + if (!view) throw new Error('CM6 view is not exposed') + const text = view.state.doc.toString() + const from = text.indexOf(needle) + if (from < 0) throw new Error(`Benchmark cursor needle missing: ${needle}`) + const line = view.state.doc.lineAt(from) + view.dispatch({ selection: { anchor: shouldInsert ? line.to : from + needle.length } }) + }, { cursorNeedle, insertListItem }) + await page.click('.cm6-editor-container') + if (insertListItem) await page.keyboard.press('Enter') + await installKeyInstrumentation(page) + // The list-item Enter is setup, not one of the measured characters. + await page.evaluate(() => window.__reamsPerfDiagnostics__?.startCollection()) + + for (let i = 0; i < CHARACTER_COUNT; i++) { + await page.keyboard.press('a') + if (cadenceMs === undefined) { + await page.waitForFunction( + (count: number) => { + const samples = (window as BrowserBenchmarkWindow).__repeatedCharacterSamples__ ?? [] + const sample = samples[count - 1] + return samples.length >= count && sample?.dom !== undefined && sample.nextFrame !== undefined + }, + i + 1, + ) + await page.waitForFunction( + (count: number) => window.__reamsPerfDiagnostics__?.getPerfSamples().filter(sample => sample.metric === 'cm6.transaction').length === count, + i + 1, + { timeout: 10000 }, + ) + } else { + await page.waitForTimeout(cadenceMs) + } + } + await page.waitForFunction( + (count: number) => { + const samples = (window as BrowserBenchmarkWindow).__repeatedCharacterSamples__ ?? [] + return samples.length === count && samples.every(sample => sample.dom !== undefined && sample.nextFrame !== undefined) + }, + CHARACTER_COUNT, + // CPU-throttled list widgets can defer a final observer/frame callback for + // longer than the normal lane timeout. This only extends collection drain; + // it does not alter the measured clocks or regression thresholds. + { timeout: 120000 }, + ) + await page.waitForFunction( + (count: number) => window.__reamsPerfDiagnostics__?.getPerfSamples().filter(sample => sample.metric === 'cm6.transaction').length === count, + CHARACTER_COUNT, + { timeout: 30000 }, + ) + // Do not stop the app's debounce/index pipeline; collect its samples too. + await page.waitForFunction( + () => window.__reamsPerfDiagnostics__?.getPerfSamples().some(sample => sample.metric === 'persistence.write') === true, + { timeout: 30000 }, + ) + await page.waitForTimeout(100) + + const result = await page.evaluate(() => { + const browserSamples = (window as BrowserBenchmarkWindow).__repeatedCharacterSamples__ ?? [] + const transactions = window.__reamsPerfDiagnostics__?.getPerfSamples() + .filter(sample => sample.metric === 'cm6.transaction') ?? [] + const persistence = window.__reamsPerfDiagnostics__?.getPerfSamples() + .filter(sample => sample.metric === 'persistence.write') ?? [] + const overlayReads = window.__reamsPerfDiagnostics__?.getPerfSamples() + .filter(sample => sample.label === 'list-overlay.read') ?? [] + const overlayWrites = window.__reamsPerfDiagnostics__?.getPerfSamples() + .filter(sample => sample.label === 'list-overlay.write') ?? [] + const longTasks = window.__reamsPerfDiagnostics__?.getPerfEntries() + .filter(entry => entry.entryType === 'longtask') ?? [] + const transactionDurations = transactions.map(sample => sample.duration) + const dom = browserSamples.flatMap(sample => sample.dom === undefined ? [] : [sample.dom]) + const nextFrame = browserSamples.flatMap(sample => sample.nextFrame === undefined ? [] : [sample.nextFrame]) + const keyWindows = browserSamples.map(sample => ({ start: sample.keydown, end: sample.keydown + (sample.nextFrame ?? 0) })) + const overlaps = browserSamples.filter(sample => persistence.some(write => { + const writeStart = write.timestamp - write.duration + const writeEnd = write.timestamp + const keyEnd = sample.keydown + (sample.nextFrame ?? 0) + return writeStart <= keyEnd && writeEnd >= sample.keydown + })).length + const perKey = browserSamples.map((sample, index) => { + const persistenceOverlap = persistence.some(write => { + const writeStart = write.timestamp - write.duration + const writeEnd = write.timestamp + const keyEnd = sample.keydown + (sample.nextFrame ?? 0) + return writeStart <= keyEnd && writeEnd >= sample.keydown + }) + return { + transactionDuration: transactions[index]?.duration ?? Number.NaN, + keydownToDomMutation: sample.dom, + keydownToPostUpdateFrame: sample.nextFrame, + persistenceOverlap, + } satisfies KeyboardBenchmarkKeySample + }) + return { + transactionDurations, + dom, + nextFrame, + perKey, + keyWindows, + overlayReads: overlayReads.map(sample => ({ value: sample.duration, timestamp: sample.timestamp })), + overlayWrites: overlayWrites.map(sample => ({ value: sample.duration, timestamp: sample.timestamp })), + longTasks: longTasks.map(sample => ({ value: sample.duration, timestamp: sample.startTime })), + persistenceOverlapSamples: overlaps, + missingDomMutationSamples: browserSamples.length - dom.length, + missingNextFrameSamples: browserSamples.length - nextFrame.length, + } + }) + const measured = { + name: scenario, + ...(cadenceMs === undefined ? {} : { cadenceMs }), + perKey: result.perKey, + transaction: stats(result.transactionDurations), + keydownToDomMutation: stats(result.dom), + keydownToPostUpdateFrame: stats(result.nextFrame), + listOverlayRead: stats(result.overlayReads.map(sample => sample.value)), + listOverlayWrite: stats(result.overlayWrites.map(sample => sample.value)), + longTasks: stats(result.longTasks.map(sample => sample.value)), + raw: { + transaction: result.transactionDurations, + domMutation: result.dom, + postUpdateFrame: result.nextFrame, + listOverlayRead: result.overlayReads.map(sample => sample.value), + listOverlayWrite: result.overlayWrites.map(sample => sample.value), + longTasks: result.longTasks.map(sample => sample.value), + }, + worst: { + transaction: worst(result.transactionDurations.map((value, keyIndex) => ({ value, timestamp: result.keyWindows[keyIndex]?.end })), result.keyWindows), + domMutation: worst(result.dom.map((value, keyIndex) => ({ value, timestamp: result.keyWindows[keyIndex]?.start })), result.keyWindows), + postUpdateFrame: worst(result.nextFrame.map((value, keyIndex) => ({ value, timestamp: result.keyWindows[keyIndex]?.end })), result.keyWindows), + listOverlayRead: worst(result.overlayReads, result.keyWindows), + listOverlayWrite: worst(result.overlayWrites, result.keyWindows), + longTask: worst(result.longTasks, result.keyWindows), + persistenceOverlap: result.perKey[result.perKey.findIndex(sample => sample.persistenceOverlap)]?.persistenceOverlap + ? { keyIndex: result.perKey.findIndex(sample => sample.persistenceOverlap), value: 1 } + : undefined, + }, + persistenceOverlapSamples: result.persistenceOverlapSamples, + missingDomMutationSamples: result.missingDomMutationSamples, + missingNextFrameSamples: result.missingNextFrameSamples, + } + console.info(`[perf] ${scenario} | transaction median/p95/max ${measured.transaction.median.toFixed(2)}/${measured.transaction.p95.toFixed(2)}/${measured.transaction.max.toFixed(2)}ms | DOM median/p95/max ${measured.keydownToDomMutation.median.toFixed(2)}/${measured.keydownToDomMutation.p95.toFixed(2)}/${measured.keydownToDomMutation.max.toFixed(2)}ms | post-update-frame median/p95/max ${measured.keydownToPostUpdateFrame.median.toFixed(2)}/${measured.keydownToPostUpdateFrame.p95.toFixed(2)}/${measured.keydownToPostUpdateFrame.max.toFixed(2)}ms | persistence-overlap ${measured.persistenceOverlapSamples}`) + return measured +} + +describe.skipIf(!RUN_PERF || RUN_LOW_END_LANE)('exact-corpus repeated-character browser benchmark', () => { + beforeAll(async () => { + await assertDevServerAvailable(config.devServerUrl) + }) + + it('measures isolated real keyboard characters in nested bodies and new list items', { timeout: 180000 }, async () => { + const session = await openPerfSession(config, 'perf-repeated-character-browser') + const fixture = PERF_FIXTURES.realWorldActionListDoc + const scenarios: KeyboardBenchmarkArtifact['scenarios'] = [] + try { + await navigateToPerfApp(session.page, config) + for (const scenario of [ + { name: 'nested-task-body', needle: 'how to link to block types?', insertListItem: false, cadenceMs: undefined }, + { name: 'new-list-item', needle: 'list create is difficult (needs a button?)', insertListItem: true, cadenceMs: undefined }, + ...Array.from({ length: 3 }, (_, index) => ({ + name: `nested-task-body-30ms-cadence-run-${index + 1}`, + needle: 'how to link to block types?', + insertListItem: false, + cadenceMs: 30, + })), + ]) { + const result = await runPerfScenario(session.page, { + name: `repeated-character-${scenario.name}`, + artifactDir: '.perf-artifacts/repeated-character', + setup: async page => { + await replaceCorpus(page) + await page.evaluate(() => { + const view = window.__cm6_editor_view__ + if (!view) throw new Error('CM6 view is not exposed') + view.focus() + }) + }, + preGate: async page => (await readEditorSnapshot(page)).docText === fixture.content, + interaction: async page => { + scenarios.push(await runKeys(page, scenario.name, scenario.needle, scenario.insertListItem, scenario.cadenceMs)) + }, + postGate: async page => (await readEditorSnapshot(page)).docText.includes('a'.repeat(CHARACTER_COUNT)), + }) + expect(result.artifact.canonicalRevisionAsserted).toBe(true) + } + + const assertions = scenarios.flatMap(scenario => [ + { + scenario: scenario.name, + metric: 'transaction', + p95: scenario.transaction.p95, + targetMs: TARGET_MS, + enforced: true, + passed: scenario.transaction.p95 < TARGET_MS, + note: 'CM6 transaction processing budget is enforced for every scenario.', + }, + { + scenario: scenario.name, + metric: 'keydownToDomMutation', + p95: scenario.keydownToDomMutation.p95, + targetMs: TARGET_MS, + enforced: true, + passed: scenario.keydownToDomMutation.p95 < TARGET_MS, + note: 'Keydown-to-DOM mutation processing budget is enforced for every scenario.', + }, + { + scenario: scenario.name, + metric: 'keydownToPostUpdateFrame', + p95: scenario.keydownToPostUpdateFrame.p95, + targetMs: TARGET_MS, + enforced: scenario.cadenceMs === 30, + passed: scenario.cadenceMs === 30 ? scenario.keydownToPostUpdateFrame.p95 < TARGET_MS : null, + note: scenario.cadenceMs === 30 + ? 'Enforced for realistic 30ms cadence.' + : 'Report-only: isolated keys begin immediately after the prior frame and can phase-lock the 60Hz refresh wait; this is not a processing gate.', + }, + ]) + const benchmark: KeyboardBenchmarkArtifact = { + corpus: fixture.name, + characterCount: CHARACTER_COUNT, + scenarios, + targetMs: TARGET_MS, + assertions, + } + for (const scenario of scenarios) { + expect(scenario.transaction.count, `${scenario.name} transaction count`).toBe(CHARACTER_COUNT) + expect(scenario.missingDomMutationSamples, `${scenario.name} DOM samples`).toBe(0) + expect(scenario.missingNextFrameSamples, `${scenario.name} frame samples`).toBe(0) + } + const artifact = await session.page.evaluate(() => window.__reamsPerfDiagnostics__?.getSummary()) + if (!artifact) throw new Error('Missing perf summary') + writePerfArtifact({ + scenario: 'repeated-character-browser', + revision: getBuildHash(), + timestamp: new Date().toISOString(), + environment: { browser: await session.page.evaluate(() => navigator.userAgent), device: 'Desktop', viewport: session.page.viewportSize() ?? { width: 1280, height: 900 }, cache: 'default (Playwright)', throttling: 'none', seed: fixture.seed }, + reactProfiler: { samples: artifact.profilerSamples, commitCount: artifact.commitCount, totalActualDuration: artifact.totalActualDuration, maxActualDuration: artifact.maxActualDuration, totalBaseDuration: artifact.totalBaseDuration }, + perfSamples: artifact.perfSamples, + browserPerf: { longTasks: [], layoutShifts: [], eventTimingEntries: artifact.eventTimingEntries, userTimingEntries: artifact.userTimingEntries, unsupportedEntryTypes: artifact.unsupportedEntryTypes }, + cdpTrace: { path: '', eventCount: 0 }, + workCounters: { longTasks: artifact.longTaskCount, layoutShifts: artifact.layoutShiftCount }, + timings: { totalScenarioTime: 0 }, + canonicalRevisionAsserted: true, + keyboardBenchmark: benchmark, + }, '.perf-artifacts/repeated-character') + // Isolated post-update-frame wall time is intentionally report-only: its + // refresh-phase wait is controlled by the previous frame, not processing + // work. Transaction, DOM, and realistic-cadence frame budgets are real. + for (const assertion of assertions) { + if (assertion.enforced) expect(assertion.p95, `${assertion.scenario} ${assertion.metric} p95`).toBeLessThan(assertion.targetMs) + } + } finally { + await session.close() + } + }) +}) diff --git a/tests/perf/support/artifacts.ts b/tests/perf/support/artifacts.ts index 92e88209..f2904369 100644 --- a/tests/perf/support/artifacts.ts +++ b/tests/perf/support/artifacts.ts @@ -6,6 +6,7 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { execSync } from 'node:child_process' import path from 'node:path' import type { ProfilerSample } from '../../../src/lib/perf-profile' +import type { PerfSample } from '../../../src/lib/perf-profile' import type { PerfEntry } from '../../../src/testing/perf/reams-perf-diagnostics' export interface PerfArtifact { @@ -27,6 +28,7 @@ export interface PerfArtifact { maxActualDuration: number totalBaseDuration: number } + perfSamples: readonly PerfSample[] browserPerf: { longTasks: PerfEntry[] layoutShifts: PerfEntry[] @@ -47,6 +49,74 @@ export interface PerfArtifact { totalScenarioTime: number } canonicalRevisionAsserted: boolean + keyboardBenchmark?: KeyboardBenchmarkArtifact +} + +export interface KeyboardBenchmarkStats { + count: number + median: number + p95: number + max: number +} + +export interface KeyboardBenchmarkAttribution { + keyIndex: number + value: number + timestamp?: number + label?: string +} + +export interface KeyboardBenchmarkKeySample { + transactionDuration: number + keydownToDomMutation?: number + keydownToPostUpdateFrame?: number + persistenceOverlap: boolean +} + +export interface KeyboardBenchmarkArtifact { + corpus: string + characterCount: number + scenarios: Array<{ + name: string + cadenceMs?: number + perKey: KeyboardBenchmarkKeySample[] + transaction: KeyboardBenchmarkStats + keydownToDomMutation: KeyboardBenchmarkStats + keydownToPostUpdateFrame: KeyboardBenchmarkStats + listOverlayRead: KeyboardBenchmarkStats + listOverlayWrite: KeyboardBenchmarkStats + longTasks: KeyboardBenchmarkStats + raw: { + transaction: number[] + domMutation: number[] + postUpdateFrame: number[] + listOverlayRead: number[] + listOverlayWrite: number[] + longTasks: number[] + } + worst: { + transaction?: KeyboardBenchmarkAttribution + domMutation?: KeyboardBenchmarkAttribution + postUpdateFrame?: KeyboardBenchmarkAttribution + listOverlayRead?: KeyboardBenchmarkAttribution + listOverlayWrite?: KeyboardBenchmarkAttribution + longTask?: KeyboardBenchmarkAttribution + persistenceOverlap?: KeyboardBenchmarkAttribution + } + persistenceOverlapSamples: number + missingDomMutationSamples: number + missingNextFrameSamples: number + }> + targetMs: number + assertions: Array<{ + scenario: string + metric: string + p95: number + targetMs: number + enforced: boolean + passed: boolean | null + note: string + }> } /** Get the current git revision (best-effort). */ diff --git a/tests/perf/support/fixtures.ts b/tests/perf/support/fixtures.ts index ec2d50c2..a6cb3778 100644 --- a/tests/perf/support/fixtures.ts +++ b/tests/perf/support/fixtures.ts @@ -76,4 +76,71 @@ export const PERF_FIXTURES = { content: generateNestedActionListDoc(), seed: 'perf-nested-action-list', }, + /** Real-world action-list corpus supplied specifically for performance testing. */ + realWorldActionListDoc: { + name: 'perf-reams-features-action-list.md', + content: `--- +title: reams features +tags: [reams] +date: '260718-0324' +--- + +- [ ] Types and Queries + - [ ] how to link to block types? + - [x] can we use lexicons as block types? some method/plugin for defining their interaction? + - [ ] properties right panel? + - [ ] like https://tolaria.md + - [ ] query lang for nodes in reams + - [ ] copy obsidian? + - [ ] breadcrumbs + - [ ] node viewer (focus for block and sub blocks) + - [ ] journal view +- [ ] Editor + - [ ] gesture for next/previous open tab + - [ ] double space = period (+ editor setting) + - [ ] drag nodes like notion + - [ ] image in paragraph sized small + - [ ] resize images? + - [ ] backlinks in page + - [ ] some way to do macros for "updated" + - [ ] select text and create page action +- [ ] document tabs behind setting (so they can be disabled) +- [ ] full keyboard navigation / shortcuts +- [ ] accessibility?? + - [ ] font size + - [ ] font style? +- [x] animate folding of nested lists +- [ ] reams configs + - [ ] zettlr / outline mode + - [ ] saved config defaults for different experiences + - [[260716-1457|Trawler]] / no file tree for Graham + - ![[Pasted image 20260809011735343.png]] Notion Inspo +- [ ] frontmatter + - [ ] list create is difficult (needs a button?) + - [ ] creation buttons (in context) + - [ ] front matter generally buggy + - [ ] auto suggest tags +- [ ] persisted index's +- [ ] automerge-atproto + - [ ] nice profile popup + - [ ] profile page + - [ ] @ mentions + - [ ] pages.parts + - [ ] publish to standard site per page + - [ ] query lang for atproto records? + - [ ] inlay.at / webtil.es for components? + - [ ] some way to manage spaces +- [x] dataview plugin +- [ ] Sidebar + - [ ] similar pages sidebar? + - [ ] calendar sidebar? + - [ ] multi / scrolling sidebar (view of multiple sidebar items without needing to switch tabs) +- [ ] import mast into reams somehow? +- [ ] really good PWA + - [ ] web haptics + - [ ] no reload when opening again + - [ ] no slowdown on typing +`, + seed: 'perf-reams-features-action-list', + }, } as const satisfies Record diff --git a/tests/perf/support/perf-runner.ts b/tests/perf/support/perf-runner.ts index 4b4b8697..7ab62607 100644 --- a/tests/perf/support/perf-runner.ts +++ b/tests/perf/support/perf-runner.ts @@ -51,6 +51,7 @@ export interface PerfScenarioOptions { interaction: (page: Page) => Promise postGate: (page: Page) => Promise traceCategories?: string[] + throttling?: string } export interface PerfScenarioResult { @@ -63,7 +64,7 @@ export async function runPerfScenario( page: Page, options: PerfScenarioOptions, ): Promise { - const { name, artifactDir, setup, preGate, interaction, postGate, traceCategories } = options + const { name, artifactDir, setup, preGate, interaction, postGate, traceCategories, throttling = 'none' } = options // Setup (not timed) await setup(page) @@ -119,7 +120,7 @@ export async function runPerfScenario( device: 'Desktop', viewport, cache: 'default (Playwright)', - throttling: 'none', + throttling, seed: name, }, reactProfiler: { @@ -129,6 +130,7 @@ export async function runPerfScenario( maxActualDuration: summary.maxActualDuration, totalBaseDuration: summary.totalBaseDuration, }, + perfSamples: summary.perfSamples, browserPerf: { longTasks: summary.perfEntries.filter((e: { entryType: string }) => e.entryType === 'longtask'), layoutShifts: summary.perfEntries.filter((e: { entryType: string }) => e.entryType === 'layout-shift'), @@ -256,6 +258,7 @@ export async function runBootScenario( maxActualDuration: summary.maxActualDuration, totalBaseDuration: summary.totalBaseDuration, }, + perfSamples: summary.perfSamples, browserPerf: { longTasks: [], layoutShifts: [], diff --git a/vite.config.ts b/vite.config.ts index 83677303..8774bdcd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -45,6 +45,10 @@ if (!Number.isInteger(devPort) || devPort <= 0 || devPort > 65_535) { export default defineConfig({ plugins: [react(), wasm(), topLevelAwait()], + worker: { + format: 'es', + plugins: () => [wasm()], + }, server: { port: devPort, host: true, -- 2.54.0