@rangojs/router 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The client entry's head hint at default fetch priority, after the head chunk
3
+ * scripts (issue #1025).
4
+ *
5
+ * For `bootstrapModules`, Fizz writes `<link rel="modulepreload"
6
+ * fetchPriority="low">` into the head and claims the URL
7
+ * (`moduleScriptResources[url] = null`, react-dom 19.3.0
8
+ * `react-dom-server.edge.production.js:546-565`) before the render starts, so
9
+ * no `preloadModule`/`preinitModule` call can replace it, and the
10
+ * `bootstrapModules` entry shape has no priority field. Chromium fetches that
11
+ * hint at Low, and once it boosts in-viewport images to High after first
12
+ * layout, the entry queues behind them; the executing end-of-shell module
13
+ * script reuses the in-flight Low fetch through the module map. Hydration
14
+ * then waits on a few-hundred-byte file while the head chunk scripts it
15
+ * imports (High) have long arrived: measured +3.8 s on a priority-honouring
16
+ * HTTP/2 link at Slow 4G.
17
+ *
18
+ * The fix serves that one tag without the attribute, so it fetches at
19
+ * Chromium's modulepreload default (High), and moves it after the head
20
+ * chunk scripts. Fizz flushes the bootstrap hint before the head chunk scripts; at
21
+ * High in that position it took one of HTTP/1.1's six connections ahead of
22
+ * the react chunk and delayed the last head chunk (hydration +91 ms, LCP
23
+ * +84 ms measured). After the scripts it is the last High request in the
24
+ * queue, so it never displaces a chunk hydration also needs. The accepted
25
+ * cost: an HTTP/2 server that sends equal-priority streams first in, first
26
+ * out delivers it with the last head chunk instead of before it (text-only
27
+ * pages +12 to +56 ms measured, against -0.15 to -3.3 s hydration on image
28
+ * pages; CHANGELOG has the matrix). Everything else about the bootstrap pair
29
+ * stays Fizz's: the nonce, the executing `id="_R_"` tag, the URL claim that
30
+ * keeps plugin-rsc's and the preinit hook's calls for the entry inert, and the
31
+ * PPR prelude/resume behaviour.
32
+ *
33
+ * The match is the exact tag Fizz writes for a string `bootstrapModules`
34
+ * entry (`rel`, `fetchPriority`, `nonce`, `href` in that order), not a
35
+ * pattern, and only before the first `<body`. A Fizz version that writes it
36
+ * differently leaves the bytes untouched: the output keeps the Low hint.
37
+ *
38
+ * Where the tag goes: right after the run of Fizz resource tags that directly
39
+ * follows the hint, before the first token that is anything else (`</head>`,
40
+ * the app's own head children, or body content in a head-less document).
41
+ * Fizz flushes the head chunk scripts and the bulk preloads straight after
42
+ * the bootstrap hint (react-dom 19.3.0 `flushCompletedQueues`, `:7321-7325`),
43
+ * as `<script …></script>` with no body and `<link …/>`; the scan accepts
44
+ * only those two shapes and stops at any other token without reading into it.
45
+ * It never searches the app's head content for `</head>` or `<body`: React
46
+ * does not escape either inside `<script>` or `<style>` text, so a head script
47
+ * holding the string `"<body>"` once received the tag inside its source and
48
+ * threw. Fizz escapes `>` in attribute values, so the first `>` ends a tag.
49
+ * Every insertion point is the boundary between two complete tags; a stream
50
+ * that ends mid-run, or a run tag longer than {@link MAX_HELD_TAG}, gets the
51
+ * tag before the unfinished one. Only that one tag is held back while the run
52
+ * streams; each complete run tag is forwarded as soon as it is scanned.
53
+ */
54
+ /**
55
+ * Rewrite a complete document prefix (the PPR shell capture's prelude).
56
+ * Returns `bytes` unchanged when no hint precedes the first `<body`.
57
+ */
58
+ export declare function rewriteEntryPreloadPriority(bytes: Uint8Array, entryUrl: string, nonce?: string): Uint8Array;
59
+ /**
60
+ * Streaming form for live SSR: the same rewrite as
61
+ * {@link rewriteEntryPreloadPriority}, then pass-through. Before the hint is
62
+ * found, only a chunk tail that may be the start of a split `<body` or hint
63
+ * tag is held back. After it, complete run tags are forwarded as they are
64
+ * scanned and only an unfinished one (at most {@link MAX_HELD_TAG} bytes) is
65
+ * held, so every byte is scanned a bounded number of times. Once the tag is
66
+ * placed, or once `<body` passes without a hint, chunks are forwarded
67
+ * untouched and unscanned.
68
+ */
69
+ export declare function entryPreloadPriorityTransform(entryUrl: string, nonce?: string): TransformStream<Uint8Array, Uint8Array>;
@@ -145,6 +145,7 @@ export interface SSRDependencies<TEnv = unknown> {
145
145
  * undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
146
146
  * never installed the preinit hook cannot drift into the half-converted
147
147
  * state on upgrade (the generated entry always passes an explicit value).
148
+ * Any other value throws when the handler is created.
148
149
  */
149
150
  headScripts?: HeadScriptsOption;
150
151
  /**
@@ -55,11 +55,23 @@ export declare function runWithPreinitNonce<T>(nonce: string | undefined, fn: ()
55
55
  *
56
56
  * Known trades (deliberate, measured neutral-to-positive on the e2e apps —
57
57
  * PR #694 has the Lighthouse/hydration numbers):
58
- * - Fetch priority: an executing async module script fetches at Chromium's
59
- * async-script priority, below a bare modulepreload hint; preinitModule
60
- * forwards no fetchPriority (react-dom's public API drops it — only
61
- * `preinit` forwards it). Execution-overlap is bought with hint priority,
62
- * the same trade Next.js ships via ReactDOM.preinit.
58
+ * - Fetch priority: Chromium gives a parser-inserted `async` module script
59
+ * initial priority High: below the render-blocking stylesheet (Highest),
60
+ * and alongside the LCP image once Chromium boosts the in-viewport image to
61
+ * High (#1021). Every head chunk gets the same priority: the shared
62
+ * dependency chunks (react, router, entry.rsc and the bundler runtime) and
63
+ * every client-component chunk. The entry itself is a Fizz bootstrapModules
64
+ * hint, not a preinit (Fizz claims its URL, so this hook's call for it is
65
+ * inert); the handlers serve that hint at default priority, High as well,
66
+ * after these scripts (entry-preload-priority.ts, #1025).
67
+ * Lowering the chunks to `fetchpriority="low"` was measured and rejected
68
+ * (#1021; Chromium 153, 20 interleaved runs per variant and cell). On pages
69
+ * with images above the fold the Low chunks queue behind the boosted
70
+ * images: hydration came 206 to 6407 ms later on HTTP/1.1 and 65 to 642 ms
71
+ * later on HTTP/2. The one gain, FCP -136 ms, needed an image-free page
72
+ * with 35 KB of render-blocking CSS on HTTP/1.1 Slow 4G, and hydration was
73
+ * still 98 ms later there. `headScripts: "preload"` is the way to get Low
74
+ * chunk fetches.
63
75
  * - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
64
76
  * dev documents have no head chunk scripts — a client module whose module
65
77
  * scope assumes body-parsed DOM can break in production only. The
@@ -68,6 +68,12 @@ export interface ClientChunkMeta {
68
68
  * strategy**. It splits app `"use client"` modules by **route id** — the segment
69
69
  * after a route-root directory (`routes`, `app`, `pages`, `features`, `handlers`,
70
70
  * …) — so `routes/dashboard/**` becomes `app-dashboard` at any nesting depth.
71
+ * The first marker in the path wins, except that an `app/` directly followed
72
+ * by another marker with a directory after it is treated as a source root:
73
+ * `app/routes/product/**` becomes `app-product`, while `app/dashboard/**`
74
+ * (Next-style), `app/components/**` and files directly in `app/routes/` stay
75
+ * `app-dashboard` / `app-components` / `app-routes`. The same route id under
76
+ * different markers is one group.
71
77
  * Where it finds NO route structure (a flat `src/components/`, or host sub-apps
72
78
  * already split by a dynamic `import()`), it inherits the default grouping
73
79
  * unchanged — so the shared `src/components` chunk stays shared and host apps do
@@ -79,10 +85,14 @@ export interface ClientChunkMeta {
79
85
  * back to the default grouping for that one module. Forwarded directly to
80
86
  * `@vitejs/plugin-rsc`'s `clientChunks`.
81
87
  *
82
- * Every module maps to exactly one group, so there is no byte duplication: a
83
- * component used by two routes lives in one group and is fetched whenever it
84
- * renders. Put genuinely shared client components OUTSIDE route directories so
85
- * they land in the shared group rather than one route's chunk.
88
+ * Every module maps to exactly one group, so there is no byte duplication. A
89
+ * group is also the loading unit: rendering any one member downloads the whole
90
+ * group's chunk, every other member included. A component used by two routes
91
+ * lives in one group, and rendering it on either route fetches that entire
92
+ * group. Put genuinely shared client components OUTSIDE route directories so
93
+ * they land in the shared group rather than one route's chunk, and keep an
94
+ * always-rendered component (a layout header) out of a large group.
95
+ * `DEBUG=rango:chunks vite build` logs each group's module count and size.
86
96
  *
87
97
  * @default true
88
98
  */
@@ -114,14 +124,18 @@ interface RangoBaseOptions {
114
124
  * - `"preinit"` (**default**): client-reference chunks render as EXECUTING
115
125
  * `<script type="module" async>` tags hoisted into `<head>` (upgrading
116
126
  * plugin-rsc's modulepreload hints in place), and the browser entry ships
117
- * as Fizz `bootstrapModules` — a head `modulepreload fetchpriority=low`
118
- * hint plus the executing end-of-shell `id="_R_"` module script. Chunk
127
+ * as Fizz `bootstrapModules` — a `modulepreload` hint after the head
128
+ * chunk scripts (default priority; Fizz's `fetchpriority=low` is dropped,
129
+ * #1025)
130
+ * plus the executing end-of-shell `id="_R_"` module script. Chunk
119
131
  * execution overlaps body streaming instead of waiting for the hydration
120
132
  * import walk; under PPR everything lands in the stored shell prelude.
121
133
  * - `"preload"`: the previous behavior — `<link rel="modulepreload">` hints
122
134
  * only, entry as an inline `import()` script at end of shell. Chunks
123
135
  * fetch+compile early but execute only when hydration imports them.
124
136
  *
137
+ * Any other value throws when the generated SSR entry is built.
138
+ *
125
139
  * Build-only for the chunk half: plugin-rsc resolves no JS deps per client
126
140
  * reference in dev, so dev documents carry no head chunk scripts in either
127
141
  * mode (the bootstrap conversion does apply in dev). Trades and upstream
@@ -5,7 +5,9 @@ export declare const VIRTUAL_ENTRY_BROWSER: string;
5
5
  * option: "preinit" (default) installs the client-reference preinit hook and
6
6
  * threads `getClientEntryUrl` so Fizz emits `bootstrapModules`;
7
7
  * "preload" omits the hook and uses the deprecated inline
8
- * `loadBootstrapScriptContent` bootstrap.
8
+ * `loadBootstrapScriptContent` bootstrap. Any other value throws: an unknown
9
+ * string such as "prenit" used to run as "preinit" silently
10
+ * (`headScripts !== "preload"` since #694).
9
11
  */
10
12
  export declare function getVirtualEntrySSR(headScripts?: HeadScriptsOption, progressiveChunkSize?: number): string;
11
13
  /**
@@ -1,4 +1,6 @@
1
+ import type { Plugin } from "vite";
1
2
  import type { ClientChunkMeta, ClientChunks } from "../plugin-types.js";
3
+ import { type Debugger } from "../debug.js";
2
4
  /** The callback shape @vitejs/plugin-rsc's `clientChunks` option accepts. */
3
5
  export type RscClientChunksFn = (meta: ClientChunkMeta) => string | undefined;
4
6
  /**
@@ -36,8 +38,11 @@ export interface ClientChunkContext {
36
38
  * 1. Shared runtime (React / router / node_modules) -> `undefined` (never split).
37
39
  * 2. A registered error/notFound fallback (`ctx.fallbackRefs`) -> `app-fallback`,
38
40
  * regardless of location, so the error UI is decoupled from the happy path.
39
- * 3. A {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on that
40
- * next segment (the route id), robust to any nesting depth.
41
+ * 3. The first {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on
42
+ * that next segment (the route id), robust to any nesting depth. A
43
+ * {@link SOURCE_ROOT_DIRS} marker (`app`) directly followed by another marker
44
+ * with a directory after it defers to that inner marker:
45
+ * `app/routes/<id>/…` -> `app-<id>`, while `app/<segment>/…` -> `app-<segment>`.
41
46
  * 4. Otherwise `undefined` (inherit the default `serverChunk` grouping).
42
47
  */
43
48
  export declare function directoryClientChunks(meta: ClientChunkMeta, ctx?: ClientChunkContext): string | undefined;
@@ -53,3 +58,18 @@ export declare function directoryClientChunks(meta: ClientChunkMeta, ctx?: Clien
53
58
  * fallback refinement does not apply — the consumer owns the grouping).
54
59
  */
55
60
  export declare function resolveClientChunks(option: ClientChunks | undefined, ctx?: ClientChunkContext): RscClientChunksFn | undefined;
61
+ /**
62
+ * Build-end group report under `DEBUG=rango:chunks`: one line per client
63
+ * reference group with its client-reference count and the size of the group's
64
+ * own chunk (code split into shared chunks such as `react`/`router` is not
65
+ * counted). A group is the loading unit (plugin-rsc imports the group's virtual
66
+ * module to resolve any one member), so a large group that an always-rendered
67
+ * component belongs to is downloaded on every route; this line makes that
68
+ * visible. Covers every group, including ones named by a custom `clientChunks`
69
+ * function or by plugin-rsc's default `serverChunk` grouping. `undefined` when
70
+ * the namespace is off, so a normal build does not register it. Group names are
71
+ * printed with NUL escaped: plugin-rsc's default group is named
72
+ * `facade:\0virtual:…`, and a raw NUL makes `grep` treat the output as binary
73
+ * and drop that line, usually the largest group. `log` is injectable for tests.
74
+ */
75
+ export declare function clientChunksReport(log?: Debugger | undefined): Plugin | undefined;
@@ -4369,7 +4369,12 @@ function emitProgressiveChunkSize(value) {
4369
4369
  return JSON.stringify(value);
4370
4370
  }
4371
4371
  function getVirtualEntrySSR(headScripts = "preinit", progressiveChunkSize) {
4372
- const preinit = headScripts !== "preload";
4372
+ if (headScripts !== "preinit" && headScripts !== "preload") {
4373
+ throw new Error(
4374
+ `rango({ headScripts }) must be "preinit" or "preload", received ${JSON.stringify(headScripts)}`
4375
+ );
4376
+ }
4377
+ const preinit = headScripts === "preinit";
4373
4378
  const depsImportNames = preinit ? "createFromReadableStream,\n setOnClientReference,\n getClientEntryUrl," : "createFromReadableStream,";
4374
4379
  const ssrImportNames = preinit ? "\n installClientReferencePreinit," : "";
4375
4380
  const install = preinit ? `
@@ -4578,7 +4583,7 @@ import { resolve } from "node:path";
4578
4583
  // package.json
4579
4584
  var package_default = {
4580
4585
  name: "@rangojs/router",
4581
- version: "0.19.1",
4586
+ version: "0.20.0",
4582
4587
  description: "Django-inspired RSC router with composable URL patterns",
4583
4588
  keywords: [
4584
4589
  "react",
@@ -6443,6 +6448,7 @@ function getManualChunks(id) {
6443
6448
  // src/vite/utils/client-chunks.ts
6444
6449
  init_debug();
6445
6450
  init_client_ref_hashing();
6451
+ import { gzipSync } from "node:zlib";
6446
6452
  var debugChunks = createRangoDebugger(NS.chunks);
6447
6453
  function isSharedRuntime(meta) {
6448
6454
  return [meta.id, meta.normalizedId].some(
@@ -6468,6 +6474,7 @@ var ROUTE_ROOT_DIRS = /* @__PURE__ */ new Set([
6468
6474
  "screens",
6469
6475
  "sections"
6470
6476
  ]);
6477
+ var SOURCE_ROOT_DIRS = /* @__PURE__ */ new Set(["app"]);
6471
6478
  function directoryClientChunks(meta, ctx) {
6472
6479
  if (isSharedRuntime(meta)) {
6473
6480
  return void 0;
@@ -6478,14 +6485,13 @@ function directoryClientChunks(meta, ctx) {
6478
6485
  }
6479
6486
  const segments = meta.normalizedId.split("/").filter(Boolean);
6480
6487
  const dirCount = segments.length - 1;
6481
- if (dirCount >= 1) {
6482
- for (let i = 0; i < dirCount - 1; i++) {
6483
- if (ROUTE_ROOT_DIRS.has(segments[i].toLowerCase())) {
6484
- const group = `app-${sanitizeGroup(segments[i + 1])}`;
6485
- debugChunks?.("split %s -> %s", meta.normalizedId, group);
6486
- return group;
6487
- }
6488
- }
6488
+ const isMarkerAt = (i) => i < dirCount - 1 && ROUTE_ROOT_DIRS.has(segments[i].toLowerCase());
6489
+ for (let i = 0; i < dirCount - 1; i++) {
6490
+ if (!isMarkerAt(i)) continue;
6491
+ const marker = SOURCE_ROOT_DIRS.has(segments[i].toLowerCase()) && isMarkerAt(i + 1) ? i + 1 : i;
6492
+ const group = `app-${sanitizeGroup(segments[marker + 1])}`;
6493
+ debugChunks?.("split %s -> %s", meta.normalizedId, group);
6494
+ return group;
6489
6495
  }
6490
6496
  debugChunks?.(
6491
6497
  "shared %s (no route-root marker; inherits default grouping)",
@@ -6498,6 +6504,30 @@ function resolveClientChunks(option, ctx) {
6498
6504
  if (option === true) return (meta) => directoryClientChunks(meta, ctx);
6499
6505
  return option;
6500
6506
  }
6507
+ var CLIENT_GROUP_PREFIX = "\0virtual:vite-rsc/client-references/group/";
6508
+ function clientChunksReport(log = debugChunks) {
6509
+ if (!log) return void 0;
6510
+ return {
6511
+ name: "@rangojs/router:client-chunks-report",
6512
+ apply: "build",
6513
+ applyToEnvironment: (env) => env.name === "client",
6514
+ generateBundle(_options, bundle) {
6515
+ for (const chunk of Object.values(bundle)) {
6516
+ if (chunk.type !== "chunk") continue;
6517
+ const facade = chunk.facadeModuleId;
6518
+ if (!facade?.startsWith(CLIENT_GROUP_PREFIX)) continue;
6519
+ log(
6520
+ "group %s: %d client reference(s), %d B (%d B gzip) -> %s",
6521
+ facade.slice(CLIENT_GROUP_PREFIX.length).replaceAll("\0", "\\0"),
6522
+ this.getModuleInfo(facade)?.importedIds.length ?? 0,
6523
+ Buffer.byteLength(chunk.code),
6524
+ gzipSync(chunk.code).length,
6525
+ chunk.fileName
6526
+ );
6527
+ }
6528
+ }
6529
+ };
6530
+ }
6501
6531
 
6502
6532
  // src/vite/plugins/vercel-output.ts
6503
6533
  import { rm, mkdir, cp, writeFile } from "node:fs/promises";
@@ -13349,6 +13379,7 @@ If this is a multi-app host router, export a createHostRouter() instance and set
13349
13379
  );
13350
13380
  plugins.push(clientRefDedup());
13351
13381
  }
13382
+ plugins.push(clientChunksReport());
13352
13383
  plugins.push({
13353
13384
  name: "@rangojs/router:client-component-hmr",
13354
13385
  hotUpdate(ctx) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -15,7 +15,7 @@ Your app builds in three Vite environments — `client`, `ssr`, and `rsc` — an
15
15
  1. **Server code leaking into the client.** A file that imports `node:fs`, calls a database, or contains action logic ends up in the client bundle because a client component pulled it in transitively. Symptom: your client bundle is much larger than expected, sometimes with imports that fail at runtime.
16
16
  2. **Both dev and prod React in the SSR/RSC bundle.** When `process.env.NODE_ENV` isn't folded at build time, React's CJS files ship both `.development.js` _and_ `.production.js` variants — doubling React's footprint. The Cloudflare Vite plugin and rango's `vercel` preset fold NODE_ENV automatically; on the default `node` preset, `vite build` does it for the client but not always for SSR/RSC, so add the `define` from Step 2.
17
17
  3. **An oversized routes-manifest in your RSC worker.** The `virtual:rsc-router/routes-manifest/<routerId>` chunk holds your route trie and precomputed entries — large only in proportion to your route count. If it's surprisingly big, you may have unintentionally generated routes (e.g., parametrized fixtures) that bloated the trie.
18
- 4. **Poor client-chunk grouping.** `rango({ clientChunks })` decides how `"use client"` modules are grouped into browser chunks. The default groups them by route directory (`routes/<id>/**` → `app-<id>`), with flat folders like `src/components/` in one shared chunk; `clientChunks: false` ships one client chunk per router. A route that pulls in unrelated routes' client code, or one oversized shared chunk, points at the grouping.
18
+ 4. **Poor client-chunk grouping.** `rango({ clientChunks })` decides how `"use client"` modules are grouped into browser chunks. The default groups them by route directory (`routes/<id>/**` → `app-<id>`), with flat folders like `src/components/` in one shared chunk; `clientChunks: false` ships one client chunk per router. A group is the loading unit: rendering any one member downloads the whole group's chunk. A route that pulls in unrelated routes' client code, or one oversized shared chunk, points at the grouping; `DEBUG=rango:chunks vite build` logs each group's size at build end.
19
19
 
20
20
  Tree-shaking does _not_ catch generated data inlined as string literals, or data-dependent conditionals like React's `NODE_ENV` branches. You need a visualizer.
21
21
 
@@ -234,8 +234,14 @@ Notes:
234
234
 
235
235
  - `clientChunks` default groups app client components by the directory after a
236
236
  route root (`routes/`, `app/`, `pages/`, `features/`, `handlers/`, ...), so
237
- `routes/dashboard/**` becomes chunk `app-dashboard`. Flat folders such as
238
- `src/components/` stay in the shared chunk. `false` restores
237
+ `routes/dashboard/**` becomes chunk `app-dashboard`. An `app/` directly
238
+ followed by another marker with a directory after it is a source root, so
239
+ `app/routes/product/**` is `app-product`; `app/<segment>/**`,
240
+ `app/components/**` and files directly in `app/routes/` keep `app-<segment>`
241
+ / `app-components` / `app-routes`. Flat folders such as
242
+ `src/components/` stay in the shared chunk. A group is the loading unit:
243
+ rendering any member downloads the whole group's chunk;
244
+ `DEBUG=rango:chunks vite build` logs each group's size. `false` restores
239
245
  `@vitejs/plugin-rsc`'s grouping (one client chunk per router). A function
240
246
  `(meta) => string | undefined` names the group per module (`undefined` keeps
241
247
  the default); `directoryClientChunks(meta)`, the default strategy, is
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The client entry's head hint at default fetch priority, after the head chunk
3
+ * scripts (issue #1025).
4
+ *
5
+ * For `bootstrapModules`, Fizz writes `<link rel="modulepreload"
6
+ * fetchPriority="low">` into the head and claims the URL
7
+ * (`moduleScriptResources[url] = null`, react-dom 19.3.0
8
+ * `react-dom-server.edge.production.js:546-565`) before the render starts, so
9
+ * no `preloadModule`/`preinitModule` call can replace it, and the
10
+ * `bootstrapModules` entry shape has no priority field. Chromium fetches that
11
+ * hint at Low, and once it boosts in-viewport images to High after first
12
+ * layout, the entry queues behind them; the executing end-of-shell module
13
+ * script reuses the in-flight Low fetch through the module map. Hydration
14
+ * then waits on a few-hundred-byte file while the head chunk scripts it
15
+ * imports (High) have long arrived: measured +3.8 s on a priority-honouring
16
+ * HTTP/2 link at Slow 4G.
17
+ *
18
+ * The fix serves that one tag without the attribute, so it fetches at
19
+ * Chromium's modulepreload default (High), and moves it after the head
20
+ * chunk scripts. Fizz flushes the bootstrap hint before the head chunk scripts; at
21
+ * High in that position it took one of HTTP/1.1's six connections ahead of
22
+ * the react chunk and delayed the last head chunk (hydration +91 ms, LCP
23
+ * +84 ms measured). After the scripts it is the last High request in the
24
+ * queue, so it never displaces a chunk hydration also needs. The accepted
25
+ * cost: an HTTP/2 server that sends equal-priority streams first in, first
26
+ * out delivers it with the last head chunk instead of before it (text-only
27
+ * pages +12 to +56 ms measured, against -0.15 to -3.3 s hydration on image
28
+ * pages; CHANGELOG has the matrix). Everything else about the bootstrap pair
29
+ * stays Fizz's: the nonce, the executing `id="_R_"` tag, the URL claim that
30
+ * keeps plugin-rsc's and the preinit hook's calls for the entry inert, and the
31
+ * PPR prelude/resume behaviour.
32
+ *
33
+ * The match is the exact tag Fizz writes for a string `bootstrapModules`
34
+ * entry (`rel`, `fetchPriority`, `nonce`, `href` in that order), not a
35
+ * pattern, and only before the first `<body`. A Fizz version that writes it
36
+ * differently leaves the bytes untouched: the output keeps the Low hint.
37
+ *
38
+ * Where the tag goes: right after the run of Fizz resource tags that directly
39
+ * follows the hint, before the first token that is anything else (`</head>`,
40
+ * the app's own head children, or body content in a head-less document).
41
+ * Fizz flushes the head chunk scripts and the bulk preloads straight after
42
+ * the bootstrap hint (react-dom 19.3.0 `flushCompletedQueues`, `:7321-7325`),
43
+ * as `<script …></script>` with no body and `<link …/>`; the scan accepts
44
+ * only those two shapes and stops at any other token without reading into it.
45
+ * It never searches the app's head content for `</head>` or `<body`: React
46
+ * does not escape either inside `<script>` or `<style>` text, so a head script
47
+ * holding the string `"<body>"` once received the tag inside its source and
48
+ * threw. Fizz escapes `>` in attribute values, so the first `>` ends a tag.
49
+ * Every insertion point is the boundary between two complete tags; a stream
50
+ * that ends mid-run, or a run tag longer than {@link MAX_HELD_TAG}, gets the
51
+ * tag before the unfinished one. Only that one tag is held back while the run
52
+ * streams; each complete run tag is forwarded as soon as it is scanned.
53
+ */
54
+
55
+ const LOW_PRIORITY_ATTR = ' fetchPriority="low"';
56
+ const encoder = new TextEncoder();
57
+ const BODY_OPEN = encoder.encode("<body");
58
+ const LINK_OPEN = encoder.encode("<link");
59
+ const SCRIPT_OPEN = encoder.encode("<script");
60
+ const SCRIPT_CLOSE = encoder.encode("</script>");
61
+ const GT = 0x3e;
62
+
63
+ /**
64
+ * Longest run tag held while its end has not arrived. Fizz's chunk scripts
65
+ * and preload links are a few hundred bytes; past this the tag goes before
66
+ * the held one, which keeps the per-chunk work bounded.
67
+ */
68
+ const MAX_HELD_TAG = 8192;
69
+
70
+ /** React's escapeTextForBrowser: the attribute escaping Fizz applies. */
71
+ function escapeAttr(value: string): string {
72
+ return value.replace(/["&'<>]/g, (c) =>
73
+ c === '"'
74
+ ? "&quot;"
75
+ : c === "&"
76
+ ? "&amp;"
77
+ : c === "'"
78
+ ? "&#x27;"
79
+ : c === "<"
80
+ ? "&lt;"
81
+ : "&gt;",
82
+ );
83
+ }
84
+
85
+ interface EntryHintBytes {
86
+ low: Uint8Array;
87
+ auto: Uint8Array;
88
+ }
89
+
90
+ function entryHintBytes(
91
+ entryUrl: string,
92
+ nonce: string | undefined,
93
+ ): EntryHintBytes {
94
+ const tail =
95
+ (nonce ? ` nonce="${escapeAttr(nonce)}"` : "") +
96
+ ` href="${escapeAttr(entryUrl)}"/>`;
97
+ return {
98
+ low: encoder.encode(`<link rel="modulepreload"${LOW_PRIORITY_ATTR}${tail}`),
99
+ auto: encoder.encode(`<link rel="modulepreload"${tail}`),
100
+ };
101
+ }
102
+
103
+ function indexOfBytes(
104
+ haystack: Uint8Array,
105
+ needle: Uint8Array,
106
+ from = 0,
107
+ ): number {
108
+ const first = needle[0];
109
+ const last = haystack.length - needle.length;
110
+ outer: for (let i = from; i <= last; i++) {
111
+ if (haystack[i] !== first) continue;
112
+ for (let j = 1; j < needle.length; j++) {
113
+ if (haystack[i + j] !== needle[j]) continue outer;
114
+ }
115
+ return i;
116
+ }
117
+ return -1;
118
+ }
119
+
120
+ /**
121
+ * Length of the longest suffix of `buf` that is a proper prefix of `needle`:
122
+ * the bytes a chunk boundary may have split off a match.
123
+ */
124
+ function partialSuffixLength(buf: Uint8Array, needle: Uint8Array): number {
125
+ const max = Math.min(buf.length, needle.length - 1);
126
+ outer: for (let len = max; len > 0; len--) {
127
+ const start = buf.length - len;
128
+ for (let j = 0; j < len; j++) {
129
+ if (buf[start + j] !== needle[j]) continue outer;
130
+ }
131
+ return len;
132
+ }
133
+ return 0;
134
+ }
135
+
136
+ function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
137
+ const out = new Uint8Array(a.length + b.length);
138
+ out.set(a, 0);
139
+ out.set(b, a.length);
140
+ return out;
141
+ }
142
+
143
+ /** Index of the hint in `buf` when it precedes the first `<body`, else -1. */
144
+ function findHint(buf: Uint8Array, hint: EntryHintBytes): number {
145
+ const bodyAt = indexOfBytes(buf, BODY_OPEN);
146
+ return indexOfBytes(bodyAt === -1 ? buf : buf.subarray(0, bodyAt), hint.low);
147
+ }
148
+
149
+ /**
150
+ * 1 when `needle` starts at `pos`, 0 when it does not, -1 when `buf` ends
151
+ * before that can be decided (the available bytes match a prefix).
152
+ */
153
+ function matchAt(buf: Uint8Array, pos: number, needle: Uint8Array): number {
154
+ const n = Math.min(needle.length, buf.length - pos);
155
+ for (let j = 0; j < n; j++) {
156
+ if (buf[pos + j] !== needle[j]) return 0;
157
+ }
158
+ return n === needle.length ? 1 : -1;
159
+ }
160
+
161
+ /** HTML whitespace, `/` or `>`: the byte that ends a tag name. */
162
+ function endsTagName(byte: number): boolean {
163
+ return (
164
+ byte === 0x20 ||
165
+ byte === 0x09 ||
166
+ byte === 0x0a ||
167
+ byte === 0x0c ||
168
+ byte === 0x0d ||
169
+ byte === 0x2f ||
170
+ byte === GT
171
+ );
172
+ }
173
+
174
+ interface RunScan {
175
+ /** The run ended: `at` is the first byte of the token after it. */
176
+ ended: boolean;
177
+ /** Insertion point, or the start of the unfinished token when not ended. */
178
+ at: number;
179
+ }
180
+
181
+ /**
182
+ * Skip the run of Fizz resource tags at the start of `buf`: `<link …>` and
183
+ * `<script …></script>` with nothing between the two tags.
184
+ */
185
+ function scanRun(buf: Uint8Array): RunScan {
186
+ let pos = 0;
187
+ while (pos < buf.length) {
188
+ const link = matchAt(buf, pos, LINK_OPEN);
189
+ const script = link === 1 ? 0 : matchAt(buf, pos, SCRIPT_OPEN);
190
+ if (link !== 1 && script !== 1) {
191
+ return { ended: link === 0 && script === 0, at: pos };
192
+ }
193
+ const nameEnd = pos + (link === 1 ? LINK_OPEN.length : SCRIPT_OPEN.length);
194
+ if (nameEnd >= buf.length) return { ended: false, at: pos };
195
+ if (!endsTagName(buf[nameEnd]!)) return { ended: true, at: pos };
196
+ const gt = buf.indexOf(GT, nameEnd);
197
+ if (gt === -1) return { ended: false, at: pos };
198
+ let end = gt + 1;
199
+ if (script === 1) {
200
+ const close = matchAt(buf, end, SCRIPT_CLOSE);
201
+ // An inline script body: the hint goes before the <script, never in it.
202
+ if (close !== 1) return { ended: close === 0, at: pos };
203
+ end += SCRIPT_CLOSE.length;
204
+ }
205
+ pos = end;
206
+ }
207
+ return { ended: false, at: pos };
208
+ }
209
+
210
+ /** `rest` with `hint` inserted at `at`. */
211
+ function insertAt(rest: Uint8Array, at: number, hint: Uint8Array): Uint8Array {
212
+ const out = new Uint8Array(rest.length + hint.length);
213
+ out.set(rest.subarray(0, at), 0);
214
+ out.set(hint, at);
215
+ out.set(rest.subarray(at), at + hint.length);
216
+ return out;
217
+ }
218
+
219
+ /**
220
+ * Rewrite a complete document prefix (the PPR shell capture's prelude).
221
+ * Returns `bytes` unchanged when no hint precedes the first `<body`.
222
+ */
223
+ export function rewriteEntryPreloadPriority(
224
+ bytes: Uint8Array,
225
+ entryUrl: string,
226
+ nonce?: string,
227
+ ): Uint8Array {
228
+ const hint = entryHintBytes(entryUrl, nonce);
229
+ const at = findHint(bytes, hint);
230
+ if (at === -1) return bytes;
231
+ const rest = bytes.subarray(at + hint.low.length);
232
+ return concat(
233
+ bytes.subarray(0, at),
234
+ insertAt(rest, scanRun(rest).at, hint.auto),
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Streaming form for live SSR: the same rewrite as
240
+ * {@link rewriteEntryPreloadPriority}, then pass-through. Before the hint is
241
+ * found, only a chunk tail that may be the start of a split `<body` or hint
242
+ * tag is held back. After it, complete run tags are forwarded as they are
243
+ * scanned and only an unfinished one (at most {@link MAX_HELD_TAG} bytes) is
244
+ * held, so every byte is scanned a bounded number of times. Once the tag is
245
+ * placed, or once `<body` passes without a hint, chunks are forwarded
246
+ * untouched and unscanned.
247
+ */
248
+ export function entryPreloadPriorityTransform(
249
+ entryUrl: string,
250
+ nonce?: string,
251
+ ): TransformStream<Uint8Array, Uint8Array> {
252
+ const hint = entryHintBytes(entryUrl, nonce);
253
+ let carry: Uint8Array | null = null;
254
+ // The unfinished run tag after the removed hint (possibly empty).
255
+ let held: Uint8Array | null = null;
256
+ let done = false;
257
+ const place = (
258
+ rest: Uint8Array,
259
+ controller: TransformStreamDefaultController<Uint8Array>,
260
+ ): void => {
261
+ const scan = scanRun(rest);
262
+ if (scan.ended || rest.length - scan.at > MAX_HELD_TAG) {
263
+ done = true;
264
+ held = null;
265
+ controller.enqueue(insertAt(rest, scan.at, hint.auto));
266
+ return;
267
+ }
268
+ if (scan.at > 0) controller.enqueue(rest.slice(0, scan.at));
269
+ held = rest.slice(scan.at);
270
+ };
271
+ return new TransformStream<Uint8Array, Uint8Array>({
272
+ transform(chunk, controller) {
273
+ if (done) {
274
+ controller.enqueue(chunk);
275
+ return;
276
+ }
277
+ if (held) {
278
+ place(concat(held, chunk), controller);
279
+ return;
280
+ }
281
+ const buf: Uint8Array = carry ? concat(carry, chunk) : chunk;
282
+ carry = null;
283
+ const at = findHint(buf, hint);
284
+ if (at !== -1) {
285
+ if (at > 0) controller.enqueue(buf.slice(0, at));
286
+ place(buf.subarray(at + hint.low.length), controller);
287
+ return;
288
+ }
289
+ if (indexOfBytes(buf, BODY_OPEN) !== -1) {
290
+ done = true;
291
+ controller.enqueue(buf);
292
+ return;
293
+ }
294
+ const hold = Math.max(
295
+ partialSuffixLength(buf, hint.low),
296
+ partialSuffixLength(buf, BODY_OPEN),
297
+ );
298
+ if (hold > 0) carry = buf.slice(buf.length - hold);
299
+ const out = hold > 0 ? buf.subarray(0, buf.length - hold) : buf;
300
+ if (out.length > 0) controller.enqueue(out);
301
+ },
302
+ flush(controller) {
303
+ if (carry) controller.enqueue(carry);
304
+ // The stream ended inside the run: the tag goes before the unfinished one.
305
+ if (held) controller.enqueue(concat(hint.auto, held));
306
+ },
307
+ });
308
+ }
package/src/ssr/index.tsx CHANGED
@@ -2,6 +2,10 @@ import React from "react";
2
2
  import { createSsrRootComponent, deserializeSsrPayload } from "./ssr-root.js";
3
3
  import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
4
4
  import { runWithPreinitNonce } from "./preinit-client-references.js";
5
+ import {
6
+ entryPreloadPriorityTransform,
7
+ rewriteEntryPreloadPriority,
8
+ } from "./entry-preload-priority.js";
5
9
  import {
6
10
  POST_QUIESCE_TASK_HOPS,
7
11
  SHELL_CAPTURE_MAX_WAIT_MS,
@@ -189,6 +193,7 @@ export interface SSRDependencies<TEnv = unknown> {
189
193
  * undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
190
194
  * never installed the preinit hook cannot drift into the half-converted
191
195
  * state on upgrade (the generated entry always passes an explicit value).
196
+ * Any other value throws when the handler is created.
192
197
  */
193
198
  headScripts?: HeadScriptsOption;
194
199
 
@@ -521,9 +526,23 @@ const MISSING_BOOTSTRAP_MSG =
521
526
  * getClientEntryUrl only counts under an explicit `headScripts: "preinit"` —
522
527
  * any other headScripts keeps the inline path, so its presence alone is a
523
528
  * misconfiguration worth flagging rather than silently ignoring.
529
+ * An unknown headScripts value throws: a custom SSR entry bypasses the
530
+ * rango({ headScripts }) check in getVirtualEntrySSR, and a typo such as
531
+ * "prenit" would otherwise run the inline path as if it were "preload".
532
+ * undefined stays valid (see {@link resolveBootstrap}).
524
533
  */
525
534
  function assertBootstrapDeps(deps: SSRDependencies): void {
526
- const preinit = deps.headScripts === "preinit";
535
+ const { headScripts } = deps;
536
+ if (
537
+ headScripts !== undefined &&
538
+ headScripts !== "preinit" &&
539
+ headScripts !== "preload"
540
+ ) {
541
+ throw new Error(
542
+ `[ssr] headScripts must be "preinit" or "preload", received ${JSON.stringify(headScripts)}`,
543
+ );
544
+ }
545
+ const preinit = headScripts === "preinit";
527
546
  if (deps.getClientEntryUrl && !preinit) {
528
547
  console.warn(
529
548
  '[ssr] getClientEntryUrl is ignored without headScripts: "preinit"; ' +
@@ -550,13 +569,18 @@ type BootstrapOptions = Pick<
550
569
  * `headScripts: "preinit"`, getClientEntryUrl() (sync — nothing to race)
551
570
  * short-circuits to bootstrapModules, and inline content that is exactly
552
571
  * `import("<entry-url>")` converts to the URL. React then emits a
553
- * `<link rel="modulepreload" fetchpriority="low">` hint in the head plus the
554
- * executing `<script type="module" src async>` at end of shell — the entry
555
- * fetch starts with the first flushed bytes instead of when the parser reaches
556
- * an opaque inline script that only reveals the URL once executed. Fizz stamps
557
- * the request nonce on both tags, and under PPR both land in the stored
558
- * prelude; on resume React has already cleared the bootstrap fields from the
559
- * postponed state, so nothing re-emits. The conversion is an explicit opt-in:
572
+ * `<link rel="modulepreload">` hint in the head plus the executing
573
+ * `<script type="module" src async>` at end of shell — the entry fetch starts
574
+ * with the first flushed bytes instead of when the parser reaches an opaque
575
+ * inline script that only reveals the URL once executed. Fizz writes that hint
576
+ * with `fetchPriority="low"` ahead of the head chunk scripts; the handlers
577
+ * serve it without the attribute after the head chunk scripts
578
+ * (entry-preload-priority.ts, issue #1025), because at Low the entry queued
579
+ * behind in-viewport images and gated hydration (+3.8 s measured on a
580
+ * prioritising HTTP/2 link). Fizz stamps the request nonce on both tags, and
581
+ * under PPR both land in the stored prelude; on resume React has already
582
+ * cleared the bootstrap fields from the postponed state, so nothing re-emits.
583
+ * The conversion is an explicit opt-in:
560
584
  * undefined headScripts (a custom SSR entry that predates the option, which
561
585
  * also never installed the preinit hook) keeps the inline bootstrap
562
586
  * byte-for-byte — converting by default would break CSPs that allowlist the
@@ -746,8 +770,15 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
746
770
  await htmlStream.allReady;
747
771
  }
748
772
 
773
+ // The entry's Fizz hint at default priority, after the head chunk
774
+ // scripts (entry-preload-priority.ts).
775
+ const entryUrl = bootstrap.bootstrapModules?.[0];
776
+ const html = entryUrl
777
+ ? htmlStream.pipeThrough(entryPreloadPriorityTransform(entryUrl, nonce))
778
+ : htmlStream;
779
+
749
780
  // Inject RSC payload into HTML as <script nonce="...">__FLIGHT_DATA__</script>
750
- return htmlStream.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
781
+ return html.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
751
782
  } catch (error) {
752
783
  reportRenderError(onError, error);
753
784
  throw error;
@@ -952,6 +983,12 @@ export function createShellCaptureHandler<TEnv = unknown>(
952
983
  const result = await prerenderPromise;
953
984
  prelude = await readStreamToUint8Array(result.prelude);
954
985
  postponed = result.postponed;
986
+ // Same hint rewrite as renderHTML, applied to the stored prelude; a
987
+ // resume never re-emits the bootstrap pair (Fizz clears it).
988
+ const entryUrl = bootstrap.bootstrapModules?.[0];
989
+ if (entryUrl) {
990
+ prelude = rewriteEntryPreloadPriority(prelude, entryUrl);
991
+ }
955
992
  } catch (error) {
956
993
  // Identity match: swallow ONLY our own deliberate abort
957
994
  // (error === abortReason). Not error.name — capture aborts before this
@@ -81,11 +81,23 @@ export function runWithPreinitNonce<T>(
81
81
  *
82
82
  * Known trades (deliberate, measured neutral-to-positive on the e2e apps —
83
83
  * PR #694 has the Lighthouse/hydration numbers):
84
- * - Fetch priority: an executing async module script fetches at Chromium's
85
- * async-script priority, below a bare modulepreload hint; preinitModule
86
- * forwards no fetchPriority (react-dom's public API drops it — only
87
- * `preinit` forwards it). Execution-overlap is bought with hint priority,
88
- * the same trade Next.js ships via ReactDOM.preinit.
84
+ * - Fetch priority: Chromium gives a parser-inserted `async` module script
85
+ * initial priority High: below the render-blocking stylesheet (Highest),
86
+ * and alongside the LCP image once Chromium boosts the in-viewport image to
87
+ * High (#1021). Every head chunk gets the same priority: the shared
88
+ * dependency chunks (react, router, entry.rsc and the bundler runtime) and
89
+ * every client-component chunk. The entry itself is a Fizz bootstrapModules
90
+ * hint, not a preinit (Fizz claims its URL, so this hook's call for it is
91
+ * inert); the handlers serve that hint at default priority, High as well,
92
+ * after these scripts (entry-preload-priority.ts, #1025).
93
+ * Lowering the chunks to `fetchpriority="low"` was measured and rejected
94
+ * (#1021; Chromium 153, 20 interleaved runs per variant and cell). On pages
95
+ * with images above the fold the Low chunks queue behind the boosted
96
+ * images: hydration came 206 to 6407 ms later on HTTP/1.1 and 65 to 642 ms
97
+ * later on HTTP/2. The one gain, FCP -136 ms, needed an image-free page
98
+ * with 35 KB of render-blocking CSS on HTTP/1.1 Slow 4G, and hydration was
99
+ * still 98 ms later there. `headScripts: "preload"` is the way to get Low
100
+ * chunk fetches.
89
101
  * - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
90
102
  * dev documents have no head chunk scripts — a client module whose module
91
103
  * scope assumes body-parsed DOM can break in production only. The
@@ -83,6 +83,12 @@ export interface ClientChunkMeta {
83
83
  * strategy**. It splits app `"use client"` modules by **route id** — the segment
84
84
  * after a route-root directory (`routes`, `app`, `pages`, `features`, `handlers`,
85
85
  * …) — so `routes/dashboard/**` becomes `app-dashboard` at any nesting depth.
86
+ * The first marker in the path wins, except that an `app/` directly followed
87
+ * by another marker with a directory after it is treated as a source root:
88
+ * `app/routes/product/**` becomes `app-product`, while `app/dashboard/**`
89
+ * (Next-style), `app/components/**` and files directly in `app/routes/` stay
90
+ * `app-dashboard` / `app-components` / `app-routes`. The same route id under
91
+ * different markers is one group.
86
92
  * Where it finds NO route structure (a flat `src/components/`, or host sub-apps
87
93
  * already split by a dynamic `import()`), it inherits the default grouping
88
94
  * unchanged — so the shared `src/components` chunk stays shared and host apps do
@@ -94,10 +100,14 @@ export interface ClientChunkMeta {
94
100
  * back to the default grouping for that one module. Forwarded directly to
95
101
  * `@vitejs/plugin-rsc`'s `clientChunks`.
96
102
  *
97
- * Every module maps to exactly one group, so there is no byte duplication: a
98
- * component used by two routes lives in one group and is fetched whenever it
99
- * renders. Put genuinely shared client components OUTSIDE route directories so
100
- * they land in the shared group rather than one route's chunk.
103
+ * Every module maps to exactly one group, so there is no byte duplication. A
104
+ * group is also the loading unit: rendering any one member downloads the whole
105
+ * group's chunk, every other member included. A component used by two routes
106
+ * lives in one group, and rendering it on either route fetches that entire
107
+ * group. Put genuinely shared client components OUTSIDE route directories so
108
+ * they land in the shared group rather than one route's chunk, and keep an
109
+ * always-rendered component (a layout header) out of a large group.
110
+ * `DEBUG=rango:chunks vite build` logs each group's module count and size.
101
111
  *
102
112
  * @default true
103
113
  */
@@ -137,14 +147,18 @@ interface RangoBaseOptions {
137
147
  * - `"preinit"` (**default**): client-reference chunks render as EXECUTING
138
148
  * `<script type="module" async>` tags hoisted into `<head>` (upgrading
139
149
  * plugin-rsc's modulepreload hints in place), and the browser entry ships
140
- * as Fizz `bootstrapModules` — a head `modulepreload fetchpriority=low`
141
- * hint plus the executing end-of-shell `id="_R_"` module script. Chunk
150
+ * as Fizz `bootstrapModules` — a `modulepreload` hint after the head
151
+ * chunk scripts (default priority; Fizz's `fetchpriority=low` is dropped,
152
+ * #1025)
153
+ * plus the executing end-of-shell `id="_R_"` module script. Chunk
142
154
  * execution overlaps body streaming instead of waiting for the hydration
143
155
  * import walk; under PPR everything lands in the stored shell prelude.
144
156
  * - `"preload"`: the previous behavior — `<link rel="modulepreload">` hints
145
157
  * only, entry as an inline `import()` script at end of shell. Chunks
146
158
  * fetch+compile early but execute only when hydration imports them.
147
159
  *
160
+ * Any other value throws when the generated SSR entry is built.
161
+ *
148
162
  * Build-only for the chunk half: plugin-rsc resolves no JS deps per client
149
163
  * reference in dev, so dev documents carry no head chunk scripts in either
150
164
  * mode (the bootstrap conversion does apply in dev). Trades and upstream
@@ -65,13 +65,20 @@ function emitProgressiveChunkSize(value: number): string {
65
65
  * option: "preinit" (default) installs the client-reference preinit hook and
66
66
  * threads `getClientEntryUrl` so Fizz emits `bootstrapModules`;
67
67
  * "preload" omits the hook and uses the deprecated inline
68
- * `loadBootstrapScriptContent` bootstrap.
68
+ * `loadBootstrapScriptContent` bootstrap. Any other value throws: an unknown
69
+ * string such as "prenit" used to run as "preinit" silently
70
+ * (`headScripts !== "preload"` since #694).
69
71
  */
70
72
  export function getVirtualEntrySSR(
71
73
  headScripts: HeadScriptsOption = "preinit",
72
74
  progressiveChunkSize?: number,
73
75
  ): string {
74
- const preinit = headScripts !== "preload";
76
+ if (headScripts !== "preinit" && headScripts !== "preload") {
77
+ throw new Error(
78
+ `rango({ headScripts }) must be "preinit" or "preload", received ${JSON.stringify(headScripts)}`,
79
+ );
80
+ }
81
+ const preinit = headScripts === "preinit";
75
82
  // The preload variant drops the preinit-only imports/install and swaps the
76
83
  // bootstrap dep, all built here so the template below stays a single
77
84
  // unconditional shape.
package/src/vite/rango.ts CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  getManualChunks,
33
33
  } from "./utils/shared-utils.js";
34
34
  import {
35
+ clientChunksReport,
35
36
  resolveClientChunks,
36
37
  type ClientChunkContext,
37
38
  } from "./utils/client-chunks.js";
@@ -537,6 +538,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
537
538
  );
538
539
  plugins.push(clientRefDedup());
539
540
  }
541
+ plugins.push(clientChunksReport());
540
542
 
541
543
  plugins.push({
542
544
  name: "@rangojs/router:client-component-hmr",
@@ -1,5 +1,7 @@
1
+ import type { Plugin } from "vite";
2
+ import { gzipSync } from "node:zlib";
1
3
  import type { ClientChunkMeta, ClientChunks } from "../plugin-types.js";
2
- import { createRangoDebugger, NS } from "../debug.js";
4
+ import { createRangoDebugger, NS, type Debugger } from "../debug.js";
3
5
  import { hashRefKey } from "../plugins/client-ref-hashing.js";
4
6
 
5
7
  /** The callback shape @vitejs/plugin-rsc's `clientChunks` option accepts. */
@@ -97,6 +99,20 @@ const ROUTE_ROOT_DIRS = new Set([
97
99
  "sections",
98
100
  ]);
99
101
 
102
+ /**
103
+ * Markers that double as a conventional source root. `app/` is both a Next-style
104
+ * route root (`app/<segment>/…`) and the source root of many apps
105
+ * (`app/components`, `app/routes/<id>`). Keying on the segment after it put every
106
+ * route under `app/routes/` into one `app-routes` group, and a group is the
107
+ * loading unit, so rendering any one member downloaded every route's client code
108
+ * (#1022). A source-root marker therefore defers to a marker that IMMEDIATELY
109
+ * follows it when that inner marker has a route directory after it
110
+ * (`app/routes/<id>/…` -> `app-<id>`). Anything else after `app/` (including
111
+ * `app/components/`) is still the Next-style route id. Only these markers defer:
112
+ * `routes/view/<sub>/…` keeps `view` as the route id.
113
+ */
114
+ const SOURCE_ROOT_DIRS = new Set(["app"]);
115
+
100
116
  /**
101
117
  * Built-in strategy used when `clientChunks: true` (also the default). Splits app
102
118
  * client components by route/feature identity ONLY where it can recognize a route
@@ -116,8 +132,11 @@ const ROUTE_ROOT_DIRS = new Set([
116
132
  * 1. Shared runtime (React / router / node_modules) -> `undefined` (never split).
117
133
  * 2. A registered error/notFound fallback (`ctx.fallbackRefs`) -> `app-fallback`,
118
134
  * regardless of location, so the error UI is decoupled from the happy path.
119
- * 3. A {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on that
120
- * next segment (the route id), robust to any nesting depth.
135
+ * 3. The first {@link ROUTE_ROOT_DIRS} marker with a directory after it -> key on
136
+ * that next segment (the route id), robust to any nesting depth. A
137
+ * {@link SOURCE_ROOT_DIRS} marker (`app`) directly followed by another marker
138
+ * with a directory after it defers to that inner marker:
139
+ * `app/routes/<id>/…` -> `app-<id>`, while `app/<segment>/…` -> `app-<segment>`.
121
140
  * 4. Otherwise `undefined` (inherit the default `serverChunk` grouping).
122
141
  */
123
142
  export function directoryClientChunks(
@@ -140,17 +159,21 @@ export function directoryClientChunks(
140
159
  }
141
160
  const segments = meta.normalizedId.split("/").filter(Boolean);
142
161
  const dirCount = segments.length - 1; // exclude the filename
143
- if (dirCount >= 1) {
144
- // Route-root marker -> the segment after it is the route id. First marker
145
- // wins, so a top-level route owns its whole subtree. The `< dirCount - 1`
146
- // bound guarantees the segment after the marker is a directory, not the file.
147
- for (let i = 0; i < dirCount - 1; i++) {
148
- if (ROUTE_ROOT_DIRS.has(segments[i].toLowerCase())) {
149
- const group = `app-${sanitizeGroup(segments[i + 1])}`;
150
- debugChunks?.("split %s -> %s", meta.normalizedId, group);
151
- return group;
152
- }
153
- }
162
+ // Route-root marker -> the segment after it is the route id. First marker wins,
163
+ // so a top-level route owns its whole subtree. The `< dirCount - 1` bound
164
+ // guarantees the segment after the marker is a directory, not the file.
165
+ const isMarkerAt = (i: number) =>
166
+ i < dirCount - 1 && ROUTE_ROOT_DIRS.has(segments[i].toLowerCase());
167
+ for (let i = 0; i < dirCount - 1; i++) {
168
+ if (!isMarkerAt(i)) continue;
169
+ // A source root (`app/`) yields to an inner marker right after it.
170
+ const marker =
171
+ SOURCE_ROOT_DIRS.has(segments[i].toLowerCase()) && isMarkerAt(i + 1)
172
+ ? i + 1
173
+ : i;
174
+ const group = `app-${sanitizeGroup(segments[marker + 1])}`;
175
+ debugChunks?.("split %s -> %s", meta.normalizedId, group);
176
+ return group;
154
177
  }
155
178
  // No recognized route structure -> inherit the default serverChunk grouping.
156
179
  // This is the actionable "silent" case: app code that did NOT split by route.
@@ -182,3 +205,46 @@ export function resolveClientChunks(
182
205
  if (option === true) return (meta) => directoryClientChunks(meta, ctx);
183
206
  return option;
184
207
  }
208
+
209
+ /** Module-id prefix of the per-group virtual module @vitejs/plugin-rsc emits. */
210
+ const CLIENT_GROUP_PREFIX = "\0virtual:vite-rsc/client-references/group/";
211
+
212
+ /**
213
+ * Build-end group report under `DEBUG=rango:chunks`: one line per client
214
+ * reference group with its client-reference count and the size of the group's
215
+ * own chunk (code split into shared chunks such as `react`/`router` is not
216
+ * counted). A group is the loading unit (plugin-rsc imports the group's virtual
217
+ * module to resolve any one member), so a large group that an always-rendered
218
+ * component belongs to is downloaded on every route; this line makes that
219
+ * visible. Covers every group, including ones named by a custom `clientChunks`
220
+ * function or by plugin-rsc's default `serverChunk` grouping. `undefined` when
221
+ * the namespace is off, so a normal build does not register it. Group names are
222
+ * printed with NUL escaped: plugin-rsc's default group is named
223
+ * `facade:\0virtual:…`, and a raw NUL makes `grep` treat the output as binary
224
+ * and drop that line, usually the largest group. `log` is injectable for tests.
225
+ */
226
+ export function clientChunksReport(
227
+ log: Debugger | undefined = debugChunks,
228
+ ): Plugin | undefined {
229
+ if (!log) return undefined;
230
+ return {
231
+ name: "@rangojs/router:client-chunks-report",
232
+ apply: "build",
233
+ applyToEnvironment: (env) => env.name === "client",
234
+ generateBundle(_options, bundle) {
235
+ for (const chunk of Object.values(bundle)) {
236
+ if (chunk.type !== "chunk") continue;
237
+ const facade = chunk.facadeModuleId;
238
+ if (!facade?.startsWith(CLIENT_GROUP_PREFIX)) continue;
239
+ log(
240
+ "group %s: %d client reference(s), %d B (%d B gzip) -> %s",
241
+ facade.slice(CLIENT_GROUP_PREFIX.length).replaceAll("\0", "\\0"),
242
+ this.getModuleInfo(facade)?.importedIds.length ?? 0,
243
+ Buffer.byteLength(chunk.code),
244
+ gzipSync(chunk.code).length,
245
+ chunk.fileName,
246
+ );
247
+ }
248
+ },
249
+ };
250
+ }