Contain a failed node-view chunk, and stop exporting its loading shell - #403
Merged
Conversation
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
Three gaps in the code split that shipped in #399.
A failed chunk took the whole tree with it
React.lazythrows a promise, which Suspense catches — but when that promise rejects, Suspense has nothing to do with it and the rejection propagates uncaught. Measured directly: 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. #399 shipped 17 lazy boundaries and no error boundary behind any of them.
PNG export could capture "Loading…" shells
exportCanvasPng.ts:158polls forChartNodeView's.chart-node-placeholderand resolves as soon as none remain. It knew nothing about.scene-node-loading, the fallback #399 added.Export is the worst possible moment for that gap:
exportCanvasAsPngdeliberately disablesonlyRenderVisibleElementsto mount the off-viewport nodes — which are exactly the ones whose chunks were never fetched.The fallback had no styling at all
grep -rn scene-node-loading src --include=*.css→ 0 hits. #399's own comment describes "a card-shaped shell rather than nothing", added specifically so "an empty fallback would [not] collapse the card and let edges snap to a zero-size box". It collapsed to whatever.scene-nodealone gives — the exact thing the comment says it exists to prevent.Change
Each node gets its own error boundary, placed outside its Suspense boundary (nested the other way, the rejection escapes past it). Blast radius of a bad chunk is one card.
No retry button, deliberately:
React.lazycaches the rejection as well as the resolution, so re-rendering replays the failure forever. A retry that cannot work is worse than none — the card says to reload, which is the only thing that recovers.Both loading fallbacks now live in one exported
NODE_LOADING_SELECTOR, so a third cannot be added without a reader of the export path seeing it..scene-node-loadingmirrors.chart-node-placeholder's dimensions.Test plan
npm run checkclean: 93 files, 2195 tests, 0 lint errors, bundle 781,017 bytes against the 804,000 ceiling.One note for the record: an earlier run of the check chain reported a single test failure that I could not reproduce in three targeted stress runs or two subsequent full runs. If it recurs it is most likely the
renderCountGatelazy-chunk wait under parallel load; flagging it rather than pretending it did not happen.🤖 Generated with Claude Code