@rangojs/router 0.0.0-experimental.146 → 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.
@@ -20,10 +20,12 @@ import React from "react";
20
20
  import { bufferToBase64 } from "../cache/cf/cf-base64.js";
21
21
  import { reportCacheError } from "../cache/cache-error.js";
22
22
  import { runBackground } from "../cache/background-task.js";
23
+ import { enqueueSerializedCapture } from "./capture-queue.js";
23
24
  import { observePhase, PHASES } from "../router/instrument.js";
24
25
  import {
25
26
  runWithRequestContext,
26
27
  setRequestContextParams,
28
+ wireRenderBarrier,
27
29
  UNTRACKED_BACKGROUND_TASK,
28
30
  type RequestContext,
29
31
  } from "../server/request-context.js";
@@ -604,14 +606,20 @@ export function scheduleShellCapture(
604
606
  inFlightCaptures.delete(key);
605
607
  }
606
608
  };
609
+ // Serialize capture EXECUTION per isolate (capture-queue.ts): concurrent
610
+ // captures starve each other's task-quantized quiet windows — one grinding
611
+ // capture makes the sibling freeze a trivial prelude and store nothing
612
+ // (rotating eternal-MISS victims on GH runners). The stampede guard above
613
+ // stays per-key (dedupe while queued); the queue is cross-key.
614
+ const serializedTask = () => enqueueSerializedCapture(captureTask);
607
615
  // The capture's own task must NOT enter reqCtx._pendingBackgroundTasks: the
608
616
  // capture drains that list before rendering (the write-barrier ordering edge),
609
617
  // and awaiting its own still-running promise would burn the whole barrier
610
618
  // deadline on every capture.
611
- (captureTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
619
+ (serializedTask as { [UNTRACKED_BACKGROUND_TASK]?: boolean })[
612
620
  UNTRACKED_BACKGROUND_TASK
613
621
  ] = true;
614
- runBackground(reqCtx, captureTask);
622
+ runBackground(reqCtx, serializedTask);
615
623
  }
616
624
 
