mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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>
154 lines
6.1 KiB
JavaScript
154 lines
6.1 KiB
JavaScript
#!/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.'
|
|
)
|