@rangojs/router 0.0.0-experimental.143 → 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.
Files changed (51) hide show
  1. package/dist/vite/index.js +24 -6
  2. package/package.json +2 -2
  3. package/skills/cache-guide/SKILL.md +3 -1
  4. package/skills/caching/SKILL.md +23 -2
  5. package/skills/catalog.json +6 -0
  6. package/skills/defer-hydration/SKILL.md +235 -0
  7. package/skills/loader/SKILL.md +5 -0
  8. package/skills/migrate-nextjs/SKILL.md +4 -2
  9. package/skills/parallel/SKILL.md +2 -0
  10. package/skills/ppr/SKILL.md +63 -33
  11. package/skills/rango/SKILL.md +10 -0
  12. package/skills/use-cache/SKILL.md +12 -2
  13. package/src/browser/logging.ts +18 -0
  14. package/src/browser/partial-update.ts +7 -0
  15. package/src/browser/rsc-router.tsx +43 -0
  16. package/src/cache/cache-key-utils.ts +29 -0
  17. package/src/cache/cache-runtime.ts +41 -51
  18. package/src/cache/cache-scope.ts +2 -17
  19. package/src/cache/cache-tag.ts +60 -14
  20. package/src/cache/cf/cf-cache-store.ts +58 -20
  21. package/src/cache/document-cache.ts +17 -11
  22. package/src/cache/types.ts +18 -4
  23. package/src/cache/vercel/vercel-cache-store.ts +15 -20
  24. package/src/redirect-origin.ts +14 -0
  25. package/src/route-map-builder.ts +17 -3
  26. package/src/router/lazy-includes.ts +8 -2
  27. package/src/router/loader-resolution.ts +14 -2
  28. package/src/router/match-handlers.ts +11 -6
  29. package/src/router/middleware.ts +4 -1
  30. package/src/router/segment-resolution/loader-cache.ts +19 -3
  31. package/src/router/segment-resolution/loader-mask.ts +4 -11
  32. package/src/router/segment-resolution/loader-snapshot.ts +14 -6
  33. package/src/router/segment-resolution/mask-nested.ts +83 -0
  34. package/src/router/telemetry.ts +9 -1
  35. package/src/router.ts +7 -8
  36. package/src/rsc/handler.ts +9 -2
  37. package/src/rsc/redirect-guard.ts +2 -1
  38. package/src/rsc/rsc-rendering.ts +122 -18
  39. package/src/rsc/shell-capture.ts +125 -20
  40. package/src/rsc/shell-serve.ts +37 -6
  41. package/src/segment-loader-promise.ts +18 -0
  42. package/src/segment-system.tsx +90 -6
  43. package/src/server/context.ts +47 -9
  44. package/src/server/cookie-store.ts +26 -5
  45. package/src/server/request-context.ts +22 -0
  46. package/src/ssr/index.tsx +160 -113
  47. package/src/ssr/inject-rsc-eager.ts +167 -0
  48. package/src/testing/dispatch.ts +7 -0
  49. package/src/vite/index.ts +7 -0
  50. package/src/vite/inject-client-debug.ts +64 -12
  51. package/src/vite/router-discovery.ts +9 -1
@@ -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
 
