@rangojs/router 0.17.0 → 0.18.0

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 (152) hide show
  1. package/README.md +3 -2
  2. package/dist/types/browser/cookie-name.d.ts +6 -0
  3. package/dist/types/browser/history-state.d.ts +16 -0
  4. package/dist/types/cache/cache-key-utils.d.ts +17 -2
  5. package/dist/types/cache/cache-policy.d.ts +17 -0
  6. package/dist/types/cache/cache-scope.d.ts +98 -14
  7. package/dist/types/cache/cache-tag.d.ts +92 -7
  8. package/dist/types/cache/cf/cf-base64.d.ts +6 -0
  9. package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
  11. package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
  12. package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
  13. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
  14. package/dist/types/cache/handle-snapshot.d.ts +4 -3
  15. package/dist/types/cache/index.d.ts +1 -0
  16. package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
  17. package/dist/types/cache/shell-memo.d.ts +178 -0
  18. package/dist/types/cache/shell-snapshot.d.ts +110 -21
  19. package/dist/types/cache/tag-invalidation.d.ts +30 -3
  20. package/dist/types/cache/types.d.ts +207 -43
  21. package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
  22. package/dist/types/prerender/build-shell-capture.d.ts +11 -4
  23. package/dist/types/router/instrument.d.ts +8 -0
  24. package/dist/types/router/middleware-types.d.ts +6 -1
  25. package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
  26. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
  27. package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
  28. package/dist/types/rsc/full-payload.d.ts +14 -0
  29. package/dist/types/rsc/handler.d.ts +1 -1
  30. package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
  31. package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
  32. package/dist/types/rsc/shell-capture.d.ts +146 -15
  33. package/dist/types/rsc/shell-serve.d.ts +140 -17
  34. package/dist/types/rsc/types.d.ts +7 -0
  35. package/dist/types/server/context.d.ts +109 -15
  36. package/dist/types/server/cookie-store.d.ts +21 -0
  37. package/dist/types/server/request-context.d.ts +159 -33
  38. package/dist/types/ssr/index.d.ts +9 -0
  39. package/dist/types/ssr/ssr-root.d.ts +9 -8
  40. package/dist/types/testing/flight.entry.d.ts +7 -0
  41. package/dist/types/testing/run-loader.d.ts +18 -3
  42. package/dist/types/testing/serve-shell-request.d.ts +132 -0
  43. package/dist/types/testing/shell-status.d.ts +23 -10
  44. package/dist/types/types/handler-context.d.ts +14 -1
  45. package/dist/types/urls/pattern-types.d.ts +22 -18
  46. package/dist/types/vite/router-discovery.d.ts +29 -2
  47. package/dist/vite/index.js +46 -9
  48. package/package.json +1 -1
  49. package/skills/breadcrumbs/SKILL.md +17 -7
  50. package/skills/cache-guide/SKILL.md +123 -51
  51. package/skills/caching/SKILL.md +253 -23
  52. package/skills/catalog.json +1 -1
  53. package/skills/client-urls/SKILL.md +11 -6
  54. package/skills/cloudflare/SKILL.md +15 -0
  55. package/skills/comparison/references/framework-comparison.md +44 -32
  56. package/skills/defer-hydration/SKILL.md +50 -30
  57. package/skills/deployment-caching/SKILL.md +5 -2
  58. package/skills/document-cache/SKILL.md +8 -2
  59. package/skills/handler-use/SKILL.md +2 -0
  60. package/skills/hooks/handle-and-actions.md +3 -1
  61. package/skills/hooks/state.md +3 -1
  62. package/skills/loader/SKILL.md +161 -39
  63. package/skills/middleware/SKILL.md +16 -9
  64. package/skills/migrate-nextjs/SKILL.md +67 -36
  65. package/skills/migrate-react-router/SKILL.md +2 -1
  66. package/skills/migrate-react-router/component-migration.md +3 -1
  67. package/skills/migrate-react-router/data-and-actions.md +3 -1
  68. package/skills/migrate-react-router/route-mapping.md +7 -4
  69. package/skills/observability/SKILL.md +64 -0
  70. package/skills/parallel/SKILL.md +13 -8
  71. package/skills/ppr/SKILL.md +655 -363
  72. package/skills/prerender/SKILL.md +27 -14
  73. package/skills/rango/SKILL.md +52 -33
  74. package/skills/route/SKILL.md +12 -7
  75. package/skills/router-setup/SKILL.md +4 -2
  76. package/skills/scripts/SKILL.md +8 -1
  77. package/skills/server-actions/SKILL.md +11 -0
  78. package/skills/shell-manifest/SKILL.md +21 -12
  79. package/skills/testing/SKILL.md +25 -21
  80. package/skills/testing/cache-prerender.md +15 -14
  81. package/skills/testing/loader.md +18 -1
  82. package/skills/testing/serve-shell-request.md +188 -0
  83. package/skills/theme/SKILL.md +52 -4
  84. package/skills/typesafety/env-and-bindings.md +10 -2
  85. package/skills/use-cache/SKILL.md +39 -16
  86. package/skills/vercel/SKILL.md +14 -2
  87. package/skills/view-transitions/SKILL.md +1 -1
  88. package/src/browser/cookie-name.ts +23 -0
  89. package/src/browser/history-state.ts +23 -0
  90. package/src/browser/rsc-router.tsx +5 -0
  91. package/src/cache/cache-key-utils.ts +22 -2
  92. package/src/cache/cache-policy.ts +22 -0
  93. package/src/cache/cache-runtime.ts +34 -7
  94. package/src/cache/cache-scope.ts +345 -33
  95. package/src/cache/cache-tag.ts +375 -11
  96. package/src/cache/cf/cf-base64.ts +36 -4
  97. package/src/cache/cf/cf-cache-constants.ts +12 -0
  98. package/src/cache/cf/cf-cache-store.ts +990 -348
  99. package/src/cache/cf/cf-cache-types.ts +32 -4
  100. package/src/cache/cf/cf-shell-frame.ts +258 -0
  101. package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
  102. package/src/cache/handle-snapshot.ts +4 -3
  103. package/src/cache/index.ts +2 -0
  104. package/src/cache/isolate-tag-memo.ts +308 -0
  105. package/src/cache/shell-memo.ts +341 -0
  106. package/src/cache/shell-snapshot.ts +217 -27
  107. package/src/cache/tag-invalidation.ts +104 -19
  108. package/src/cache/types.ts +211 -43
  109. package/src/cache/vercel/vercel-cache-store.ts +545 -61
  110. package/src/prerender/build-shell-capture.ts +65 -11
  111. package/src/route-definition/dsl-helpers.ts +4 -3
  112. package/src/router/handler-context.ts +38 -4
  113. package/src/router/instrument.ts +54 -2
  114. package/src/router/intercept-resolution.ts +19 -7
  115. package/src/router/loader-resolution.ts +63 -32
  116. package/src/router/match-api.ts +28 -9
  117. package/src/router/match-handlers.ts +5 -0
  118. package/src/router/match-middleware/cache-lookup.ts +17 -0
  119. package/src/router/match-middleware/cache-store.ts +31 -11
  120. package/src/router/middleware-types.ts +6 -1
  121. package/src/router/middleware.ts +23 -4
  122. package/src/router/prerender-match.ts +6 -0
  123. package/src/router/segment-resolution/fresh.ts +18 -9
  124. package/src/router/segment-resolution/helpers.ts +4 -2
  125. package/src/router/segment-resolution/loader-cache.ts +227 -56
  126. package/src/router/segment-resolution/loader-snapshot.ts +203 -51
  127. package/src/router/segment-resolution/mask-nested.ts +136 -42
  128. package/src/router/segment-resolution/revalidation.ts +27 -10
  129. package/src/rsc/full-payload.ts +25 -1
  130. package/src/rsc/handler.ts +14 -2
  131. package/src/rsc/progressive-enhancement.ts +3 -2
  132. package/src/rsc/response-cache-serve.ts +9 -6
  133. package/src/rsc/rsc-rendering.ts +601 -181
  134. package/src/rsc/shell-build-manifest.ts +27 -9
  135. package/src/rsc/shell-capture-constants.ts +60 -6
  136. package/src/rsc/shell-capture.ts +1001 -250
  137. package/src/rsc/shell-serve.ts +316 -34
  138. package/src/rsc/types.ts +7 -0
  139. package/src/server/context.ts +219 -28
  140. package/src/server/cookie-store.ts +87 -62
  141. package/src/server/request-context.ts +263 -38
  142. package/src/ssr/index.tsx +96 -37
  143. package/src/ssr/ssr-root.tsx +9 -8
  144. package/src/testing/flight.entry.ts +14 -0
  145. package/src/testing/run-loader.ts +63 -5
  146. package/src/testing/serve-shell-request.ts +378 -0
  147. package/src/testing/shell-status.ts +34 -12
  148. package/src/theme/ThemeProvider.tsx +7 -6
  149. package/src/types/handler-context.ts +14 -1
  150. package/src/urls/pattern-types.ts +22 -18
  151. package/src/vite/discovery/shell-prerender-phase.ts +9 -0
  152. package/src/vite/router-discovery.ts +14 -4
