Skip to content

Code-split the node views, and move the bundle ceiling down for once - #399

Merged
dovvnloading merged 1 commit into
mainfrom
perf/code-split-node-views
Sep 4, 2026
Merged

dovvnloading merged 1 commit into
mainfrom
perf/code-split-node-views

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

SceneCanvas.tsx registered all 17 *NodeView components through static imports, so every one landed in the initial chunk whether or not the open canvas held a single node of that kind.

That is the whole of the gap the bundle ratchet has been raised four times to accommodate — 815,000 → 840,000 → 866,000 → 917,000 — and every one of those amendments named this as the fix and then deferred it:

Closing the real gap still needs the eagerly-rendered node views code-split — work no stage owns, and which this stage does not pretend to do.

Change

Each kind is now its own chunk, fetched the first time a node of that kind renders.

largest chunk   890,541 -> 780,470 bytes   (-110,071, -12.4%)
total JS      1,445,539 -> 1,456,011 bytes (+10,472,  +0.7%)

The first time either number in check-bundle-size.mjs has moved down. Total going the other way is expected rather than a regression — splitting redistributes code and adds a small per-chunk overhead — and the budget ADR-019 actually sets is on the initial chunk, what the app must parse before it can paint. Both ceilings are re-anchored to ~3% over measured reality, and the raise on the total is recorded as a raise.

Stated plainly, as the raises were: at 780,470 the initial chunk is still ~52% over the 512,000-byte budget, down from ~74%. React and React Flow are the bulk of what remains. This closes the gap attributed to the node views, not the whole gap.

The Suspense boundary is per node, not one around the canvas. React Flow renders node components itself, deep in its own tree, so a single boundary higher up would suspend the whole canvas — every node plus the pane, controls and minimap — the first time any one kind loaded. The fallback renders a card-shaped shell rather than nothing, because React Flow has already positioned and sized the node by then, and an empty fallback would collapse the card and let edges snap to a zero-size box.

The render-count gate needed real work, not a relaxed assertion

renderCountGate.test.tsx measures re-renders by counting a stubbed ChatNodeView. With the views behind lazy(), the first synchronous render produces the fallback — so the gate measured zero renders, and its own "guards the guard" case caught exactly that. It would have passed vacuously if I had only touched the ceiling.

It now waits for the chunks to mount before counting. Two details were load-bearing:

  • The wait condition is positive (every expected stub on screen). "No fallback shells remain" is also true before anything renders, so it resolved immediately under load and put the zero-measurement bug straight back — it passed when the file ran alone and failed in the full suite.
  • The timeout is 5s rather than waitFor's 1s default, because what is being waited on is vitest transforming 17 new chunk entry points, which exceeded 1s in a full parallel suite.

With that fixed, the gate still measures exactly 1 re-render for a single-node content edit — which is the evidence that memoization survives the new wrapper rather than an assumption that it does.

Test plan

  • npm run check clean end to end: schema, typecheck, lint (0 errors), 2190 vitest tests, build, and the bundle gate at the new ceilings.
  • Verified in the real built app against the real backend, not just in jsdom:
    • an empty canvas fetches no node-view chunk at all;
    • creating a Conversation node fetches ConversationNodeView plus its shared card components (NodeShell, NodeMenu, NodeMarkdown, CollapseToggleButton, useStreamBuffer) and nothing else;
    • the other 16 kinds are never requested, and the node renders correctly with no stuck fallback.

🤖 Generated with Claude Code

SceneCanvas.tsx registered all 17 *NodeView components through static
imports, so every one landed in the initial chunk whether or not the open
canvas held a single node of that kind. That is the whole of the gap the
bundle ratchet has been raised four times to accommodate, and every one of
those amendments named this as the fix and then deferred it as "work no
stage owns".

Each kind is now its own chunk, fetched the first time a node of that kind
renders. Measured: largest chunk 890,541 -> 780,470 bytes (-110,071,
-12.4%) - the first time either number in check-bundle-size.mjs has moved
DOWN.