617
625
  /**
@@ -737,6 +745,9 @@ function handlerLayerIsLive(
737
745
  * - _shellCaptureRun: true — the switch loaders/cookies/headers guards read.
738
746
  * - _metricsStore: undefined so the capture never appends to the foreground's
739
747
  * (already-finalized) metrics.
748
+ * - _renderBarrier family: an own barrier wired to the fresh handle store
749
+ * (wireRenderBarrier), plus _treeHasStreaming/deadlock-guard resets — the
750
+ * capture's rendered() lifecycle is its own, not the foreground's.
740
751
  *
741
752
  * The capture is MIXED-CHAIN: its match() behaves like a normal render with
742
753
  * respect to the segment cache — cache()'d segments replay from ring 3, UNCACHED
@@ -768,6 +779,74 @@ async function attemptCapture(
768
779
  // attempt (the retry re-checks; already-settled promises are free).
769
780
  await settleTrackedBackgroundTasks(reqCtx, SHELL_CAPTURE_WRITE_BARRIER_MS);
770
781
 
782
+ const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(
783
+ reqCtx,
784
+ descriptor,
785
+ );
786
+
787
+ return runWithRequestContext(derivedCtx, async () => {
788
+ const match = await ctx.router.match(request, { env });
789
+ // A route that redirects has no shell to capture — bail (no store write, no
790
+ // retry: a redirect is deterministic).
791
+ if (match.redirect) return "redirect";
792
+
793
+ setRequestContextParams(match.params, match.routeName);
794
+
795
+ const payload = buildFullPayload(
796
+ match,
797
+ ctx,
798
+ url,
799
+ derivedCtx,
800
+ freshHandleStore,
801
+ );
802
+ const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
803
+ onError: (error: unknown) => {
804
+ ctx.callOnError(error, "rendering", { request, url, env });
805
+ },
806
+ });
807
+
808
+ // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
809
+ // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
810
+ // captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
811
+ // content (and tags propagated by async cache()/"use cache" reads) lands after
812
+ // this synchronous construction point, so snapshotting here dropped it — the
813
+ // shell-tag snapshot must sit behind the quiesce gate (issue #676).
814
+ return captureAndStoreShell(
815
+ ssrModule,
816
+ rscStream,
817
+ freshHandleStore,
818
+ derivedCtx,
819
+ descriptor,
820
+ );
821
+ });
822
+ }
823
+
824
+ /**
825
+ * The derived capture context and its fresh (mask-funneled) handle store,
826
+ * shared by BOTH shell producers: the runtime background capture
827
+ * (attemptCapture, producer A) and the build-time prerender shell capture
828
+ * (prerender/build-shell-capture.ts, producer B — issue #699). One
829
+ * implementation so the capture semantics — the nested-thenable mask funnel,
830
+ * handler-liveness bookkeeping, snapshot recording, the implicit doc-cache
831
+ * scope — cannot drift between producers.
832
+ */
833
+ export interface CaptureContextDerivation {
834
+ derivedCtx: RequestContext;
835
+ freshHandleStore: HandleStore;
836
+ }
837
+
838
+ /**
839
+ * Derive the capture request context from a base context. Producer A passes
840
+ * the foreground request's post-middleware context (the derived context
841
+ * inherits its variables/env/cookie machinery through the prototype);
842
+ * producer B passes a synthetic build-request context created by
843
+ * createRequestContext over the build env, with a fresh MemorySegmentCacheStore
844
+ * as `_cacheStore` so the recording/snapshot machinery arms identically.
845
+ */
846
+ export function deriveShellCaptureContext(
847
+ reqCtx: RequestContext<any>,
848
+ descriptor: Pick<ShellCaptureDescriptor, "ttl" | "swr">,
849
+ ): CaptureContextDerivation {
771
850
  const freshHandleStore = createHandleStore();
772
851
  freshHandleStore.onError = reqCtx._handleStore.onError;
773
852
  // Shape = liveness for handles, exactly as for bake-lane loader containers
@@ -839,6 +918,15 @@ async function attemptCapture(
839
918
 
840
919
  const derivedCtx: RequestContext = Object.create(reqCtx);
841
920
  derivedCtx._handleStore = freshHandleStore;
921
+ // Own render barrier, closure-bound to the derived ctx and the fresh store
922
+ // (issue #684, plan 009). Without this every _renderBarrier* read fell
923
+ // through the prototype to the foreground's ALREADY-RESOLVED barrier: a
924
+ // bake-lane loader's `await ctx.rendered()` resolved instantly and
925
+ // ctx.use(handle) read the FOREGROUND handle snapshot — foreground
926
+ // per-request handle data could bake into the shared shell. wireRenderBarrier
927
+ // also resets _treeHasStreaming (recomputed for the capture's tree) and the
928
+ // deadlock-guard fields as own properties.
929
+ wireRenderBarrier(derivedCtx, freshHandleStore);
842
930
  derivedCtx._shellCaptureLoaderHandleValues = loaderScopedPushValues;
843
931
  derivedCtx._requestTags = new Set<string>();
844
932
  // Own explicit-store registry: cache-store resolutions during the capture
@@ -904,41 +992,7 @@ async function attemptCapture(
904
992
  };
905
993
  }
906
994
 
907
- return runWithRequestContext(derivedCtx, async () => {
908
- const match = await ctx.router.match(request, { env });
909
- // A route that redirects has no shell to capture — bail (no store write, no
910
- // retry: a redirect is deterministic).
911
- if (match.redirect) return "redirect";
912
-
913
- setRequestContextParams(match.params, match.routeName);
914
-
915
- const payload = buildFullPayload(
916
- match,
917
- ctx,
918
- url,
919
- derivedCtx,
920
- freshHandleStore,
921
- );
922
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
923
- onError: (error: unknown) => {
924
- ctx.callOnError(error, "rendering", { request, url, env });
925
- },
926
- });
927
-
928
- // Pass the descriptor with its STATIC ppr.tags unchanged. The shell's own
929
- // render-recorded tags are snapshotted at the putShell WRITE BARRIER inside
930
- // captureAndStoreShell, not here: a tag recorded AFTER an await in async shell
931
- // content (and tags propagated by async cache()/"use cache" reads) lands after
932
- // this synchronous construction point, so snapshotting here dropped it — the
933
- // shell-tag snapshot must sit behind the quiesce gate (issue #676).
934
- return captureAndStoreShell(
935
- ssrModule,
936
- rscStream,
937
- freshHandleStore,
938
- derivedCtx,
939
- descriptor,
940
- );
941
- });
995
+ return { derivedCtx, freshHandleStore };
942
996
  }
