Skip to content

Contain a failed node-view chunk, and stop exporting its loading shell - #403

Merged
dovvnloading merged 1 commit into
mainfrom
fix/lazy-node-view-followups
Sep 4, 2026
Merged

Contain a failed node-view chunk, and stop exporting its loading shell#403
dovvnloading merged 1 commit into
mainfrom
fix/lazy-node-view-followups

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Problem

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

A failed chunk 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 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:158 polls for ChartNodeView's .chart-node-placeholder and 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: exportCanvasAsPng deliberately disables onlyRenderVisibleElements to 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=*.css0 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-node alone 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.lazy caches 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-loading mirrors .chart-node-placeholder's dimensions.

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 rather than 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). It is 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.

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 renderCountGate lazy-chunk wait under parallel load; flagging it rather than pretending it did not happen.

🤖 Generated with Claude Code

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>
@dovvnloading
dovvnloading merged commit e572e9b into main Sep 4, 2026
5 checks passed
@dovvnloading
dovvnloading deleted the fix/lazy-node-view-followups branch September 4, 2026 14:33
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