@rangojs/router 0.19.0 → 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.
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
+ }