Repository navigation
feat(router): per-route client chunking via clientChunks (default-on) - #535
Merged
Merged
Conversation
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.
commit: |
6 tasks done
… 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.
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
Full write-up in |
3 tasks done
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
Today a Rango router ships one client (
"use client") chunk containing everyroute's components and their CSS. A consumer who builds an app with a heavy
/dashboardroute and a light/settingsroute sees the dashboard's editor,charts, and grids downloaded on first load of any route — visiting
/settingsstill pulls the dashboard's client JS and CSS. The only escape hatches were a
manual
React.lazyper component, or splitting the app into host sub-apps loadedvia 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 nolonger downloads another route's client code (or CSS). Opt out with a single flag.
Before vs. after
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):
Power users can take full control:
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-upmakes that decision observable — run a production build with the new
rango:chunksdebug namespace:
The
sharedline is the actionable case: app code that did not split by route.The consumer then colocates it under a marker dir or passes a
clientChunksfunction — 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 itsafe as a default — it is a no-op for layouts it can't key on.
clientChunksvalueroutes/<id>/…,app/<id>/…,handlers/<id>/…, … (route-root marker)true/ omitted (default)app-<id>chunk + matching CSS, at any nesting depthsrc/components/Button.tsx(no marker)import()node_modulesfalse@vitejs/plugin-rscdefault grouping everywhereundefinedfalls back to the default groupingCSS 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 notforced into a parent-named
app-componentschunk — they returnundefinedandinherit the default grouping; the merged follow-up corrected a stale doc claim that
said otherwise.
Code change
src/vite/utils/client-chunks.ts—resolveClientChunks(option)mapsfalse/true/function to the@vitejs/plugin-rsccallback, anddirectoryClientChunks(meta)is the built-in strategy. It keys on the segmentafter a route-root marker (
ROUTE_ROOT_DIRS) and returnsundefined(defaultgrouping) when there is none. It classifies the Rango runtime via the absolute
meta.id(plugin-rsc'snormalizedIdis project-root-relative, so thepackage-path regex alone would miss it).
plugin-types.ts—ClientChunks/ClientChunkMetatypes and theclientChunksoption (@default true), exported from@rangojs/router/vite.rango.ts—resolveClientChunks(opts.clientChunks ?? true)wired into thersc()call in both presets (node + cloudflare).shared-utils.ts—getManualChunksanchored topackages/(rangojs-router|rsc-router)/(src|dist)/instead of the bare substring,so in-repo / colocated app code is no longer force-merged into the
routerchunk (which previously blocked splitting and misrepresented consumer bundles).
The
clientChunkscallback was chosen over RollupmanualChunksbecause itregroups client references at the level plugin-rsc actually controls —
manualChunkscan'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— newrango:chunksdebug namespace (NS.chunks).src/vite/utils/client-chunks.ts—directoryClientChunksnow logs everygrouping decision (the
split … -> app-<id>case and the actionableshared … (no route-root marker)fallback). Control-flow refactor only;grouping is byte-for-byte unchanged (15 unit tests untouched, and
resolveClientChunks(true) === directoryClientChunksstill holds so the loggingcosts 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_CHUNKSconsumer-sidetoggle (also a live demo of the public
clientChunks: falseopt-out); defaults toon when unset, so the e2e suite is unaffected (verified: the env-unset build emits
identical chunk hashes).
e2e/mini/src/router.tsx(+ syncedrouter.named-routes.gen.ts) ande2e/mini.test.ts— new/combinedroute renders two route groups' CSS onone 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).packages/rangojs-router/docs/client-chunking.mdcorrected (thestale parent-dir-fallback claim → inherits the default grouping; observability
section; measured cost-side table);
docs/research/build-chunking-plan.mdmarksthe four gates RESOLVED with the deterministic stance.
Verification (production builds, all five apps)
e2e/miniroutes/<id>/…app-widgets/app-charts(+ CSS)tests/vite-rsc-demohandlers/<feature>/…app-kanban/app-shop/app-blog/app-todos/app-loaders-demo; flatcomponents/folds into sharedentry.rsce2e/test-appsrc/components/tests/cloudflare-basicsrc/components/examples/cloudflare-multi-routerimport()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:clientChunks: falseHonest 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
client-chunks.test.ts, 15 cases): route-id grouping, same-named-subdirnon-collision, deep nesting, marker recognition, no-marker →
undefined,runtime classification (installed + workspace-relative), consumer
src/browsernot misclassified. Full router unit suite green (3315).
mini.test.ts): each route loads only its ownclient chunk + CSS, no cross-route leakage, route-colocated CSS applied;
same-named nested
components/Badge.tsxper route split correctly (build-graphtest asserts no chunk holds both badges).
vite-rsc-demoproduction split routes (kanban/blog/shop/todos= 49 tests) green;
bundle-analysisgreen.--force), lint, format, e2e-bucketing.Follow-up (#536, merged into this branch):
tools/bench-client-chunks.mjs): numbers in "Cost-sidebenchmark" above. Verified the consumer-side
RANGO_BENCH_CHUNKStoggle is atrue no-op when unset (env-unset build emits identical chunk hashes).
mini.test.ts/combinedroute renders tworoute groups' CSS on one page (where
<link>precedence interacts,vite-plugin-react#1100); dev and production assert both apply with thecorrect cascade, deterministically across a reload.
rango:chunksdebug namespace —DEBUG=rango:chunks pnpm buildlogs each module's group or its shared-fallback reason; smoke-verified.vite-rsc-demokanban production e2e — all green.Notes / call-outs
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"):
less app code on first load, +1 multiplexed request, +5% total only on a full
cold crawl. Default-on stays for 1.0.
is structurally impossible inside plugin-rsc's
clientChunkscallback (groupingis finalized in one synchronous pass over
clientReferenceMetaMapbefore anyclient chunk is rendered; the callback only sees
{id, normalizedId, serverChunk}— no sizes), and unnecessary (route-lazy chunks impose nofirst-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 fragmentationreport → fix would be a post-build chunk-merge pass.
/combinedmulti-group e2e (dev+prod).clientChunksfunction is theconfigurability path (not an ever-growing marker list); the previously-silent
fallback is now observable via
DEBUG=rango:chunks. Also fixed a stale docclaim (unmarked paths inherit the default grouping, not a parent-named
app-componentschunk).client components belong outside route directories so they land in the shared group.
clientChunksis a production-build optimization; in dev, modules loadindividually (the no-leakage property still holds, but there are no
app-*chunks).