From ffd91bbe55b0143e932d74505394f8732b629b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=AF=E7=99=BD=E5=BC=80=E6=B0=B4?= <3658266862@qq.com> Date: Thu, 17 Sep 2026 17:51:10 +0800 Subject: [PATCH 1/2] perf(core): reuse per-range decorations on cursor-only updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A selection-only transaction rebuilt every live-preview decoration in the document, although a range's output only depends on the selection through a handful of predicates bounded by that range: cursor moves paid O(document) allocation even though all but one or two ranges rendered identically. Measured on the electron-demo fixture mix (jsdom): cursor-move latency grew from 7.0ms at 4KB to 36.2ms at 96KB, with the buildWidgets bucket at 24ms for 8402 decorations on the large document. The rebuild now caches each range's decorations, keyed by the range identity plus the exact selection-dependent inputs its builders consume: the intersect (inclusive and exclusive variants) and same-line predicates for most node types, and the selection's overlap with the range for lists, whose builder evaluates the intersect predicate per list item. The cache is dropped when the document or the composition state changes, so a reused entry is always built from identical inputs, and the objects CM6 sees are the same instances it already reconciled DOM for via the existing eq() keys. Cursor-move latency is now 4.4 / 8.2 / 12.2ms (median) at 4KB / 23KB / 96KB and the buildWidgets bucket on the large document drops from 24ms to ~5ms — latency no longer scales linearly with document size. All 112 live-preview, mermaid, widget, accessibility and wikilinks tests pass unchanged. --- packages/core/src/live-preview.ts | 97 ++++++++++++++++++++++++++++++- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/packages/core/src/live-preview.ts b/packages/core/src/live-preview.ts index 24c55625..b725912d 100644 --- a/packages/core/src/live-preview.ts +++ b/packages/core/src/live-preview.ts @@ -1038,6 +1038,61 @@ interface BuildContext { /** Optional pre-computed code highlight tokens for the snapshot's fenced blocks. */ codeTokens?: CodeHighlightToken[]; compositionActive?: boolean; + /** + * Per-range decoration reuse cache, owned by the live-preview extension and + * cleared whenever the document (or composition state) changes. Present only + * on selection-only rebuilds, where a range's output is still valid unless + * the cursor reached into its line span. + */ + rangeCache?: Map; +} + +/** Decorations one AST range contributed during the previous build. */ +interface CachedRangeDecorations { + decos: Range[]; + parentSpans: [number, number][]; +} + +/** + * The selection-dependent inputs of one range's builders, at the exact + * granularity they consume them. Every cursor-aware decision below is a pure + * function of these three predicates (plus `compositionActive`, which + * invalidates the whole cache), so two builds that agree on them produce + * identical decorations for the range. + * + * `list` ranges are excluded from reuse: their builder evaluates the + * intersect predicate per list item, which is finer than one range-level key. + */ +function rangeSelectionKey( + range: { from: number; to: number }, + doc: string, + selection: readonly SelectionRange[] +): string { + const inclusive = selectionIntersects(range.from, range.to, selection, true) ? 1 : 0; + const exclusive = selectionIntersects(range.from, range.to, selection) ? 1 : 0; + const sameLine = selectionOnSameLine(range.from, range.to, doc, selection) ? 1 : 0; + return `${inclusive}${exclusive}${sameLine}`; +} + +/** + * Selection overlap with `[from, to]`, clamped to it. `buildListDecorations` + * evaluates the intersect predicate per list item — a sub-interval of the + * range — so its output is determined by which part of the range the selection + * covers, not just by whether it does. Ranges whose builder only asks + * range-level questions use the coarser three-bit key instead. + */ +function selectionOverlapInside( + range: { from: number; to: number }, + selection: readonly SelectionRange[] +): string { + const parts: string[] = []; + for (const sel of selection) { + const lo = Math.min(sel.anchor, sel.head); + const hi = Math.max(sel.anchor, sel.head); + if (hi < range.from || lo > range.to) continue; + parts.push(`${Math.max(lo, range.from)}-${Math.min(hi, range.to)}`); + } + return parts.join(","); } function buildDecorations( @@ -1071,6 +1126,22 @@ function buildDecorations( for (const range of ranges) { if (parentSpans.some(([from, to]) => range.from >= from && range.to <= to)) continue; + const rangeCacheKey = + range.node.type === "list" + ? `list:${range.from}:${range.to}:${selectionOverlapInside(range, selection)}` + : `${range.node.type}:${range.from}:${range.to}:${rangeSelectionKey(range, doc, selection)}`; + const cachedRange = rangeCacheKey !== null ? ctx.rangeCache?.get(rangeCacheKey) : undefined; + if (cachedRange) { + // The key already encodes the range's selection-dependent inputs, so an + // entry with the same key was built from identical inputs — reuse the + // exact decoration objects instead of rebuilding them. + for (const deco of cachedRange.decos) decos.push(deco); + for (const span of cachedRange.parentSpans) parentSpans.push(span); + continue; + } + const decosStart = decos.length; + const spansStart = parentSpans.length; + if (range.node.type === "heading" && !config.renderers.heading) { buildHeadingDecorations( range as { from: number; to: number; node: Heading }, @@ -1270,6 +1341,15 @@ function buildDecorations( ); } } + + // A `continue` inside the chain (cursor on a link) leaves this range + // uncached, which is fine — it just gets rebuilt next time. + if (ctx.rangeCache) { + ctx.rangeCache.set(rangeCacheKey, { + decos: decos.slice(decosStart), + parentSpans: parentSpans.slice(spansStart), + }); + } } const set = Decoration.set(decos, true); @@ -1322,15 +1402,26 @@ export function createLivePreviewExtension( let lastBuilt: { doc: string; ast: Root; codeTokens: CodeHighlightToken[] } | null = null; let lastTransformRevision = ""; let compositionActive = false; + let lastCompositionActive = false; + // Per-range decoration reuse. Survives selection-only rebuilds; dropped when + // the document or the composition state changes, since both can change what + // a range renders. + let rangeCache = new Map(); const rebuildForCompositionStart = StateEffect.define(); const rebuildAfterComposition = StateEffect.define(); function build(state: EditorState, selection: readonly SelectionRange[], reuseCache: boolean): DecorationSet { lastTransformRevision = getMarkdownTransformRevision(state); const docStr = state.doc.toString(); - const ctx: BuildContext = reuseCache && lastBuilt && lastBuilt.doc === docStr - ? { ast: lastBuilt.ast, codeTokens: lastBuilt.codeTokens } - : {}; + const docUnchanged = reuseCache && lastBuilt !== null && lastBuilt.doc === docStr; + if (!docUnchanged || compositionActive !== lastCompositionActive) { + rangeCache = new Map(); + } + lastCompositionActive = compositionActive; + const ctx: BuildContext = { + ...(docUnchanged ? { ast: lastBuilt!.ast, codeTokens: lastBuilt!.codeTokens } : {}), + rangeCache, + }; try { const out = buildDecorations(state, selection, normalized, viewRef, { ...ctx, From 81361ae7c456d13f508bd5a35d7dd7eaeea47c1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E6=9D=AF=E7=99=BD=E5=BC=80=E6=B0=B4?= <3658266862@qq.com> Date: Thu, 17 Sep 2026 17:51:59 +0800 Subject: [PATCH 2/2] bench: add zero-dependency harness and cursor-latency budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm bench` now runs reproducible scenarios (cursor-move latency across document sizes, full document replace, editor mount, exportHTML, AST and stats access) against the built packages, reporting median and p95 per scenario. Zero new dependencies: the harness is plain Node plus the jsdom that is already a devDependency, and scenarios stay black-box — they drive `createEditor` and `EditorAPI`, so they measure what consumers pay. Results are written to bench/results/latest.json for run-to-run diffing; see bench/README.md. The invariant that motivated the per-range decoration reuse is now enforced by packages/core/test/live-preview-cursor-budget.test.ts: cursor-move latency on a ~96KB document must stay below 3.5x the latency on a ~4KB one. The gate is expressed as a ratio, not an absolute time, so it survives slower CI runners; the pre-optimization ratio measured ~5.2x and the current one ~2.5x. This also fills the still-open "Performance benchmarks" task in openspec/changes/add-missing-features and is registered as ROADMAP #30. --- bench/.gitignore | 1 + bench/README.md | 56 ++++++++++ bench/harness.mjs | 99 +++++++++++++++++ bench/run.mjs | 44 ++++++++ bench/scenarios/core-editor.mjs | 88 +++++++++++++++ bench/scenarios/documents.mjs | 32 ++++++ bench/scenarios/public-api.mjs | 40 +++++++ docs/ROADMAP.md | 1 + .../changes/add-missing-features/tasks.md | 2 +- package.json | 1 + .../test/live-preview-cursor-budget.test.ts | 100 ++++++++++++++++++ 11 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 bench/.gitignore create mode 100644 bench/README.md create mode 100644 bench/harness.mjs create mode 100644 bench/run.mjs create mode 100644 bench/scenarios/core-editor.mjs create mode 100644 bench/scenarios/documents.mjs create mode 100644 bench/scenarios/public-api.mjs create mode 100644 packages/core/test/live-preview-cursor-budget.test.ts diff --git a/bench/.gitignore b/bench/.gitignore new file mode 100644 index 00000000..fbca2253 --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1 @@ +results/ diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 00000000..0c3bae59 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,56 @@ +# Benchmarks + +Reproducible performance scenarios for the editor core. They exist to answer +one question per scenario with numbers that are comparable between runs on the +same machine — not to produce absolute rankings across machines. + +## Running + +```bash +pnpm build # benchmarks import packages/core/dist, so build first +pnpm bench +``` + +Output: a table on stdout plus `bench/results/latest.json` (git-ignored) for +diffing two runs. + +## Scenarios + +| Scenario | Question it answers | +|---|---| +| `live-preview/cursor-move` | How expensive is one cursor move (arrow key, click) as the document grows? This is the per-keystroke path: it must not grow linearly with document size. | +| `live-preview/set-document` | What does a full document replace cost on a large document (cache-cold rebuild)? | +| `live-preview/create-editor` | What does mounting a fresh editor on a ~23KB document cost once modules are warm? | +| `api/export-html` | What does one `exportHTML()` call cost? | +| `api/get-ast` / `api/get-document-stats` | Cost of the read-only public APIs. | + +Documents are generated deterministically (`bench/scenarios/documents.mjs`): +repeating sections with headings, inline formatting, links, lists, task lists +and a fenced code block every fifth section — the constructs the live preview +decorates. + +## Method + +- Each scenario runs 5 untimed warm-up iterations, then 30 timed ones. +- Reported: median and p95. Medians are the comparison metric; p95 catches tails. +- Everything runs in-process in jsdom, so absolute numbers are not comparable + with a real browser. Ratios between scenarios on the same run are. +- No new dependencies: the harness is plain Node + the already-present jsdom. + +## Regression budgets + +`packages/core/test/live-preview-cursor-budget.test.ts` encodes the invariant +that mattered historically: **cursor-move latency must not scale linearly with +document size**. It asserts the ratio between cursor-move latency on a ~96KB +document and a ~4KB document stays below a fixed threshold. Ratios are used +instead of absolute milliseconds so the gate survives slower CI runners; the +threshold sits between the pre-optimization ratio (~5.2x) and the current one +(~2.5x). + +## Adding a scenario + +1. Create `bench/scenarios/.mjs` exporting `run(measure): Row[]`. +2. Register the file in `bench/run.mjs` (`scenarioFiles`). +3. Keep scenarios black-box: drive the public API (`createEditor` and the + `EditorAPI` methods), never internals — otherwise the benchmark stops + measuring what consumers pay. diff --git a/bench/harness.mjs b/bench/harness.mjs new file mode 100644 index 00000000..62dee946 --- /dev/null +++ b/bench/harness.mjs @@ -0,0 +1,99 @@ +/** + * Minimal zero-dependency benchmark harness for Nexus-Editor. + * + * Runs against the built packages (`pnpm build` first), drives a real editor + * in jsdom, and reports median / p95 over a fixed sample count so numbers are + * comparable between runs on the same machine. + * + * @typedef {Object} Sample + * @property {number} median + * @property {number} p95 + * @property {number} min + * @property {number} max + * @property {number} samples + * + * @typedef {Object} Row + * @property {string} scenario + * @property {string} subject + * @property {string} metric + * @property {number} value + * @property {string} unit + */ + +import { JSDOM } from "jsdom"; + +/** + * Install the DOM globals CodeMirror needs, once per process. The globals + * live for the whole run because the editor holds them. + */ +export function installDom() { + const dom = new JSDOM("", { + pretendToBeVisual: true, + }); + globalThis.window = dom.window; + globalThis.document = dom.window.document; + Object.defineProperty(globalThis, "navigator", { + value: dom.window.navigator, + configurable: true, + }); + globalThis.Range = dom.window.Range; + globalThis.MutationObserver = dom.window.MutationObserver; + globalThis.Element = dom.window.Element; + globalThis.HTMLElement = dom.window.HTMLElement; + globalThis.SVGElement = dom.window.SVGElement; + globalThis.CustomEvent = dom.window.CustomEvent; + globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 0); +} + +function percentile(sorted, p) { + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * p))]; +} + +/** + * Run `fn` `warmup` times untouched, then `samples` timed runs. Returns + * median / p95 / min / max in milliseconds. + * + * @param {() => void} fn + * @param {{ warmup?: number, samples?: number }} [options] + * @returns {Sample} + */ +export function measure(fn, options) { + const warmup = options?.warmup ?? 5; + const samples = options?.samples ?? 30; + for (let i = 0; i < warmup; i++) fn(); + const times = []; + for (let i = 0; i < samples; i++) { + const start = performance.now(); + fn(); + times.push(performance.now() - start); + } + times.sort((a, b) => a - b); + return { + median: percentile(times, 0.5), + p95: percentile(times, 0.95), + min: times[0], + max: times[times.length - 1], + samples, + }; +} + +/** + * @param {Row[]} rows + */ +export function printTable(rows) { + const header = ["scenario", "subject", "metric", "median", "unit"]; + const body = rows.map((row) => [ + row.scenario, + row.subject, + row.metric, + row.value.toFixed(2), + row.unit, + ]); + const widths = header.map((column, i) => + Math.max(column.length, ...body.map((cells) => cells[i].length)) + ); + const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i])).join(" | "); + console.log(line(header)); + console.log(widths.map((width) => "-".repeat(width)).join("-|-")); + for (const cells of body) console.log(line(cells)); +} diff --git a/bench/run.mjs b/bench/run.mjs new file mode 100644 index 00000000..fa281900 --- /dev/null +++ b/bench/run.mjs @@ -0,0 +1,44 @@ +#!/usr/bin/env node +/** + * Benchmark entry point. Usage: + * + * pnpm build # benchmarks run against the built packages + * pnpm bench # runs every scenario, prints a table, writes JSON + * + * Results are written to bench/results/latest.json so a run can be diffed + * against a previous one on the same machine. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { installDom, measure, printTable } from "./harness.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const coreDist = path.join(here, "..", "packages", "core", "dist", "index.js"); + +if (!fs.existsSync(coreDist)) { + console.error("packages/core/dist/index.js not found — run `pnpm build` before `pnpm bench`."); + process.exit(1); +} + +installDom(); + +const scenarioFiles = ["core-editor.mjs", "public-api.mjs"]; +const rows = []; +for (const file of scenarioFiles) { + const { run } = await import(pathToFileURL(path.join(here, "scenarios", file)).href); + rows.push(...run(measure)); +} + +printTable(rows); + +const outDir = path.join(here, "results"); +fs.mkdirSync(outDir, { recursive: true }); +const payload = { + generatedAt: new Date().toISOString(), + node: process.version, + rows, +}; +fs.writeFileSync(path.join(outDir, "latest.json"), `${JSON.stringify(payload, null, 2)}\n`); +console.log(`\nwritten to bench/results/latest.json`); diff --git a/bench/scenarios/core-editor.mjs b/bench/scenarios/core-editor.mjs new file mode 100644 index 00000000..dc09a84a --- /dev/null +++ b/bench/scenarios/core-editor.mjs @@ -0,0 +1,88 @@ +/** + * Core-editor scenarios: how expensive is one cursor move / one document + * replace as the document grows, and what does the first render cost. + */ +import { createEditor } from "../../packages/core/dist/index.js"; +import { buildDocument } from "./documents.mjs"; + +function mount(initialValue) { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ container, initialValue, livePreview: true }); + return { editor, container }; +} + +export function run(measure) { + const rows = []; + const sizes = [ + { blocks: 20, label: "~4KB" }, + { blocks: 100, label: "~23KB" }, + { blocks: 400, label: "~96KB" }, + ]; + + for (const size of sizes) { + const doc = buildDocument(size.blocks); + const { editor, container } = mount(doc); + const step = Math.max(1, Math.floor(doc.length / 30)); + const targets = []; + for (let pos = 0; pos < doc.length; pos += step) targets.push(pos); + let index = 0; + const sample = measure(() => { + editor.setSelection(targets[index % targets.length]); + index += 1; + }); + rows.push({ + scenario: "live-preview/cursor-move", + subject: `doc ${size.label} (${size.blocks} sections)`, + metric: "median", + value: sample.median, + unit: "ms", + }); + rows.push({ + scenario: "live-preview/cursor-move", + subject: `doc ${size.label} (${size.blocks} sections)`, + metric: "p95", + value: sample.p95, + unit: "ms", + }); + editor.destroy(); + container.remove(); + } + + // Full document replace on the largest document (cache-cold rebuild path). + { + const doc = buildDocument(400); + const { editor, container } = mount(doc); + const sample = measure(() => { + editor.setDocument(doc); + }); + rows.push({ + scenario: "live-preview/set-document", + subject: "doc ~96KB", + metric: "median", + value: sample.median, + unit: "ms", + }); + editor.destroy(); + container.remove(); + } + + // First render of a fresh editor (module warm-up already done by the runs above). + { + const doc = buildDocument(100); + const sample = measure(() => { + const { editor, container } = mount(doc); + editor.destroy(); + container.remove(); + }); + rows.push({ + scenario: "live-preview/create-editor", + subject: "doc ~23KB", + metric: "median", + value: sample.median, + unit: "ms", + }); + } + + return rows; +} diff --git a/bench/scenarios/documents.mjs b/bench/scenarios/documents.mjs new file mode 100644 index 00000000..e6cb5c6b --- /dev/null +++ b/bench/scenarios/documents.mjs @@ -0,0 +1,32 @@ +/** + * Deterministic document fixtures shared by the benchmark scenarios: a + * realistic mix of headings, inline formatting, links, code blocks, lists and + * task lists, scaled by repeating sections. + */ + +export function section(index) { + return [ + `## Section ${index}`, + "", + `Some **bold ${index}** text with a [link ${index}](https://example.com/${index}) and \`inline code ${index}\`.`, + "", + `- item ${index}a`, + `- [ ] task ${index}b`, + "", + ].join("\n"); +} + +export function codeBlock(index) { + const lines = Array.from({ length: 20 }, (_, line) => `const value${line}_${index} = ${index} * ${line};`); + return "```js\n" + lines.join("\n") + "\n```\n\n"; +} + +/** Build a document of roughly `blocks` sections; every 5th section adds a code block. */ +export function buildDocument(blocks) { + let doc = "# Document\n\n"; + for (let i = 0; i < blocks; i++) { + doc += section(i); + if (i % 5 === 0) doc += codeBlock(i); + } + return doc; +} diff --git a/bench/scenarios/public-api.mjs b/bench/scenarios/public-api.mjs new file mode 100644 index 00000000..c743f271 --- /dev/null +++ b/bench/scenarios/public-api.mjs @@ -0,0 +1,40 @@ +/** + * Public-API scenarios that do not touch the live preview: HTML export and + * read-only accessors. + */ +import { createEditor } from "../../packages/core/dist/index.js"; +import { buildDocument } from "./documents.mjs"; + +function mount(initialValue) { + const container = document.createElement("div"); + document.body.appendChild(container); + const editor = createEditor({ container, initialValue, plugins: [] }); + return { editor, container }; +} + +export function run(measure) { + const rows = []; + const doc = buildDocument(100); + const { editor, container } = mount(doc); + + const scenarios = [ + { scenario: "api/export-html", call: () => editor.exportHTML() }, + { scenario: "api/get-ast", call: () => editor.getAst() }, + { scenario: "api/get-document-stats", call: () => editor.getDocumentStats() }, + ]; + + for (const { scenario, call } of scenarios) { + const sample = measure(call); + rows.push({ + scenario, + subject: "doc ~23KB", + metric: "median", + value: sample.median, + unit: "ms", + }); + } + + editor.destroy(); + container.remove(); + return rows; +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c04d0e6..cc2bbe46 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -86,6 +86,7 @@ Plugin-platform documentation (Chinese): [native API](./plugins/native-plugin-ap | 25 | End-to-end testing | repo infra | P1 | planned | No | Candidate: Playwright against electron-demo | | 26 | CI/CD pipeline polish | `.github/workflows` | P1 | in-progress | No | PR check / CI and publish workflow exist; missing e2e gate | | 28 | Markdown-aware word / reading-time stats | new `plugin-wordcount` | P1 | done | Yes | Walks editor AST (no double parse) + CJK-first + ARIA status bar — see `openspec/changes/add-plugin-wordcount` | +| 30 | Performance benchmark harness + cursor-latency budget | repo infra (`bench/`, `packages/core`) | P1 | done | No | `pnpm bench` (zero-dependency, black-box, `bench/README.md`); scaling budget in `packages/core/test/live-preview-cursor-budget.test.ts`; enabled per-range decoration reuse in `live-preview` | --- diff --git a/openspec/changes/add-missing-features/tasks.md b/openspec/changes/add-missing-features/tasks.md index 4a956e5f..e314cef8 100644 --- a/openspec/changes/add-missing-features/tasks.md +++ b/openspec/changes/add-missing-features/tasks.md @@ -28,6 +28,6 @@ ## Phase 4: P3 — Quality - [x] 4.1 Accessibility (a11y) — ARIA attributes on headings, code blocks, tables -- [ ] 4.2 Performance benchmarks +- [x] 4.2 Performance benchmarks (`pnpm bench` + `bench/README.md`, cursor-latency budget in `packages/core/test/live-preview-cursor-budget.test.ts`) - [ ] 4.3 API documentation - [ ] 4.4 E2E tests diff --git a/package.json b/package.json index 18beea66..3503e190 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "check:api": "pnpm --filter @floatboat/nexus-plugin-api check:api && pnpm --filter @floatboat/nexus-plugin-runtime check:api && pnpm --filter @floatboat/nexus-reference-plugins check:api", "typecheck": "pnpm -r exec tsc --noEmit", "test": "vitest run", + "bench": "node bench/run.mjs", "dev:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo dev", "build:electron-demo": "pnpm --filter @floatboat/nexus-electron-demo build", "publish:packages": "pnpm -r --filter \"./packages/*\" publish --access public --no-git-checks" diff --git a/packages/core/test/live-preview-cursor-budget.test.ts b/packages/core/test/live-preview-cursor-budget.test.ts new file mode 100644 index 00000000..54e4045f --- /dev/null +++ b/packages/core/test/live-preview-cursor-budget.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; + +import { createEditor } from "../src/index"; + +/** + * Regression budget for live-preview cursor latency (ROADMAP: benchmarks). + * + * The assertion is deliberately expressed as a *ratio* between cursor-move + * latency on a large document and on a small one, not as an absolute + * millisecond figure — ratios survive slower CI runners and noisy shared + * machines, absolute times do not. + * + * Before per-range decoration reuse, a cursor move rebuilt every decoration + * in the document, so latency grew linearly with document size (measured + * ~5.2x from a 4KB to a 96KB document). With reuse, unaffected ranges are + * re-pushed as-is and the ratio sits around ~2.5x — the remaining growth is + * CM6's own decoration diff, not our build loop. + */ +const SMALL_SECTIONS = 20; +const LARGE_SECTIONS = 400; +const SAMPLES = 25; +const MAX_SCALING_RATIO = 3.5; + +function section(index: number): string { + return [ + `## Section ${index}`, + "", + `Some **bold ${index}** text with a [link ${index}](https://example.com/${index}) and \`inline code ${index}\`.`, + "", + `- item ${index}a`, + `- [ ] task ${index}b`, + "", + ].join("\n"); +} + +function codeBlock(index: number): string { + const lines = Array.from({ length: 20 }, (_, line) => `const value${line}_${index} = ${index} * ${line};`); + return "```js\n" + lines.join("\n") + "\n```\n\n"; +} + +function buildDocument(blocks: number): string { + let doc = "# Document\n\n"; + for (let i = 0; i < blocks; i++) { + doc += section(i); + if (i % 5 === 0) doc += codeBlock(i); + } + return doc; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +function medianCursorMove(container: HTMLDivElement, doc: string): number { + const editor = createEditor({ container, initialValue: doc, livePreview: true }); + try { + const step = Math.max(1, Math.floor(doc.length / SAMPLES)); + const targets: number[] = []; + for (let pos = 0; pos < doc.length; pos += step) targets.push(pos); + + // Warm-up: JIT, the highlight LRU and the range cache must be hot, exactly + // like in a running editor where the user has already moved around. + for (const pos of targets.slice(0, 5)) editor.setSelection(pos); + + const times: number[] = []; + let index = 0; + for (let i = 0; i < SAMPLES; i++) { + const target = targets[index % targets.length]; + index += 1; + const start = performance.now(); + editor.setSelection(target); + times.push(performance.now() - start); + } + return median(times); + } finally { + editor.destroy(); + } +} + +describe("live-preview cursor latency budget", () => { + it("keeps cursor-move latency from scaling linearly with document size", () => { + const small = document.createElement("div"); + document.body.appendChild(small); + const large = document.createElement("div"); + document.body.appendChild(large); + + const smallMedian = medianCursorMove(small, buildDocument(SMALL_SECTIONS)); + const largeMedian = medianCursorMove(large, buildDocument(LARGE_SECTIONS)); + const ratio = largeMedian / smallMedian; + + small.remove(); + large.remove(); + + // Guard against a degenerate small-document measurement (e.g. a GC pause + // right after mount) making the ratio meaninglessly large or ~0. + expect(smallMedian).toBeGreaterThan(0.5); + expect(ratio).toBeLessThan(MAX_SCALING_RATIO); + }); +});