Skip to content

feat(router): resolve clientChunks follow-up gates; default-on is the 1.0 stance - #536

Merged
ivogt merged 1 commit into
research/build-chunking-leakagefrom
research/build-chunking-followup
Jun 5, 2026
Merged

ivogt merged 1 commit into
research/build-chunking-leakagefrom
research/build-chunking-followup

Conversation

@ivogt

@ivogt ivogt commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Problem

PR #535 shipped per-route client splitting (clientChunks) default-on but
explicitly labeled it "see it in action, not proven optimal", deferring four gates
to a follow-up (see docs/research/build-chunking-plan.md). Until those gates were
closed, a consumer had no measured reason to trust the default, no way to see
why their layout did or didn't split, and the user guide carried a stale claim
about the fallback behavior. This PR closes all four gates and takes a
deterministic stance.

Verdict: clientChunks ships DEFAULT-ON for 1.0

Optimized for apps at scale — the larger the app, the more wasteful it is to ship
every route's client code on every visit, and the smaller the fraction any single
user downloads. Apps where every route's client code is trivial (the small-app
case) opt out with one line. No public API shape changed; this PR makes the
existing default provably correct and observable.

What changes for a consumer

Surface Before After
Default behavior per-route split on, unmeasured per-route split on, benchmarked + documented cost side
"Why didn't my routes split?" silent — no signal DEBUG=rango:chunks pnpm build logs every module's group or its shared-fallback reason
Unrecognized layout (src/parts/…) docs implied a parent-named app-components chunk (wrong) docs corrected: inherits the shared default grouping; use a clientChunks function for full control
Measuring your own app "build both ways and compare" node tools/bench-client-chunks.mjs <dist-off> <dist-on>

Before vs. after — observing the split (new)

# before: no way to know what the built-in strategy decided
pnpm build

# after: opt-in, zero-cost-when-off observability
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 second line is the actionable case: app code that did not split by route.
The consumer either colocates it under a marker dir or passes a clientChunks
function — which is the supported configurability path, not widening an infinite
marker list.

End-to-end consumer example — the one-line opt-out

// vite.config.ts — small app where per-route client code is trivial:
export default defineConfig({ plugins: [rango({ clientChunks: false })] });

// or hand-tune: fold a specific route back into the shared chunk
rango({
  clientChunks: ({ normalizedId }) => {
    if (normalizedId.includes("/routes/tiny-route/")) return undefined; // shared
    const m = normalizedId.match(/\/routes\/([^/]+)\//);
    return m ? `route-${m[1]}` : undefined;
  },
});

The four gates, resolved

Gate 1 — cost-side benchmark: PASSED

tools/bench-client-chunks.mjs measures tests/vite-rsc-demo (5 feature routes,
~33 KB gz app client code) built both ways:

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 fully paid only on a cold crawl of every route in one
session. Land-on-one-route and typical 1–3 route sessions both win. The win is a
function of app size — the baseline's every-route app chunk grows with total
app code, the split's per-route first-load does not.

Gate 2 — min-chunk-size coalescing: WON'T-FIX (by design)

Reason Detail
Structurally impossible plugin-rsc finalizes grouping in one synchronous pass over clientReferenceMetaMap before any client chunk is rendered; the callback only sees {id, normalizedId, serverChunk}. Emitted byte sizes (what experimentalMinChunkSize, removed in Vite 8/Rolldown, keyed on) don't exist yet.
Unnecessary Per-route groups are route-lazy: a tiny group costs one extra (multiplexed) request on its own route and never taxes another route's first load.
Not a real-app problem vite-rsc-demo's groups are 3.7–9.4 KB. Sub-1 KB groups only appear in toy apps (mini), which opt out with clientChunks: false.

Reopen trigger: a real consumer report of pathological fragmentation → the fix
would be a post-build chunk-merge pass (the only place real sizes exist), not a
callback change.

Gate 3 — CSS ordering / FOUC: COVERED

New e2e (e2e/mini.test.ts, /combined) renders client components from two
route groups on one page — the case where per-group <link> precedence actually
interacts (vite-plugin-react#1100). Asserts both stylesheets apply with the
correct cascade, deterministically across a reload, in dev and production.
mini gains a /combined route (and the synced router.named-routes.gen.ts).

Gate 4 — convention completeness: RESOLVED

  • The clientChunks function is the configurability path for any layout — the marker list is intentionally finite, not the mechanism.
  • New rango:chunks debug namespace surfaces every grouping decision (and the reason for a shared fallback), resolving the previously-silent behavior.
  • Fixed a stale doc claim: unmarked paths inherit the default grouping (fold into the shared chunk); they are not forced into a parent-named app-components chunk (which would re-introduce cross-app leakage).

Semantics (unchanged contract, now documented + observable)

Input clientChunks Result Observability
routes/<id>/… (marker) default split into app-<id> (+ CSS) split <id> -> app-<id>
flat src/components/… (no marker) default inherits default grouping (shared chunk) shared <id> (no route-root marker…)
host sub-apps via import() default per-app chunks, no cross-app merge (shared/runtime not logged)
any false opt out — default grouping everywhere n/a
any function function decides; undefined → default wrapped fn's return logged

Code-change summary

  • src/vite/debug.ts — add rango:chunks namespace.
  • src/vite/utils/client-chunks.ts — log each grouping decision (split + the actionable shared-fallback) inside directoryClientChunks; control-flow refactor preserves behavior exactly (15 unit tests unchanged, resolveClientChunks(true) === directoryClientChunks still holds for zero per-call cost when the namespace is off).
  • tools/bench-client-chunks.mjs — static cost-side analyzer (gzip sizes, shared baseline, per-route first-load + full-crawl model).
  • tests/vite-rsc-demo/vite.config.ts — RANGO_BENCH_CHUNKS consumer-side toggle (also a live demo of the public opt-out; defaults to on when unset, so e2e is unaffected — verified the env-unset build emits identical chunk hashes).
  • e2e/mini/src/router.tsx + .gen.ts + e2e/mini.test.ts — /combined multi-group CSS co-render route + dev/prod e2e.
  • packages/rangojs-router/docs/client-chunking.md — corrected fallback section, observability section, measured cost-side table; docs/research/build-chunking-plan.md — gates marked RESOLVED with the deterministic stance.

Test plan

  • Typecheck (12/12 packages)
  • Unit (3315 pass) — incl. client-chunks.test.ts (15)
  • Lint (oxlint, 0) + Format (oxfmt)
  • e2e dev and production — mini clientChunks incl. new /combined (7 dev+prod), vite-rsc-demo kanban production (8) against the split build
  • rango:chunks observability smoke-verified on a real build
  • e2e-bucketing guard (pre-commit) green

Notes

… 1.0 stance

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 merged commit 8793bfb into research/build-chunking-leakage Jun 5, 2026
23 checks passed
@ivogt
ivogt deleted the research/build-chunking-followup branch June 5, 2026 08:59
ivogt added a commit that referenced this pull request Jun 5, 2026
…for 1.0) (#535)

Per-route client ("use client") splitting, on by default. Groups components by route id; flat and host-split layouts inherit the default grouping unchanged. Opt out with clientChunks: false, or a function for full control.

Includes the follow-up that resolved the four deferred gates (was #536, merged into this branch): cost-side benchmark (first-load app bytes ~51-66% lower, +1 multiplexed request, +5% total only on a full cold crawl), coalescing won't-fix rationale, multi-group CSS co-render e2e (mini /combined, dev+prod), and rango:chunks observability. Default-on is the settled 1.0 stance.
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