perf(editor): skip markdown TOC parse while the panel is closed (#6695)

The rich markdown editor rebuilt the entire Table-of-Contents outline on
every content change by running a full-document remark parse
(buildMarkdownTableOfContents), then discarded the result whenever the TOC
panel was closed — which is the default state. The parse is driven by the
300ms-debounced serialize path, so it fired ~3x/sec during sustained typing,
with cost scaling linearly with document size.

Gate the memo on showTableOfContents so the parse only runs when the panel is
actually open. Including showTableOfContents in the deps rebuilds the outline
the moment the panel opens, so there is no stale TOC.

Benchmark (config/scripts/markdown-toc-parse-benchmark.mjs), per content
change while typing:
  175 KiB doc / 200 headings -> ~65 ms median  (13.2 s cumulative over a
                                                 ~1 min typing burst)
  351 KiB doc / 400 headings -> ~138 ms median (27.8 s cumulative)
With the fix this drops to ~0 ms while the panel is closed.

Adds a unit test proving the parse is skipped (and a stable empty-array
reference returned) while closed, and still runs when open.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-06-28 21:19:10 -07:00
committed by GitHub
co-authored by Orca
parent aa79465df5
commit ad87bb0123
4 changed files with 218 additions and 2 deletions
@@ -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.'
)
@@ -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]
@@ -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'])
})
})
@@ -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
}