Skip to content

feat(router): per-route client chunking via clientChunks (default-on) - #535

Merged
ivogt merged 3 commits into
mainfrom
research/build-chunking-leakage
Jun 5, 2026
Merged

ivogt merged 3 commits into
mainfrom
research/build-chunking-leakage

Conversation

@ivogt

@ivogt ivogt commented Jun 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Today a Rango router ships one client ("use client") chunk containing every
route's components and their CSS. A consumer who builds an app with a heavy
/dashboard route and a light /settings route sees the dashboard's editor,
charts, and grids downloaded on first load of any route — visiting /settings
still pulls the dashboard's client JS and CSS. The only escape hatches were a
manual React.lazy per component, or splitting the app into host sub-apps loaded
via dynamic import(). There was no per-route splitting for a single router.

(Host sub-apps already split correctly — each .lazy(() => import("./app/handler"))
is a real server-graph boundary — and that is unchanged.)

What changes for a consumer

Per-route client splitting is now on by default — the settled 1.0 stance,
backed by a measured cost-side benchmark (see "Cost-side benchmark" below). Plain
rango() groups "use client" components by route id, so visiting one route no
longer downloads another route's client code (or CSS). Opt out with a single flag.

Before vs. after

// vite.config.ts — before: one client chunk for all routes
export default defineConfig({ plugins: [rango()] });
// build: dist/client/assets/entry.rsc-*.js  (every route's client code)

// after: same config, now split by route id
export default defineConfig({ plugins: [rango()] });
// build: app-dashboard-*.js + app-dashboard-*.css  (loads only on /dashboard)
//        app-settings-*.js  + app-settings-*.css   (loads only on /settings)
//        react-*.js, router-*.js                   (shared, unchanged)

// opt out (restore the old single-chunk behavior):
export default defineConfig({ plugins: [rango({ clientChunks: false })] });

End-to-end consumer example

Colocate each route's client components under a directory named for the route;
the default splits them automatically (any nesting depth):

src/routes/
  dashboard/
    Chart.tsx              // "use client"  -> app-dashboard-*.js (+ chart.css)
    chart.css
    components/
      Legend.tsx           // "use client"  -> ALSO app-dashboard (route id, not "components")
  settings/
    Form.tsx               // "use client"  -> app-settings-*.js
    components/
      Field.tsx            // "use client"  -> ALSO app-settings  (no collision)
src/components/
  Button.tsx               // "use client"  -> stays in the shared default chunk

Power users can take full control:

