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/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, 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); + }); +});