943
997
 
944
998
  /**
@@ -1262,8 +1316,15 @@ async function captureAndStoreShell(
1262
1316
  }
1263
1317
  }
1264
1318
 
1265
- // Exported for unit tests that drive the capture core directly.
1266
- export { runShellCapture, captureAndStoreShell };
1319
+ // Exported for unit tests that drive the capture core directly, and — with the
1320
+ // cold-graph retry pieces — for producer B (prerender/build-shell-capture.ts),
1321
+ // which mirrors the runtime capture's retry-in-place with the same delay.
1322
+ export {
1323
+ runShellCapture,
1324
+ captureAndStoreShell,
1325
+ delay,
1326
+ SHELL_CAPTURE_RETRY_DELAY_MS,
1327
+ };
1267
1328
 
1268
1329
  // Exported for unit tests that pin the refused-capture backoff policy directly
1269
1330
  // (dev cap vs production exponential growth, stored-clears, cold-start re-probe).
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Segment Flight-fragment envelopes (PPR fast-path payload splice, issue #700).
3
+ *
4
+ * On a shell-HIT tail, replayed (baked) segments do NOT round-trip through the
5
+ * server codec (deserialize -> re-serialize) per request. The stored RSC
6
+ * fragment STRINGS ride the outgoing payload verbatim inside these envelopes
7
+ * — the outer Flight render copies a string instead of re-serializing a whole
8
+ * element tree — and the CONSUMER expands each envelope through its own Flight
9
+ * deserializer: the SSR resume pass (ssr/ssr-root.tsx) and browser hydration
10
+ * (browser/rsc-router.tsx). Each fragment is its own row space, decoded
11
+ * independently, exactly like the segment codec's per-record decode — so
12
+ * there is no Flight row-id collision or shared-row dedupe hazard.
13
+ *
14
+ * Envelopes appear ONLY on fields that hold ReactNodes (component / layout /
15
+ * loading). A plain object is never a valid ReactNode, so the marker cannot
16
+ * collide with legitimate segment content; loader data fields (consumer data,
17
+ * any shape) are never enveloped.
18
+ *
19
+ * This module is shared client/server code (browser + SSR + RSC import it) —
20
+ * it must stay dependency-free: no plugin-rsc, no request-context.
21
+ */
22
+
23
+ import type { ResolvedSegment } from "./types.js";
24
+
25
+ /**
26
+ * One RSC-encoded fragment traveling inside an RscPayload segment field.
27
+ * `f` is the stored Flight document (segment-codec output) for that field.
28
+ */
29
+ export interface SegmentFlightFragment {
30
+ __rangoFragment: 1;
31
+ f: string;
32
+ }
33
+
34
+ /** Wrap a stored fragment string for the wire. */
35
+ export function segmentFragment(encoded: string): SegmentFlightFragment {
36
+ return { __rangoFragment: 1, f: encoded };
37
+ }
38
+
39
+ /** True iff `value` is a fragment envelope (see collision note in the header). */
40
+ export function isSegmentFragment(
41
+ value: unknown,
42
+ ): value is SegmentFlightFragment {
43
+ return (
44
+ typeof value === "object" &&
45
+ value !== null &&
46
+ (value as { __rangoFragment?: unknown }).__rangoFragment === 1 &&
47
+ typeof (value as { f?: unknown }).f === "string"
48
+ );
49
+ }
50
+
51
+ /**
52
+ * A consumer-side Flight deserializer: the browser's or the SSR runtime's
53
+ * createFromReadableStream (each resolves client references through its own
54
+ * module map, exactly as it does for the outer payload stream). Non-generic
55
+ * so both environments' generic createFromReadableStream signatures assign
56
+ * directly.
57
+ */
58
+ export type FragmentDecoder = (
59
+ stream: ReadableStream<Uint8Array>,
60
+ ) => Promise<unknown>;
61
+
62
+ /** One-chunk byte stream over an encoded fragment (local: this module must not
63
+ * import segment-codec, which pulls plugin-rsc into client bundles). */
64
+ function fragmentToStream(encoded: string): ReadableStream<Uint8Array> {
65
+ const bytes = new TextEncoder().encode(encoded);
66
+ return new ReadableStream<Uint8Array>({
67
+ start(controller) {
68
+ controller.enqueue(bytes);
69
+ controller.close();
70
+ },
71
+ });
72
+ }
73
+
74
+ /** The segment fields that may carry an envelope (ReactNode fields only). */
75
+ const FRAGMENT_FIELDS = ["component", "layout", "loading"] as const;
76
+
77
+ /**
78
+ * Expand every fragment envelope in `segments` IN PLACE via `decode`,
79
+ * in parallel. A payload with no envelopes (every non-shell-HIT payload) costs
80
+ * one synchronous field scan. A failing fragment decode rejects the whole
81
+ * expansion: the caller's payload promise rejects, which on the SSR side
82
+ * errors the HIT tail (serveShellHit then schedules the healing recapture) —
83
+ * the same posture as a parseable-but-mismatched postponed blob.
84
+ */
85
+ export async function expandSegmentFragments(
86
+ segments: ResolvedSegment[] | undefined,
87
+ decode: FragmentDecoder,
88
+ ): Promise<void> {
89
+ if (!segments || segments.length === 0) return;
90
+ const decodes: Promise<void>[] = [];
91
+ for (const segment of segments) {
92
+ for (const field of FRAGMENT_FIELDS) {
93
+ const value = segment[field];
94
+ if (isSegmentFragment(value)) {
95
+ // Promise.resolve() adoption is load-bearing: some deserializers
96
+ // (react-server-dom's Flight client) return a THENABLE Chunk whose
97
+ // .then returns undefined — chaining directly would push undefined
98
+ // into the barrier and Promise.all would not wait for the decode.
99
+ decodes.push(
100
+ Promise.resolve(decode(fragmentToStream(value.f))).then((node) => {
101
+ segment[field] = node as ResolvedSegment[typeof field];
102
+ }),
103
+ );
104
+ }
105
+ }
106
+ }
107
+ if (decodes.length > 0) await Promise.all(decodes);
108
+ }
109
+
110
+ /**
111
+ * True when any segment carries an unexpanded envelope. Cheap scan used by
112
+ * consumers that only need to know whether an expansion pass is required.
113
+ */
114
+ export function hasSegmentFragments(
115
+ segments: ResolvedSegment[] | undefined,
116
+ ): boolean {
117
+ if (!segments) return false;
118
+ for (const segment of segments) {
119
+ for (const field of FRAGMENT_FIELDS) {
120
+ if (isSegmentFragment(segment[field])) return true;
121
+ }
122
+ }
123
+ return false;
124
+ }
@@ -228,6 +228,19 @@ export interface RequestContext<
228
228
  store?: SegmentCacheStore;
