@rangojs/router 0.0.0-experimental.145 → 0.0.0-experimental.147
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/bin/rango.js +8 -40
- package/dist/vite/index.js +840 -243
- package/package.json +6 -1
- package/src/browser/event-controller.ts +16 -2
- package/src/browser/rsc-router.tsx +11 -0
- package/src/cache/cache-scope.ts +41 -3
- package/src/cache/cf/cf-cache-store.ts +23 -0
- package/src/cache/handle-snapshot.ts +22 -1
- package/src/cache/memory-segment-store.ts +32 -0
- package/src/cache/segment-codec.ts +47 -0
- package/src/cache/shell-snapshot.ts +47 -0
- package/src/cache/types.ts +27 -0
- package/src/cache/vercel/vercel-cache-store.ts +71 -2
- package/src/deps/ssr.ts +4 -1
- package/src/prerender/build-shell-capture.ts +237 -0
- package/src/prerender/shell-manifest-key.ts +20 -0
- package/src/prerender/store.ts +10 -1
- package/src/router/loader-resolution.ts +16 -0
- package/src/router/match-api.ts +9 -2
- package/src/router/match-handlers.ts +13 -0
- package/src/router/match-middleware/cache-lookup.ts +12 -1
- package/src/router/prerender-match.ts +21 -0
- package/src/router/segment-resolution/mask-nested.ts +19 -3
- package/src/rsc/capture-queue.ts +67 -0
- package/src/rsc/rsc-rendering.ts +136 -25
- package/src/rsc/shell-build-manifest.ts +244 -0
- package/src/rsc/shell-capture.ts +194 -43
- package/src/segment-fragments.ts +124 -0
- package/src/segment-system.tsx +49 -19
- package/src/server/request-context.ts +112 -11
- package/src/ssr/index.tsx +151 -22
- package/src/ssr/inject-rsc-eager.ts +2 -2
- package/src/ssr/preinit-client-references.ts +106 -0
- package/src/ssr/ssr-root.tsx +35 -2
- package/src/vite/discovery/discover-routers.ts +27 -0
- package/src/vite/discovery/prerender-collection.ts +16 -0
- package/src/vite/discovery/shell-prerender-phase.ts +395 -0
- package/src/vite/discovery/state.ts +42 -0
- package/src/vite/index.ts +1 -0
- package/src/vite/plugin-types.ts +33 -0
- package/src/vite/plugins/version-plugin.ts +8 -0
- package/src/vite/plugins/virtual-entries.ts +37 -4
- package/src/vite/rango.ts +11 -2
- package/src/vite/router-discovery.ts +292 -8
- package/src/vite/utils/prerender-utils.ts +25 -6
- package/src/vite/utils/shared-utils.ts +4 -2
|
@@ -210,6 +210,66 @@ export interface RequestContext<
|
|
|
210
210
|
*/
|
|
211
211
|
_shellLoaderSeed?: Map<string, unknown>;
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* @internal Shell fast-path marker: makes the NEXT full match treat the whole
|
|
215
|
+
* matched route as an implicit doc-level cache() boundary (see
|
|
216
|
+
* resolveShellImplicitCacheScope in cache/cache-scope.ts). Set ONLY on
|
|
217
|
+
* (a) the capture's derived context — with a record-only store so the
|
|
218
|
+
* capture's cacheRoute write lands in the snapshot, never the real store —
|
|
219
|
+
* and (b) a HIT tail's seeded context when the entry is eligible
|
|
220
|
+
* (!handlerLiveHoles), where the SeededShellStore serves the recorded doc
|
|
221
|
+
* entry and the match skips handler execution entirely. Routes with their
|
|
222
|
+
* own cache() config (including cache(false)) are never overridden: the
|
|
223
|
+
* marker only applies when the route tree derived NO cache scope.
|
|
224
|
+
*/
|
|
225
|
+
_shellImplicitCache?: {
|
|
226
|
+
ttl?: number;
|
|
227
|
+
swr?: number;
|
|
228
|
+
store?: SegmentCacheStore;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* @internal Shell-HIT tail marker: cache/prerender-store hits during THIS
|
|
233
|
+
* render emit stored segment fragments VERBATIM into the payload
|
|
234
|
+
* (segment-codec fragmentSegments) instead of deserialize -> re-serialize
|
|
235
|
+
* per request; the payload consumers (SSR resume + browser hydration)
|
|
236
|
+
* expand them (segment-fragments.ts, issue #700). Own property of
|
|
237
|
+
* serveShellHit's derived tail context ONLY — it must never be visible to a
|
|
238
|
+
* capture render: the capture SSR-prerenders the payload AND serializes
|
|
239
|
+
* segments into records (cacheRoute), and an envelope reaching
|
|
240
|
+
* serializeSegments would store a double-encoded fragment.
|
|
241
|
+
*/
|
|
242
|
+
_shellFragmentPayload?: boolean;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* @internal Handler-layer liveness observed DURING a shell capture, from
|
|
246
|
+
* three sources: (a) the capture handle-store push wrapper (shell-capture.ts)
|
|
247
|
+
* when a push made OUTSIDE a DSL loader scope carries a nested thenable
|
|
248
|
+
* (masked to a never-filling hole); (b) still-pending top-level handler
|
|
249
|
+
* pushes (liveness unknowable at the barrier); (c) a handler-invoked loader
|
|
250
|
+
* executing during the capture (loader-resolution.ts — its consumption-lane
|
|
251
|
+
* value would freeze on a handler-free HIT). captureAndStoreShell folds it
|
|
252
|
+
* into ShellCacheEntry.handlerLiveHoles at the putShell barrier. Own
|
|
253
|
+
* property of the capture's derived context only.
|
|
254
|
+
*/
|
|
255
|
+
_shellCaptureHandleLiveness?: {
|
|
256
|
+
holes: boolean;
|
|
257
|
+
pendingPushes: number;
|
|
258
|
+
handlerInvokedLoader: boolean;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* @internal Handle values pushed from a DSL loader scope DURING a shell
|
|
263
|
+
* capture (identity set; populated by the capture push wrapper in
|
|
264
|
+
* shell-capture.ts). cacheRoute threads it into captureHandles so those
|
|
265
|
+
* values stay out of cache-write handle records — loaders re-run fresh on
|
|
266
|
+
* every HIT, so replaying their captured (masked) values would duplicate
|
|
267
|
+
* the fresh push and stall the Flight handle encode. Own property of the
|
|
268
|
+
* capture's derived context only; render-time handle consumers are
|
|
269
|
+
* unaffected (the exclusion applies only at the captureHandles call site).
|
|
270
|
+
*/
|
|
271
|
+
_shellCaptureLoaderHandleValues?: WeakSet<object>;
|
|
272
|
+
|
|
213
273
|
/**
|
|
214
274
|
* @internal Set (to the offending fn name) by the cookies()/headers()
|
|
215
275
|
* capture guard when it throws DURING a capture render. Load-bearing for the
|
|
@@ -528,6 +588,7 @@ export type PublicRequestContext<
|
|
|
528
588
|
| "_transitionWhen"
|
|
529
589
|
| "_cacheStore"
|
|
530
590
|
| "_shellCaptureRun"
|
|
591
|
+
| "_shellFragmentPayload"
|
|
531
592
|
| "_shellCaptureGuardTrippedLoaderId"
|
|
532
593
|
| "_explicitTaggedStores"
|
|
533
594
|
| "_requestTags"
|
|
@@ -1058,10 +1119,56 @@ export function createRequestContext<TEnv>(
|
|
|
1058
1119
|
reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
|
|
1059
1120
|
};
|
|
1060
1121
|
|
|
1061
|
-
|
|
1122
|
+
wireRenderBarrier(ctx, handleStore);
|
|
1123
|
+
|
|
1124
|
+
ctx.use = createUseFunction({
|
|
1125
|
+
handleStore,
|
|
1126
|
+
loaderPromises,
|
|
1127
|
+
getContext: () => ctx,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
(ctx as any)[NOCACHE_SYMBOL] = true;
|
|
1131
|
+
return ctx;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Wire a fresh render barrier onto `ctx`, closure-bound to THIS ctx and THIS
|
|
1136
|
+
* handle store. Called by createRequestContext for every fresh context, and by
|
|
1137
|
+
* deriveShellCaptureContext (rsc/shell-capture.ts) for the PPR capture's
|
|
1138
|
+
* derived context.
|
|
1139
|
+
*
|
|
1140
|
+
* The derived-context call is load-bearing (issue #684, plan 009): the capture
|
|
1141
|
+
* context is `Object.create(reqCtx)`, so without its own wiring every
|
|
1142
|
+
* `_renderBarrier*` read fell through the prototype to the FOREGROUND
|
|
1143
|
+
* request's barrier — whose getter and resolver are closure-bound to the
|
|
1144
|
+
* foreground ctx and its handle store, and whose resolver no-ops once
|
|
1145
|
+
* resolved. A bake-lane loader's `await ctx.rendered()` during capture then
|
|
1146
|
+
* resolved instantly against the foreground's barrier and `ctx.use(handle)`
|
|
1147
|
+
* read the foreground's handle snapshot; the capture's fresh `_handleStore`
|
|
1148
|
+
* was invisible, so foreground per-request handle data could bake into the
|
|
1149
|
+
* shared shell.
|
|
1150
|
+
*/
|
|
1151
|
+
export function wireRenderBarrier(
|
|
1152
|
+
ctx: RequestContext<any, any>,
|
|
1153
|
+
handleStore: HandleStore,
|
|
1154
|
+
): void {
|
|
1155
|
+
// Reset the whole barrier family as OWN properties. No-op for a fresh
|
|
1156
|
+
// context; for the derived capture context this shadows the foreground's
|
|
1157
|
+
// resolved state so the capture runs its own barrier lifecycle. In
|
|
1158
|
+
// particular _treeHasStreaming must be recomputed for the CAPTURE's tree
|
|
1159
|
+
// (cache-lookup/segment-resolution only set it when undefined): an
|
|
1160
|
+
// inherited `true` made a capture-lane rendered() seal the capture's fresh
|
|
1161
|
+
// store at loader start and pair it with the foreground's segment order.
|
|
1162
|
+
ctx._renderBarrierSegmentOrder = undefined;
|
|
1163
|
+
ctx._renderBarrierWaiters = undefined;
|
|
1164
|
+
ctx._renderBarrierHandleSnapshot = undefined;
|
|
1165
|
+
ctx._renderBarrierGuardClosed = undefined;
|
|
1166
|
+
ctx._handlerLoaderDeps = undefined;
|
|
1167
|
+
ctx._treeHasStreaming = undefined;
|
|
1168
|
+
|
|
1169
|
+
// Lazy allocation: only create the Promise when a loader calls rendered().
|
|
1062
1170
|
let barrierResolved = false;
|
|
1063
1171
|
let resolveBarrier: (() => void) | undefined;
|
|
1064
|
-
ctx._renderBarrier = null as any;
|
|
1065
1172
|
ctx._resolveRenderBarrier = (
|
|
1066
1173
|
segments: Array<{ type: string; id: string }>,
|
|
1067
1174
|
) => {
|
|
@@ -1089,6 +1196,9 @@ export function createRequestContext<TEnv>(
|
|
|
1089
1196
|
}
|
|
1090
1197
|
if (resolveBarrier) resolveBarrier();
|
|
1091
1198
|
};
|
|
1199
|
+
// defineProperty, not assignment: on a derived context the prototype's
|
|
1200
|
+
// _renderBarrier may already be a non-writable data property (the getter
|
|
1201
|
+
// pins it after first access), which would reject a plain assignment.
|
|
1092
1202
|
Object.defineProperty(ctx, "_renderBarrier", {
|
|
1093
1203
|
get() {
|
|
1094
1204
|
const p = barrierResolved
|
|
@@ -1105,15 +1215,6 @@ export function createRequestContext<TEnv>(
|
|
|
1105
1215
|
},
|
|
1106
1216
|
configurable: true,
|
|
1107
1217
|
});
|
|
1108
|
-
|
|
1109
|
-
ctx.use = createUseFunction({
|
|
1110
|
-
handleStore,
|
|
1111
|
-
loaderPromises,
|
|
1112
|
-
getContext: () => ctx,
|
|
1113
|
-
});
|
|
1114
|
-
|
|
1115
|
-
(ctx as any)[NOCACHE_SYMBOL] = true;
|
|
1116
|
-
return ctx;
|
|
1117
1218
|
}
|
|
1118
1219
|
|
|
1119
1220
|
// Capture the Max-Age value so it can be parsed numerically. A leading zero
|
package/src/ssr/index.tsx
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { createSsrRootComponent } from "./ssr-root.js";
|
|
3
3
|
import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
|
|
4
|
+
import { runWithPreinitNonce } from "./preinit-client-references.js";
|
|
4
5
|
import type { ErrorPhase } from "../types.js";
|
|
6
|
+
import type { HeadScriptsOption } from "../vite/plugin-types.js";
|
|
7
|
+
|
|
8
|
+
export { installClientReferencePreinit } from "./preinit-client-references.js";
|
|
5
9
|
|
|
6
10
|
/**
|
|
7
11
|
* Options for injectRSCPayload
|
|
@@ -18,6 +22,7 @@ export interface InjectRSCPayloadOptions {
|
|
|
18
22
|
*/
|
|
19
23
|
interface RenderToReadableStreamOptions {
|
|
20
24
|
bootstrapScriptContent?: string;
|
|
25
|
+
bootstrapModules?: string[];
|
|
21
26
|
nonce?: string;
|
|
22
27
|
formState?: unknown;
|
|
23
28
|
}
|
|
@@ -35,6 +40,7 @@ interface ReactDOMReadableStream extends ReadableStream<Uint8Array> {
|
|
|
35
40
|
interface PrerenderOptions {
|
|
36
41
|
signal?: AbortSignal;
|
|
37
42
|
bootstrapScriptContent?: string;
|
|
43
|
+
bootstrapModules?: string[];
|
|
38
44
|
onError?: (error: unknown) => void;
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -133,6 +139,17 @@ export interface SSRDependencies<TEnv = unknown> {
|
|
|
133
139
|
*/
|
|
134
140
|
loadBootstrapScriptContent: () => Promise<string>;
|
|
135
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Document script strategy; the generated virtual SSR entry threads the
|
|
144
|
+
* `rango({ headScripts })` plugin option here (canonical docs on
|
|
145
|
+
* `RangoBaseOptions.headScripts` in vite/plugin-types.ts). The
|
|
146
|
+
* bootstrapModules conversion runs ONLY on an explicit `"preinit"`:
|
|
147
|
+
* undefined keeps the inline bootstrap verbatim, so a custom SSR entry that
|
|
148
|
+
* never installed the preinit hook cannot drift into the half-converted
|
|
149
|
+
* state on upgrade (the generated entry always passes an explicit value).
|
|
150
|
+
*/
|
|
151
|
+
headScripts?: HeadScriptsOption;
|
|
152
|
+
|
|
136
153
|
/**
|
|
137
154
|
* prerender from react-dom/static.edge. Optional; required only by
|
|
138
155
|
* {@link createShellCaptureHandler} for PPR shell capture.
|
|
@@ -184,13 +201,16 @@ const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
|
|
|
184
201
|
* cached segments that are ALREADY serialized, so it emits the whole shell payload
|
|
185
202
|
* in the first tick and the gate declares quiesce almost immediately (~a few ms).
|
|
186
203
|
* On the old fresh-execution path the Flight dribbled out as handlers ran, so
|
|
187
|
-
* Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed.
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
204
|
+
* Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed.
|
|
205
|
+
*
|
|
206
|
+
* Hops alone are NOT render-readiness: fizz cannot emit even <html> until the
|
|
207
|
+
* payload root settles, which waits on every referenced client-module LOAD —
|
|
208
|
+
* real module-runner I/O in dev (100ms+ cold), which no fixed count of near-
|
|
209
|
+
* zero-cost task hops can buy. captureShellHTML therefore awaits the payload-
|
|
210
|
+
* settled signal (SsrRootOptions.onPayloadSettled, deadline-bounded) between
|
|
211
|
+
* quiesce and these hops; the hops then only flush the settled tree and mark
|
|
212
|
+
* pending boundaries POSTPONED. Still task-based (masked loaders never emit,
|
|
213
|
+
* so more hops never lets a hole settle). Bounded by maxWaitMs end to end.
|
|
194
214
|
*/
|
|
195
215
|
const POST_QUIESCE_TASK_HOPS = 16;
|
|
196
216
|
|
|
@@ -326,6 +346,47 @@ interface ShellResumeOptions {
|
|
|
326
346
|
nonce?: string;
|
|
327
347
|
}
|
|
328
348
|
|
|
349
|
+
/**
|
|
350
|
+
* The exact shape plugin-rsc's loadBootstrapScriptContent returns in both dev
|
|
351
|
+
* and build: a single dynamic import of the browser entry, nothing else.
|
|
352
|
+
* Escapes/other statements never appear in that generated content; anything
|
|
353
|
+
* that doesn't match falls back to inline bootstrapScriptContent unchanged.
|
|
354
|
+
*/
|
|
355
|
+
const BOOTSTRAP_IMPORT_ONLY_RE =
|
|
356
|
+
/^\s*import\(\s*(["'])([^"'\\]+)\1\s*\)\s*;?\s*$/;
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Prefer bootstrapModules over the inline import() bootstrap. When the content
|
|
360
|
+
* is exactly `import("<entry-url>")`, hand Fizz the URL instead: React then
|
|
361
|
+
* emits a `<link rel="modulepreload" fetchpriority="low">` hint in the head
|
|
362
|
+
* plus the executing `<script type="module" src async>` at end of shell — the
|
|
363
|
+
* entry fetch starts with the first flushed bytes instead of when the parser
|
|
364
|
+
* reaches an opaque inline script that only reveals the URL once executed.
|
|
365
|
+
* Fizz stamps the request nonce on both tags (the inline form needed that
|
|
366
|
+
* too), and under PPR both land in the stored prelude; on resume React has
|
|
367
|
+
* already cleared the bootstrap fields from the postponed state, so nothing
|
|
368
|
+
* re-emits.
|
|
369
|
+
*/
|
|
370
|
+
function resolveBootstrapOptions(
|
|
371
|
+
content: string,
|
|
372
|
+
headScripts: SSRDependencies["headScripts"],
|
|
373
|
+
): Pick<
|
|
374
|
+
RenderToReadableStreamOptions,
|
|
375
|
+
"bootstrapScriptContent" | "bootstrapModules"
|
|
376
|
+
> {
|
|
377
|
+
// Explicit opt-in only: undefined (a custom SSR entry that predates the
|
|
378
|
+
// option, which also never installed the preinit hook) keeps the inline
|
|
379
|
+
// bootstrap byte-for-byte — converting by default would break CSPs that
|
|
380
|
+
// allowlist the known inline import() via a script hash.
|
|
381
|
+
if (headScripts !== "preinit") {
|
|
382
|
+
return { bootstrapScriptContent: content };
|
|
383
|
+
}
|
|
384
|
+
const match = BOOTSTRAP_IMPORT_ONLY_RE.exec(content);
|
|
385
|
+
return match
|
|
386
|
+
? { bootstrapModules: [match[2]!] }
|
|
387
|
+
: { bootstrapScriptContent: content };
|
|
388
|
+
}
|
|
389
|
+
|
|
329
390
|
/**
|
|
330
391
|
* Create an SSR handler that converts RSC streams to HTML.
|
|
331
392
|
*
|
|
@@ -383,12 +444,16 @@ export function createSSRHandler<TEnv = unknown>(deps: SSRDependencies<TEnv>) {
|
|
|
383
444
|
|
|
384
445
|
// Render React tree to HTML stream
|
|
385
446
|
// Pass formState for useActionState progressive enhancement if provided
|
|
386
|
-
// Pass nonce for CSP if provided
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
447
|
+
// Pass nonce for CSP if provided. runWithPreinitNonce makes the same
|
|
448
|
+
// nonce visible to the client-reference preinit hook (ALS — the hook is
|
|
449
|
+
// isolate-global, the nonce per request).
|
|
450
|
+
const htmlStream = await runWithPreinitNonce(nonce, () =>
|
|
451
|
+
renderToReadableStream(<SsrRoot />, {
|
|
452
|
+
...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
|
|
453
|
+
formState,
|
|
454
|
+
nonce,
|
|
455
|
+
}),
|
|
456
|
+
);
|
|
392
457
|
|
|
393
458
|
// Wait for all Suspense boundaries to resolve when streamMode is "allReady".
|
|
394
459
|
// This buffers the entire HTML before flushing — used for bots that
|
|
@@ -455,9 +520,18 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
455
520
|
const deadline = createCancelableTimeout(maxWaitMs);
|
|
456
521
|
try {
|
|
457
522
|
// No nonce (nonce'd requests never reach capture); no formState.
|
|
523
|
+
// payloadSettled: fires when the Flight payload root settles — i.e.
|
|
524
|
+
// every client-module load the payload references completed and fizz
|
|
525
|
+
// can actually emit the tree. The abort below gates on it (bounded by
|
|
526
|
+
// the same deadline): Flight byte-quiet alone is NOT render-readiness.
|
|
527
|
+
let settlePayload!: () => void;
|
|
528
|
+
const payloadSettled = new Promise<void>((resolve) => {
|
|
529
|
+
settlePayload = resolve;
|
|
530
|
+
});
|
|
458
531
|
const SsrRoot = createSsrRootComponent({
|
|
459
532
|
createFromReadableStream,
|
|
460
533
|
rscStream,
|
|
534
|
+
onPayloadSettled: settlePayload,
|
|
461
535
|
});
|
|
462
536
|
|
|
463
537
|
// Bootstrap load raced against the deadline. A load that never resolves
|
|
@@ -490,7 +564,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
490
564
|
const abortReason = { rangoShellCaptureAbort: true };
|
|
491
565
|
const prerenderPromise = prerender(<SsrRoot />, {
|
|
492
566
|
signal: controller.signal,
|
|
493
|
-
bootstrapScriptContent,
|
|
567
|
+
...resolveBootstrapOptions(bootstrapScriptContent, deps.headScripts),
|
|
494
568
|
// Abort is how capture WORKS: once the shell is quiet we abort() to
|
|
495
569
|
// freeze the prelude and let the still-pending holes postpone. React
|
|
496
570
|
// reports the abort reason for each pending boundary through onError.
|
|
@@ -526,10 +600,36 @@ export function createShellCaptureHandler<TEnv = unknown>(
|
|
|
526
600
|
// wall-clock debounce here — maxWaitMs is only the pathological guard for a
|
|
527
601
|
// shell that never goes quiet (a root postpone / hung handle).
|
|
528
602
|
await Promise.race([opts.quiesce, deadline.promise]);
|
|
603
|
+
// Then wait for fizz RENDER-READINESS, bounded by the same deadline:
|
|
604
|
+
// the payload root settles only after every client-module load the
|
|
605
|
+
// payload references completed (real module-runner I/O in dev; 100ms+
|
|
606
|
+
// on a cold graph). Flight byte-quiet does NOT imply this — a fully
|
|
607
|
+
// REPLAYED (prerendered) route's Flight stream finishes in ~1-3ms and
|
|
608
|
+
// an abort taken on quiet-plus-task-hops alone landed BEFORE fizz could
|
|
609
|
+
// emit <html>, freezing a zero-byte prelude: the eternal-MISS shape
|
|
610
|
+
// this route class showed on every cold graph (dev cold boots, GH
|
|
611
|
+
// runners) while ordinary routes — whose live handler execution keeps
|
|
612
|
+
// Flight noisy long enough — never hit it. Masked-loader holes do not
|
|
613
|
+
// block payload settlement (they postpone below the root), so this
|
|
614
|
+
// await costs a genuinely hole-y shell nothing; a payload that NEVER
|
|
615
|
+
// settles (hung handles) degrades at the deadline exactly as before.
|
|
616
|
+
// A prerender that SETTLES first (early success or a hard rejection)
|
|
617
|
+
// ends the wait immediately — fizz is already done either way.
|
|
618
|
+
const prerenderSettled = prerenderPromise.then(
|
|
619
|
+
() => {},
|
|
620
|
+
() => {},
|
|
621
|
+
);
|
|
622
|
+
await Promise.race([payloadSettled, prerenderSettled, deadline.promise]);
|
|
529
623
|
// Fixed task hops before the abort: give React's fizz worker turns to flush
|
|
530
624
|
// the now-complete shell and mark the still-pending boundaries as POSTPONED
|
|
531
|
-
// rather than errored. Deterministic (the byte set is already frozen
|
|
532
|
-
// fixed count of turns suffices — no
|
|
625
|
+
// rather than errored. Deterministic (the byte set is already frozen and
|
|
626
|
+
// the payload is settled), so a fixed count of turns suffices — no
|
|
627
|
+
// wall-clock. Ready-but-queued fizz render work (however large — a multi-MB
|
|
628
|
+
// outlined boundary) can never lose this race: tracked-postpones pings run
|
|
629
|
+
// on scheduleMicrotask, so every runnable task drains before the FIRST
|
|
630
|
+
// setTimeout hop fires; only tasks parked on genuinely pending promises
|
|
631
|
+
// (masked loaders, real per-request I/O) remain, and those are exactly the
|
|
632
|
+
// holes that must postpone (issue #702).
|
|
533
633
|
for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
|
|
534
634
|
await macrotask();
|
|
535
635
|
}
|
|
@@ -637,11 +737,6 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
637
737
|
nonce,
|
|
638
738
|
});
|
|
639
739
|
|
|
640
|
-
const resumed = await resume(<SsrRoot />, JSON.parse(postponed), {
|
|
641
|
-
onError: (error) => reportRenderError(onError, error),
|
|
642
|
-
nonce,
|
|
643
|
-
});
|
|
644
|
-
|
|
645
740
|
// EAGER injection (resume-only): the stored prelude — a complete document
|
|
646
741
|
// through </body></html> — is already on the wire ahead of this stream,
|
|
647
742
|
// so a Flight <script> is valid as the first tail byte. The stock
|
|
@@ -649,7 +744,41 @@ export function createShellResumeHandler<TEnv = unknown>(
|
|
|
649
744
|
// first hole's loaders resolve — parking the whole hydration payload
|
|
650
745
|
// (root row included) behind the slowest live loader. See
|
|
651
746
|
// inject-rsc-eager.ts for the measured failure mode.
|
|
652
|
-
|
|
747
|
+
//
|
|
748
|
+
// EAGER HANDOVER: return the injector's readable BEFORE awaiting
|
|
749
|
+
// resume(). react-dom's resume() promise resolves only when the resumed
|
|
750
|
+
// shell (everything above the postponed holes) completes — which waits
|
|
751
|
+
// on the live loaders — so `await resume(...).pipeThrough(...)` parked
|
|
752
|
+
// the already-flowing Flight bytes a second time, behind the handshake
|
|
753
|
+
// instead of the stream (measured: injector output at +9ms, first tail
|
|
754
|
+
// byte on the wire at +735ms). Piping fizz in when it materializes lets
|
|
755
|
+
// serveShellHit start draining Flight immediately; pipeTo closes the
|
|
756
|
+
// writable on completion, which runs the injector's flush (trailer). A
|
|
757
|
+
// resume() rejection aborts the writable so the response errors instead
|
|
758
|
+
// of hanging.
|
|
759
|
+
const injector = injectRSCPayloadEager(rscStream2, { nonce });
|
|
760
|
+
void (async () => {
|
|
761
|
+
try {
|
|
762
|
+
// Nonce wrap mirrors renderHTML: client references first discovered
|
|
763
|
+
// during resume (holes the shell never rendered) preinit into the
|
|
764
|
+
// resumed stream and need the per-request nonce.
|
|
765
|
+
const resumed = await runWithPreinitNonce(nonce, () =>
|
|
766
|
+
resume(<SsrRoot />, JSON.parse(postponed), {
|
|
767
|
+
onError: (error) => reportRenderError(onError, error),
|
|
768
|
+
nonce,
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
await resumed.pipeTo(injector.writable);
|
|
772
|
+
} catch (error) {
|
|
773
|
+
reportRenderError(onError, error);
|
|
774
|
+
try {
|
|
775
|
+
await injector.writable.abort(error);
|
|
776
|
+
} catch {
|
|
777
|
+
// Writable already errored/closed; the readable side has the error.
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
})();
|
|
781
|
+
return injector.readable;
|
|
653
782
|
} catch (error) {
|
|
654
783
|
reportRenderError(onError, error);
|
|
655
784
|
throw error;
|
|
@@ -81,7 +81,7 @@ export function injectRSCPayloadEager(
|
|
|
81
81
|
if (INTERNAL_RANGO_DEBUG && !loggedFirstHtml && buffered.length > 0) {
|
|
82
82
|
loggedFirstHtml = true;
|
|
83
83
|
console.log(
|
|
84
|
-
`[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms`,
|
|
84
|
+
`[Server][ppr] eager-inject: first resumed HTML batch +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
for (const chunk of buffered) {
|
|
@@ -123,7 +123,7 @@ export function injectRSCPayloadEager(
|
|
|
123
123
|
if (INTERNAL_RANGO_DEBUG && !loggedFirstFlight) {
|
|
124
124
|
loggedFirstFlight = true;
|
|
125
125
|
console.log(
|
|
126
|
-
`[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms`,
|
|
126
|
+
`[Server][ppr] eager-inject: first flight script +${Math.round(performance.now() - t0)}ms (abs ${Math.round(performance.now())})`,
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
writeScript(controller, jsExpr, nonce);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { preinitModule } from "react-dom";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* JS/CSS asset deps plugin-rsc resolves for a client reference. Structural
|
|
6
|
+
* mirror of @vitejs/plugin-rsc's ResolvedAssetDeps — deliberately not imported:
|
|
7
|
+
* @rangojs/router/ssr never imports plugin-rsc directly; every plugin binding
|
|
8
|
+
* is injected by the virtual SSR entry (see SSRDependencies).
|
|
9
|
+
*/
|
|
10
|
+
export interface ClientReferenceDeps {
|
|
11
|
+
js: string[];
|
|
12
|
+
css: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Callback shape of @vitejs/plugin-rsc/ssr's setOnClientReference. Fired
|
|
17
|
+
* (synchronously, inside the Fizz render) whenever a client reference module
|
|
18
|
+
* is accessed during SSR — the same moment plugin-rsc issues its own
|
|
19
|
+
* ReactDOM.preloadModule calls for the reference's chunks.
|
|
20
|
+
*/
|
|
21
|
+
export type OnClientReference = (reference: {
|
|
22
|
+
id: string;
|
|
23
|
+
deps: ClientReferenceDeps;
|
|
24
|
+
}) => void;
|
|
25
|
+
|
|
26
|
+
/** setOnClientReference from @vitejs/plugin-rsc/ssr (injected). */
|
|
27
|
+
export type SetOnClientReference = (
|
|
28
|
+
callback: OnClientReference | undefined,
|
|
29
|
+
) => void;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Per-request CSP nonce channel for the preinit hook. The hook is installed
|
|
33
|
+
* once per isolate while the nonce is per request, and Fizz interleaves
|
|
34
|
+
* concurrent renders at task granularity — a module-scoped variable would race
|
|
35
|
+
* across requests and stamp request A's scripts with request B's nonce. ALS is
|
|
36
|
+
* the only channel that survives from the render call into the client-reference
|
|
37
|
+
* proxy access. A lost context degrades to nonce-less preinit (CSP blocks the
|
|
38
|
+
* head script; hydration still works through the nonce'd bootstrap), never to a
|
|
39
|
+
* wrong nonce.
|
|
40
|
+
*/
|
|
41
|
+
const preinitNonceStorage = new AsyncLocalStorage<string | undefined>();
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Run a Fizz render (renderToReadableStream / prerender / resume) with the
|
|
45
|
+
* request's nonce visible to the client-reference preinit hook. Nonce-less
|
|
46
|
+
* requests (the common non-CSP configuration) skip the ALS frame entirely —
|
|
47
|
+
* getStore() on an unentered storage already returns undefined, so the hook
|
|
48
|
+
* reads the same value either way.
|
|
49
|
+
*/
|
|
50
|
+
export function runWithPreinitNonce<T>(
|
|
51
|
+
nonce: string | undefined,
|
|
52
|
+
fn: () => T,
|
|
53
|
+
): T {
|
|
54
|
+
return nonce === undefined ? fn() : preinitNonceStorage.run(nonce, fn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Upgrade plugin-rsc's client-reference modulepreload hints to executing
|
|
59
|
+
* scripts: for every JS chunk a client reference needs, emit
|
|
60
|
+
* `<script type="module" src async>` hoisted into the document head instead of
|
|
61
|
+
* only `<link rel="modulepreload">`.
|
|
62
|
+
*
|
|
63
|
+
* Why: modulepreload fetches + compiles but never executes; the chunks then
|
|
64
|
+
* execute only when the entry's hydration import walks the graph — after the
|
|
65
|
+
* whole document has streamed. preinitModule starts execution as soon as each
|
|
66
|
+
* chunk arrives, overlapping it with body streaming (the pattern Next.js uses
|
|
67
|
+
* via ReactDOM.preinit for all non-bootstrap chunks). Under PPR the preinits
|
|
68
|
+
* run during shell capture, so the executing tags live in the stored prelude
|
|
69
|
+
* and chunk execution starts on the first flushed bytes.
|
|
70
|
+
*
|
|
71
|
+
* No duplicate tags: plugin-rsc's preloadModule fires first in the same
|
|
72
|
+
* synchronous block; Fizz's preinitModuleScript then clears the queued preload
|
|
73
|
+
* chunks for that URL and adopts its credentials (ReactFizzConfigDOM,
|
|
74
|
+
* renderState.preloads.moduleScripts). Capture→resume double-emission is
|
|
75
|
+
* prevented the same way: the `moduleScriptResources[src] = null` markers
|
|
76
|
+
* serialize inside the postponed state, so the resume pass preinits only
|
|
77
|
+
* references the shell never saw.
|
|
78
|
+
*
|
|
79
|
+
* crossOrigin "" matches plugin-rsc's preloadModule creds so the upgrade path
|
|
80
|
+
* reuses the same resource instead of forking on credential mismatch.
|
|
81
|
+
*
|
|
82
|
+
* Known trades (deliberate, measured neutral-to-positive on the e2e apps —
|
|
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.
|
|
89
|
+
* - Build only: plugin-rsc's dev load path reports `js: []` per reference, so
|
|
90
|
+
* dev documents have no head chunk scripts — a client module whose module
|
|
91
|
+
* scope assumes body-parsed DOM can break in production only. The
|
|
92
|
+
* `rango({ headScripts: "preload" })` escape hatch restores hint-only.
|
|
93
|
+
* - plugin-rsc's setOnClientReference is a single-slot, last-write-wins
|
|
94
|
+
* setter: another registrant in the SSR environment silently replaces this
|
|
95
|
+
* hook (or is replaced by it). No composition API exists upstream yet.
|
|
96
|
+
*/
|
|
97
|
+
export function installClientReferencePreinit(
|
|
98
|
+
setOnClientReference: SetOnClientReference,
|
|
99
|
+
): void {
|
|
100
|
+
setOnClientReference(({ deps }) => {
|
|
101
|
+
const nonce = preinitNonceStorage.getStore();
|
|
102
|
+
for (const href of deps.js) {
|
|
103
|
+
preinitModule(href, { as: "script", crossOrigin: "", nonce });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
}
|
package/src/ssr/ssr-root.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
+
import { expandSegmentFragments } from "../segment-fragments.js";
|
|
2
3
|
import { renderSegments } from "../segment-system.js";
|
|
3
4
|
import {
|
|
4
5
|
filterSegmentOrder,
|
|
@@ -128,6 +129,18 @@ export interface SsrRootOptions {
|
|
|
128
129
|
rscStream: ReadableStream<Uint8Array>;
|
|
129
130
|
/** Nonce for CSP; propagated to NonceContext. */
|
|
130
131
|
nonce?: string;
|
|
132
|
+
/**
|
|
133
|
+
* Fires once when the Flight payload root settles (resolve OR reject) — the
|
|
134
|
+
* signal that every client-module load the payload references completed and
|
|
135
|
+
* fizz can start emitting the tree. The capture pass gates its abort on
|
|
136
|
+
* this: a fully REPLAYED (prerendered) route's Flight stream goes
|
|
137
|
+
* byte-quiet in ~1-3ms, but fizz cannot render even <html> until the
|
|
138
|
+
* module loads finish (real module-runner I/O in dev; 100ms+ on a cold
|
|
139
|
+
* graph), so an abort gated on Flight quiet alone fires first and freezes
|
|
140
|
+
* a zero-byte prelude. Masked-loader holes do NOT block this signal —
|
|
141
|
+
* they postpone below the root.
|
|
142
|
+
*/
|
|
143
|
+
onPayloadSettled?: () => void;
|
|
131
144
|
}
|
|
132
145
|
|
|
133
146
|
/**
|
|
@@ -148,7 +161,7 @@ export interface SsrRootOptions {
|
|
|
148
161
|
* re-running the whole segment-tree build unless the promise is memoized.
|
|
149
162
|
*/
|
|
150
163
|
export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
151
|
-
const { createFromReadableStream, rscStream, nonce } = opts;
|
|
164
|
+
const { createFromReadableStream, rscStream, nonce, onPayloadSettled } = opts;
|
|
152
165
|
|
|
153
166
|
let payload: Promise<RscPayload> | undefined;
|
|
154
167
|
let handlesPromise: Promise<HandleData> | undefined;
|
|
@@ -156,7 +169,27 @@ export function createSsrRootComponent(opts: SsrRootOptions): React.FC {
|
|
|
156
169
|
let rootPromise: Promise<React.ReactNode> | undefined;
|
|
157
170
|
|
|
158
171
|
return function SsrRoot() {
|
|
159
|
-
payload
|
|
172
|
+
if (payload === undefined) {
|
|
173
|
+
// Shell-HIT tails carry replayed segments as VERBATIM stored fragments
|
|
174
|
+
// (segment-fragments.ts, issue #700); expand them through this
|
|
175
|
+
// environment's deserializer before anything reads the segments. Every
|
|
176
|
+
// other payload (full render, capture, actions) has no envelopes and
|
|
177
|
+
// pays one field scan. onPayloadSettled fires AFTER expansion: the
|
|
178
|
+
// capture's fizz-readiness gate must include fragment module loads.
|
|
179
|
+
// Promise.resolve() adoption is load-bearing: some wirings (the build
|
|
180
|
+
// temp server's vendored Flight client) return a THENABLE Chunk whose
|
|
181
|
+
// .then returns undefined — chaining on it directly yields undefined.
|
|
182
|
+
payload = Promise.resolve(
|
|
183
|
+
createFromReadableStream<RscPayload>(rscStream),
|
|
184
|
+
).then(async (resolvedPayload) => {
|
|
185
|
+
await expandSegmentFragments(
|
|
186
|
+
resolvedPayload.metadata?.segments,
|
|
187
|
+
createFromReadableStream,
|
|
188
|
+
);
|
|
189
|
+
return resolvedPayload;
|
|
190
|
+
});
|
|
191
|
+
if (onPayloadSettled) payload.then(onPayloadSettled, onPayloadSettled);
|
|
192
|
+
}
|
|
160
193
|
const resolved = React.use(payload);
|
|
161
194
|
|
|
162
195
|
const themeConfig = resolved.metadata?.themeConfig ?? null;
|
|
@@ -381,6 +381,33 @@ export async function discoverRouters(
|
|
|
381
381
|
state.perRouterTrieMap = newPerRouterTrieMap;
|
|
382
382
|
state.mergedRouteTrie = newMergedRouteTrie;
|
|
383
383
|
|
|
384
|
+
// Install the route tries into the RSC realm BEFORE prerender collection.
|
|
385
|
+
// matchForPrerender resolves each enumerated URL via findMatch, and without
|
|
386
|
+
// a trie findMatch silently falls back to the insertion-order regex matcher
|
|
387
|
+
// — a root `path("/*")` declared before a nested static route then wins the
|
|
388
|
+
// match, and the artifact bakes the CATCH-ALL page under `catchAll/<hash>`
|
|
389
|
+
// while runtime (trie-ranked: wildcard last) matches the real route and
|
|
390
|
+
// misses the manifest — wrong-content bake for plain Prerender routes, a
|
|
391
|
+
// guaranteed 404 once handler eviction runs. Dev never hits this because
|
|
392
|
+
// propagateDiscoveryState (router-discovery.ts) pushes the same setters on
|
|
393
|
+
// every discovery/HMR pass; configureServer early-returns in build mode, so
|
|
394
|
+
// collection was the one findMatch consumer running trieless. Mirrors the
|
|
395
|
+
// dev perRouterSetters loop; deliberately does NOT markRouterTrieAuthoritative
|
|
396
|
+
// so a genuine trie gap keeps the regex fallback, exactly as in dev.
|
|
397
|
+
if (serverMod.setRouteTrie && newMergedRouteTrie) {
|
|
398
|
+
serverMod.setRouteTrie(newMergedRouteTrie);
|
|
399
|
+
}
|
|
400
|
+
const perRouterSetters: Array<[Map<string, unknown>, string]> = [
|
|
401
|
+
[newPerRouterManifestDataMap, "setRouterManifest"],
|
|
402
|
+
[newPerRouterTrieMap, "setRouterTrie"],
|
|
403
|
+
[newPerRouterPrecomputedMap, "setRouterPrecomputedEntries"],
|
|
404
|
+
];
|
|
405
|
+
for (const [map, fn] of perRouterSetters) {
|
|
406
|
+
const setter = serverMod[fn];
|
|
407
|
+
if (typeof setter !== "function") continue;
|
|
408
|
+
for (const [routerId, value] of map) setter(routerId, value);
|
|
409
|
+
}
|
|
410
|
+
|
|
384
411
|
// Expand prerender routes and render static handlers (build mode only)
|
|
385
412
|
await expandPrerenderRoutes(state, rscEnv, registry, allManifests);
|
|
386
413
|
await renderStaticHandlers(state, rscEnv, registry);
|
|
@@ -281,6 +281,22 @@ export async function expandPrerenderRoutes(
|
|
|
281
281
|
"__pr",
|
|
282
282
|
mainValue,
|
|
283
283
|
);
|
|
284
|
+
// Prerender + ppr composition: flag the URL as a build-time shell
|
|
285
|
+
// candidate for the post-build capture phase (producer B, #699).
|
|
286
|
+
// The payload JSON is retained in memory so that phase can seed an
|
|
287
|
+
// in-realm prerender store the capture's match() will HIT.
|
|
288
|
+
if (result.ppr !== undefined && result.ppr !== false) {
|
|
289
|
+
(state.shellCandidates ??= []).push({
|
|
290
|
+
urlPath: entry.urlPath,
|
|
291
|
+
routeName: result.routeName,
|
|
292
|
+
paramHash,
|
|
293
|
+
ppr: result.ppr === true ? true : result.ppr,
|
|
294
|
+
});
|
|
295
|
+
(state.prerenderPayloadValues ??= new Map()).set(
|
|
296
|
+
mainKey,
|
|
297
|
+
mainValue,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
284
300
|
if (result.interceptSegments?.length) {
|
|
285
301
|
const interceptKey = `${result.routeName}/${paramHash}/i`;
|
|
286
302
|
const interceptValue = JSON.stringify({
|