Skip to content

feat(router): dedicated app-fallback chunk for error/notFound boundaries - #537

Merged
ivogt merged 3 commits into
mainfrom
feat/fallback-chunk-grouping
Jun 5, 2026
Merged

ivogt merged 3 commits into
mainfrom
feat/fallback-chunk-grouping

Conversation

@ivogt

@ivogt ivogt commented Jun 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A "use client" component registered as an errorBoundary/notFoundBoundary
fallback 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-*.js weighing 487 KB that is actually the
theme — 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-fallback
chunk by the built-in strategy (clientChunks: true/default), regardless of where
the component lives. No API change — it just works.

Before vs. after

# before: the small boundary names the big shared chunk it sits in
dist/client/assets/ErrorBoundary-*.js   487 KB   <- actually the theme + the 1KB boundary

# after: the boundary is pulled out; the big chunk is named after a real module
dist/client/assets/<RealThemeModule>-*.js   ~486 KB
dist/client/assets/app-fallback-*.js          ~1 KB   <- the boundary, on its own

End-to-end consumer example

// router.tsx
import { ClientErrorFallback } from "./ClientErrorFallback.js"; // "use client"
import { NotFound } from "./NotFound.js";                       // "use client"

errorBoundary(<ClientErrorFallback />); // -> app-fallback-*.js
notFoundBoundary(<NotFound />);         // -> app-fallback-*.js

app-fallback is 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

Module Built-in strategy result
registered error/notFound fallback — route-tree errorBoundary(<X/>) or router-level createRouter({ defaultErrorBoundary, defaultNotFoundBoundary, notFound }), incl. handler-fn and provider-wrapped forms app-fallback (wins over markers, any location)
loading() Suspense skeleton unchanged — stays eager (must paint while content streams)
routes/<id>/… marker app-<id> (unchanged)
flat src/components/… inherits default grouping (unchanged)
React / router runtime / node_modules shared (unchanged)
custom clientChunks function function owns grouping — fallback refinement does NOT apply

Verified 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>:

before after
The "monolith" ErrorBoundary-*.js 487.7 KB (named after a ~1 KB boundary) gone
Renamed to a real module — Search-*.js 434.3 KB
Decoupled error UI inside the 487 KB app-fallback 7.1 KB + NotFound 3.3 KB (loaded only on error/404)

DEBUG=rango:chunks confirms all five boundaries (incl. the FallbackIntl
provider, itself "use client") route to app-fallback. First-load drops ~53 KB;
total client JS is unchanged (relocation + rename, not deletion).

Code change

  • generate-manifest.ts — optional collectClientFallbackRef walks the
    EntryData errorBoundary/notFoundBoundary arrays; for each element whose
    .type is a plugin-rsc client reference, reports its $$id (stripping the
    #<exportName> suffix). The otherwise-discarded fallback elements are surfaced
    here and nowhere else.
  • discover-routers.ts — during the build-time route-discovery pass, normalizes
    each reported key via computeProductionHash into a ClientChunkContext.fallbackRefs
    set (only when the built-in strategy is active).
  • client-chunks.ts — directoryClientChunks checks hashRefKey(meta.normalizedId)
    against fallbackRefs and returns app-fallback. client-ref-hashing.ts exports
    the shared hashRefKey.
  • rango.ts / state.ts — create the ClientChunkContext, thread it to both
    resolveClientChunks and the discovery plugin.
  • router.ts / router-interfaces.ts — expose the router-level boundary
    defaults (__defaultErrorBoundary / __defaultNotFoundBoundary / __notFound)
    on the instance so discovery can read them: they are createRouter options
    applied at match time and never land in EntryData (the path the reporter's
    app uses — without this the decoupling silently no-op'd).
  • collect-fallback-refs.ts (new, build entry) — collectFallbackClientRefs
    handles 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) and discover-routers (router
    defaults, via the build entry so it runs in the RSC-runner realm).

Build-time only (the strategy runs only during vite build); zero runtime/bundle
cost, and it shrinks the happy-path chunk by the fallback's bytes.

Test plan

  • Unit — client-chunks.test.ts (fallback grouping, marker precedence, hash
    correlation) + collect-fallback-refs.test.ts (8 cases: direct element,
    server-wrapped, handler fn, throwing handler, slot prop, data-prop safety,
    arrays).
  • e2e dev and production (mini.test.ts) — build-graph asserts BOTH the
    route-tree helper (direct element) AND a router-level defaultErrorBoundary
    that is a handler fn wrapping the client boundary in a server component
    land only in app-fallback; runtime asserts the fallback is not fetched
    on a happy route
    and renders (hydrated) on error.
  • Both presets — node (mini) and cloudflare (cloudflare-basic, wrapped
    handler incl. a conditional error ? <Client/> : null, manually verified).
  • Real consumer app — see "Verified on a real app".
  • typecheck, lint, format; full router unit suite green (3327).

Notes / call-outs

  • Considered and dropped: naming the catch-all shared chunk after the router id
    (app-menu).
    It can't rename a single chunk without either merging others into
    it (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-fallback fix, which removes the alarming
    name for any topology by pulling the boundary out.
  • A component used as both a fallback and route UI lands in app-fallback (one
    module, one group). Keep dedicated fallback components separate.
  • Limit: 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
    (documented in docs/client-chunking.md).
  • The remaining theme weight (the renamed Search chunk above) is a separate
    per-route/lazy concern, app-side (custom clientChunks fn or lazy()), out of
    scope here.

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).
@pkg-pr-new

pkg-pr-new Bot commented Jun 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@537

commit: 99e00c3

…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
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).
@ivogt
ivogt marked this pull request as ready for review June 5, 2026 15:13
@ivogt
ivogt merged commit e09a56e into main Jun 5, 2026
39 checks passed
@ivogt
ivogt deleted the feat/fallback-chunk-grouping branch June 5, 2026 15:22
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