rango({
  clientChunks: ({ normalizedId }) => {
    const m = normalizedId.match(/\/routes\/([^/]+)\//);
    return m ? `route-${m[1]}` : undefined; // undefined -> default grouping
  },
});

Observing the split (added in the merged follow-up #536)

The route-root marker list is finite by design, so a layout it does not recognize
(e.g. src/parts/<feature>/…) silently inherits the shared grouping. The follow-up
makes that decision observable — run a production build with the new rango:chunks
debug namespace:

DEBUG=rango:chunks pnpm build
# rango:chunks split  src/routes/dashboard/Chart.tsx -> app-dashboard
# rango:chunks shared src/parts/editor/Editor.tsx (no route-root marker; inherits default grouping)

The shared line is the actionable case: app code that did not split by route.
The consumer then colocates it under a marker dir or passes a clientChunks
function — the supported configurability path, rather than an ever-growing marker
list. Zero cost when the namespace is off.

Semantics

The built-in strategy splits only where it recognizes a route structure and
otherwise inherits @vitejs/plugin-rsc's default grouping. This is what makes it
safe as a default — it is a no-op for layouts it can't key on.

Input clientChunks value Result
routes/<id>/…, app/<id>/…, handlers/<id>/…, … (route-root marker) true / omitted (default) split into app-<id> chunk + matching CSS, at any nesting depth
flat src/components/Button.tsx (no marker) default unchanged — inherits the default grouping (one shared app chunk)
host sub-apps split by a dynamic import() default unchanged — per-app chunks, no cross-app merge
React / router runtime / node_modules default never split (shared)
any false opt out — @vitejs/plugin-rsc default grouping everywhere
any function function decides; undefined falls back to the default grouping

CSS follows the same grouping as JS (one group → one stylesheet, injected by the
RSC render so only the current route's CSS is linked).

Every grouping decision above is now observable under DEBUG=rango:chunks (see
"Observing the split"). The flat-src/components/ and host-sub-app rows are not
forced into a parent-named app-components chunk — they return undefined and
inherit the default grouping; the merged follow-up corrected a stale doc claim that
said otherwise.

Code change

  • New src/vite/utils/client-chunks.ts — resolveClientChunks(option) maps
    false/true/function to the @vitejs/plugin-rsc callback, and
    directoryClientChunks(meta) is the built-in strategy. It keys on the segment
    after a route-root marker (ROUTE_ROOT_DIRS) and returns undefined (default
    grouping) when there is none. It classifies the Rango runtime via the absolute
    meta.id (plugin-rsc's normalizedId is project-root-relative, so the
    package-path regex alone would miss it).
  • plugin-types.ts — ClientChunks / ClientChunkMeta types and the
    clientChunks option (@default true), exported from @rangojs/router/vite.
  • rango.ts — resolveClientChunks(opts.clientChunks ?? true) wired into the
    rsc() call in both presets (node + cloudflare).
  • shared-utils.ts — getManualChunks anchored to
    packages/(rangojs-router|rsc-router)/(src|dist)/ instead of the bare substring,
    so in-repo / colocated app code is no longer force-merged into the router
    chunk (which previously blocked splitting and misrepresented consumer bundles).

The clientChunks callback was chosen over Rollup manualChunks because it
regroups client references at the level plugin-rsc actually controls — manualChunks
can't see the client-reference grouping, and forcing per-route server chunks would
churn the RSC build.

Follow-up changes (#536, merged into this branch)

The follow-up that resolved the four gates is merged into this branch; its code
changes are therefore part of this PR:

  • src/vite/debug.ts — new rango:chunks debug namespace (NS.chunks).
  • src/vite/utils/client-chunks.ts — directoryClientChunks now logs every
    grouping decision (the split … -> app-<id> case and the actionable
    shared … (no route-root marker) fallback). Control-flow refactor only;
    grouping is byte-for-byte unchanged (15 unit tests untouched, and
    resolveClientChunks(true) === directoryClientChunks still holds so the logging
    costs nothing when the namespace is off).
  • tools/bench-client-chunks.mjs — new static cost-side analyzer: gzip sizes,
    shared baseline, per-route first-load + full-crawl model. Produces the numbers in
    "Cost-side benchmark".
  • tests/vite-rsc-demo/vite.config.ts — RANGO_BENCH_CHUNKS consumer-side
    toggle (also a live demo of the public clientChunks: false opt-out); defaults to
    on when unset, so the e2e suite is unaffected (verified: the env-unset build emits
    identical chunk hashes).
  • e2e/mini/src/router.tsx (+ synced router.named-routes.gen.ts) and
    e2e/mini.test.ts — new /combined route renders two route groups' CSS on
    one page; dev+prod e2e asserts both apply with the correct cascade,
    deterministically across a reload (the multi-group <link> precedence case,
    vite-plugin-react#1100).
  • Docs — packages/rangojs-router/docs/client-chunking.md corrected (the
    stale parent-dir-fallback claim → inherits the default grouping; observability
    section; measured cost-side table); docs/research/build-chunking-plan.md marks
    the four gates RESOLVED with the deterministic stance.

Verification (production builds, all five apps)

App Layout Result
e2e/mini routes/<id>/… splits app-widgets / app-charts (+ CSS)
tests/vite-rsc-demo handlers/<feature>/… splits app-kanban / app-shop / app-blog / app-todos / app-loaders-demo; flat components/ folds into shared entry.rsc
e2e/test-app flat src/components/ unchanged (one shared app chunk)
tests/cloudflare-basic flat src/components/ unchanged (one shared app chunk)
examples/cloudflare-multi-router host sub-apps via import() unchanged — per-app handler-*.js, no cross-app merge (each holds only its own app's components)

Cost-side benchmark (the deferred gate, now measured)

The default-on decision was originally labeled "see it in action". The four
deferred gates are now resolved in this branch (follow-up #536, merged). The
go/no-go gate was the cost side — not just per-route byte savings, but first-load
request count and total transferred bytes. Measured on tests/vite-rsc-demo
(5 feature routes, ~33 KB gz app client code) built both ways with the new
tools/bench-client-chunks.mjs:

Metric clientChunks: false default (on)
Shared runtime (every route) 95.3 KB gz 95.6 KB gz (identical)
App code on every first paint 38.3 KB (one combined chunk) 13–19 KB (residual + this route's group)
Per-route first-load saving — 20–25 KB gz (~51–66% of app bytes)
First-load requests shared + 1 app chunk shared + 1 group (+1, multiplexed)
Total client JS (whole app) 134.3 KB 141.0 KB (+5% fragmentation overhead)

Honest cost side: +1 first-load request and +5% total client JS (per-chunk gzip
overhead), the latter paid only on a cold crawl of every route in one session.
Land-on-one-route and typical 1–3 route sessions both win, and the win is a
function of app size — the baseline's every-route app chunk grows with total
app code while the split's per-route first-load does not. This is why default-on is
the right long-term-scale choice; small apps where the split is a wash opt out with
one line.

Test plan

  • Unit (client-chunks.test.ts, 15 cases): route-id grouping, same-named-subdir
    non-collision, deep nesting, marker recognition, no-marker → undefined,
    runtime classification (installed + workspace-relative), consumer src/browser
    not misclassified. Full router unit suite green (3315).
  • e2e dev and production (mini.test.ts): each route loads only its own
    client chunk + CSS, no cross-route leakage, route-colocated CSS applied;
    same-named nested components/Badge.tsx per route split correctly (build-graph
    test asserts no chunk holds both badges).
  • e2e regression: vite-rsc-demo production split routes (kanban/blog/shop/todos
    = 49 tests) green; bundle-analysis green.
  • typecheck (--force), lint, format, e2e-bucketing.

Follow-up (#536, merged into this branch):

  • Cost-side benchmark (tools/bench-client-chunks.mjs): numbers in "Cost-side
    benchmark" above. Verified the consumer-side RANGO_BENCH_CHUNKS toggle is a
    true no-op when unset (env-unset build emits identical chunk hashes).
  • CSS ordering / FOUC: new mini.test.ts /combined route renders two
    route groups' CSS on one page (where <link> precedence interacts,
    vite-plugin-react#1100); dev and production assert both apply with the
    correct cascade, deterministically across a reload.
  • Observability: new rango:chunks debug namespace — DEBUG=rango:chunks pnpm build logs each module's group or its shared-fallback reason; smoke-verified.
  • Re-ran unit (3315), typecheck, lint, format, mini clientChunks e2e (dev+prod),
    vite-rsc-demo kanban production e2e — all green.

Notes / call-outs

  • Default-on is proven, not provisional — the four gates are RESOLVED in this
    branch (follow-up feat(router): resolve clientChunks follow-up gates; default-on is the 1.0 stance #536, merged). Full write-up in
    docs/research/build-chunking-plan.md ("Follow-up PR — the four gates,
    RESOLVED"):
    1. Cost-side benchmark — measured (see "Cost-side benchmark" above): ~51–66%
      less app code on first load, +1 multiplexed request, +5% total only on a full
      cold crawl. Default-on stays for 1.0.
    2. Min-chunk-size coalescing — WON'T-FIX, by design. Byte-based coalescing
      is structurally impossible inside plugin-rsc's clientChunks callback (grouping
      is finalized in one synchronous pass over clientReferenceMetaMap before any
      client chunk is rendered
      ; the callback only sees {id, normalizedId, serverChunk} — no sizes), and unnecessary (route-lazy chunks impose no
      first-load tax). Real apps make multi-KB groups; only toy apps make sub-KB ones,
      which opt out with clientChunks: false. Reopen only on a real fragmentation
      report → fix would be a post-build chunk-merge pass.
    3. CSS-ordering/FOUC — covered by the new /combined multi-group e2e (dev+prod).
    4. Marker-list completeness — the clientChunks function is the
      configurability path (not an ever-growing marker list); the previously-silent
      fallback is now observable via DEBUG=rango:chunks. Also fixed a stale doc
      claim (unmarked paths inherit the default grouping, not a parent-named
      app-components chunk).
  • No byte duplication: each module maps to exactly one group. Genuinely shared
    client components belong outside route directories so they land in the shared group.
  • clientChunks is a production-build optimization; in dev, modules load
    individually (the no-leakage property still holds, but there are no app-* chunks).

Add a public `clientChunks` Vite plugin option that splits the client
("use client") bundle by route id instead of shipping one chunk for every
route. On by default pre-1.0 (opt out with `clientChunks: false`); accepts a
custom function for full control.

The built-in strategy keys on the segment after a route-root marker
(routes/app/pages/features/handlers/...) and returns undefined where it finds no
route structure, so flat `src/components` apps and host sub-apps split by a
dynamic import() inherit the default grouping unchanged (no collision, no
cross-app leakage). React and the router runtime are never split.

- New: src/vite/utils/client-chunks.ts (resolver + directory strategy), exported
  types ClientChunks / ClientChunkMeta from @rangojs/router/vite.
- getManualChunks anchored to packages/(rangojs-router|rsc-router)/(src|dist)/ so
  in-repo/colocated app code is no longer force-merged into the router chunk.
- mini gains route-colocated /widgets and /charts (with CSS + a same-named nested
  components/Badge.tsx each) and uses plain rango() to exercise the default;
  dev+prod e2e assert per-route JS+CSS split, no cross-route leakage, and that the
  same-named badges do not collide. Unit tests cover the grouping logic.
- User guide: packages/rangojs-router/docs/client-chunking.md.
@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@535

commit: 3e5c052

… 1.0 stance (#536)

Follow-up to #535. Closes the four deferred gates in
docs/research/build-chunking-plan.md with a deterministic stance: per-route
client splitting (clientChunks) ships DEFAULT-ON for 1.0. Optimized for apps at
scale (where shipping every route's client code on every visit is most wasteful);
small apps where the split is a wash opt out with one line.

Gate 1 (cost-side benchmark): tools/bench-client-chunks.mjs measures vite-rsc-demo
both ways. First-load app bytes drop 38.3 KB -> 13-19 KB (~51-66%); +1
multiplexed request; +5% total client JS, paid only on a full cold crawl. The
win scales with app size.

Gate 2 (min-chunk-size coalescing): reasoned WON'T-FIX. Byte-based coalescing is
structurally impossible inside plugin-rsc's clientChunks callback (grouping is
finalized before chunks are rendered; the callback only sees id/normalizedId/
serverChunk) and unnecessary (route-lazy chunks impose no first-load tax). Real
apps produce multi-KB groups; toy apps opt out with clientChunks: false.

Gate 3 (CSS ordering/FOUC): mini gains a /combined route that renders two route
groups' CSS on one page; e2e asserts both stylesheets apply with the correct
cascade, deterministically across a reload, in dev and production.

Gate 4 (convention completeness): the clientChunks function is the configurability
path for unrecognized layouts (not an ever-growing marker list); new rango:chunks
debug namespace makes the previously-silent shared fallback observable. Also fixed
a stale doc claim (unmarked paths inherit the default grouping, not a parent-named
app-components chunk).

Validation: typecheck, unit (3315), lint, format, mini clientChunks e2e (dev+prod
incl. /combined), vite-rsc-demo kanban production e2e — all green.
@ivogt

ivogt commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author

Updated the description: the four gates that were "deferred to a follow-up PR" are now resolved in this branch via #536 (merged into research/build-chunking-leakage).

  • Gate 1 — cost-side benchmark: measured. First-load app bytes 38.3 KB → 13–19 KB (~51–66%), +1 multiplexed request, +5% total client JS (paid only on a full cold crawl). Default-on stays for 1.0. New tools/bench-client-chunks.mjs.
  • Gate 2 — min-chunk coalescing: reasoned WON'T-FIX (structurally impossible inside plugin-rsc's clientChunks callback — grouping is finalized before chunks render — and unnecessary because route groups are lazy). Toy apps opt out with clientChunks: false.
  • Gate 3 — CSS ordering/FOUC: new /combined multi-group co-render e2e (dev + production).
  • Gate 4 — convention completeness: the clientChunks function is the config path; new rango:chunks debug namespace surfaces the previously-silent shared fallback; fixed a stale doc claim.

Full write-up in docs/research/build-chunking-plan.md ("Follow-up PR — the four gates, RESOLVED"). Re-validated: typecheck, unit (3315), lint, format, mini clientChunks e2e (dev+prod incl. /combined), vite-rsc-demo kanban production e2e.

@ivogt
ivogt merged commit 2a5eb67 into main Jun 5, 2026
25 checks passed
@ivogt
ivogt deleted the research/build-chunking-leakage branch June 5, 2026 09:23
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