@@ -770,14 +770,23 @@ const loaderScopeALS: AsyncLocalStorage<{ active: true }> = ((
770
770
 
771
771
  // Purity-only scope: marks that a loader FUNCTION BODY is executing, regardless
772
772
  // of how the loader was invoked (DSL via runInsideLoaderScope, or handler-
773
- // invoked via ctx.use). Consulted ONLY by isInsideCacheScope() to exempt
774
- // request-scoped reads. It deliberately does NOT affect isInsideLoaderScope(),
775
- // so rendered()/barrier/deadlock gating (which must distinguish DSL from
776
- // handler-invoked loaders) is unchanged.
773
+ // invoked via ctx.use). Consulted by isInsideCacheScope() to exempt
774
+ // request-scoped reads, by getCurrentLoaderBodyId() for guard-warning
775
+ // attribution, and by isInsideHandlerInvokedLoaderBody() for the
776
+ // consumption-lane rule (the shell-capture guard exemption). It deliberately
777
+ // does NOT affect isInsideLoaderScope(), so rendered()/barrier/deadlock
778
+ // gating (which must distinguish DSL from handler-invoked loaders) is
779
+ // unchanged.
777
780
  const LOADER_BODY_SCOPE_KEY = Symbol.for("rangojs-router:loader-body-scope");
778
- const loaderBodyScopeALS: AsyncLocalStorage<{ active: true }> = ((
779
- globalThis as any
780
- )[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{ active: true }>());
781
+ const loaderBodyScopeALS: AsyncLocalStorage<{
782
+ active: true;
783
+ loaderId?: string;
784
+ handlerInvoked?: boolean;
785
+ }> = ((globalThis as any)[LOADER_BODY_SCOPE_KEY] ??= new AsyncLocalStorage<{
786
+ active: true;
787
+ loaderId?: string;
788
+ handlerInvoked?: boolean;
789
+ }>());
781
790
 
782
791
  /**
783
792
  * Check if the current execution is inside a cache() DSL boundary.
@@ -822,8 +831,37 @@ export function runInsideLoaderScope<T>(fn: () => T): T {
822
831
  * and handler-invoked via ctx.use) so request-scoped reads inside a loader
823
832
  * never trip the cache-scope guards — loaders always run fresh.
824
833
  */
825
- export function runInsideLoaderBodyScope<T>(fn: () => T): T {
826
- return loaderBodyScopeALS.run({ active: true }, fn);
834
+ export function runInsideLoaderBodyScope<T>(
835
+ fn: () => T,
836
+ loaderId?: string,
837
+ handlerInvoked?: boolean,
838
+ ): T {
839
+ return loaderBodyScopeALS.run({ active: true, loaderId, handlerInvoked }, fn);
840
+ }
841
+
842
+ /**
843
+ * The $$id of the loader whose body is currently executing, or undefined
844
+ * outside any loader body. Used by the shell-capture identity guard
845
+ * (cookie-store.ts) so its refusal warning can name the loader that read
846
+ * cookies()/headers() instead of blaming a lane it cannot see — the old
847
+ * hardcoded "bake-lane loader" text misled a live-lane debugging session
848
+ * (issue #672, secondary).
849
+ */
850
+ export function getCurrentLoaderBodyId(): string | undefined {
851
+ return loaderBodyScopeALS.getStore()?.loaderId;
852
+ }
853
+
854
+ /**
855
+ * True while a HANDLER-invoked loader body (`await ctx.use(Loader)` from a
856
+ * handler, not the DSL segment funnel) is executing. The consumption-lane
857
+ * rule keys off this: handler consumption yields a BAKED copy in every shared
858
+ * artifact — cache(), "use cache", and the PPR shell — so the shell-capture
859
+ * identity guard (cookie-store.ts) permits cookies()/headers() here, exactly
860
+ * like the cache-purity guards do. DSL segment loaders (live lane masked at
861
+ * capture, bake lane guarded) never set the flag.
862
+ */
863
+ export function isInsideHandlerInvokedLoaderBody(): boolean {
864
+ return loaderBodyScopeALS.getStore()?.handlerInvoked === true;
827
865
  }
828
866
 
829
867
  // Scope for handle PUSH CALLBACKS (push(() => ...), including async ones).
@@ -9,7 +9,11 @@
9
9
 
10
10
  import type { CookieOptions } from "../router/middleware-types.js";
11
11
  import { getRequestContext, _getRequestContext } from "./request-context.js";
12
- import { isInsideCacheScope } from "./context.js";
12
+ import {
13
+ isInsideCacheScope,
14
+ getCurrentLoaderBodyId,
15
+ isInsideHandlerInvokedLoaderBody,
16
+ } from "./context.js";
13
17
  import { INSIDE_CACHE_EXEC } from "../cache/taint.js";
14
18
 
15
19
  /**
@@ -139,9 +143,19 @@ function assertNotInsideCacheContext(ctx: unknown, fnName: string): void {
139
143
  * shell-capture.ts). The captured shell prelude is shared across every user
140
144
  * hitting the URL, so a request-scoped read here would bake one user's
141
145
  * cookies/headers into markup served to others — same hazard as the cache
142
- * scopes above, at the document tier. Loaders need no exemption: they are
143
- * masked (never executed) during capture and remain the per-request holes of
144
- * the shell.
146
+ * scopes above, at the document tier. DSL segment loaders need no exemption:
147
+ * the live lane is masked (never executed) during capture, and the bake lane
148
+ * is exactly what this guard exists for.
149
+ *
150
+ * HANDLER-INVOKED loader bodies (`await ctx.use(Loader)` from a handler) are
151
+ * EXEMPT — the consumption-lane rule: handler consumption yields a BAKED
152
+ * shared copy in every artifact tier, and the cache-purity guards above
153
+ * already permit identity reads there (cache()/"use cache" bake the same
154
+ * reads today). Guarding only the PPR tier made the same code legal under
155
+ * cache() but capture-refusing under ppr (issue #672 / #674). The trade is
156
+ * documented: an identity read in a handler-consumed loader bakes the CAPTURE
157
+ * request's value into the shared shell; client-side consumption (useLoader)
158
+ * is the live lane.
145
159
  *
146
160
  * Keys off `_shellCaptureRun`, NOT the `_shellCapture` descriptor: the descriptor
147
161
  * is also present during the FOREGROUND render (it means "a capture is wanted"),
@@ -164,12 +178,19 @@ function assertNotInsideShellCapture(ctx: unknown, fnName: string): void {
164
178
  typeof ctx === "object" &&
165
179
  (ctx as { _shellCaptureRun?: unknown })._shellCaptureRun === true
166
180
  ) {
181
+ if (isInsideHandlerInvokedLoaderBody()) return;
167
182
  // Flag the capture context BEFORE throwing: inside an executing bake-lane
168
183
  // loader this throw is swallowed by wrapLoaderPromise into per-loader error
169
184
  // UI, which would bake silently into the shared shell. The capture checks
170
- // the flag after the render and refuses (shell-capture.ts).
185
+ // the flag after the render and refuses (shell-capture.ts). Also record
186
+ // WHICH loader body (if any) made the read, so the refusal warning can
187
+ // name the real source instead of hardcoding a lane — the read may come
188
+ // from a bake-lane loader OR from handler/render code (issue #672).
171
189
  (ctx as { _shellCaptureGuardTripped?: string })._shellCaptureGuardTripped =
172
190
  fnName;
191
+ (
192
+ ctx as { _shellCaptureGuardTrippedLoaderId?: string }
193
+ )._shellCaptureGuardTrippedLoaderId = getCurrentLoaderBodyId();
173
194
  throw new Error(
174
195
  `${fnName}() cannot be called while capturing a shared shell ` +
175
196
  `(shell-cache middleware). The captured shell is served to every user ` +
@@ -220,6 +220,16 @@ export interface RequestContext<
220
220
  */
221
221
  _shellCaptureGuardTripped?: string;
222
222
 
223
+ /**
224
+ * @internal The loader $$id whose BODY was executing when the capture guard
225
+ * tripped (read off the loader-body ALS scope at trip time), or undefined
226
+ * when the read came from handler/render code. Only used to make the
227
+ * once-per-key refusal warning name the real source — the old text
228
+ * hardcoded "a bake-lane loader", which misattributed handler-land reads
229
+ * and sent users debugging the wrong lane (issue #672, secondary).
230
+ */
231
+ _shellCaptureGuardTrippedLoaderId?: string;
232
+
223
233
  /**
224
234
  * @internal Handler-owned registry of explicit per-scope stores from
225
235
  * cache({ store }). Created once per createRSCHandler() and threaded into
@@ -468,6 +478,15 @@ export interface RequestContext<
468
478
  /** @internal Request-scoped performance metrics store */
469
479
  _metricsStore?: MetricsStore;
470
480
 
481
+ /**
482
+ * @internal True request entry timestamp (performance.now() at handler entry).
483
+ * Set once at request-context creation (rsc/handler.ts) so a metrics store
484
+ * created MID-request — ctx.debugPerformance() or the getMetricsStore wrapper —
485
+ * anchors its timeline to the real request start instead of the opt-in moment,
486
+ * keeping phases that began before the opt-in at their true (non-negative) offset.
487
+ */
488
+ _handlerStart?: number;
489
+
471
490
  /** @internal Resolved platform phase-span tracing for this request (Cloudflare or OTel) */
472
491
  _tracing?: ResolvedTracing;
473
492
 
@@ -509,6 +528,7 @@ export type PublicRequestContext<
509
528
  | "_transitionWhen"
510
529
  | "_cacheStore"
511
530
  | "_shellCaptureRun"
531
+ | "_shellCaptureGuardTrippedLoaderId"
512
532
  | "_explicitTaggedStores"
513
533
  | "_requestTags"
514
534
  | "_cacheProfiles"
@@ -536,6 +556,7 @@ export type PublicRequestContext<
536
556
  | "_reportBackgroundError"
537
557
  | "_debugPerformance"
538
558
  | "_metricsStore"
559
+ | "_handlerStart"
539
560
  | "_basename"
540
561
  | "_setStatus"
541
562
  | "_rotateStateCookie"
@@ -1028,6 +1049,7 @@ export function createRequestContext<TEnv>(
1028
1049
 
1029
1050
  _reportedErrors: new WeakSet<object>(),
1030
1051
  _metricsStore: undefined,
1052
+ _handlerStart: undefined,
1031
1053
 
1032
1054
  _renderBarrier: null as any,
1033
1055
  _resolveRenderBarrier: null as any,
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
  /**
@@ -247,11 +248,22 @@ async function readStreamToUint8Array(
247
248
  const reader = stream.getReader();
248
249
  const chunks: Uint8Array[] = [];
249
250
  let total = 0;
250
- while (true) {
251
- const { done, value } = await reader.read();
252
- if (done) break;
253
- chunks.push(value);
254
- total += value.length;
251
+ try {
252
+ while (true) {
253
+ const { done, value } = await reader.read();
254
+ if (done) break;
255
+ chunks.push(value);
256
+ total += value.length;
257
+ }
258
+ } catch (error) {
259
+ // Mid-read abort path (documented): the prelude stream errors with our
260
+ // abort reason while we are still reading it. Cancel the source before
261
+ // rethrowing so it is not left uncancelled; releaseLock always runs in
262
+ // finally. Mirrors src/rsc/rsc-rendering.ts's serve-side reader cleanup.
263
+ reader.cancel(error).catch(() => {});
264
+ throw error;
265
+ } finally {
266
+ reader.releaseLock();
255
267
  }
256
268
  const out = new Uint8Array(total);
257
269
  let offset = 0;
@@ -434,113 +446,140 @@ export function createShellCaptureHandler<TEnv = unknown>(
434
446
  ): Promise<ShellCaptureResult | null> {
435
447
  const maxWaitMs = opts.maxWaitMs ?? DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS;
436
448
 
437
- // No nonce (nonce'd requests never reach capture); no formState.
438
- const SsrRoot = createSsrRootComponent({
439
- createFromReadableStream,
440
- rscStream,
441
- });
442
-
443
- const bootstrapScriptContent = await loadBootstrapScriptContent();
444
-
445
- // Start prerender first, then run the abort schedule concurrently. When
446
- // holes are pending, prerender's promise settles only after abort(); when
447
- // the shell completes with no holes it settles on its own and the later
448
- // abort() is a harmless no-op (the DATA variant).
449
- const controller = new AbortController();
450
- const prerenderPromise = prerender(<SsrRoot />, {
451
- signal: controller.signal,
452
- bootstrapScriptContent,
453
- // Abort is how capture WORKS: once the shell is quiet we abort() to freeze
454
- // the prelude and let the still-pending holes postpone. React reports the
455
- // abort reason for each pending boundary through onError. Without an onError
456
- // here React falls back to console.error, so every capture that still has a
457
- // live hole at abort time (the normal case, and every cold-module capture
458
- // where the shell is not yet done) dumps a DOMException [AbortError] stack —
459
- // once per pending boundary. That is EXPECTED degradation, not a failure, so
460
- // swallow the abort here. Genuine shell render errors (a component throwing)
461
- // are NOT the abort and still surface through the deps.onError channel, the
462
- // same one renderHTML uses. See docs/design/ppr-shell-resume.md.
463
- onError: (error: unknown) => {
464
- if (
465
- controller.signal.aborted &&
466
- (error as { name?: string } | null)?.name === "AbortError"
467
- ) {
468
- return;
469
- }
470
- reportRenderError(onError, error);
471
- },
472
- });
473
- // Pre-attach a no-op catch: the real await sits AFTER quiesce + the
474
- // post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
475
- // loader tripping the identity guard within milliseconds) would otherwise
476
- // spend several turns handler-less and crash the worker as an unhandled
477
- // rejection. The actual rejection handling still happens at the await
478
- // below; this parallel handler only keeps the gap crash-free.
479
- prerenderPromise.catch(() => {});
480
-
481
- // Wait for the caller's quiesce signal. By the time it resolves the Flight
482
- // input is byte-quiet and FROZEN by the capture gate (shell-capture.ts
483
- // gateFlightForCapture), so there is no wall-clock debounce here — maxWaitMs
484
- // is only the pathological guard for a shell that never goes quiet (a root
485
- // postpone / hung handle), and should never fire in tests.
486
- const timer = createCancelableTimeout(maxWaitMs);
487
- try {
488
- await Promise.race([opts.quiesce, timer.promise]);
489
- } finally {
490
- timer.cancel();
491
- }
492
- // Fixed task hops before the abort: give React's fizz worker turns to flush
493
- // the now-complete shell and mark the still-pending boundaries as POSTPONED
494
- // rather than errored. Deterministic (the byte set is already frozen), so a
495
- // fixed count of turns suffices — no wall-clock.
496
- for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
497
- await macrotask();
498
- }
499
- controller.abort();
500
-
501
- // A hard prerender rejection (fatal shell error) propagates. Expected
502
- // degradation surfaces three ways and all return null: a trivial prelude
503
- // (sanity gate below), the prerender REJECTING with an AbortError, or the
504
- // prelude STREAM erroring with the abort reason mid-read — both abort
505
- // shapes happen when our own abort lands before the shell completed (seen
506
- // on dev cold paths, where module transform / first-render latency
507
- // outlasts flight quiesce; a later request re-captures against warm
508
- // modules and succeeds).
509
- let prelude: Uint8Array;
510
- let postponed: unknown;
449
+ // Arm the maxWaitMs deadline BEFORE the first await so it bounds the ENTIRE
450
+ // capture, the bootstrap-script load included. loadBootstrapScriptContent()
451
+ // used to run before the timer, so a hung/slow bootstrap load hung
452
+ // captureShellHTML with no upper bound and held the background capture task
453
+ // open. One deadline, shared by the bootstrap race below and the quiesce
454
+ // race, keeps the whole path "bounded by maxWaitMs like every quiesce input".
455
+ const deadline = createCancelableTimeout(maxWaitMs);
511
456
  try {
512
- const result = await prerenderPromise;
513
- prelude = await readStreamToUint8Array(result.prelude);
514
- postponed = result.postponed;
515
- } catch (error) {
516
- // Name-based match: the rejection is a DOMException on workerd/Node,
517
- // which is not an Error subclass there, so instanceof Error would let
518
- // the abort escape as a spurious reported error.
519
- if (
520
- controller.signal.aborted &&
521
- (error as { name?: string } | null)?.name === "AbortError"
522
- ) {
457
+ // No nonce (nonce'd requests never reach capture); no formState.
458
+ const SsrRoot = createSsrRootComponent({
459
+ createFromReadableStream,
460
+ rscStream,
461
+ });
462
+
463
+ // Bootstrap load raced against the deadline. A load that never resolves
464
+ // within maxWaitMs is the same bounded no-shell degrade as a shell that
465
+ // never goes quiet: return null, do not hang. A load that REJECTS is a
466
+ // genuine error and still propagates (it is not the deadline). `null` is
467
+ // the deadline sentinel — disjoint from the load's `Promise<string>`, so
468
+ // the race narrows to `string | null` with no wrapper. The no-op catch
469
+ // keeps a late rejection off the unhandledRejection path when the deadline
470
+ // already won; a rejection that lands first still propagates out.
471
+ const load = loadBootstrapScriptContent();
472
+ load.catch(() => {});
473
+ const bootstrapScriptContent = await Promise.race([
474
+ load,
475
+ deadline.promise.then(() => null),
476
+ ]);
477
+ if (bootstrapScriptContent === null) {
523
478
  return null;
524
479
  }
525
- throw error;
526
- }
527
480
 
528
- // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
529
- // Return null and store nothing; the request falls back to axis 1 and a
530
- // later request re-captures. The dominant real-world cause is a loader
531
- // route WITHOUT a route-level loading() boundary: renderSegments' loading-
532
- // less branch awaits loader data at TREE-BUILD, so the masked loader pins
533
- // the whole tree above <body> (root postpone). Root-postponing layouts and
534
- // hung handles degrade the same way. shell-capture.ts logs a once-per-key
535
- // warning so the eternal-MISS shape is diagnosable.
536
- if (!new TextDecoder().decode(prelude).includes("<body")) {
537
- return null;
538
- }
481
+ // Start prerender first, then run the abort schedule concurrently. When
482
+ // holes are pending, prerender's promise settles only after abort(); when
483
+ // the shell completes with no holes it settles on its own and the later
484
+ // abort() is a harmless no-op (the DATA variant).
485
+ const controller = new AbortController();
486
+ // Private reason object: the deliberate abort is identified by object
487
+ // IDENTITY in both the onError below and the post-await catch. React
488
+ // propagates this EXACT object to onError for every still-pending boundary
489
+ // (verified identity-preserving), and rejects/errors the prelude with it.
490
+ const abortReason = { rangoShellCaptureAbort: true };
491
+ const prerenderPromise = prerender(<SsrRoot />, {
492
+ signal: controller.signal,
493
+ bootstrapScriptContent,
494
+ // Abort is how capture WORKS: once the shell is quiet we abort() to
495
+ // freeze the prelude and let the still-pending holes postpone. React
496
+ // reports the abort reason for each pending boundary through onError.
497
+ // Without an onError here React falls back to console.error, so every
498
+ // capture that still has a live hole at abort time (the normal case)
499
+ // dumps a stack once per pending boundary. That is EXPECTED degradation,
500
+ // so swallow OUR abort — matched by IDENTITY (error === abortReason).
501
+ // Discriminate by identity, NOT error.name: capture aborts before
502
+ // awaiting, so signal.aborted is unconditionally true and a name check
503
+ // swallowed genuine AbortError-named throws (a component's own
504
+ // fetch/AbortController cancellation) as if they were our abort. Genuine
505
+ // render errors are NOT our sentinel and still surface through
506
+ // deps.onError, the same channel renderHTML uses. See
507
+ // docs/design/ppr-shell-resume.md.
508
+ onError: (error: unknown) => {
509
+ if (error === abortReason) {
510
+ return;
511
+ }
512
+ reportRenderError(onError, error);
513
+ },
514
+ });
515
+ // Pre-attach a no-op catch: the real await sits AFTER quiesce + the
516
+ // post-quiesce hops, so an early prerender rejection (e.g. a bake-lane
517
+ // loader tripping the identity guard within milliseconds) would otherwise
518
+ // spend several turns handler-less and crash the worker as an unhandled
519
+ // rejection. The actual rejection handling still happens at the await
520
+ // below; this parallel handler only keeps the gap crash-free.
521
+ prerenderPromise.catch(() => {});
522
+
523
+ // Wait for the caller's quiesce signal, bounded by the SAME deadline. By
524
+ // the time it resolves the Flight input is byte-quiet and FROZEN by the
525
+ // capture gate (shell-capture.ts gateFlightForCapture), so there is no
526
+ // wall-clock debounce here — maxWaitMs is only the pathological guard for a
527
+ // shell that never goes quiet (a root postpone / hung handle).
528
+ await Promise.race([opts.quiesce, deadline.promise]);
529
+ // Fixed task hops before the abort: give React's fizz worker turns to flush
530
+ // 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.
533
+ for (let i = 0; i < POST_QUIESCE_TASK_HOPS; i++) {
534
+ await macrotask();
535
+ }
536
+ controller.abort(abortReason);
537
+
538
+ // A hard prerender rejection (fatal shell error) propagates. Expected
539
+ // degradation surfaces three ways and all return null: a trivial prelude
540
+ // (sanity gate below), the prerender REJECTING with our abort reason, or
541
+ // the prelude STREAM erroring with our abort reason mid-read — both abort
542
+ // shapes happen when our own abort lands before the shell completed (seen
543
+ // on dev cold paths, where module transform / first-render latency
544
+ // outlasts flight quiesce; a later request re-captures against warm
545
+ // modules and succeeds).
546
+ let prelude: Uint8Array;
547
+ let postponed: unknown;
548
+ try {
549
+ const result = await prerenderPromise;
550
+ prelude = await readStreamToUint8Array(result.prelude);
551
+ postponed = result.postponed;
552
+ } catch (error) {
553
+ // Identity match: swallow ONLY our own deliberate abort
554
+ // (error === abortReason). Not error.name — capture aborts before this
555
+ // await, so signal.aborted is always true, and a name check let a
556
+ // genuine AbortError-named throw masquerade as our abort and degrade
557
+ // into a retryable no-shell, hiding real failures from reportCacheError.
558
+ if (error === abortReason) {
559
+ return null;
560
+ }
561
+ throw error;
562
+ }
563
+
564
+ // Sanity gate: a prelude with no `<body` is the no-shell failure mode.
565
+ // Return null and store nothing; the request falls back to axis 1 and a
566
+ // later request re-captures. The dominant real-world cause is a loader
567
+ // route WITHOUT a route-level loading() boundary: renderSegments' loading-
568
+ // less branch awaits loader data at TREE-BUILD, so the masked loader pins
569
+ // the whole tree above <body> (root postpone). Root-postponing layouts and
570
+ // hung handles degrade the same way. shell-capture.ts logs a once-per-key
571
+ // warning so the eternal-MISS shape is diagnosable.
572
+ if (!new TextDecoder().decode(prelude).includes("<body")) {
573
+ return null;
574
+ }
539
575
 
540
- return {
541
- prelude,
542
- postponed: postponed == null ? null : JSON.stringify(postponed),
543
- };
576
+ return {
577
+ prelude,
578
+ postponed: postponed == null ? null : JSON.stringify(postponed),
579
+ };
580
+ } finally {
581
+ deadline.cancel();
582
+ }
544
583
  };
545
584
  }
546
585
 
@@ -556,7 +595,7 @@ export function createShellCaptureHandler<TEnv = unknown>(
556
595
  export function createShellResumeHandler<TEnv = unknown>(
557
596
  deps: SSRDependencies<TEnv>,
558
597
  ) {
559
- const { createFromReadableStream, injectRSCPayload, resume, onError } = deps;
598
+ const { createFromReadableStream, resume, onError } = deps;
560
599
 
561
600
  /**
562
601
  * @param rscStream - Fresh full Flight stream for this request.
@@ -571,11 +610,12 @@ export function createShellResumeHandler<TEnv = unknown>(
571
610
  try {
572
611
  if (postponed === null) {
573
612
  // DATA variant: the stored prelude is the complete shell. No fizz runs;
574
- // feed injectRSCPayload a minimal HTML stream so its flush appends the
575
- // fresh Flight payload scripts after the shell. The stream must emit at
576
- // 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).
577
617
  return createDataVariantHtmlStream().pipeThrough(
578
- injectRSCPayload(rscStream, { nonce }),
618
+ injectRSCPayloadEager(rscStream, { nonce }),
579
619
  );
580
620
  }
581
621
 
@@ -602,7 +642,14 @@ export function createShellResumeHandler<TEnv = unknown>(
602
642
  nonce,
603
643
  });
604
644
 
605
- 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 }));
606
653
  } catch (error) {
607
654
  reportRenderError(onError, error);
608
655
  throw error;