229
229
  };
230
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
+
231
244
  /**
232
245
  * @internal Handler-layer liveness observed DURING a shell capture, from
233
246
  * three sources: (a) the capture handle-store push wrapper (shell-capture.ts)
@@ -575,6 +588,7 @@ export type PublicRequestContext<
575
588
  | "_transitionWhen"
576
589
  | "_cacheStore"
577
590
  | "_shellCaptureRun"
591
+ | "_shellFragmentPayload"
578
592
  | "_shellCaptureGuardTrippedLoaderId"
579
593
  | "_explicitTaggedStores"
580
594
  | "_requestTags"
@@ -1105,10 +1119,56 @@ export function createRequestContext<TEnv>(
1105
1119
  reverse: createReverseFunction(getGlobalRouteMap(), undefined, {}),
1106
1120
  };
1107
1121
 
1108
- // 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().
1109
1170
  let barrierResolved = false;
1110
1171
  let resolveBarrier: (() => void) | undefined;
1111
- ctx._renderBarrier = null as any;
1112
1172
  ctx._resolveRenderBarrier = (
1113
1173
  segments: Array<{ type: string; id: string }>,
1114
1174
  ) => {
@@ -1136,6 +1196,9 @@ export function createRequestContext<TEnv>(
1136
1196
  }
1137
1197
  if (resolveBarrier) resolveBarrier();
1138
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.
1139
1202
  Object.defineProperty(ctx, "_renderBarrier", {
1140
1203
  get() {
1141
1204
  const p = barrierResolved
@@ -1152,15 +1215,6 @@ export function createRequestContext<TEnv>(
1152
1215
  },
1153
1216
  configurable: true,
1154
1217
  });
1155
-
1156
- ctx.use = createUseFunction({
1157
- handleStore,
1158
- loaderPromises,
1159
- getContext: () => ctx,
1160
- });
1161
-
1162
- (ctx as any)[NOCACHE_SYMBOL] = true;
1163
- return ctx;
1164
1218
  }
1165
1219
 
1166
1220
  // Capture the Max-Age value so it can be parsed numerically. A leading zero
package/src/ssr/index.tsx CHANGED
@@ -201,13 +201,16 @@ const DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS = 5000;
201
201
  * cached segments that are ALREADY serialized, so it emits the whole shell payload
202
202
  * in the first tick and the gate declares quiesce almost immediately (~a few ms).
203
203
  * On the old fresh-execution path the Flight dribbled out as handlers ran, so
204
- * Flight-quiet effectively meant "the shell has rendered" and 2 hops sufficed. Under
205
- * replay, Flight-quiet fires BEFORE the fizz side has consumed the instant payload
206
- * and rendered the shell to `<body>`, so the fizz needs a real buffer of turns after
207
- * quiesce otherwise the abort lands on an unrendered tree (empty prelude, root
208
- * postpone) and the sanity gate refuses. Still task-based (masked loaders never
209
- * emit, so more hops never lets a hole settle); a cold worker whose first
210
- * 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.
211
214
  */
212
215
  const POST_QUIESCE_TASK_HOPS = 16;
213
216
 
@@ -517,9 +520,18 @@ export function createShellCaptureHandler<TEnv = unknown>(
517
520
  const deadline = createCancelableTimeout(maxWaitMs);
518
521
  try {
519
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
+ });
520
531
  const SsrRoot = createSsrRootComponent({
521
532
  createFromReadableStream,
522
533
  rscStream,
534
+ onPayloadSettled: settlePayload,
523
535
  });
524
536
 
525
537
  // Bootstrap load raced against the deadline. A load that never resolves
@@ -588,10 +600,36 @@ export function createShellCaptureHandler<TEnv = unknown>(
588
600
  // wall-clock debounce here — maxWaitMs is only the pathological guard for a
589
601
  // shell that never goes quiet (a root postpone / hung handle).
590
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]);
591
623
  // Fixed task hops before the abort: give React's fizz worker turns to flush
592
624
  // the now-complete shell and mark the still-pending boundaries as POSTPONED
593
- // rather than errored. Deterministic (the byte set is already frozen), so a
594
- // 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).
595
633
  for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
596
634
  await macrotask();
597
635
  }
@@ -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({