@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.
- package/dist/types/ssr/entry-preload-priority.d.ts +69 -0
- package/dist/types/ssr/index.d.ts +1 -0
- package/dist/types/ssr/preinit-client-references.d.ts +17 -5
- package/dist/types/vite/plugin-types.d.ts +20 -6
- package/dist/types/vite/plugins/virtual-entries.d.ts +3 -1
- package/dist/types/vite/utils/client-chunks.d.ts +22 -2
- package/dist/vite/index.js +41 -10
- package/package.json +1 -1
- package/skills/bundle-analysis/SKILL.md +1 -1
- package/skills/router-setup/SKILL.md +8 -2
- package/src/ssr/entry-preload-priority.ts +308 -0
- package/src/ssr/index.tsx +46 -9
- package/src/ssr/preinit-client-references.ts +17 -5
- package/src/vite/plugin-types.ts +20 -6
- package/src/vite/plugins/virtual-entries.ts +9 -2
- package/src/vite/rango.ts +2 -0
- package/src/vite/utils/client-chunks.ts +80 -14
|
@@ -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:
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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
|
|
118
|
-
*
|
|
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.
|
|
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;
|
package/dist/vite/index.js
CHANGED
|
@@ -4369,7 +4369,12 @@ function emitProgressiveChunkSize(value) {
|
|
|
4369
4369
|
return JSON.stringify(value);
|
|
4370
4370
|
}
|
|
4371
4371
|
function getVirtualEntrySSR(headScripts = "preinit", progressiveChunkSize) {
|
|
4372
|
-
|
|
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.
|
|
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
|
-
|
|
6482
|
-
|
|
6483
|
-
|
|
6484
|
-
|
|
6485
|
-
|
|
6486
|
-
|
|
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
|
@@ -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`.
|
|
238
|
-
|
|
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
|
+
? """
|
|
75
|
+
: c === "&"
|
|
76
|
+
? "&"
|
|
77
|
+
: c === "'"
|
|
78
|
+
? "'"
|
|
79
|
+
: c === "<"
|
|
80
|
+
? "<"
|
|
81
|
+
: ">",
|
|
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
|
|
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"
|
|
554
|
-
*
|
|
555
|
-
*
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
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
|
|
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:
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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
|
package/src/vite/plugin-types.ts
CHANGED
|
@@ -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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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
|
|
141
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
}
|