diff --git a/config/scripts/markdown-toc-parse-benchmark.mjs b/config/scripts/markdown-toc-parse-benchmark.mjs new file mode 100644 index 00000000000..34d890cca1f --- /dev/null +++ b/config/scripts/markdown-toc-parse-benchmark.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +// Benchmark: cost of the rich-markdown Table-of-Contents parse that fires on +// every debounced content change while typing. +// +// Before the fix (RichMarkdownEditor.tsx), buildMarkdownTableOfContents() ran a +// full-document remark parse on EVERY content change even when the TOC panel was +// closed (the default), then discarded the result. This benchmark mirrors that +// parse workload across document sizes and reports the main-thread time spent +// per "typing burst" so the wasted work is quantified. +// +// The fix gates the memo on showTableOfContents, so when the panel is closed the +// per-burst cost drops to ~0 (a stable empty array, no parse). This script +// measures the "panel closed" cost that the gate eliminates. +import { performance } from 'node:perf_hooks' +import remarkFrontmatter from 'remark-frontmatter' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import { unified } from 'unified' + +const DOC_HEADINGS = Number.parseInt(process.env.ORCA_TOC_BENCH_HEADINGS ?? '400', 10) +const PARAGRAPHS_PER_HEADING = Number.parseInt(process.env.ORCA_TOC_BENCH_PARAS ?? '6', 10) +// Number of debounced content changes in a sustained typing burst. The editor +// debounces serialize at 300ms, so ~200 changes ≈ a minute of steady typing. +const CONTENT_CHANGES = Number.parseInt(process.env.ORCA_TOC_BENCH_CHANGES ?? '200', 10) +const WARMUP = Number.parseInt(process.env.ORCA_TOC_BENCH_WARMUP ?? '5', 10) + +for (const [name, value] of [ + ['ORCA_TOC_BENCH_HEADINGS', DOC_HEADINGS], + ['ORCA_TOC_BENCH_PARAS', PARAGRAPHS_PER_HEADING], + ['ORCA_TOC_BENCH_CHANGES', CONTENT_CHANGES], + ['ORCA_TOC_BENCH_WARMUP', WARMUP] +]) { + if (!Number.isInteger(value) || value <= 0) { + throw new Error(`${name} must be a positive integer, received ${value}`) + } +} + +// Mirror of buildMarkdownTableOfContents()'s parse + heading walk +// (src/renderer/src/components/editor/markdown-table-of-contents.ts). Kept inline +// so the benchmark exercises the real remark pipeline without bundling the TS. +function buildMarkdownTableOfContentsLike(markdown) { + const tree = unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkFrontmatter, ['yaml', 'toml']) + .parse(markdown) + const headings = [] + const visit = (node) => { + if (node.type === 'heading' && typeof node.depth === 'number') { + let title = '' + const collect = (n) => { + if (typeof n.value === 'string') { + title += n.value + } + for (const child of n.children ?? []) { + collect(child) + } + } + collect(node) + if (title) { + headings.push({ depth: node.depth, title }) + } + } + for (const child of node.children ?? []) { + visit(child) + } + } + visit(tree) + return headings +} + +function buildDocument(headings, parasPerHeading) { + const lines = ['---', 'title: Benchmark Document', 'tags: [perf, toc]', '---', ''] + for (let h = 0; h < headings; h += 1) { + const level = (h % 3) + 1 + lines.push(`${'#'.repeat(level)} Section ${h} \`code\` **bold** [link](https://example.com)`) + lines.push('') + for (let p = 0; p < parasPerHeading; p += 1) { + lines.push( + `Paragraph ${p} for section ${h} with *emphasis*, \`inline code\`, and ` + + 'some filler text to give the parser realistic body content to walk over.' + ) + lines.push('') + } + if (h % 5 === 0) { + lines.push('| Col A | Col B | Col C |', '| --- | --- | --- |', '| 1 | 2 | 3 |', '') + } + } + return lines.join('\n') +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid] +} + +function measureDoc(headings, parasPerHeading) { + const baseDoc = buildDocument(headings, parasPerHeading) + // Simulate typing: each content change appends one character so `content` + // changes identity every time (matching the memo dependency in the editor). + const perChange = [] + for (let i = 0; i < WARMUP + CONTENT_CHANGES; i += 1) { + const doc = `${baseDoc}\nedit-${i}` + const t0 = performance.now() + const toc = buildMarkdownTableOfContentsLike(doc) + const elapsed = performance.now() - t0 + if (i >= WARMUP) { + perChange.push(elapsed) + } + if (toc.length === 0) { + throw new Error('benchmark produced an empty TOC; document generation is broken') + } + } + const total = perChange.reduce((sum, value) => sum + value, 0) + return { + bytes: Buffer.byteLength(baseDoc, 'utf8'), + headings, + perChangeMedianMs: median(perChange), + perChangeMaxMs: Math.max(...perChange), + burstTotalMs: total + } +} + +const sizes = [ + { headings: Math.round(DOC_HEADINGS / 8), paras: PARAGRAPHS_PER_HEADING }, + { headings: Math.round(DOC_HEADINGS / 2), paras: PARAGRAPHS_PER_HEADING }, + { headings: DOC_HEADINGS, paras: PARAGRAPHS_PER_HEADING } +] + +console.log('Markdown TOC parse benchmark (cost incurred per content change while typing)') +console.log( + `changes/burst=${CONTENT_CHANGES} warmup=${WARMUP} paras/heading=${PARAGRAPHS_PER_HEADING}\n` +) +console.log( + ' doc size │ headings │ per-change median │ per-change max │ burst total (closed-panel waste)' +) +console.log( + ' ─────────┼──────────┼───────────────────┼────────────────┼──────────────────────────────────' +) +for (const size of sizes) { + const result = measureDoc(size.headings, size.paras) + const kib = (result.bytes / 1024).toFixed(0).padStart(5) + const headingsCol = String(result.headings).padStart(8) + const med = `${result.perChangeMedianMs.toFixed(2)} ms`.padStart(17) + const max = `${result.perChangeMaxMs.toFixed(2)} ms`.padStart(14) + const burst = `${result.burstTotalMs.toFixed(0)} ms`.padStart(10) + console.log(` ${kib} KiB │ ${headingsCol} │ ${med} │ ${max} │ ${burst}`) +} +console.log( + '\nWith the fix, every row above costs ~0 ms while the TOC panel is closed' + + ' (the default state): the parse is skipped and a stable empty array is returned.' +) diff --git a/src/renderer/src/components/editor/RichMarkdownEditor.tsx b/src/renderer/src/components/editor/RichMarkdownEditor.tsx index 7ba24e51df9..c107666dd84 100644 --- a/src/renderer/src/components/editor/RichMarkdownEditor.tsx +++ b/src/renderer/src/components/editor/RichMarkdownEditor.tsx @@ -9,7 +9,8 @@ import { useLinkBubble } from './useLinkBubble' import { useEditorScrollRestore } from './useEditorScrollRestore' import { useModifierHeldClass } from './useModifierHeldClass' import { registerPendingEditorFlush } from './editor-pending-flush' -import { buildMarkdownTableOfContents, type MarkdownTocItem } from './markdown-table-of-contents' +import { type MarkdownTocItem } from './markdown-table-of-contents' +import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' import { RichMarkdownEditorSurface } from './RichMarkdownEditorSurface' import { useRichMarkdownEditorInstance } from './useRichMarkdownEditorInstance' import { useRichMarkdownMenuController } from './useRichMarkdownMenuController' @@ -138,7 +139,14 @@ export default function RichMarkdownEditor({ worktreeId, worktreeRoot }) - const tableOfContentsItems = useMemo(() => buildMarkdownTableOfContents(content), [content]) + // Why: building the table of contents runs a full-document remark parse on + // every content change. The result is only used while the panel is open + // (closed by default), so gate the parse on visibility; including + // showTableOfContents in deps rebuilds the outline the moment it opens. + const tableOfContentsItems = useMemo( + () => selectMarkdownTableOfContents(showTableOfContents, content), + [content, showTableOfContents] + ) const flatTableOfContentsItems = useMemo( () => flattenMarkdownTocItems(tableOfContentsItems), [tableOfContentsItems] diff --git a/src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts b/src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts new file mode 100644 index 00000000000..c31721203a7 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-toc-visibility-gate.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' +import { selectMarkdownTableOfContents } from './markdown-toc-visibility-gate' + +const SAMPLE = '# Intro\n\n## Setup\n\n### Install\n\n## Usage' + +describe('selectMarkdownTableOfContents', () => { + it('does not parse markdown when the panel is closed', () => { + const build = vi.fn(() => []) + const result = selectMarkdownTableOfContents(false, SAMPLE, build) + expect(build).not.toHaveBeenCalled() + expect(result).toEqual([]) + }) + + it('returns a stable empty-array reference across content changes while closed', () => { + // Why: a fresh [] every call would change the memo's value and force the + // downstream TOC panel/handlers to re-render even though nothing is shown. + const first = selectMarkdownTableOfContents(false, 'a') + const second = selectMarkdownTableOfContents(false, 'a\nb') + expect(first).toBe(second) + }) + + it('parses the document when the panel is open', () => { + const build = vi.fn(() => [{ id: 'x', level: 1 as const, title: 'X', children: [] }]) + const result = selectMarkdownTableOfContents(true, SAMPLE, build) + expect(build).toHaveBeenCalledExactlyOnceWith(SAMPLE) + expect(result).toHaveLength(1) + }) + + it('builds a real outline through the default builder when open', () => { + const toc = selectMarkdownTableOfContents(true, SAMPLE) + expect(toc.map((item) => item.title)).toEqual(['Intro']) + expect(toc[0]?.children.map((item) => item.title)).toEqual(['Setup', 'Usage']) + }) +}) diff --git a/src/renderer/src/components/editor/markdown-toc-visibility-gate.ts b/src/renderer/src/components/editor/markdown-toc-visibility-gate.ts new file mode 100644 index 00000000000..28ba9f5ba29 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-toc-visibility-gate.ts @@ -0,0 +1,21 @@ +import { buildMarkdownTableOfContents, type MarkdownTocItem } from './markdown-table-of-contents' + +// Why: the TOC panel is closed by default, so a single stable empty array lets +// the editor's memo skip the full-document remark parse while keeping a constant +// reference (no spurious downstream renders) until the panel actually opens. +const EMPTY_MARKDOWN_TOC: MarkdownTocItem[] = [] + +/** + * Why: building the table of contents runs a full-document remark parse on + * every content change. The result is only consumed when the TOC panel is open, + * so gate the parse on visibility. Factored out so a vitest can prove the parse + * is skipped while closed — the failure mode (silent wasted CPU on every + * keystroke-debounced content change) is otherwise invisible. + */ +export function selectMarkdownTableOfContents( + showTableOfContents: boolean, + content: string, + build: (markdown: string) => MarkdownTocItem[] = buildMarkdownTableOfContents +): MarkdownTocItem[] { + return showTableOfContents ? build(content) : EMPTY_MARKDOWN_TOC +}