@@ -13,9 +13,13 @@ import {
13
13
  } from "../server/request-context.js";
14
14
  import {
15
15
  SeededShellStore,
16
+ ShellRecordUnavailableError,
16
17
  buildShellLoaderSeed,
18
+ countSnapshotFamilies,
19
+ hasDocRecord,
17
20
  } from "../cache/shell-snapshot.js";
18
21
  import { appendMetric } from "../router/metrics.js";
22
+ import type { MetricsStore } from "../server/context.js";
19
23
  import { observePhase, PHASES } from "../router/instrument.js";
20
24
  import type { TraceSpan } from "../router/tracing.js";
21
25
  import { getSSRSetup, createSsrHtmlStage, isRscRequest } from "./ssr-setup.js";
@@ -45,6 +49,8 @@ import type { HandlerContext } from "./handler-context.js";
45
49
  import { gateTransitions } from "./transition-gate.js";
46
50
  import { buildFullPayload } from "./full-payload.js";
47
51
  import {
52
+ capShellWindow,
53
+ lastStoredCaptureSeq,
48
54
  scheduleShellCapture,
49
55
  resolveShellCaptureDebugSink,
50
56
  takeCaptureDebugEventForTiming,
@@ -59,7 +65,15 @@ import {
59
65
  shellSearchSeed,
60
66
  isValidShellHit,
61
67
  hasIntactShellPayload,
62
- base64ToBytes,
68
+ openShellDocument,
69
+ navigationShellKey,
70
+ partitionShellKey,
71
+ inlineShellScript,
72
+ shellReloadScript,
73
+ partitionBuildShellCheckDone,
74
+ notePartitionBuildShellCheck,
75
+ warnPprWindowCappedOnce,
76
+ SHELL_PRELUDE_CHUNK_BYTES,
63
77
  hasShellFamily,
64
78
  warnShellStoreMissingOnce,
65
79
  warnPprNonceActiveOnce,
@@ -67,13 +81,19 @@ import {
67
81
  publishShellTailTiming,
68
82
  takeShellTailTimingForServerTiming,
69
83
  type ResolvedPprConfig,
84
+ type ShellDocument,
70
85
  type ShellTailTiming,
71
86
  } from "./shell-serve.js";
72
87
  import {
88
+ hasBuildShell,
73
89
  lookupBuildShell,
74
90
  type DevShellLookup,
75
91
  } from "./shell-build-manifest.js";
76
92
  import { contextGet } from "../context-var.js";
93
+ import {
94
+ resolveSwrWindow,
95
+ staleShellRecaptureDue,
96
+ } from "../cache/cache-policy.js";
77
97
  import {
78
98
  getNavigationContextHeader,
79
99
  prerenderStoreShortCircuits,
@@ -88,13 +108,16 @@ import {
88
108
  safeSameOriginLanding,
89
109
  } from "../redirect-origin.js";
90
110
  import { nonce as nonceToken } from "./nonce.js";
111
+ import { escapeJsonForScript } from "../escape-script.js";
112
+ import { resolveShellPartition } from "../cache/cache-scope.js";
91
113
  import { reportCacheError } from "../cache/cache-error.js";
92
114
  import type { SearchParamsFilter } from "../cache/search-params-filter.js";
93
115
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
94
116
  import type {
95
- DocumentShellCacheEntry,
96
117
  SegmentCacheStore,
97
118
  ShellCacheEntry,
119
+ ShellDocumentRead,
120
+ ShellReadStats,
98
121
  ShellSnapshotRecord,
99
122
  } from "../cache/types.js";
100
123
  import {
@@ -116,8 +139,6 @@ type PprReplayBypassReason =
116
139
  | "no-entry"
117
140
  | "invalid-version"
118
141
  | "corrupt-entry"
119
- | "handler-live-holes"
120
- | "transition-when"
121
142
  | "no-segment-snapshot"
122
143
  | "snapshot-miss"
123
144
  | "explicit-cache-hit"
@@ -143,13 +164,6 @@ function describePprReplayStatus(status: PprReplayStatus): string {
143
164
  : `bypass:${status.reason}`;
144
165
  }
145
166
 
146
- function buildNavigationShellKey(
147
- url: URL,
148
- filter?: SearchParamsFilter,
149
- ): string {
150
- return `${buildShellKey(url, filter)}:navigation`;
151
- }
152
-
153
167
  function createShellCaptureDescriptor(
154
168
  ctx: HandlerContext<any>,
155
169
  key: string,
@@ -242,8 +256,6 @@ function replayableShellSnapshot(
242
256
  if (!entry.navigationOnly && !hasIntactShellPayload(entry)) {
243
257
  return { reason: "corrupt-entry" };
244
258
  }
245
- if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
246
- if (entry.transitionWhen) return { reason: "transition-when" };
247
259
  // Eligibility requires the CANONICAL doc segment record (`docKey`), not just
248
260
  // any segment record: captures under an explicit cache() scope also record
249
261
  // that tier's reads/writes under keys a partial lookup can never resolve,
@@ -251,25 +263,8 @@ function replayableShellSnapshot(
251
263
  // (a timing-dependent snapshot-miss/no-segment-snapshot flip-flop). Entries
252
264
  // captured before the field existed read as no-segment-snapshot and heal on
253
265
  // recapture (pre-release, same treatment as the buildVersion field).
254
- const docKey = entry.docKey;
255
266
  const snapshot = entry.snapshot;
256
- const hasDocRecord =
257
- docKey !== undefined &&
258
- snapshot?.some((record) => {
259
- if (
260
- !record ||
261
- typeof record !== "object" ||
262
- record.family !== "segment" ||
263
- record.key !== docKey ||
264
- typeof record.value !== "object" ||
265
- record.value === null
266
- ) {
267
- return false;
268
- }
269
- const segments = (record.value as { segments?: unknown }).segments;
270
- return Array.isArray(segments) && segments.length > 0;
271
- });
272
- return hasDocRecord && snapshot
267
+ return snapshot && hasDocRecord(snapshot, entry.docKey)
273
268
  ? { snapshot }
274
269
  : { reason: "no-segment-snapshot" };
275
270
  }
@@ -439,13 +434,14 @@ function* requestRenderPlan<TEnv>(
439
434
  response.headers.set(SHELL_STATUS_HEADER, "MISS");
440
435
  }
441
436
 
437
+ const captureKey = prepared.partialCaptureKey;
442
438
  if (
443
439
  isPartial &&
444
440
  prepared.partialCaptureNeeded &&
441
+ captureKey !== undefined &&
445
442
  !reqCtx._dynamic &&
446
443
  response.status === 200
447
444
  ) {
448
- const captureKey = prepared.partialCaptureKey;
449
445
  yield* handoff("navigation-shell-capture", () =>
450
446
  scheduleNavigationShellCapture(input, captureKey),
451
447
  );
@@ -454,6 +450,32 @@ function* requestRenderPlan<TEnv>(
454
450
  return response;
455
451
  }
456
452
 
453
+ /**
454
+ * Dev: warn once per route when the route's cache() entry reduces an explicit
455
+ * ppr window. The window is what capShellWindow gives a capture from a
456
+ * record written just now, the widest it can get; null (a cache() ttl + swr
457
+ * of 0) is the capture's own warning.
458
+ */
459
+ function warnIfPprWindowReduced(
460
+ routeName: string,
461
+ pprOption: { ttl?: number; swr?: number },
462
+ pprConfig: ResolvedPprConfig,
463
+ cacheScope: { ttl: number; swr: number | undefined },
464
+ store: SegmentCacheStore<any>,
465
+ ): void {
466
+ const cache = { ttl: cacheScope.ttl, swr: cacheScope.swr ?? 0 };
467
+ const shell = capShellWindow(
468
+ pprConfig.ttl,
469
+ resolveSwrWindow(pprConfig.swr, store.defaults),
470
+ {
471
+ freshUntil: cache.ttl * 1000,
472
+ staleUntil: (cache.ttl + cache.swr) * 1000,
473
+ },
474
+ 0,
475
+ );
476
+ if (shell) warnPprWindowCappedOnce(routeName, pprOption, shell, cache);
477
+ }
478
+
457
479
  /**
458
480
  * Axis 2 shell serve. Sync gates stay plan code; store and SSR effects run as
459
481
  * named steps. Outcomes: serve a composed shell response, report a MISS (the
@@ -474,8 +496,13 @@ function* shellServePlan<TEnv>(
474
496
  ) {
475
497
  return { kind: "pass" };
476
498
  }
477
- const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
499
+ const manifestEntry = reqCtx._classifiedRoute?.manifestEntry;
500
+ const pprConfig = resolvePprConfig(manifestEntry);
478
501
  if (!pprConfig) return { kind: "pass" };
502
+ // A degraded HIT's reload (shellReloadScript) renders like a cache miss:
503
+ // no shell read, no capture, so it can never degrade again. The handler
504
+ // stripped the marker from the request and left this flag.
505
+ if (reqCtx._shellForcedMiss) return { kind: "pass" };
479
506
 
480
507
  // A per-request CSP nonce pins the route to axis 1: a shared shell would
481
508
  // freeze the capture request's nonce and CSP would reject it for every
@@ -484,8 +511,8 @@ function* shellServePlan<TEnv>(
484
511
  // the provider-only check missed the latter (issue #656).
485
512
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
486
513
  const store = reqCtx._cacheStore;
487
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
488
- mirrorPprServerTimingsForDev(key, reqCtx);
514
+ const baseKey = buildShellKey(url, reqCtx._searchParamsFilter);
515
+ let key = baseKey;
489
516
  if (activeNonce !== undefined) {
490
517
  // Declared intent that cannot be honored deserves a diagnostic (unlike an
491
518
  // undeclared route, which is silent): a ppr route gated off by an active
@@ -499,11 +526,69 @@ function* shellServePlan<TEnv>(
499
526
  warnShellStoreMissingOnce(key);
500
527
  return { kind: "pass" };
501
528
  }
502
-
529
+ // The route's own cache() opt-out — cache(false), or a condition() that
530
+ // refuses THIS request — is absolute, and a HIT tail never consults the
531
+ // route scope (it replays the shell's doc record), so it is decided here,
532
+ // before any shell byte: the request renders like a cache miss (axis 1) and
533
+ // schedules no capture (the capture could not record the handler layer).
534
+ const route = classifiedRouteSnapshot(reqCtx);
535
+ if (route?.cacheScope && !route.cacheScope.allowsCache("read")) {
536
+ return { kind: "pass" };
537
+ }
538
+ // The shell is capped to the route cache() entry it captures from
539
+ // (capShellWindow); an explicit ppr window the cap reduces warns in dev.
540
+ // resolvePprConfig returned a config, so the entry is a ppr route.
541
+ const pprOption = manifestEntry?.type === "route" && manifestEntry.ppr;
542
+ if (
543
+ process.env.NODE_ENV !== "production" &&
544
+ route?.cacheScope?.enabled &&
545
+ typeof pprOption === "object" &&
546
+ (pprOption.ttl !== undefined || pprOption.swr !== undefined)
547
+ ) {
548
+ warnIfPprWindowReduced(
549
+ reqCtx._classifiedRoute?.routeKey ?? url.pathname,
550
+ pprOption,
551
+ pprConfig,
552
+ route.cacheScope,
553
+ store,
554
+ );
555
+ }
556
+ // A route whose cache() record is partitioned by the request (its `key()`,
557
+ // or the store's keyGenerator) partitions its shell the same way: each
558
+ // partition captures and serves its own shell. A failed key resolution
559
+ // serves no shell, never another partition's.
560
+ const partitionedKey = partitionedShellKey(
561
+ baseKey,
562
+ route?.cacheScope,
563
+ store,
564
+ reqCtx.url.pathname,
565
+ route?.params,
566
+ );
567
+ if (partitionedKey) {
568
+ const resolved = yield* step("shell-partition", () =>
569
+ partitionedKey.then(
570
+ (value): string | undefined => value,
571
+ (error: unknown) => {
572
+ reportCacheError(error, "cache-read", "[ShellServe] shell key");
573
+ return undefined;
574
+ },
575
+ ),
576
+ );
577
+ if (resolved === undefined) return { kind: "pass" };
578
+ key = resolved;
579
+ }
580
+ reqCtx._shellKey = key;
581
+ // The buffered capture and tail timings are keyed by the partitioned key.
582
+ mirrorPprServerTimings(key, reqCtx);
583
+
584
+ // A MISS schedules its capture after rendering: taken before the read, this
585
+ // lets that capture skip itself when another request's capture stored the
586
+ // shell in between (ShellCaptureDescriptor.storedSeqAtRead).
587
+ const storedSeqAtRead = lastStoredCaptureSeq(key);
503
588
  // SSR setup starts before route handling, so read the shell before joining it
504
589
  // to overlap the remaining setup work with cache I/O.
505
590
  const cached = yield* step("shell-read", () =>
506
- readShellEntry(store, key, reqCtx),
591
+ readShellEntry(store, key, reqCtx, pprConfig.tags),
507
592
  );
508
593
 
509
594
  // allReady (ssr.resolveStreaming) bypasses PPR entirely: buffering defeats
@@ -528,12 +613,21 @@ function* shellServePlan<TEnv>(
528
613
  store,
529
614
  );
530
615
 
616
+ // A HIT replays the handler layer from the entry's doc record (`docKey`),
617
+ // or, for a Prerender route, from the prerender store. An entry that has
618
+ // neither is a MISS: serving it would run handlers after the commit.
531
619
  if (
532
620
  cached &&
533
621
  !cached.entry.navigationOnly &&
622
+ (cached.entry.docKey !== undefined || route?.matched?.pr === true) &&
534
623
  isValidShellHit(cached.entry, ctx.version)
535
624
  ) {
536
- if (!hasIntactShellPayload(cached.entry)) {
625
+ const document = openShellDocumentMetered(
626
+ reqCtx,
627
+ cached.entry,
628
+ cached.read,
629
+ );
630
+ if (!document) {
537
631
  // Corrupt stored payload (undecodable prelude / unparseable
538
632
  // postponed): a store-layer fault worth a diagnostic, unlike the
539
633
  // silent version-mismatch lifecycle misses above. Degrade to MISS
@@ -548,8 +642,12 @@ function* shellServePlan<TEnv>(
548
642
  );
549
643
  } else {
550
644
  // Stale (SWR) hit: serve the stale shell now, recapture in the
551
- // background (stampede-guarded + backoff inside scheduleShellCapture).
552
- if (cached.shouldRevalidate) {
645
+ // background (stampede-guarded + backoff inside scheduleShellCapture),
646
+ // at most once per SHELL_MIN_RECAPTURE_INTERVAL_MS of the shell's age.
647
+ if (
648
+ cached.shouldRevalidate &&
649
+ staleShellRecaptureDue(cached.entry, Date.now())
650
+ ) {
553
651
  yield* handoff("shell-recapture", () =>
554
652
  scheduleShellCapture(
555
653
  ctx,
@@ -562,7 +660,6 @@ function* shellServePlan<TEnv>(
562
660
  ),
563
661
  );
564
662
  }
565
- const entry = cached.entry;
566
663
  const response = yield* step("shell-hit", () =>
567
664
  serveShellHit(
568
665
  ctx,
@@ -572,34 +669,76 @@ function* shellServePlan<TEnv>(
572
669
  reqCtx,
573
670
  handleStore,
574
671
  ssrModule,
575
- entry,
672
+ document,
576
673
  descriptor,
674
+ activeNonce,
577
675
  ),
578
676
  );
579
677
  return { kind: "serve", response };
580
678
  }
581
679
  }
582
680
 
681
+ // A runtime tombstone (degradeUnreplayableShell: this key's HIT could not
682
+ // replay its handler layer) shadows the build shell too, or a build shell
683
+ // whose tail cannot be replayed would degrade on every request until a
684
+ // runtime capture lands.
685
+ if (
686
+ cached?.entry.navigationOnly &&
687
+ isValidShellHit(cached.entry, ctx.version)
688
+ ) {
689
+ return {
690
+ kind: "miss",
691
+ descriptor: { ...descriptor, storedSeqAtRead },
692
+ ssrModule,
693
+ };
694
+ }
695
+
583
696
  // Build-time shell read-through (producer B, #699): on a runtime
584
697
  // store MISS a Prerender+ppr route serves its `vite build`-baked
585
- // shell through the SAME serve path. lookupBuildShell owns every
586
- // gate and fails to null (ordinary MISS path takes over); past
587
- // ppr.ttl the baked entry still serves while SWR recaptures — the
588
- // upgrade path from build entry to runtime entry.
589
- const buildHit = yield* step("build-shell-lookup", () =>
590
- lookupBuildShell(
591
- url,
592
- ctx.version,
593
- store,
594
- // Dev: no build manifest exists; producer B runs on demand via
595
- // the dev server's /__rsc_shell endpoint for PRERENDERED routes
596
- // only (production's exact candidate set). Folded away in
597
- // production builds (NODE_ENV is a compile-time constant).
598
- resolveDevShellLookup(reqCtx, pprConfig),
599
- reqCtx._searchParamsFilter,
600
- ),
601
- );
602
- if (buildHit) {
698
+ // shell through the SAME serve path. A partitioned route has none: the
699
+ // build captured one partition, which no request may be assumed to
700
+ // share, and a route that has a build shell says so once. lookupBuildShell
701
+ // owns the validity gates and fails to null; openShellDocument below is
702
+ // the prelude's integrity check and only decode (either failing leaves the
703
+ // ordinary MISS path); past ppr.ttl the baked entry still serves while SWR
704
+ // recaptures — the upgrade path from build entry to runtime entry.
705
+ //
706
+ // Dev: no build manifest exists; producer B runs on demand via the dev
707
+ // server's /__rsc_shell endpoint for PRERENDERED routes only (production's
708
+ // exact candidate set). Folded away in production builds (NODE_ENV is a
709
+ // compile-time constant).
710
+ const devShellLookup = resolveDevShellLookup(reqCtx, pprConfig);
711
+ let buildHit: Awaited<ReturnType<typeof lookupBuildShell>> = null;
712
+ if (key !== baseKey) {
713
+ // Probed once per path without a build shell, and not again once the
714
+ // route warned. A search-bearing URL never reads one: nothing to probe.
715
+ const routeName = reqCtx._classifiedRoute?.routeKey;
716
+ if (
717
+ !partitionBuildShellCheckDone(url.pathname, routeName) &&
718
+ shellSearchSeed(url, reqCtx._searchParamsFilter) === ""
719
+ ) {
720
+ const found = yield* step("build-shell-check", () =>
721
+ hasBuildShell(url.pathname, devShellLookup),
722
+ );
723
+ notePartitionBuildShellCheck(url.pathname, routeName, found);
724
+ }
725
+ } else {
726
+ buildHit = yield* step("build-shell-lookup", () =>
727
+ lookupBuildShell(
728
+ url,
729
+ ctx.version,
730
+ store,
731
+ devShellLookup,
732
+ reqCtx._searchParamsFilter,
733
+ ),
734
+ );
735
+ }
736
+ // The document serve decodes the baked prelude once here; an undecodable
737
+ // one is a MISS like a corrupt runtime entry.
738
+ const buildDocument = buildHit
739
+ ? openShellDocumentMetered(reqCtx, buildHit.entry)
740
+ : null;
741
+ if (buildHit && buildDocument) {
603
742
  // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
604
743
  if (buildHit.stale) {
605
744
  yield* handoff("shell-recapture", () =>
@@ -623,8 +762,9 @@ function* shellServePlan<TEnv>(
623
762
  reqCtx,
624
763
  handleStore,
625
764
  ssrModule,
626
- buildHit.entry,
765
+ buildDocument,
627
766
  descriptor,
767
+ activeNonce,
628
768
  ),
629
769
  );
630
770
  return { kind: "serve", response };
@@ -632,7 +772,11 @@ function* shellServePlan<TEnv>(
632
772
 
633
773
  // MISS (no entry, invalid reactVersion, or store read failure): axis 1
634
774
  // + a background capture scheduled once the response is known servable.
635
- return { kind: "miss", descriptor, ssrModule };
775
+ return {
776
+ kind: "miss",
777
+ descriptor: { ...descriptor, storedSeqAtRead },
778
+ ssrModule,
779
+ };
636
780
  }
637
781
 
638
782
  /**
@@ -782,53 +926,157 @@ async function matchPartialAndRecordParams<TEnv>(
782
926
  return replay;
783
927
  }
784
928
 
929
+ /** A document serve's store read: the entry, plus the prelude-first read when the store has one. */
930
+ interface ShellStoreRead {
931
+ entry: ShellCacheEntry;
932
+ shouldRevalidate?: boolean;
933
+ read?: ShellDocumentRead;
934
+ }
935
+
785
936
  /**
786
937
  * Shell store read that degrades to null on failure (axis 1 MISS, never a 500)
787
938
  * and records the raw `ppr:shell-read` outcome (pre-validity-gates, so a
788
939
  * version-mismatch lifecycle miss stays distinguishable from a store miss).
940
+ * A store with a prelude-first read (readShellDocument) resolves before the
941
+ * snapshot is read, so the HIT commits without waiting for it.
789
942
  */
790
- async function readShellEntry<
791
- TStore extends { getShell(key: string): Promise<unknown> },
792
- >(
793
- store: TStore,
943
+ async function readShellEntry(
944
+ store: SegmentCacheStore & {
945
+ getShell: NonNullable<SegmentCacheStore["getShell"]>;
946
+ },
794
947
  key: string,
795
948
  reqCtx: ReturnType<typeof getRequestContext>,
796
- ): Promise<Awaited<ReturnType<TStore["getShell"]>> | null> {
797
- let cached: Awaited<ReturnType<TStore["getShell"]>> | null = null;
949
+ tagHints: readonly string[] | undefined,
950
+ ): Promise<ShellStoreRead | null> {
951
+ let cached: ShellStoreRead | null = null;
798
952
  const shellReadStart = reqCtx._metricsStore ? performance.now() : 0;
799
953
  try {
800
- // The constraint types the call as Promise<unknown>; the instantiated
801
- // TStore carries the real entry type, so the assertion restores it.
802
- cached = (await store.getShell(key)) as Awaited<
803
- ReturnType<TStore["getShell"]>
804
- >;
954
+ if (store.readShellDocument) {
955
+ const read = await store.readShellDocument(
956
+ key,
957
+ tagHints ? { tagHints } : undefined,
958
+ );
959
+ cached = read
960
+ ? { entry: read.entry, shouldRevalidate: read.shouldRevalidate, read }
961
+ : null;
962
+ } else {
963
+ cached = await store.getShell(key);
964
+ }
805
965
  } catch (error) {
806
966
  reportCacheError(error, "cache-read", "[ShellServe] getShell");
807
967
  }
808
968
  if (reqCtx._metricsStore) {
969
+ const stats = cached?.read?.stats;
809
970
  appendMetric(
810
971
  reqCtx._metricsStore,
811
972
  "ppr:shell-read",
812
973
  shellReadStart,
813
974
  performance.now() - shellReadStart,
814
975
  undefined,
815
- cached ? "hit" : "miss",
976
+ cached ? (stats ? `hit ${stats.tier}` : "hit") : "miss",
816
977
  );
978
+ if (stats)
979
+ recordShellReadStats(reqCtx._metricsStore, shellReadStart, stats);
817
980
  }
818
981
  return cached;
819
982
  }
820
983
 
821
984
  /**
822
- * Dev Server-Timing mirror (issue #651): a capture completes AFTER its
823
- * triggering response committed, so its outcome can only ride a LATER
824
- * response's header. Read-and-clear keeps one capture = one report;
825
- * dev-only (see takeCaptureDebugEventForTiming).
985
+ * A prelude-first read's sub-steps as `debugPerformance` rows under
986
+ * `ppr:shell-read`. The marker row starts with the prelude row because the
987
+ * two run in parallel; its desc says how long the read still waited on the
988
+ * marker once the prelude was in. On a deployed worker the clock only
989
+ * advances on I/O, so the byte counts are the cost signal for the CPU parts.
826
990
  */
827
- function mirrorPprServerTimingsForDev(
991
+ function recordShellReadStats(
992
+ store: MetricsStore,
993
+ readStart: number,
994
+ stats: ShellReadStats,
995
+ ): void {
996
+ let at = readStart;
997
+ const row = (label: string, ms: number | undefined, desc: string): void => {
998
+ if (ms !== undefined) appendMetric(store, label, at, ms, 1, desc);
999
+ };
1000
+ if (stats.memo) {
1001
+ row(
1002
+ "ppr:shell-memo",
1003
+ 0,
1004
+ `${stats.memo.hit ? "hit" : "miss"} size=${stats.memo.bytes}b`,
1005
+ );
1006
+ }
1007
+ // A KV hit after an L1 miss: the L1 attempt first, then the KV read.
1008
+ row("ppr:shell-l1-miss", stats.l1MissMs, stats.l1MissReason ?? "miss");
1009
+ at += stats.l1MissMs ?? 0;
1010
+ row("ppr:shell-match", stats.matchMs, stats.tier);
1011
+ at += stats.matchMs ?? 0;
1012
+ row("ppr:shell-head", stats.headMs, `bytes=${stats.headBytes ?? 0}`);
1013
+ at += stats.headMs ?? 0;
1014
+ row("ppr:shell-prelude", stats.preludeMs, `bytes=${stats.preludeBytes ?? 0}`);
1015
+ let marker = `tags=${stats.tags ?? 0} ${stats.markerSerial ? "serial" : "parallel"} commit-wait=${(stats.markerWaitMs ?? 0).toFixed(2)}ms`;
1016
+ if (stats.markerMemo) marker += ` memo=${stats.markerMemo}`;
1017
+ if (stats.markerHinted) {
1018
+ marker += ` hint=${stats.markerHintHits ?? 0}/${stats.markerHinted.length}`;
1019
+ if (stats.markerLeadMs !== undefined) {
1020
+ marker += ` lead=${stats.markerLeadMs.toFixed(2)}ms`;
1021
+ }
1022
+ }
1023
+ if (stats.freshReads) marker += " fresh-reads";
1024
+ row("ppr:shell-marker", stats.markerMs, marker);
1025
+ }
1026
+
1027
+ /**
1028
+ * openShellDocument (the HIT's integrity gate and only prelude decode) as a
1029
+ * `ppr:shell-open` row: CPU only, so it reads 0 on a deployed worker.
1030
+ */
1031
+ function openShellDocumentMetered(
1032
+ reqCtx: RequestContext<any>,
1033
+ entry: ShellCacheEntry,
1034
+ read?: ShellDocumentRead,
1035
+ ): ShellDocument | null {
1036
+ const store = reqCtx._metricsStore;
1037
+ if (!store) return openShellDocument(entry, read);
1038
+ const start = performance.now();
1039
+ const document = openShellDocument(entry, read);
1040
+ appendMetric(
1041
+ store,
1042
+ "ppr:shell-open",
1043
+ start,
1044
+ performance.now() - start,
1045
+ undefined,
1046
+ `cpu ${read ? "raw" : "base64-decode"} prelude=${document?.prelude.length ?? 0}b`,
1047
+ );
1048
+ return document;
1049
+ }
1050
+
1051
+ /**
1052
+ * The shell key a request reads and captures: `baseKey`, partitioned by the
1053
+ * route's request partition (cache-scope.ts resolveShellPartition; a
1054
+ * partition equal to the default key leaves it unchanged). Undefined,
1055
+ * synchronously and with no work, when nothing partitions the route; a
1056
+ * rejection is a failed key resolution.
1057
+ */
1058
+ function partitionedShellKey(
1059
+ baseKey: string,
1060
+ ...partition: Parameters<typeof resolveShellPartition>
1061
+ ): Promise<string> | undefined {
1062
+ return resolveShellPartition(...partition)?.then((resolved) =>
1063
+ resolved === null ? baseKey : partitionShellKey(baseKey, resolved),
1064
+ );
1065
+ }
1066
+
1067
+ /**
1068
+ * Server-Timing mirror (issue #651): a capture or a HIT tail completes AFTER
1069
+ * its triggering response committed, so its numbers can only ride a LATER
1070
+ * response's header. Read-and-clear keeps one report per run. The capture
1071
+ * half is dev-only (see takeCaptureDebugEventForTiming); the tail half runs
1072
+ * wherever a tail was buffered (serveShellHit).
1073
+ */
1074
+ function mirrorPprServerTimings(
828
1075
  key: string,
829
1076
  reqCtx: ReturnType<typeof getRequestContext>,
830
1077
  ): void {
831
- if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
1078
+ if (!reqCtx._metricsStore) return;
1079
+ if (process.env.NODE_ENV !== "production") {
832
1080
  const lastCapture = takeCaptureDebugEventForTiming(key);
833
1081
  if (lastCapture) {
834
1082
  appendMetric(
@@ -841,22 +1089,23 @@ function mirrorPprServerTimingsForDev(
841
1089
  describeShellCaptureEvent({ ...lastCapture, attemptMs: undefined }),
842
1090
  );
843
1091
  }
844
- // Same mirror for the previous HIT's tail: its per-stage numbers
845
- // (seed/match/handover/first-html/complete) finished after that
846
- // response's headers were committed, so they ride THIS request's
847
- // Server-Timing as `ppr:tail;dur=<complete ms>`.
848
- const lastTail = takeShellTailTimingForServerTiming(key);
849
- if (lastTail) {
850
- appendMetric(
851
- reqCtx._metricsStore,
852
- "ppr:tail",
853
- performance.now(),
854
- lastTail.completeMs ?? 0,
855
- undefined,
856
- // completeMs already rides as this entry's dur — drop it from desc.
857
- describeShellTailTiming({ ...lastTail, completeMs: undefined }),
858
- );
859
- }
1092
+ }
1093
+ // Same mirror for the previous HIT's tail: its per-stage numbers
1094
+ // (snapshot/seed/match/handover/first-html/complete) finished after that
1095
+ // response's headers were committed, so they ride THIS request's
1096
+ // Server-Timing as `ppr:tail;dur=<complete ms>`. In production a tail is
1097
+ // buffered only when its own HIT collected metrics (serveShellHit).
1098
+ const lastTail = takeShellTailTimingForServerTiming(key);
1099
+ if (lastTail) {
1100
+ appendMetric(
1101
+ reqCtx._metricsStore,
1102
+ "ppr:tail",
1103
+ performance.now(),
1104
+ lastTail.completeMs ?? 0,
1105
+ undefined,
1106
+ // completeMs already rides as this entry's dur — drop it from desc.
1107
+ describeShellTailTiming({ ...lastTail, completeMs: undefined }),
1108
+ );
860
1109
  }
861
1110
  }
862
1111
 
@@ -944,7 +1193,7 @@ function renderPreparedRscResponse<TEnv>(
944
1193
  */
945
1194
  function scheduleNavigationShellCapture<TEnv>(
946
1195
  input: RequestRenderInput<TEnv>,
947
- partialCaptureKey: string | undefined,
1196
+ captureKey: string,
948
1197
  ): void {
949
1198
  const { ctx, request, env, url, reqCtx } = input;
950
1199
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
@@ -967,8 +1216,7 @@ function scheduleNavigationShellCapture<TEnv>(
967
1216
  },
968
1217
  createShellCaptureDescriptor(
969
1218
  ctx,
970
- partialCaptureKey ??
971
- buildNavigationShellKey(url, reqCtx._searchParamsFilter),
1219
+ captureKey,
972
1220
  shellSearchSeed(url, reqCtx._searchParamsFilter),
973
1221
  url.origin,
974
1222
  pprConfig,
@@ -1127,8 +1375,26 @@ async function matchPartialWithPprReplay<TEnv>(
1127
1375
  return runMatch({ outcome: "BYPASS", reason: "cache-disabled" });
1128
1376
  }
1129
1377
 
1130
- const key = buildShellKey(url, reqCtx._searchParamsFilter);
1131
- const navigationKey = `${key}:navigation`;
1378
+ // The visitor's own partition (resolveShellPartition): replay never
1379
+ // crosses into another partition's shell.
1380
+ const baseKey = buildShellKey(url, reqCtx._searchParamsFilter);
1381
+ let key = baseKey;
1382
+ const partitionedKey = partitionedShellKey(
1383
+ baseKey,
1384
+ routeCacheScope,
1385
+ store,
1386
+ reqCtx.url.pathname,
1387
+ routeSnapshot?.params,
1388
+ );
1389
+ if (partitionedKey) {
1390
+ try {
1391
+ key = await partitionedKey;
1392
+ } catch (error) {
1393
+ reportCacheError(error, "cache-read", "[NavigationPPR] shell key");
1394
+ return runMatch({ outcome: "BYPASS", reason: "read-error" });
1395
+ }
1396
+ }
1397
+ const navigationKey = navigationShellKey(key);
1132
1398
  let cached: Awaited<ReturnType<typeof store.getShell>> = null;
1133
1399
  try {
1134
1400
  cached = await store.getShell(key, { claimRevalidation: false });
@@ -1152,11 +1418,7 @@ async function matchPartialWithPprReplay<TEnv>(
1152
1418
  }
1153
1419
  }
1154
1420
 
1155
- if (
1156
- !snapshot &&
1157
- bypassReason !== "handler-live-holes" &&
1158
- bypassReason !== "transition-when"
1159
- ) {
1421
+ if (!snapshot) {
1160
1422
  let navigationCached: Awaited<ReturnType<typeof store.getShell>> = null;
1161
1423
  try {
1162
1424
  navigationCached = await store.getShell(navigationKey, {
@@ -1185,13 +1447,16 @@ async function matchPartialWithPprReplay<TEnv>(
1185
1447
  // Production build manifests are local module data. In dev, resolving a
1186
1448
  // missing build shell would foreground-fetch /__rsc_shell and block an
1187
1449
  // otherwise ordinary navigation on capture, so replay remains runtime-only.
1188
- const buildHit = await lookupBuildShell(
1189
- url,
1190
- ctx.version,
1191
- store,
1192
- undefined,
1193
- reqCtx._searchParamsFilter,
1194
- );
1450
+ const buildHit =
1451
+ key !== baseKey
1452
+ ? null
1453
+ : await lookupBuildShell(
1454
+ url,
1455
+ ctx.version,
1456
+ store,
1457
+ undefined,
1458
+ reqCtx._searchParamsFilter,
1459
+ );
1195
1460
  if (buildHit?.stale) {
1196
1461
  bypassReason ??= "stale-build-entry";
1197
1462
  } else if (buildHit) {
@@ -1259,6 +1524,7 @@ async function matchPartialWithPprReplay<TEnv>(
1259
1524
  reqCtx._pprReplayPostMatchReason === undefined &&
1260
1525
  (shouldHealReplayMiss(bypassReason) ||
1261
1526
  bypassReason === "no-segment-snapshot"),
1527
+ captureKey: navigationKey,
1262
1528
  };
1263
1529
  } finally {
1264
1530
  reqCtx._shellImplicitCache = previousImplicitCache;
@@ -1273,10 +1539,13 @@ async function matchPartialWithPprReplay<TEnv>(
1273
1539
  captureNeeded:
1274
1540
  reqCtx._pprReplayPostMatchReason === undefined &&
1275
1541
  shouldHealReplayMiss(bypassReason),
1542
+ captureKey: navigationKey,
1276
1543
  };
1277
1544
  }
1278
1545
 
1279
1546
  const previousImplicitCache = reqCtx._shellImplicitCache;
1547
+ const previousLoaderSeed = reqCtx._shellLoaderSeed;
1548
+ let loaderSeed: ReturnType<typeof buildShellLoaderSeed> | undefined;
1280
1549
  let segmentReplayHit = false;
1281
1550
  let segmentReplayCorrupt = false;
1282
1551
  let explicitCacheHit = false;
@@ -1288,8 +1557,18 @@ async function matchPartialWithPprReplay<TEnv>(
1288
1557
  segmentsOnly: true,
1289
1558
  }),
1290
1559
  keyPrefix: "doc",
1291
- onHit: () => {
1560
+ // Arms the bake-lane loader pins a document HIT serves (loader-cache.ts
1561
+ // resolveLoaderData), decoded only now: the lookup awaits this before
1562
+ // the route's loaders resolve, and an explicit-tier hit or a miss runs
1563
+ // the loaders fresh and never reads a pin.
1564
+ onHit: async () => {
1292
1565
  segmentReplayHit = true;
1566
+ // Never rejects: the lookup awaits this inside its own try, and an
1567
+ // unreadable seed leaves those loaders to run fresh.
1568
+ const seed = await (loaderSeed ??= buildShellLoaderSeed(snapshot).catch(
1569
+ () => undefined,
1570
+ ));
1571
+ if (seed) reqCtx._shellLoaderSeed = seed;
1293
1572
  },
1294
1573
  onCorrupt: () => {
1295
1574
  segmentReplayCorrupt = true;
@@ -1337,6 +1616,7 @@ async function matchPartialWithPprReplay<TEnv>(
1337
1616
  };
1338
1617
  } finally {
1339
1618
  reqCtx._shellImplicitCache = previousImplicitCache;
1619
+ reqCtx._shellLoaderSeed = previousLoaderSeed;
1340
1620
  }
1341
1621
  }
1342
1622
 
@@ -1381,6 +1661,75 @@ export function resolveShellHitRedirectTarget(
1381
1661
  );
1382
1662
  }
1383
1663
 
1664
+ /**
1665
+ * Buffer a finished tail timing for the next request's Server-Timing, and
1666
+ * under debugPerformance print it for this request (whose own Server-Timing
1667
+ * was sent at the commit, before the tail ran).
1668
+ */
1669
+ function publishTailTiming(
1670
+ tailTiming: ShellTailTiming | null,
1671
+ metricsStore: MetricsStore | undefined,
1672
+ request: Request,
1673
+ url: URL,
1674
+ ): void {
1675
+ if (!tailTiming) return;
1676
+ publishShellTailTiming(tailTiming);
1677
+ if (metricsStore) {
1678
+ console.log(
1679
+ `[RSC Perf] ${request.method} ${url.pathname} shell tail: ${describeShellTailTiming(tailTiming)}`,
1680
+ );
1681
+ }
1682
+ }
1683
+
1684
+ const PENDING = Symbol("pending");
1685
+
1686
+ /**
1687
+ * Whether `value` is not a promise, or a promise that has already settled:
1688
+ * racing it against an already-resolved promise, a settled one's reaction is
1689
+ * queued first and wins.
1690
+ */
1691
+ async function isSettled(value: unknown): Promise<boolean> {
1692
+ if (!(value instanceof Promise)) return true;
1693
+ return Promise.race([value, PENDING]).then(
1694
+ (first) => first !== PENDING,
1695
+ () => true,
1696
+ );
1697
+ }
1698
+
1699
+ /**
1700
+ * Evict a shell whose doc record cannot be replayed. The store has no shell
1701
+ * delete, so the entry is overwritten with a tombstone: a navigationOnly
1702
+ * entry with no document half and no snapshot, which document serving
1703
+ * treats as a MISS (shellServePlan, the build shell included) and partial
1704
+ * replay as `no-segment-snapshot`. The recapture overwrites it with a sound
1705
+ * entry. Only for a broken entry: a snapshot read that was merely slow
1706
+ * (`snapshotFailure: "unavailable"`) leaves the entry alone.
1707
+ */
1708
+ async function degradeUnreplayableShell(
1709
+ descriptor: ShellCaptureDescriptor,
1710
+ entry: ShellCacheEntry,
1711
+ scheduleRecapture: () => void,
1712
+ ): Promise<void> {
1713
+ const store = descriptor.store;
1714
+ try {
1715
+ await store?.putShell?.(
1716
+ descriptor.key,
1717
+ {
1718
+ reactVersion: entry.reactVersion,
1719
+ buildVersion: entry.buildVersion,
1720
+ navigationOnly: true,
1721
+ createdAt: Date.now(),
1722
+ },
1723
+ descriptor.ttl,
1724
+ 0,
1725
+ );
1726
+ } catch (error) {
1727
+ reportCacheError(error, "cache-write", "[ShellServe] tombstone");
1728
+ }
1729
+ store?.dropShellMemo?.(descriptor.key);
1730
+ scheduleRecapture();
1731
+ }
1732
+
1384
1733
  /**
1385
1734
  * Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
1386
1735
  * live tail behind them inside the response stream. Plain byte concatenation is
@@ -1398,15 +1747,18 @@ function serveShellHit(
1398
1747
  reqCtx: RequestContext<any>,
1399
1748
  handleStore: ReturnType<typeof getRequestContext>["_handleStore"],
1400
1749
  ssrModule: SSRModule,
1401
- entry: DocumentShellCacheEntry,
1750
+ document: ShellDocument,
1402
1751
  descriptor: ShellCaptureDescriptor,
1752
+ nonce: string | undefined,
1403
1753
  ): Response {
1404
- const preludeBytes = base64ToBytes(entry.prelude);
1405
- // Per-stage tail timing for the dev `ppr:tail` Server-Timing mirror. Dev
1406
- // only (NODE_ENV folds the branch away in production builds); offsets are
1407
- // relative to this commit point.
1754
+ const { entry, prelude: preludeBytes } = document;
1755
+ const metricsStore = reqCtx._metricsStore;
1756
+ // Per-stage tail timing for the `ppr:tail` Server-Timing mirror and the
1757
+ // perf console line: always in dev, in production only when this request
1758
+ // collects debugPerformance metrics. Offsets are relative to this commit
1759
+ // point.
1408
1760
  const tailTiming: ShellTailTiming | null =
1409
- process.env.NODE_ENV !== "production"
1761
+ process.env.NODE_ENV !== "production" || metricsStore
1410
1762
  ? {
1411
1763
  key: descriptor.key,
1412
1764
  outcome: "complete",
@@ -1414,6 +1766,8 @@ function serveShellHit(
1414
1766
  }
1415
1767
  : null;
1416
1768
  const tailT0 = tailTiming ? performance.now() : 0;
1769
+ const scheduleRecapture = (): void =>
1770
+ scheduleShellCapture(ctx, request, env, url, reqCtx, ssrModule, descriptor);
1417
1771
 
1418
1772
  const createTailContext = (): RequestContext<any> => {
1419
1773
  const tailCtx: RequestContext<any> = Object.create(reqCtx);
@@ -1486,7 +1840,7 @@ function serveShellHit(
1486
1840
  }
1487
1841
  return observePhase(PHASES.ssr, () =>
1488
1842
  ssrModule.resumeShellHTML!(rscStream, {
1489
- postponed: entry.postponed,
1843
+ postponed: document.postponed,
1490
1844
  nonce: undefined,
1491
1845
  // The shell key's own search — identical to the capture seed for
1492
1846
  // this key, so the resume tree matches the captured tree.
@@ -1509,64 +1863,78 @@ function serveShellHit(
1509
1863
  // values via a SeededShellStore overlay while unpinned reads (the holes)
1510
1864
  // stay live. The overlay lives on a DERIVED context so the shared reqCtx is
1511
1865
  // untouched.
1512
- if (entry.snapshot && entry.snapshot.length > 0) {
1513
- const seededCtx = createTailContext();
1514
- if (reqCtx._cacheStore) {
1515
- seededCtx._cacheStore = new SeededShellStore(
1516
- reqCtx._cacheStore,
1517
- entry.snapshot,
1518
- );
1519
- }
1520
- // Loader-family records (bake-lane containers, loader-container-bake):
1521
- // decode into a seed Map for the resolveLoaderData overlay, so the
1522
- // payload's baked container bytes match the frozen prelude while the
1523
- // hole-marker paths keep the fresh run's live nested promises.
1524
- const seedStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
1525
- const loaderSeed = await buildShellLoaderSeed(entry.snapshot);
1526
- if (tailTiming) {
1527
- tailTiming.seedMs = Math.round(performance.now() - tailT0);
1528
- }
1529
- if (INTERNAL_RANGO_DEBUG) {
1530
- console.log(
1531
- `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
1532
- );
1533
- }
1534
- if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
1535
- // Shell fast path (serve side): if the capture recorded the implicit doc
1536
- // segment record and the handler layer declared no liveness, the tail's
1537
- // cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
1538
- // not re-executed (loaders still run fresh). Anything else degrades to
1539
- // the full tail automatically.
1540
- if (!entry.handlerLiveHoles && !entry.transitionWhen) {
1541
- seededCtx._shellImplicitCache = {
1542
- ttl: descriptor.ttl,
1543
- swr: descriptor.swr,
1544
- keyPrefix: "doc",
1545
- };
1546
- if (INTERNAL_RANGO_DEBUG) {
1547
- console.log(
1548
- `[Server][ppr] shell HIT: fast path armed (implicit doc cache) (abs ${Math.round(performance.now())})`,
1549
- );
1550
- }
1551
- } else if (INTERNAL_RANGO_DEBUG) {
1552
- console.log(
1553
- `[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
1554
- );
1866
+ // A prelude-first read delivers the snapshot on its own promise: the
1867
+ // prelude is already committed, so only the tail waits for it.
1868
+ const inMemory = await isSettled(document.snapshot);
1869
+ const snapshot = await document.snapshot;
1870
+ // A snapshot already in memory (shell memo, memory store, build shells)
1871
+ // resolves at once, and the tail's seeding and match would run ahead of
1872
+ // the runtime writing the prelude: one macrotask first. A snapshot still
1873
+ // arriving on I/O has yielded by the time it resolves.
1874
+ if (inMemory) await new Promise<void>((resolve) => setTimeout(resolve, 0));
1875
+ if (tailTiming) {
1876
+ tailTiming.snapshotMs = Math.round(performance.now() - tailT0);
1877
+ if (snapshot)
1878
+ tailTiming.snapshotRecords = countSnapshotFamilies(snapshot);
1879
+ tailTiming.snapshotPruned = entry.prunedRecords;
1880
+ const snapshotStats = document.stats?.snapshot;
1881
+ if (snapshotStats) {
1882
+ tailTiming.snapshotReadMs = Math.round(snapshotStats.readMs);
1883
+ tailTiming.snapshotBytes = snapshotStats.bytes;
1884
+ tailTiming.snapshotParseMs = Math.round(snapshotStats.parseMs);
1555
1885
  }
1556
- // Fragment splice (issue #700): store hits in THIS tail emit their stored
1557
- // segment fragments verbatim (expanded by segment-fragments.ts). The flag
1558
- // lives on the derived context so it can never leak into a capture render,
1559
- // which serializes segments and must see real elements.
1560
- seededCtx._shellFragmentPayload = true;
1561
- return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
1562
1886
  }
1563
- // No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
1564
- // fragment splice on a derived context — never the shared reqCtx, from which
1565
- // scheduleShellCapture derives the capture context (the flag must not be
1566
- // inherited there).
1567
- const fragmentCtx = createTailContext();
1568
- fragmentCtx._shellFragmentPayload = true;
1569
- return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
1887
+ const seededCtx = createTailContext();
1888
+ const records = snapshot ?? [];
1889
+ if (reqCtx._cacheStore && records.length > 0) {
1890
+ seededCtx._cacheStore = new SeededShellStore(reqCtx._cacheStore, records);
1891
+ }
1892
+ // Loader-family records (bake-lane containers, loader-container-bake):
1893
+ // decode into a seed Map for the resolveLoaderData overlay, so the
1894
+ // payload's baked container bytes match the frozen prelude while the
1895
+ // hole-marker paths keep the fresh run's live nested promises.
1896
+ const seedStart =
1897
+ INTERNAL_RANGO_DEBUG || tailTiming ? performance.now() : 0;
1898
+ const loaderSeed =
1899
+ records.length > 0 ? await buildShellLoaderSeed(records) : undefined;
1900
+ if (tailTiming) {
1901
+ const seededAt = performance.now();
1902
+ tailTiming.seedMs = Math.round(seededAt - tailT0);
1903
+ tailTiming.seedCpuMs = Math.round(seededAt - seedStart);
1904
+ }
1905
+ if (INTERNAL_RANGO_DEBUG) {
1906
+ console.log(
1907
+ `[Server][ppr] shell HIT: loader seed built +${Math.round(performance.now() - seedStart)}ms (abs ${Math.round(performance.now())})`,
1908
+ );
1909
+ }
1910
+ if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
1911
+ // The handler layer is REPLAYED from the entry's own doc record, never
1912
+ // executed: the implicit doc scope replaces any route-derived scope
1913
+ // (docTail), looks the record up by the key the capture wrote
1914
+ // (fixedDocKey), and reads it from a segment-only overlay of the snapshot
1915
+ // (the real store never holds a doc: record). A lookup that does not hit
1916
+ // throws ShellRecordUnavailableError (withCacheLookup), handled by the
1917
+ // degrade below. Live and promise-carrying bake-lane loaders still run
1918
+ // fresh; a promise-free bake-lane loader is served from its pin
1919
+ // (_shellLoaderSeed, loader-cache.ts). A Prerender route's tail takes
1920
+ // the handler layer from the prerender store before this scope is
1921
+ // consulted.
1922
+ seededCtx._shellImplicitCache = {
1923
+ ttl: descriptor.ttl,
1924
+ swr: descriptor.swr,
1925
+ store: new SeededShellStore(reqCtx._cacheStore!, records, {
1926
+ segmentsOnly: true,
1927
+ }),
1928
+ keyPrefix: "doc",
1929
+ docTail: true,
1930
+ fixedDocKey: entry.docKey,
1931
+ };
1932
+ // Fragment splice (issue #700): store hits in THIS tail emit their stored
1933
+ // segment fragments verbatim (expanded by segment-fragments.ts). The flag
1934
+ // lives on the derived context so it can never leak into a capture render,
1935
+ // which serializes segments and must see real elements.
1936
+ seededCtx._shellFragmentPayload = true;
1937
+ return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
1570
1938
  })();
1571
1939
  // The stream below is the only consumer; pre-attach a no-op catch so a tail
1572
1940
  // failure before the stream is pulled never surfaces as an unhandled rejection.
@@ -1575,7 +1943,31 @@ function serveShellHit(
1575
1943
  const serveStart = INTERNAL_RANGO_DEBUG ? performance.now() : 0;
1576
1944
  const body = new ReadableStream<Uint8Array>({
1577
1945
  async start(controller) {
1578
- controller.enqueue(preludeBytes);
1946
+ // Fixed-size chunks (SHELL_PRELUDE_CHUNK_BYTES): a compressor in front
1947
+ // of the worker emits after each chunk instead of after the whole
1948
+ // prelude. slice() gives every chunk its own buffer.
1949
+ const commitStart = metricsStore ? performance.now() : 0;
1950
+ let chunks = 0;
1951
+ for (
1952
+ let offset = 0;
1953
+ offset < preludeBytes.length;
1954
+ offset += SHELL_PRELUDE_CHUNK_BYTES
1955
+ ) {
1956
+ controller.enqueue(
1957
+ preludeBytes.slice(offset, offset + SHELL_PRELUDE_CHUNK_BYTES),
1958
+ );
1959
+ chunks++;
1960
+ }
1961
+ if (metricsStore) {
1962
+ appendMetric(
1963
+ metricsStore,
1964
+ "ppr:shell-commit",
1965
+ commitStart,
1966
+ performance.now() - commitStart,
1967
+ undefined,
1968
+ `cpu chunks=${chunks} prelude=${preludeBytes.length}b`,
1969
+ );
1970
+ }
1579
1971
  if (INTERNAL_RANGO_DEBUG) {
1580
1972
  console.log(
1581
1973
  `[Server][ppr] shell HIT: prelude enqueued (${preludeBytes.length}b) +${Math.round(performance.now() - serveStart)}ms`,
@@ -1647,7 +2039,10 @@ function serveShellHit(
1647
2039
  );
1648
2040
  controller.enqueue(
1649
2041
  new TextEncoder().encode(
1650
- `<script>location.replace(${JSON.stringify(safeTarget)})</script>`,
2042
+ inlineShellScript(
2043
+ `location.replace(${escapeJsonForScript(JSON.stringify(safeTarget))})`,
2044
+ nonce,
2045
+ ),
1651
2046
  ),
1652
2047
  );
1653
2048
  if (tailTiming) {
@@ -1655,9 +2050,34 @@ function serveShellHit(
1655
2050
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
1656
2051
  }
1657
2052
  }
1658
- if (tailTiming) publishShellTailTiming(tailTiming);
2053
+ publishTailTiming(tailTiming, metricsStore, request, url);
1659
2054
  controller.close();
1660
2055
  } catch (error) {
2056
+ if (error instanceof ShellRecordUnavailableError) {
2057
+ // The doc record could not supply the handler layer, and running
2058
+ // handlers behind a committed prelude is exactly what a HIT must
2059
+ // never do: reload the page into a forced MISS. A broken entry is
2060
+ // also replaced (tombstone, memo dropped, recapture); one whose
2061
+ // snapshot read was only slow stays, and the next request reads it
2062
+ // again.
2063
+ if ((await document.snapshotFailure) !== "unavailable") {
2064
+ await degradeUnreplayableShell(
2065
+ descriptor,
2066
+ entry,
2067
+ scheduleRecapture,
2068
+ );
2069
+ }
2070
+ controller.enqueue(
2071
+ new TextEncoder().encode(shellReloadScript(nonce)),
2072
+ );
2073
+ if (tailTiming) {
2074
+ tailTiming.outcome = "error";
2075
+ tailTiming.completeMs = Math.round(performance.now() - tailT0);
2076
+ }
2077
+ publishTailTiming(tailTiming, metricsStore, request, url);
2078
+ controller.close();
2079
+ return;
2080
+ }
1661
2081
  // Self-heal on a failed tail: errors the pre-commit gates cannot catch
1662
2082
  // (mismatched postponed blob, hard render error above the holes) throw
1663
2083
  // here AFTER the 200 + prelude flushed and would re-fail on every
@@ -1676,8 +2096,8 @@ function serveShellHit(
1676
2096
  if (tailTiming) {
1677
2097
  tailTiming.outcome = "error";
1678
2098
  tailTiming.completeMs = Math.round(performance.now() - tailT0);
1679
- publishShellTailTiming(tailTiming);
1680
2099
  }
2100
+ publishTailTiming(tailTiming, metricsStore, request, url);
1681
2101
  controller.error(error);
1682
2102
  }
1683
2103
  },