Skip to content

Get the markdown stack and the PNG exporter out of the initial chunk - #416

Merged
dovvnloading merged 1 commit into
mainfrom
perf/split-document-view
Sep 4, 2026
Merged

Get the markdown stack and the PNG exporter out of the initial chunk#416
dovvnloading merged 1 commit into
mainfrom
perf/split-document-view

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

check-bundle-size.mjs has 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:

bytes share
react-dom 132,810 17.0%
app:lib/bridge-core 84,345 10.8%
@xyflow/react 83,000 10.6%
app:app/canvas 81,900 10.5%
app:app/chrome 77,841 10.0%
@xyflow/system 46,877 6.0%
micromark-core-commonmark 27,571 3.5%
all node_modules 511,393 65.5%
all app source 264,981 33.9%

Dependencies 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.tsx statically imported DocumentViewPanel, which pulled in
DocumentViewMarkdown (react-markdown + six remark/rehype plugins +
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 renders.

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-image

Reached 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 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.

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 first open and never resets, so closing still doesn't
unmount: scroll position, search text and width survive exactly as before. Same
reasoning, and the same latch, as App.tsx's own LazySurface.

One observable difference

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.

Result

before after
Largest chunk 781,060 595,517 (−185,543, −23.8%)
Dependencies in it 511,393 328,585
Over the ADR-019 budget by ~52% ~16%
Chunks fetched on first paint 2

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/canvas and
app/chrome. The remaining gap is app-source splitting, not a dependency
decision.

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 was
once vacuous (expect(() => run()).not.toThrow() held for run: () => {}). It
now 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.
  • Python suite: 3,285 passed, 20 skipped.
  • Verified in the running app against the built bundle: the <aside> is in the
    DOM before anything opens, at width 0 with width in its transition-property
    list; exactly 2 JS chunks are fetched on first paint; and the
    exportCanvasPng chunk appears only after clicking Export PNG.

🤖 Generated with Claude Code

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
dovvnloading merged commit f01a590 into main Sep 4, 2026
5 checks passed
@dovvnloading
dovvnloading deleted the perf/split-document-view branch September 4, 2026 22:25
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant