Repository navigation
Code-split the node views, and move the bundle ceiling down for once - #399
Merged
Merged
Conversation
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
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>
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
SceneCanvas.tsxregistered all 17*NodeViewcomponents 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:Change
Each kind is now its own chunk, fetched the first time a node of that kind renders.
The first time either number in
check-bundle-size.mjshas 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.tsxmeasures re-renders by counting a stubbedChatNodeView. With the views behindlazy(), 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:
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 checkclean end to end: schema, typecheck, lint (0 errors), 2190 vitest tests, build, and the bundle gate at the new ceilings.ConversationNodeViewplus its shared card components (NodeShell,NodeMenu,NodeMarkdown,CollapseToggleButton,useStreamBuffer) and nothing else;🤖 Generated with Claude Code