@rangojs/router 0.0.0-experimental.144 → 0.0.0-experimental.145

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Capture-side nested-thenable masking — the mechanism behind "nested-promise
3
+ * shape is the liveness declaration" for BOTH bake-lane loader containers
4
+ * (loader-cache.ts) and pushed handle containers (request-context.ts
5
+ * createUseFunction).
6
+ *
7
+ * Deliberately a LEAF module: request-context needs the mask for handle
8
+ * pushes, and loader-mask (the other natural home) imports request-context —
9
+ * importing from there would cycle. This module imports only is-thenable.
10
+ */
11
+
12
+ import { isThenable } from "../../handles/is-thenable.js";
13
+
14
+ /**
15
+ * A promise that never settles — the masked stand-in for a per-request value
16
+ * during shell capture. The consuming Suspense subtree suspends forever, so
17
+ * the static prerender postpones it as a hole instead of baking a per-request
18
+ * value into the shared shell. The capture abort (`maxWaitMs` in
19
+ * captureShellHTML) bounds how long the prerender waits before it freezes the
20
+ * prelude, so this never hangs the request.
21
+ */
22
+ export function createMaskedLoaderPromise<T = unknown>(): Promise<T> {
23
+ return new Promise<T>(() => {});
24
+ }
25
+
26
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
27
+ if (typeof value !== "object" || value === null) return false;
28
+ const proto = Object.getPrototypeOf(value);
29
+ return proto === Object.prototype || proto === null;
30
+ }
31
+
32
+ /**
33
+ * Deep-copy a container with every NESTED thenable replaced by a masked
34
+ * (never-resolving) promise. Applied during shell capture to (a) bake-lane
35
+ * loader containers (loader-cache.ts) and (b) pushed handle containers
36
+ * (createUseFunction) — the two rango-owned funnels where consumers declare
37
+ * per-request data by promise SHAPE.
38
+ *
39
+ * Why: a nested promise that happened to SETTLE before the capture's quiet
40
+ * window closed used to bake its value into the SHARED shell (and, for
41
+ * loaders, the snapshot pinned it for every HIT) — per-request data frozen
42
+ * and served cross-session (found live: a storefront basket, carrying the
43
+ * capturing session's basketId/customer identifiers, served to anonymous
44
+ * visitors). The window waits for the slowest shared material on the page, so
45
+ * any real data source (a 5ms SQL read, a 200ms basket API) lost the race.
46
+ * Masking makes the consuming subtree postpone as a hole no matter when the
47
+ * promise settles: liveness by declaration, not by racing the window.
48
+ *
49
+ * Only plain objects/arrays are traversed; other values are leaves. The INPUT
50
+ * IS NEVER MUTATED — handler-side loader consumption (the consumption-lane
51
+ * rule, semantic-matrix PPR3) shares the raw container and must keep real
52
+ * values. Cycles are preserved as cycles in the copy.
53
+ */
54
+ export function maskNestedContainerThenables(
55
+ value: unknown,
56
+ seen: Map<object, unknown> = new Map(),
57
+ ): unknown {
58
+ if (isThenable(value)) return createMaskedLoaderPromise();
59
+
60
+ if (Array.isArray(value)) {
61
+ const cached = seen.get(value);
62
+ if (cached !== undefined) return cached;
63
+ const out: unknown[] = new Array(value.length);
64
+ seen.set(value, out);
65
+ for (let i = 0; i < value.length; i++) {
66
+ out[i] = maskNestedContainerThenables(value[i], seen);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ if (isPlainObject(value)) {
72
+ const cached = seen.get(value);
73
+ if (cached !== undefined) return cached;
74
+ const out: Record<string, unknown> = {};
75
+ seen.set(value, out);
76
+ for (const key of Object.keys(value)) {
77
+ out[key] = maskNestedContainerThenables(value[key], seen);
78
+ }
79
+ return out;
80
+ }
81
+
82
+ return value;
83
+ }
@@ -38,6 +38,7 @@ import {
38
38
  resolvePprConfig,
39
39
  buildShellKey,
40
40
  isValidShellHit,
41
+ hasIntactShellPayload,
41
42
  base64ToBytes,
42
43
  hasShellFamily,
43
44
  warnShellStoreMissingOnce,
@@ -164,6 +165,7 @@ async function handleRscRenderingInner<TEnv>(
164
165
  ) {
165
166
  const descriptor: ShellCaptureDescriptor = {
166
167
  key,
168
+ buildVersion: ctx.version,
167
169
  ttl: pprConfig.ttl,
168
170
  swr: pprConfig.swr,
169
171
  tags: pprConfig.tags,
@@ -177,30 +179,46 @@ async function handleRscRenderingInner<TEnv>(
177
179
  // A failing store read degrades to axis 1 (MISS), never a 500.
178
180
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
179
181
  }
180
- if (cached && isValidShellHit(cached.entry)) {
181
- // Stale (SWR) hit: serve the stale shell now, recapture in the
182
- // background (stampede-guarded + backoff inside scheduleShellCapture).
183
- if (cached.shouldRevalidate) {
184
- scheduleShellCapture(
182
+ if (cached && isValidShellHit(cached.entry, ctx.version)) {
183
+ if (!hasIntactShellPayload(cached.entry)) {
184
+ // Corrupt stored payload (undecodable prelude / unparseable
185
+ // postponed): a store-layer fault worth a diagnostic, unlike the
186
+ // silent version-mismatch lifecycle misses above. Degrade to MISS
187
+ // — pprMiss below schedules the recapture that overwrites it.
188
+ reportCacheError(
189
+ new Error(
190
+ `corrupt shell entry for "${key}": prelude/postponed failed ` +
191
+ "the integrity check; serving axis 1 and recapturing",
192
+ ),
193
+ "cache-read",
194
+ "[ShellServe] getShell",
195
+ );
196
+ } else {
197
+ // Stale (SWR) hit: serve the stale shell now, recapture in the
198
+ // background (stampede-guarded + backoff inside scheduleShellCapture).
199
+ if (cached.shouldRevalidate) {
200
+ scheduleShellCapture(
201
+ ctx,
202
+ request,
203
+ env,
204
+ url,
205
+ reqCtx,
206
+ ssrModule,
207
+ descriptor,
208
+ );
209
+ }
210
+ return serveShellHit(
185
211
  ctx,
186
212
  request,
187
213
  env,
188
214
  url,
189
215
  reqCtx,
216
+ handleStore,
190
217
  ssrModule,
218
+ cached.entry,
191
219
  descriptor,
192
220
  );
193
221
  }
194
- return serveShellHit(
195
- ctx,
196
- request,
197
- env,
198
- url,
199
- reqCtx,
200
- handleStore,
201
- ssrModule,
202
- cached.entry,
203
- );
204
222
  }
205
223
  // MISS (no entry, invalid reactVersion, or store read failure): axis 1
206
224
  // + a background capture scheduled once the response is known servable.
@@ -468,6 +486,7 @@ function serveShellHit(
468
486
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
469
487
  ssrModule: SSRModule,
470
488
  entry: ShellCacheEntry,
489
+ descriptor: ShellCaptureDescriptor,
471
490
  ): Response {
472
491
  const preludeBytes = base64ToBytes(entry.prelude);
473
492
 
@@ -493,11 +512,32 @@ function serveShellHit(
493
512
  }
494
513
  // Full Flight render per request: hydration needs the whole payload (there
495
514
  // is no Flight-side resume — a React limitation, not ours).
496
- const rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
515
+ let rscStream = ctx.renderToReadableStream<RscPayload>(payload, {
497
516
  onError: (error: unknown) => {
498
517
  ctx.callOnError(error, "rendering", { request, url, env });
499
518
  },
500
519
  });
520
+ // Timing tap: when does the Flight render produce its FIRST byte? Compared
521
+ // with the eager-inject/first-tail logs this proves whether hydration-start
522
+ // latency is genuine server work (loaders) or stream plumbing holding
523
+ // ready bytes back.
524
+ if (INTERNAL_RANGO_DEBUG) {
525
+ const tapStart = performance.now();
526
+ let first = false;
527
+ rscStream = rscStream.pipeThrough(
528
+ new TransformStream({
529
+ transform(chunk, controller) {
530
+ if (!first) {
531
+ first = true;
532
+ console.log(
533
+ `[Server][ppr] flight render: first chunk +${Math.round(performance.now() - tapStart)}ms`,
534
+ );
535
+ }
536
+ controller.enqueue(chunk);
537
+ },
538
+ }),
539
+ );
540
+ }
501
541
  return observePhase(PHASES.ssr, () =>
502
542
  ssrModule.resumeShellHTML!(rscStream, {
503
543
  postponed: entry.postponed,
@@ -541,17 +581,30 @@ function serveShellHit(
541
581
  // failure before the stream is pulled never surfaces as an unhandled rejection.
542
582
  tailPromise.catch(() => {});
543
583
 
584
+ const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
544
585
  const body = new ReadableStream<Uint8Array>({
545
586
  async start(controller) {
546
587
  controller.enqueue(preludeBytes);
588
+ if (INTERNAL_RANGO_DEBUG) {
589
+ console.log(
590
+ `[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
591
+ );
592
+ }
547
593
  try {
548
594
  const tail = await tailPromise;
549
595
  if (tail instanceof ReadableStream) {
550
596
  const reader = tail.getReader();
597
+ let firstTailChunk = true;
551
598
  try {
552
599
  for (;;) {
553
600
  const { done, value } = await reader.read();
554
601
  if (done) break;
602
+ if (INTERNAL_RANGO_DEBUG && firstTailChunk) {
603
+ firstTailChunk = false;
604
+ console.log(
605
+ `[Server][ppr] shell HIT: first tail chunk on the wire +${Math.round(performance.now() - serveStart)}ms`,
606
+ );
607
+ }
555
608
  controller.enqueue(value);
556
609
  }
557
610
  } finally {
@@ -577,6 +630,24 @@ function serveShellHit(
577
630
  }
578
631
  controller.close();
579
632
  } catch (error) {
633
+ // Self-heal on a failed tail: the pre-commit gates (isValidShellHit +
634
+ // hasIntactShellPayload) cannot catch a parseable-but-mismatched
635
+ // postponed blob or a hard render error above the holes — those throw
636
+ // here, AFTER the 200 + prelude flushed, and would otherwise re-fail on
637
+ // every request until the entry ages out (nothing else evicts it).
638
+ // Recapturing overwrites the entry with one the current server
639
+ // produced. A client disconnect mid-stream also lands here and
640
+ // schedules a spurious-but-idempotent recapture — bounded by the
641
+ // stampede guard + backoff inside scheduleShellCapture.
642
+ scheduleShellCapture(
643
+ ctx,
644
+ request,
645
+ env,
646
+ url,
647
+ reqCtx,
648
+ ssrModule,
649
+ descriptor,
650
+ );
580
651
  controller.error(error);
581
652
  }
582
653
  },
@@ -28,6 +28,8 @@ import {
28
28
  type RequestContext,
29
29
  } from "../server/request-context.js";
30
30
  import { createHandleStore, type HandleStore } from "../server/handle-store.js";
31
+ import { maskNestedContainerThenables } from "../router/segment-resolution/mask-nested.js";
32
+ import { isThenable } from "../handles/is-thenable.js";
31
33
  import type {
32
34
  ShellCacheEntry,
33
35
  SegmentCacheStore,
@@ -529,6 +531,13 @@ export function gateFlightForCapture(
529
531
  */
530
532
  export interface ShellCaptureDescriptor {
531
533
  key: string;
534
+ /**
535
+ * The RSC handler's build version (HandlerContext.version), stamped into the
536
+ * stored entry as ShellCacheEntry.buildVersion — the serve-side
537
+ * isValidShellHit gate compares it against the running build so a persistent
538
+ * store can never resume a stale build's postponed blob.
539
+ */
540
+ buildVersion: string;
532
541
  ttl?: number;
533
542
  swr?: number;
534
543
  tags?: string[];
@@ -744,6 +753,28 @@ async function attemptCapture(
744
753
 
745
754
  const freshHandleStore = createHandleStore();
746
755
  freshHandleStore.onError = reqCtx._handleStore.onError;
756
+ // Shape = liveness for handles, exactly as for bake-lane loader containers
757
+ // (mask-nested.ts): nested thenables in a pushed handle container are
758
+ // per-request by declaration, so the CAPTURE's copy masks them — the
759
+ // consuming boundary postpones as a hole regardless of settle timing,
760
+ // instead of a fast-settling nested value baking into the shared shell. A
761
+ // TOP-LEVEL promise push keeps its documented bake contract (awaited
762
+ // pre-SSR, gate held open for it), but the container it RESOLVES to gets
763
+ // the same nested masking. Wrapping THIS store's push is the single funnel:
764
+ // the store exists only for this capture attempt, so every push wrapper
765
+ // (setupLoaderAccess, createUseFunction, prerender) inherits the policy and
766
+ // the foreground store is untouched.
767
+ const rawCapturePush = freshHandleStore.push.bind(freshHandleStore);
768
+ freshHandleStore.push = (
769
+ handleName: string,
770
+ segmentId: string,
771
+ value: unknown,
772
+ ) => {
773
+ const masked = isThenable(value)
774
+ ? value.then((v: unknown) => maskNestedContainerThenables(v))
775
+ : maskNestedContainerThenables(value);
776
+ rawCapturePush(handleName, segmentId, masked);
777
+ };
747
778
 
748
779
  const derivedCtx: RequestContext = Object.create(reqCtx);
749
780
  derivedCtx._handleStore = freshHandleStore;
@@ -1104,6 +1135,7 @@ async function captureAndStoreShell(
1104
1135
  prelude: bufferToBase64(result.prelude.slice().buffer as ArrayBuffer),
1105
1136
  postponed: result.postponed,
1106
1137
  reactVersion: React.version,
1138
+ buildVersion: capture.buildVersion,
1107
1139
  // The theme this capture's payload was built with (buildFullPayload
1108
1140
  // reads reqCtx.theme off the derived context). The serve tail replays
1109
1141
  // it so the resume tree matches the frozen prelude — see
@@ -75,13 +75,44 @@ export function buildShellKey(url: URL): string {
75
75
  }
76
76
 
77
77
  /**
78
- * React version captured at prerender time is the invalidation gate: a stored
79
- * shell whose reactVersion differs from the running React cannot be resumed (the
80
- * postponed blob is build-coupled), so it is treated as a miss — the recapture
81
- * overwrites the same key and the entry otherwise ages out via TTL.
78
+ * Version gates for a stored shell: reactVersion AND buildVersion must both
79
+ * match the running server. The postponed blob encodes hole positions against
80
+ * one exact tree, so resuming it under a different React OR a different app
81
+ * build tree-mismatches inside resume() — after the 200 + prelude committed,
82
+ * with no recovery. Either mismatch is a miss: the recapture overwrites the
83
+ * same key (self-healing) and the entry otherwise ages out via TTL. An entry
84
+ * with no buildVersion (stored before the field existed) is a miss for the
85
+ * same reason — its build is unknown, so it cannot be proven resumable.
82
86
  */
83
- export function isValidShellHit(entry: ShellCacheEntry): boolean {
84
- return entry.reactVersion === React.version;
87
+ export function isValidShellHit(
88
+ entry: ShellCacheEntry,
89
+ buildVersion: string,
90
+ ): boolean {
91
+ return (
92
+ entry.reactVersion === React.version && entry.buildVersion === buildVersion
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Payload integrity gate, run BEFORE the HIT response commits: a stored entry
98
+ * whose prelude is not decodable base64 or whose postponed blob is not
99
+ * parseable JSON would otherwise throw AFTER the 200 + full static prelude
100
+ * flushed (`serveShellHit` decodes at stream construction, `resumeShellHTML`
101
+ * parses in the tail) — the client gets a visually complete page that never
102
+ * hydrates, re-served on every request until the entry ages out (no eviction
103
+ * path exists; failure schedules no recapture by itself). Checking here turns
104
+ * a corrupt entry (store-layer fault) into a plain MISS the recapture
105
+ * overwrites. Cost: one duplicate decode/parse per HIT, sub-ms against a
106
+ * prelude flush that dominates the path.
107
+ */
108
+ export function hasIntactShellPayload(entry: ShellCacheEntry): boolean {
109
+ try {
110
+ base64ToBytes(entry.prelude);
111
+ if (entry.postponed !== null) JSON.parse(entry.postponed);
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
85
116
  }
86
117
 
87
118
  /** Decode a base64 prelude back into bytes for stream composition. */
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
+ import { INTERNAL_RANGO_DEBUG } from "./internal-debug.js";
2
3
 
3
4
  /**
4
5
  * Cache of aggregate Promise.all results keyed on the first loader's
@@ -83,6 +84,23 @@ export function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
83
84
  if (loaders.length === 0) {
84
85
  return Promise.resolve([]);
85
86
  }
87
+ // Debug tap (browser only): log when each PENDING loader promise settles —
88
+ // i.e. when its data actually lands from the flight stream — independent of
89
+ // when the tree build awaits it. `.then(cb, cb)` observes on a branch, so
90
+ // rejections still propagate to the real consumers untouched.
91
+ if (INTERNAL_RANGO_DEBUG && IS_BROWSER) {
92
+ const tapStart = performance.now();
93
+ for (const loader of loaders) {
94
+ if (loader.loaderData instanceof Promise) {
95
+ const settle = (outcome: string) => () =>
96
+ console.log(
97
+ `[Browser][segments] loader ${loader.loaderId} ${outcome} @ ${Math.round(performance.now())}ms`,
98
+ { msSinceRequested: Math.round(performance.now() - tapStart) },
99
+ );
100
+ loader.loaderData.then(settle("settled"), settle("rejected"));
101
+ }
102
+ }
103
+ }
86
104
  return Promise.all(
87
105
  loaders.map((loader) =>
88
106
  loader.loaderData instanceof Promise
@@ -17,6 +17,22 @@ import {
17
17
  getMemoizedLoaderPromise,
18
18
  } from "./segment-loader-promise.js";
19
19
 
20
+ /**
21
+ * Client-only debug log for the segment tree build. Gated on the baked flag
22
+ * AND `typeof window` (renderSegments also runs during SSR/RSC, which must
23
+ * stay silent). Timestamped so tree-build steps line up with the
24
+ * `[Browser][boot]` sequence around hydrateRoot.
25
+ */
26
+ function segDebugLog(msg: string, details?: Record<string, unknown>): void {
27
+ if (!(INTERNAL_RANGO_DEBUG && typeof window === "object")) return;
28
+ const prefix = `[Browser][segments] ${msg} @ ${Math.round(performance.now())}ms`;
29
+ if (details) {
30
+ console.log(prefix, details);
31
+ return;
32
+ }
33
+ console.log(prefix);
34
+ }
35
+
20
36
  // ViewTransition is only available in React experimental.
21
37
  // Access via namespace import to avoid compile-time errors on stable React.
22
38
  const ReactViewTransition: any =
@@ -212,6 +228,17 @@ export async function renderSegments(
212
228
  rootLayout: RootLayout,
213
229
  } = options || {};
214
230
 
231
+ const segDebug = INTERNAL_RANGO_DEBUG && typeof window === "object";
232
+ const segDebugStart = segDebug ? performance.now() : 0;
233
+ if (segDebug) {
234
+ segDebugLog("renderSegments start", {
235
+ segments: segments.map((s) => `${s.id}:${s.type}`),
236
+ isAction: !!isAction,
237
+ forceAwait: !!forceAwait,
238
+ intercepts: interceptSegments?.length ?? 0,
239
+ });
240
+ }
241
+
215
242
  const temporalLazyRefs: Promise<any>[] = [];
216
243
  const normalizedSegments = restoreParallelLoaderMarkers(segments);
217
244
  const normalizedInterceptSegments = interceptSegments
@@ -273,6 +300,15 @@ export async function renderSegments(
273
300
  );
274
301
  const { component, id, params, loading } = node.segment;
275
302
 
303
+ if (segDebug) {
304
+ segDebugLog(`segment ${id}`, {
305
+ type: node.segment.type,
306
+ loaders: node.loaders.map((l) => l.loaderId).filter(Boolean),
307
+ hasLoading: loading !== undefined && loading !== null,
308
+ parallel: node.parallel.map((p) => p.id),
309
+ });
310
+ }
311
+
276
312
  // Param-agnostic keys are opt-in via the transition() DSL (see
277
313
  // inTransitionScope above). A route (and its route-owned layouts) inside a
278
314
  // transition scope drops the param from its key, so navigating between two
@@ -403,10 +439,25 @@ export async function renderSegments(
403
439
 
404
440
  if (loading !== undefined && loading !== null) {
405
441
  const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
442
+ let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
443
+ if (forceAwait || isAction) {
444
+ const awaitStart = segDebug ? performance.now() : 0;
445
+ boundaryLoaderData = await loaderDataPromise;
446
+ if (segDebug) {
447
+ segDebugLog(`segment ${id}: loaders awaited (forceAwait/action)`, {
448
+ loaderIds,
449
+ ms: Math.round(performance.now() - awaitStart),
450
+ });
451
+ }
452
+ } else if (segDebug) {
453
+ segDebugLog(
454
+ `segment ${id}: streaming loaders via LoaderBoundary (suspense)`,
455
+ { loaderIds },
456
+ );
457
+ }
406
458
  content = createElement(LoaderBoundary, {
407
459
  key: `loader-boundary-${key}`,
408
- loaderDataPromise:
409
- forceAwait || isAction ? await loaderDataPromise : loaderDataPromise,
460
+ loaderDataPromise: boundaryLoaderData,
410
461
  loaderIds,
411
462
  fallback: loading,
412
463
  outletKey: key,
@@ -430,7 +481,17 @@ export async function renderSegments(
430
481
  );
431
482
 
432
483
  const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
484
+ // No loading() on this segment, so its loader data cannot stream behind
485
+ // a Suspense fallback — the tree build BLOCKS here until the data
486
+ // arrives. On the initial document this await runs before hydrateRoot.
487
+ const layoutAwaitStart = segDebug ? performance.now() : 0;
433
488
  const resolvedData = await buildLoaderPromise(layoutLoaders);
489
+ if (segDebug) {
490
+ segDebugLog(`segment ${id}: layout loaders awaited (blocking)`, {
491
+ loaderIds: layoutLoaderIds,
492
+ ms: Math.round(performance.now() - layoutAwaitStart),
493
+ });
494
+ }
434
495
  const { loaderData, errorFallback } = decodeLoaderResults(
435
496
  resolvedData,
436
497
  layoutLoaderIds,
@@ -463,10 +524,27 @@ export async function renderSegments(
463
524
 
464
525
  p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
465
526
  const aggregated = getMemoizedLoaderPromise(ownedLoaders);
466
- p.loaderDataPromise =
467
- (forceAwait || isAction) && aggregated instanceof Promise
468
- ? await aggregated
469
- : aggregated;
527
+ if ((forceAwait || isAction) && aggregated instanceof Promise) {
528
+ const parallelAwaitStart = segDebug ? performance.now() : 0;
529
+ p.loaderDataPromise = await aggregated;
530
+ if (segDebug) {
531
+ segDebugLog(
532
+ `segment ${id}: parallel ${p.id} loaders awaited (forceAwait/action)`,
533
+ {
534
+ loaderIds: p.loaderIds,
535
+ ms: Math.round(performance.now() - parallelAwaitStart),
536
+ },
537
+ );
538
+ }
539
+ } else {
540
+ p.loaderDataPromise = aggregated;
541
+ if (segDebug) {
542
+ segDebugLog(
543
+ `segment ${id}: parallel ${p.id} loaders streaming (suspense)`,
544
+ { loaderIds: p.loaderIds },
545
+ );
546
+ }
547
+ }
470
548
  }
471
549
  }
472
550
 
@@ -524,6 +602,12 @@ export async function renderSegments(
524
602
  });
525
603
  }
526
604
 
605
+ if (segDebug) {
606
+ segDebugLog("renderSegments complete", {
607
+ ms: Math.round(performance.now() - segDebugStart),
608
+ });
609
+ }
610
+
527
611
  return result;
528
612
  }
529
613
 
package/src/ssr/index.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import { createSsrRootComponent } from "./ssr-root.js";
3
+ import { injectRSCPayloadEager } from "./inject-rsc-eager.js";
3
4
  import type { ErrorPhase } from "../types.js";
4
5
 
5
6
  /**
@@ -594,7 +595,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
594
595
  export function createShellResumeHandler<TEnv = unknown>(
595
596
  deps: SSRDependencies<TEnv>,
596
597
  ) {
597
- const { createFromReadableStream, injectRSCPayload, resume, onError } = deps;
598
+ const { createFromReadableStream, resume, onError } = deps;
598
599
 
599
600
  /**
600
601
  * @param rscStream - Fresh full Flight stream for this request.
@@ -609,11 +610,12 @@ export function createShellResumeHandler<TEnv = unknown>(
609
610
  try {
610
611
  if (postponed === null) {
611
612
  // DATA variant: the stored prelude is the complete shell. No fizz runs;
612
- // feed injectRSCPayload a minimal HTML stream so its flush appends the
613
- // fresh Flight payload scripts after the shell. The stream must emit at
614
- // least one chunk — see createDataVariantHtmlStream.
613
+ // the eager injector pumps the fresh Flight payload scripts without
614
+ // needing an HTML chunk to trigger it (the stock injector deadlocked on
615
+ // a chunkless stream — see createDataVariantHtmlStream, kept for the
616
+ // batching invariant's sake).
615
617
  return createDataVariantHtmlStream().pipeThrough(
616
- injectRSCPayload(rscStream, { nonce }),
618
+ injectRSCPayloadEager(rscStream, { nonce }),
617
619
  );
618
620
  }
619
621
 
@@ -640,7 +642,14 @@ export function createShellResumeHandler<TEnv = unknown>(
640
642
  nonce,
641
643
  });
642
644
 
643
- return resumed.pipeThrough(injectRSCPayload(rscStream2, { nonce }));
645
+ // EAGER injection (resume-only): the stored prelude — a complete document
646
+ // through </body></html> — is already on the wire ahead of this stream,
647
+ // so a Flight <script> is valid as the first tail byte. The stock
648
+ // injector waits for the first fizz chunk, which only appears when the
649
+ // first hole's loaders resolve — parking the whole hydration payload
650
+ // (root row included) behind the slowest live loader. See
651
+ // inject-rsc-eager.ts for the measured failure mode.
652
+ return resumed.pipeThrough(injectRSCPayloadEager(rscStream2, { nonce }));
644
653
  } catch (error) {
645
654
  reportRenderError(onError, error);
646
655
  throw error;