@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.
Files changed (46) hide show
  1. package/dist/bin/rango.js +8 -40
  2. package/dist/vite/index.js +840 -243
  3. package/package.json +6 -1
  4. package/src/browser/event-controller.ts +16 -2
  5. package/src/browser/rsc-router.tsx +11 -0
  6. package/src/cache/cache-scope.ts +41 -3
  7. package/src/cache/cf/cf-cache-store.ts +23 -0
  8. package/src/cache/handle-snapshot.ts +22 -1
  9. package/src/cache/memory-segment-store.ts +32 -0
  10. package/src/cache/segment-codec.ts +47 -0
  11. package/src/cache/shell-snapshot.ts +47 -0
  12. package/src/cache/types.ts +27 -0
  13. package/src/cache/vercel/vercel-cache-store.ts +71 -2
  14. package/src/deps/ssr.ts +4 -1
  15. package/src/prerender/build-shell-capture.ts +237 -0
  16. package/src/prerender/shell-manifest-key.ts +20 -0
  17. package/src/prerender/store.ts +10 -1
  18. package/src/router/loader-resolution.ts +16 -0
  19. package/src/router/match-api.ts +9 -2
  20. package/src/router/match-handlers.ts +13 -0
  21. package/src/router/match-middleware/cache-lookup.ts +12 -1
  22. package/src/router/prerender-match.ts +21 -0
  23. package/src/router/segment-resolution/mask-nested.ts +19 -3
  24. package/src/rsc/capture-queue.ts +67 -0
  25. package/src/rsc/rsc-rendering.ts +136 -25
  26. package/src/rsc/shell-build-manifest.ts +244 -0
  27. package/src/rsc/shell-capture.ts +194 -43
  28. package/src/segment-fragments.ts +124 -0
  29. package/src/segment-system.tsx +49 -19
  30. package/src/server/request-context.ts +112 -11
  31. package/src/ssr/index.tsx +151 -22
  32. package/src/ssr/inject-rsc-eager.ts +2 -2
  33. package/src/ssr/preinit-client-references.ts +106 -0
  34. package/src/ssr/ssr-root.tsx +35 -2
  35. package/src/vite/discovery/discover-routers.ts +27 -0
  36. package/src/vite/discovery/prerender-collection.ts +16 -0
  37. package/src/vite/discovery/shell-prerender-phase.ts +395 -0
  38. package/src/vite/discovery/state.ts +42 -0
  39. package/src/vite/index.ts +1 -0
  40. package/src/vite/plugin-types.ts +33 -0
  41. package/src/vite/plugins/version-plugin.ts +8 -0
  42. package/src/vite/plugins/virtual-entries.ts +37 -4
  43. package/src/vite/rango.ts +11 -2
  44. package/src/vite/router-discovery.ts +292 -8
  45. package/src/vite/utils/prerender-utils.ts +25 -6
  46. 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
- // Lazy allocation: only create Promise when a loader calls rendered().
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. Under
188
- * replay, Flight-quiet fires BEFORE the fizz side has consumed the instant payload
189
- * and rendered the shell to `<body>`, so the fizz needs a real buffer of turns after
190
- * quiesce otherwise the abort lands on an unrendered tree (empty prelude, root
191
- * postpone) and the sanity gate refuses. Still task-based (masked loaders never
192
- * emit, so more hops never lets a hole settle); a cold worker whose first
193
- * attempt still under-renders heals on the in-place retry. Bounded by maxWaitMs.
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
- const htmlStream = await renderToReadableStream(<SsrRoot />, {
388
- bootstrapScriptContent,
389
- formState,
390
- nonce,
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), so a
532
- // fixed count of turns suffices — no wall-clock.
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
- return resumed.pipeThrough(injectRSCPayloadEager(rscStream2, { nonce }));
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
+ }
@@ -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 ??= createFromReadableStream<RscPayload>(rscStream);
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({