Repository navigation
perf: improve first frame and progressive-mount speed, optimize large documents - #54
Merged
Merged
Conversation
… first-frame Match ticks paint over the thumb as soon as a pattern is typed, so the scroll bench could not locate the scrollbar column on docs where every track row has a match. Locate the column before the search bar opens and poll until ticks land instead of assuming one task is enough. first-frame now goes through buildDocument so DOT, frontmatter, and heading-line work is measured, and reports the parse slice. Add bench/stages.ts (per-stage pipeline timing) and bench/gen-fixture.ts (parameterized large fixture generator).
useProgressiveMount minted a new nodes slice on every Viewer render, so NodeList's memo never held and every chunk re-reconciled every mounted block and relaid out the whole tree. Memoize the slice and memo NodeRenderer on node identity plus path value. Full mount of a 2200-node doc drops from 11.2s to 5.1s. Pin NODE_ENV=production in both build scripts: React picked the development reconciler at bundle time, shipping its per-commit profiling and prop-diff instrumentation. bench/scroll.tsx now drains until the renderable count is stable (60 fixed iterations left large docs half-mounted) and times every step loop with the same keypress-plus-render protocol.
…per scroll estimateTotalRows re-walked the unmounted tail on every chunk commit, O(n^2) over a full mount. Build a row-estimate prefix sum once per document and difference it. scroll() resolved headings after every keyboard scroll, but the watched scrollPosition setter already resolves synchronously on any change, so each keypress walked the renderable tree twice. large.md: full mount 5.1s -> 4.3s, scroll step p50 10.9ms -> 9.6ms.
computeHeadingLines ran its own full marked.lexer pass over the body, doubling parse time. The mermaid/DOT preprocess is now a token pass (renderDiagramBlocks) that rewrites code tokens' text/lang and leaves raw intact, so heading line numbers and the tree are both derived from one lex. Handling diagrams at token level also stops a mermaid fence quoted inside a wider fence from being rendered. countNewlines walks with indexOf instead of per-character iteration. huge.md (26k lines): buildDocument 69ms -> 39ms; README 0.44 -> 0.24ms.
The sidebar mounted every heading row before first paint, which was the whole of first-frame's scaling with document length once the Viewer mounted progressively. Extract the chunked-growth counter the Viewer uses into useProgressiveCount and drive the TOC from it: two screenfuls of rows on first paint, the rest one chunk per task behind a spacer that keeps the sidebar scrollbar honest. First frame: 2200-node doc 157ms -> 120ms; 11000-node doc 320ms -> 176ms; README unchanged.
Startup benchmark (
|
| build | mean | ratio | verdict |
|---|---|---|---|
| baseline (main@f596175) | 394.0ms ± 14.8ms | — | |
| PR | 373.6ms ± 10.6ms | 0.95× | ✅ ok |
Thresholds: warn ≥ 1.1×, fail ≥ 1.25×. Baseline built from main.
denolfe
added a commit
that referenced
this pull request
Sep 9, 2026
- feat: copy selected text (#58) (a9f836e) - fix(hr): stop adding a resize listener per horizontal rule (#57) (beedc6d) - ci: add first-frame and scroll benchmarks to the PR bench job (#56) (b7ab07d) - perf: improve first frame and progressive-mount speed, optimize large documents (#54) (3b6d9f2) - chore(ci): pin bun 1.4.1 via mise (#53) (f596175)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Profiles each stage of the viewer (parse, first paint, progressive mount, scroll) on synthetic 5k- and 26k-line documents and fixes the cost centers found. README-sized documents are unchanged. Method and fixtures are in
bench/BASELINE.md.Cumulative, baseline
f596175vs head (Bun 1.4.1, M2 Max, headless 120x40, React dev build). large.md = 2202 nodes / 401 headings, huge.md = 11002 nodes / 2001 headings.buildDocumenthuge.mdRoot Cause
Four independent defects, each proportional to document length:
useProgressiveMountbuilt a freshnodes.slice()on every Viewer render.NodeListis memoized on that array, so every chunk (and every shell re-render mid-mount) re-reconciled every mounted block and relaid out the whole tree.computeHeadingLinesran its own fullmarked.lexerpass on top of the one inbuildTree, doubling parse time.The build scripts also set no
NODE_ENV, so both the npm bundle and the compiled binary shipped the developmentreact-reconcilerwith its per-commit profiling instrumentation.Key Changes
[nodes, mountedCount].NodeRendereris memoized on node identity plus path value, sinceNodeListbuilds a newpatharray per element.useProgressiveCount, shared by the Viewer and the TOC. The TOC paints two screenfuls of rows first and grows behind a spacer that keeps the sidebar scrollbar honest.renderDiagramBlocks). It rewrites a code token'stextandlangand leavesrawintact, so heading line numbers and the tree both come from the same token list. A side effect: a mermaid fence quoted inside a wider fence is no longer rendered.scroll()relies on the watched scroll setter instead of resolving headings a second time.NODE_ENV=production. Verified by rebuilding: zeroreact-reconciler.developmentreferences indist/npm/main.jsanddist/bin/viewmd, smoke test passes.bench/scroll.tsxdrains until the renderable count is stable (60 fixed iterations left large docs half-mounted) and times every loop with the same keypress-plus-render protocol.bench/first-frame.tsxruns the realbuildDocumentpipeline. New:bench/stages.ts(per-stage parse timing) andbench/gen-fixture.ts(sized fixtures).Per-change A/B, each taken when the commit landed:
memo(NodeRenderer)(a71fc69)NODE_ENV=production(a71fc69), measured from source via env varbuildDocumentcomputeHeadingLinesbuildDocumentCaveats: the prefix-sum and duplicate-resolve changes were measured together. The production-React row is a from-source proxy; the shipped binary was verified by string inspection and smoke test, not benched. The cumulative mount and scroll numbers exclude the production-React gain because the benches run from source in dev mode.
Design Decisions
TOC: progressive mount over windowing. Windowing would also shrink the per-frame layout walk, but TOC rows wrap when the sidebar hits its 40% width clamp, so fixed-height windowing needs truncation (a visible UI change) or post-layout height measurement. Progressive mount gets the first-frame win with no UI change and a small diff.
Diagram preprocessing at token level rather than correcting line offsets after a text replace. The lexer already handles fence nesting and indentation inside lists and blockquotes; the regex did not.
Tried and dropped: deferring
projectionMappast first paint. The scroll-marks path builds it before first paint regardless, so the change only moved the cost (huge.md first frame 313 ± 17ms to 347 ± 21ms).Not addressed: the remaining floor of about 7ms per scroll step on an 18k-renderable tree is OpenTUI's per-frame layout walk. Only virtualizing the Viewer moves it.