Repository navigation
feat(router): dedicated app-fallback chunk for error/notFound boundaries - #537
Merged
Merged
Conversation
A "use client" component registered as errorBoundary/notFoundBoundary is now grouped into a dedicated app-fallback chunk by the built-in clientChunks strategy, regardless of where it lives. Two wins, biggest for root/layout-level boundaries that wrap large subtrees: - Honest chunk names: a small fallback (a 1KB error component is common) no longer anchors and mis-names the large shared chunk it happened to sort first in (the "487KB ErrorBoundary-*.js that is really the theme" problem). Pulling it out lets that chunk be named after a real module. - Resilience: the error UI is no longer co-bundled with the very route code it exists to catch failures for; as its own chunk it survives a route-chunk failure. It is genuinely off the happy path: error/notFound fallbacks resolve server-side and only become client references on an actual error/404, so app-fallback is fetched only when a fallback renders, never on a successful navigation. loading() skeletons are intentionally excluded (they must paint eagerly while content streams). Mechanism (build-time only; zero runtime/bundle cost): discovery walks the EntryData error/notFound boundary elements, reads each "use client" fallback's client-reference $$id (stripping the #export suffix), and stores its production hash on a ClientChunkContext. The built-in strategy hashes meta.normalizedId and routes matches to app-fallback. A custom clientChunks function is unaffected. Validation: unit (fallback grouping + hash correlation), e2e dev+prod (mini: app-fallback isolated in the build graph; fallback off the happy path, renders on error), typecheck, lint, format. Full router unit suite green (3319).
commit: |
…chunk The #537 app-fallback grouping only walked EntryData.errorBoundary/notFoundBoundary (the route-tree errorBoundary()/notFoundBoundary() DSL helpers). A boundary registered as a createRouter() option — defaultErrorBoundary, defaultNotFoundBoundary, notFound — is applied at match time and never lands in EntryData, so it was never collected: fallbackRefs stayed empty for those modules and the app-fallback decoupling silently no-op'd. This is the common registration path (and what surfaced it: a cloudflare-preset host-router app registering defaultErrorBoundary saw zero -> app-* lines under DEBUG=rango:chunks). Preset-independent — node and cloudflare both hit it. Fix: expose the three router-level boundary defaults on the router instance (__defaultErrorBoundary / __defaultNotFoundBoundary / __notFound) and collect their "use client" client-reference $$id during discovery, alongside the existing EntryData walk. Both registration paths now route to app-fallback. Verified: - mini (node preset): a router-level defaultErrorBoundary ("use client") lands in app-fallback; build-graph test now pins BOTH the DSL helper and the router-default paths, asserting each marker lives only in app-fallback. - cloudflare-basic (cloudflare preset): a router-level "use client" defaultErrorBoundary produces an app-fallback chunk holding it (manually verified, reverted). - typecheck, lint, format, full router unit suite, mini clientChunks e2e dev+prod.
ivogt
marked this pull request as draft
June 5, 2026 14:34
…dler fns
The previous app-fallback collection only inspected the TOP-LEVEL boundary node's
.type, so it caught only a bare client element. Real boundaries are usually a
handler function that wraps the "use client" boundary in server providers (the
unmounted layout would otherwise supply them):
defaultErrorBoundary: ({ error }) => (
<FallbackIntl locales={...}><ThemedError error={error} /></FallbackIntl>
)
Here the registered value is a FUNCTION (.type undefined) and the client boundary
(ThemedError) is nested in the JSX it returns at runtime, so nothing was collected
-> fallbackRefs empty -> app-fallback silently no-op'd (the reporter's exact case).
Fix: new collectFallbackClientRefs walks the whole boundary. If it's a handler
function it invokes it with synthetic props to obtain the JSX tree (only
constructs elements - inner components are element `type`s, never rendered, so no
hooks run; fully guarded - a boundary needing a real render context is skipped
gracefully), then recurses the element tree (children + element-valued props,
depth-capped) reporting every client-reference `$$id`. Used for BOTH the EntryData
route-tree helpers (generate-manifest) and the router-level defaults
(discover-routers, via the build entry export so it runs in the RSC-runner realm).
Verified both presets with the wrapped-handler shape: mini (node) - build-graph
test now asserts the nested client boundary (DefaultClientError, behind a server
FallbackWrap, returned by a handler fn) lands ONLY in app-fallback; cloudflare-basic
(cloudflare) - same, manually verified incl. a conditional `error ? <Client/> : null`.
Limit (documented): a boundary that conditionally renders a DIFFERENT client
component based on the runtime error, or needs a real render context to return its
tree, stays on the default grouping; the custom clientChunks fn is the escape hatch.
Tests: 8 unit cases for collectFallbackClientRefs (direct, server-wrapped, handler
fn, throwing handler, slot prop, data-prop safety, arrays); mini clientChunks e2e
dev+prod; typecheck, lint, format, full router unit suite (3327).
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
A
"use client"component registered as anerrorBoundary/notFoundBoundaryfallback was co-bundled into whatever client chunk it happened to be reachable
from. For a root/layout-level boundary that wraps a large subtree, that chunk is
the app's big shared chunk — and because a tiny fallback (a ~1 KB error component
is common) often sorts first, rolldown named the whole chunk after it. The
result was an alarming
ErrorBoundary-*.jsweighing 487 KB that is actually thetheme — the boundary was an innocent bystander that lent its name to it. Worse, the
error UI was physically co-bundled with the very code it exists to catch failures
for: if that chunk fails to load, the boundary that should render the failure can't
load either.
What changes for a consumer
Registered error/notFound fallbacks are now grouped into a dedicated
app-fallbackchunk by the built-in strategy (
clientChunks: true/default), regardless of wherethe component lives. No API change — it just works.
Before vs. after
End-to-end consumer example
app-fallbackis fetched only when a fallback actually renders (error/404),never on a successful navigation — error/notFound fallbacks resolve server-side and
only become client references on the catch path.
Semantics
errorBoundary(<X/>)or router-levelcreateRouter({ defaultErrorBoundary, defaultNotFoundBoundary, notFound }), incl. handler-fn and provider-wrapped formsapp-fallback(wins over markers, any location)loading()Suspense skeletonroutes/<id>/…markerapp-<id>(unchanged)src/components/…node_modulesclientChunksfunctionVerified on a real app (cloudflare host-router, wrapped boundaries)
A consumer app whose boundaries are handler functions wrapping the client boundary
in a server provider —
({ error }) => <FallbackIntl><ThemedError/></FallbackIntl>:ErrorBoundary-*.js487.7 KB (named after a ~1 KB boundary)Search-*.js434.3 KBapp-fallback7.1 KB +NotFound3.3 KB (loaded only on error/404)DEBUG=rango:chunksconfirms all five boundaries (incl. theFallbackIntlprovider, itself
"use client") route toapp-fallback. First-load drops ~53 KB;total client JS is unchanged (relocation + rename, not deletion).
Code change
generate-manifest.ts— optionalcollectClientFallbackRefwalks theEntryDataerrorBoundary/notFoundBoundaryarrays; for each element whose.typeis a plugin-rsc client reference, reports its$$id(stripping the#<exportName>suffix). The otherwise-discarded fallback elements are surfacedhere and nowhere else.
discover-routers.ts— during the build-time route-discovery pass, normalizeseach reported key via
computeProductionHashinto aClientChunkContext.fallbackRefsset (only when the built-in strategy is active).
client-chunks.ts—directoryClientChunkscheckshashRefKey(meta.normalizedId)against
fallbackRefsand returnsapp-fallback.client-ref-hashing.tsexportsthe shared
hashRefKey.rango.ts/state.ts— create theClientChunkContext, thread it to bothresolveClientChunksand the discovery plugin.router.ts/router-interfaces.ts— expose the router-level boundarydefaults (
__defaultErrorBoundary/__defaultNotFoundBoundary/__notFound)on the instance so discovery can read them: they are
createRouteroptionsapplied at match time and never land in
EntryData(the path the reporter'sapp uses — without this the decoupling silently no-op'd).
collect-fallback-refs.ts(new, build entry) —collectFallbackClientRefshandles both registration paths and the common wrapped shape: if the boundary
is a handler function it invokes it with synthetic props to obtain the JSX tree
(only constructs elements — inner components are element
types, never rendered,so no hooks run; fully
try/catch-guarded), then recursively walks the tree(children + element-valued props, depth-capped) reporting every client reference.
Used by
generate-manifest(EntryData helpers) anddiscover-routers(routerdefaults, via the build entry so it runs in the RSC-runner realm).
Build-time only (the strategy runs only during
vite build); zero runtime/bundlecost, and it shrinks the happy-path chunk by the fallback's bytes.
Test plan
client-chunks.test.ts(fallback grouping, marker precedence, hashcorrelation) +
collect-fallback-refs.test.ts(8 cases: direct element,server-wrapped, handler fn, throwing handler, slot prop, data-prop safety,
arrays).
mini.test.ts) — build-graph asserts BOTH theroute-tree helper (direct element) AND a router-level
defaultErrorBoundarythat is a handler fn wrapping the client boundary in a server component
land only in
app-fallback; runtime asserts the fallback is not fetchedon a happy route and renders (hydrated) on error.
handler incl. a conditional
error ? <Client/> : null, manually verified).Notes / call-outs
(
app-menu). It can't rename a single chunk without either merging others intoit (collapsing dynamic-import handler splits) or per-chunk hashing, and it only
targets
shared:serverChunks — which single-router apps (mini, vite-rsc-demo,55 routes) don't even produce. It never fired on anything testable, so it was
dropped in favor of the verified
app-fallbackfix, which removes the alarmingname for any topology by pulling the boundary out.
app-fallback(onemodule, one group). Keep dedicated fallback components separate.
based on the runtime error, or needs a real render context to return its tree,
stays on the default grouping; the custom
clientChunksfn is the escape hatch(documented in
docs/client-chunking.md).Searchchunk above) is a separateper-route/lazy concern, app-side (custom
clientChunksfn orlazy()), out ofscope here.