Get the markdown stack and the PNG exporter out of the initial chunk - #416
Merged
Conversation
A sourcemap attribution of the initial chunk - every output byte charged back to the module it came from - found two things sitting in it that are reachable only through a click. The bigger one was the Document View's markdown machinery. App.tsx statically imported DocumentViewPanel, and that pulled in DocumentViewMarkdown (react-markdown plus six remark/rehype plugins and react-medium-image-zoom) and documentViewHeadings, which runs its own unified/remark-parse pass so the table of contents' ids provably match what rehype-slug will render. The eleven node views that also render markdown were already lazy; this single eager importer was holding the whole unified/micromark/mdast stack in the initial chunk for every session, including the many that never open the panel at all. The smaller one was html-to-image, reached from AppBar's Export PNG button and the command palette's export command. Measured: largest chunk 781,060 -> 595,517 bytes, -185,543 (-23.8%). Total JS 1,456,011 -> 1,458,443 (+2,432), the usual per-chunk overhead of splitting; the budget ADR-019 sets is on the initial chunk. THE PANEL SHELL DELIBERATELY STAYS EAGER, and that is the whole design of this change. The panel is an <aside> that animates `width 220ms ease` from 0, and a CSS transition does not fire on a freshly mounted element - lazy- mounting the shell would have cost the first open its slide-in. Only its contents moved. Verified in the running app against the built bundle: the <aside> is in the DOM before anything is opened, at width 0 with `width` in its transition-property list, and exactly two JS chunks are fetched on first paint. A latch, not an isOpen check, gates the two dynamic imports. React.lazy inside an always-rendered subtree requests its chunk moments after page load - splitting the bundle without deferring the fetch, which is the whole point. `hasOpened` latches on the first open and never resets, so closing the panel still does not unmount anything: scroll position, search text and width survive exactly as they did when this was all eager. Same reasoning, and the same latch, as App.tsx's own LazySurface. The one observable difference is the table of contents. extractHeadings now resolves through a dynamic import, so it is state fed by an effect rather than a useMemo, and the outline is empty for one microtask after content changes. DocumentViewToc already renders nothing below two headings, so that tick looks like a document with no outline. Tests: the panel test that asserted "content stays mounted when closed" was correct to fail - it rendered a never-opened panel. Replaced by two that state the real contract rather than weakening it: a never-opened panel does not load its markdown chunk, and a panel that has been opened keeps its content mounted after closing. commands.test.ts's export assertion has a comment recording that it was once vacuous; it now awaits the microtask instead of losing that, so it still fails if the command is wired to nothing. Both ceilings re-anchored DOWN to ~3% over measured reality, and the comment records what is actually left: of 595,517 bytes, ~328,600 is dependencies (React ~140,000, React Flow and its d3 deps ~175,000, neither splittable) and ~262,300 is this app's own source, concentrated in lib/bridge-core, app/canvas and app/chrome. The ADR-019 budget is 512,000 and the initial chunk is now ~16% over it, down from ~74% before this pass - close enough that the remaining gap is app-source splitting, not a dependency decision. Test plan: npm run check (schema, typecheck, lint, 2,195 vitest tests, build, bundle gate) all green. Python suite 3,285 passed / 20 skipped. Verified in the running app: only 2 chunks on first paint, and the exportCanvasPng chunk fetched only after clicking Export PNG. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dovvnloading
added a commit
that referenced
this pull request
Sep 5, 2026
) PR #416 split the Document View's markdown body behind a React.lazy import and updated its tests to await the result. Those awaits use testing-library's default 1000ms, which is enough when the file runs alone and not enough when the full suite runs: measured, two full-suite runs in three failed on `findByRole("heading")` or `findByText("still here")`. CI passed on #416 and on the merge, so this was already on main and would have started failing unrelated pull requests. The wait is long for a reason that only exists in the test environment. In a built bundle the lazy body is a prebuilt chunk; under vitest it is the FIRST transform of react-markdown plus six remark/rehype plugins, and 93 test files are competing for the worker pool while it happens. Nothing about the panel is slow. So the fix is to wait properly rather than to reach for fake timers or stub the import away - stubbing it would stop the tests exercising the thing #416 actually changed. One named constant at the four call sites that await the lazy body, with the measurement written down next to it. Verified both directions, because a longer timeout is exactly the kind of change that can quietly turn a test into a no-op: - five consecutive full-suite runs, 2,197 passed every time (was 2 failures in 3 runs before) - with the panel's body wired to never render, seven tests in the file fail and the suite goes red, so the wait has not made them vacuous Test plan: npm run check green. Python suite 3,285 passed / 20 skipped. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
Problem
check-bundle-size.mjshas carried the same note through four amendments:the ADR-019 budget for the initial chunk is 500 KiB (512,000 bytes), and we
were ~52% over it at 781,060. Every amendment said closing the gap needed
work "no stage owns".
So I measured it rather than guessing — a sourcemap attribution of the initial
chunk, charging every output byte back to the module it came from:
react-domapp:lib/bridge-core@xyflow/reactapp:app/canvasapp:app/chrome@xyflow/systemmicromark-core-commonmarkDependencies alone were 607 bytes under the entire budget, before a line of
app code. But two of them had no business being there at all.
Change
The Document View's markdown machinery
App.tsxstatically importedDocumentViewPanel, which pulled inDocumentViewMarkdown(react-markdown + six remark/rehype plugins +react-medium-image-zoom) and
documentViewHeadings— which runs its ownunified/remark-parsepass so the table of contents' ids provably match whatrehype-slugrenders.The eleven node views that also render markdown were already lazy. This one
eager importer was holding the whole unified/micromark/mdast stack in the
initial chunk for every session, including the many that never open the panel.
html-to-imageReached from AppBar's Export PNG button and the command palette's export
command. Both now import it on activation.
The shell stays eager — that's the design
The panel is an
<aside>that animateswidth 220ms easefrom 0, and a CSStransition does not fire on a freshly mounted element. Lazy-mounting the shell
would have cost the first open its slide-in. Only its contents moved.
A latch, not an
isOpencheck, gates the two dynamic imports:React.lazyinside an always-rendered subtree requests its chunk moments after page load —
splitting the bundle without deferring the fetch, which is the whole point.
hasOpenedlatches on first open and never resets, so closing still doesn'tunmount: scroll position, search text and width survive exactly as before. Same
reasoning, and the same latch, as
App.tsx's ownLazySurface.One observable difference
extractHeadingsnow resolves through a dynamic import, so it is state fed byan effect rather than a
useMemo, and the outline is empty for one microtaskafter content changes.
DocumentViewTocalready renders nothing below twoheadings, so that tick looks like a document with no outline.
Result
Both ceilings re-anchored down, and the comment now records what is actually
left instead of deferring it again: of 595,517 bytes, ~328,600 is dependencies
(React ~140,000, React Flow and its d3 deps ~175,000 — neither splittable) and
~262,300 is app source, concentrated in
lib/bridge-core,app/canvasandapp/chrome. The remaining gap is app-source splitting, not a dependencydecision.
On the tests
The panel test asserting "content stays mounted when closed" was correct to
fail — it rendered a never-opened panel. I replaced it with two that state the
real contract rather than weakening it: a never-opened panel does not load its
markdown chunk, and a panel that has been opened keeps its content mounted
after closing.
commands.test.ts's export assertion carries a comment recording that it wasonce vacuous (
expect(() => run()).not.toThrow()held forrun: () => {}). Itnow awaits the microtask rather than losing that — it still fails if the command
is wired to nothing.
Test plan
npm run check— schema, typecheck, lint, 2,195 vitest tests, build,bundle gate: all green.
<aside>is in theDOM before anything opens, at width 0 with
widthin its transition-propertylist; exactly 2 JS chunks are fetched on first paint; and the
exportCanvasPngchunk appears only after clicking Export PNG.🤖 Generated with Claude Code