Total JS went the other way, 1,445,539 -> 1,456,011 (+10,472, +0.7%).
That is expected rather than a regression: splitting redistributes code
and adds a small per-chunk overhead, and the budget ADR-019 sets is on the
INITIAL chunk - what the app parses before it can paint. Both ceilings are
re-anchored to ~3% over measured reality, and the raise on the total is
recorded as a raise.

The Suspense boundary is per node, not one around the canvas. React Flow
renders node components itself, deep in its own tree, so a single boundary
higher up would suspend the whole canvas - every node plus the pane,
controls and minimap - the first time any one kind loaded. The fallback
renders a card-shaped shell rather than nothing, because React Flow has
already positioned and sized the node by then and an empty fallback would
collapse the card.

renderCountGate.test.tsx needed real work, not a relaxed assertion. It
measures re-renders by counting a stubbed ChatNodeView, and with the views
behind lazy() the first synchronous render produces the fallback - so the
gate measured ZERO renders and its own "guards the guard" case caught
that. It now waits for the chunks to mount before counting. Two details
were load-bearing: the wait condition is POSITIVE (every expected stub on
screen) because "no fallback shells remain" is also true before anything
renders, and it resolved immediately under load; and the timeout is 5s
rather than waitFor's 1s default because what is being waited on is vitest
transforming 17 new chunk entry points, which exceeded 1s in a full
parallel suite.

With that fixed the gate still measures exactly 1 re-render for a
single-node content edit, which is the evidence that memoization survives
the new wrapper.

Test plan:
- npm run check clean end to end: schema, typecheck, lint (0 errors),
  2190 vitest tests, build, and the bundle gate at the new ceilings.
- Verified in the real built app on the real backend: an empty canvas
  fetches no node-view chunk at all, and creating a Conversation node
  fetches ConversationNodeView plus its shared card components and
  nothing else - the other 16 kinds are never requested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit e74b504 into main Sep 4, 2026
4 checks passed
@dovvnloading
dovvnloading deleted the perf/code-split-node-views branch September 4, 2026 12:11
dovvnloading added a commit that referenced this pull request Sep 4, 2026
#403)

Three gaps in the code split that shipped in #399.

A chunk that fails to load took the whole tree with it. React.lazy throws
a PROMISE, which Suspense catches - but when that promise REJECTS,
Suspense has nothing to do with it and the rejection propagates uncaught.
Measured: it surfaces as an unhandled error. In the app that means one
node kind failing to fetch - a stale asset index after a deploy, a
connection dropped mid-session - blanks the entire canvas and the app
chrome with it.

Each node now has its own error boundary outside its Suspense boundary,
so the blast radius of a bad chunk is one card. There is deliberately no
retry button: React.lazy caches the rejection as well as the resolution,
so re-rendering replays the failure forever and a retry that cannot work
is worse than none. The card says to reload, which is the only thing that
recovers.

PNG export could capture "Loading..." shells. exportCanvasPng polls for
ChartNodeView's `.chart-node-placeholder` and resolved as soon as none
remained - it knew nothing about `.scene-node-loading`, the fallback #399
added. Export is the worst possible moment for that gap: it deliberately
disables onlyRenderVisibleElements to mount the off-viewport nodes, which
are exactly the ones whose chunks were never fetched. Both fallbacks now
live in one NODE_LOADING_SELECTOR constant.

The fallback had no styling at all. `.scene-node-loading` appears in no
stylesheet - #399's own comment describes "a card-shaped shell" that in
fact collapsed to whatever `.scene-node` alone gives, which is the
zero-size box the comment says it exists to prevent. It now mirrors
`.chart-node-placeholder`.

Test plan:
- 5 new tests: a failing chunk leaves the surrounding tree mounted, the
  error card names the recovery and offers no dead retry, the underlying
  error is logged not swallowed, the loading shell carries the class
  export polls for, and all 17 exported views go through the wrapper so
  no kind can bypass the boundary.
- The direct "Suspense alone does not contain a rejection" demonstration
  is deliberately NOT a test: the unhandled rejection fails the vitest
  run itself (measured, exit 1). Recorded as a comment on the test that
  asserts the same thing positively.
- npm run check clean: 93 files, 2195 tests, 0 lint errors, bundle
  781,017 bytes against the 804,000 ceiling.

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