@rangojs/router 0.1.0 → 0.2.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 (128) hide show
  1. package/README.md +2 -1
  2. package/dist/bin/rango.js +11 -1
  3. package/dist/types/browser/dev-discovery.d.ts +11 -0
  4. package/dist/types/browser/navigation-store.d.ts +3 -13
  5. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  6. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  7. package/dist/types/browser/types.d.ts +4 -16
  8. package/dist/types/build/generate-manifest.d.ts +6 -0
  9. package/dist/types/cache/cache-scope.d.ts +7 -5
  10. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  11. package/dist/types/cache/document-cache.d.ts +3 -1
  12. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  13. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  14. package/dist/types/cache/types.d.ts +25 -7
  15. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  16. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  17. package/dist/types/prerender/store.d.ts +1 -0
  18. package/dist/types/route-definition/helpers-types.d.ts +6 -6
  19. package/dist/types/route-map-builder.d.ts +4 -29
  20. package/dist/types/router/match-handlers.d.ts +2 -3
  21. package/dist/types/router/prerender-match.d.ts +4 -1
  22. package/dist/types/router/router-interfaces.d.ts +3 -1
  23. package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
  24. package/dist/types/router/transition-when.d.ts +13 -0
  25. package/dist/types/rsc/handler-context.d.ts +1 -0
  26. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  27. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  28. package/dist/types/rsc/shell-capture.d.ts +4 -8
  29. package/dist/types/rsc/shell-serve.d.ts +2 -0
  30. package/dist/types/rsc/transition-gate.d.ts +10 -14
  31. package/dist/types/rsc/types.d.ts +2 -0
  32. package/dist/types/server/context.d.ts +16 -0
  33. package/dist/types/server/request-context.d.ts +22 -8
  34. package/dist/types/server.d.ts +1 -1
  35. package/dist/types/testing/e2e/index.d.ts +1 -1
  36. package/dist/types/testing/index.d.ts +2 -2
  37. package/dist/types/testing/run-transition-when.d.ts +6 -5
  38. package/dist/types/testing/shell-status.d.ts +23 -3
  39. package/dist/types/types/route-entry.d.ts +3 -0
  40. package/dist/types/types/segments.d.ts +27 -22
  41. package/dist/types/urls/path-helper-types.d.ts +4 -3
  42. package/dist/types/vite/discovery/state.d.ts +3 -2
  43. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  44. package/dist/vite/index.js +194 -107
  45. package/package.json +24 -20
  46. package/skills/cache-guide/SKILL.md +6 -3
  47. package/skills/caching/SKILL.md +1 -1
  48. package/skills/catalog.json +7 -1
  49. package/skills/deployment-caching/SKILL.md +176 -0
  50. package/skills/document-cache/SKILL.md +30 -3
  51. package/skills/ppr/SKILL.md +90 -14
  52. package/skills/prerender/SKILL.md +15 -8
  53. package/skills/rango/SKILL.md +20 -17
  54. package/skills/testing/SKILL.md +1 -1
  55. package/skills/testing/cache-prerender.md +5 -1
  56. package/skills/vercel/SKILL.md +22 -1
  57. package/skills/view-transitions/SKILL.md +12 -8
  58. package/src/browser/dev-discovery.ts +66 -0
  59. package/src/browser/navigation-client.ts +6 -0
  60. package/src/browser/navigation-store.ts +4 -271
  61. package/src/browser/partial-update.ts +7 -14
  62. package/src/browser/prefetch/cache.ts +1 -1
  63. package/src/browser/prefetch/loader.ts +110 -0
  64. package/src/browser/prefetch/runtime.ts +7 -0
  65. package/src/browser/react/Link.tsx +7 -10
  66. package/src/browser/react/NavigationProvider.tsx +1 -1
  67. package/src/browser/react/use-router.ts +1 -1
  68. package/src/browser/rsc-router.tsx +4 -2
  69. package/src/browser/types.ts +4 -27
  70. package/src/build/generate-manifest.ts +11 -0
  71. package/src/cache/cache-scope.ts +23 -7
  72. package/src/cache/cf/cf-cache-store.ts +63 -39
  73. package/src/cache/document-cache.ts +4 -2
  74. package/src/cache/memory-segment-store.ts +17 -6
  75. package/src/cache/shell-snapshot.ts +45 -15
  76. package/src/cache/types.ts +24 -6
  77. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  78. package/src/dev-discovery-protocol.ts +11 -0
  79. package/src/prerender/build-shell-capture.ts +7 -1
  80. package/src/prerender/store.ts +5 -0
  81. package/src/route-definition/dsl-helpers.ts +7 -2
  82. package/src/route-definition/helpers-types.ts +6 -6
  83. package/src/route-map-builder.ts +56 -49
  84. package/src/router/handler-context.ts +13 -5
  85. package/src/router/lazy-includes.ts +1 -0
  86. package/src/router/match-api.ts +8 -4
  87. package/src/router/match-handlers.ts +44 -6
  88. package/src/router/match-middleware/cache-lookup.ts +10 -3
  89. package/src/router/prerender-match.ts +19 -2
  90. package/src/router/router-interfaces.ts +4 -0
  91. package/src/router/segment-resolution/static-store.ts +36 -10
  92. package/src/router/segment-resolution/view-transition-default.ts +9 -5
  93. package/src/router/transition-when.ts +76 -0
  94. package/src/router.ts +34 -3
  95. package/src/rsc/handler-context.ts +1 -0
  96. package/src/rsc/handler.ts +3 -1
  97. package/src/rsc/loader-fetch.ts +2 -2
  98. package/src/rsc/manifest-init.ts +4 -11
  99. package/src/rsc/progressive-enhancement.ts +9 -14
  100. package/src/rsc/render-pipeline.ts +10 -2
  101. package/src/rsc/rsc-rendering.ts +319 -147
  102. package/src/rsc/shell-capture.ts +34 -11
  103. package/src/rsc/shell-serve.ts +3 -0
  104. package/src/rsc/transition-gate.ts +37 -40
  105. package/src/rsc/types.ts +2 -0
  106. package/src/server/context.ts +28 -0
  107. package/src/server/request-context.ts +38 -10
  108. package/src/server.ts +0 -2
  109. package/src/testing/dispatch.ts +1 -0
  110. package/src/testing/e2e/index.ts +5 -0
  111. package/src/testing/index.ts +9 -1
  112. package/src/testing/run-transition-when.ts +42 -9
  113. package/src/testing/shell-status.ts +92 -3
  114. package/src/types/route-entry.ts +3 -0
  115. package/src/types/segments.ts +27 -22
  116. package/src/urls/include-helper.ts +1 -0
  117. package/src/urls/path-helper-types.ts +4 -3
  118. package/src/urls/path-helper.ts +8 -4
  119. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  120. package/src/vite/discovery/discover-routers.ts +7 -66
  121. package/src/vite/discovery/prerender-collection.ts +13 -2
  122. package/src/vite/discovery/state.ts +4 -4
  123. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  124. package/src/vite/plugins/version-injector.ts +0 -1
  125. package/src/vite/plugins/virtual-entries.ts +11 -1
  126. package/src/vite/router-discovery.ts +132 -22
  127. package/src/vite/utils/manifest-utils.ts +1 -4
  128. package/src/vite/utils/shared-utils.ts +7 -0
@@ -10,6 +10,7 @@ import {
10
10
  getRequestContext,
11
11
  setRequestContextParams,
12
12
  runWithRequestContext,
13
+ wireRenderBarrier,
13
14
  } from "../server/request-context.js";
14
15
  import {
15
16
  SeededShellStore,
@@ -39,6 +40,7 @@ import {
39
40
  type ShellCaptureDescriptor,
40
41
  } from "./shell-capture.js";
41
42
  import {
43
+ PPR_REPLAY_STATUS_HEADER,
42
44
  SHELL_STATUS_HEADER,
43
45
  resolvePprConfig,
44
46
  buildShellKey,
@@ -51,9 +53,13 @@ import {
51
53
  describeShellTailTiming,
52
54
  publishShellTailTiming,
53
55
  takeShellTailTimingForServerTiming,
56
+ type ResolvedPprConfig,
54
57
  type ShellTailTiming,
55
58
  } from "./shell-serve.js";
56
- import { lookupBuildShell } from "./shell-build-manifest.js";
59
+ import {
60
+ lookupBuildShell,
61
+ type DevShellLookup,
62
+ } from "./shell-build-manifest.js";
57
63
  import { contextGet } from "../context-var.js";
58
64
  import {
59
65
  resolveSameOriginRedirect,
@@ -62,7 +68,88 @@ import {
62
68
  import { nonce as nonceToken } from "./nonce.js";
63
69
  import { reportCacheError } from "../cache/cache-error.js";
64
70
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
65
- import type { ShellCacheEntry } from "../cache/types.js";
71
+ import type { ShellCacheEntry, ShellSnapshotRecord } from "../cache/types.js";
72
+
73
+ type PprReplayBypassReason =
74
+ | "method"
75
+ | "dynamic"
76
+ | "nonce"
77
+ | "store-unavailable"
78
+ | "passive-read-unsupported"
79
+ | "read-error"
80
+ | "no-entry"
81
+ | "invalid-version"
82
+ | "corrupt-entry"
83
+ | "handler-live-holes"
84
+ | "transition-when"
85
+ | "no-segment-snapshot"
86
+ | "snapshot-miss"
87
+ | "stale-build-entry";
88
+
89
+ type PprReplayStatus =
90
+ | { outcome: "HIT"; freshness: "fresh" | "stale" }
91
+ | { outcome: "BYPASS"; reason: PprReplayBypassReason };
92
+
93
+ type ShellReplayDecision =
94
+ | { snapshot: ShellSnapshotRecord[] }
95
+ | { reason: PprReplayBypassReason };
96
+
97
+ function serializePprReplayStatus(status: PprReplayStatus): string {
98
+ return status.outcome === "HIT"
99
+ ? `HIT; freshness=${status.freshness}`
100
+ : `BYPASS; reason=${status.reason}`;
101
+ }
102
+
103
+ function describePprReplayStatus(status: PprReplayStatus): string {
104
+ return status.outcome === "HIT"
105
+ ? status.freshness
106
+ : `bypass:${status.reason}`;
107
+ }
108
+
109
+ function resolveDevShellLookup(
110
+ reqCtx: RequestContext<any>,
111
+ pprConfig: ResolvedPprConfig,
112
+ ): DevShellLookup | undefined {
113
+ if (process.env.NODE_ENV === "production") return undefined;
114
+ return {
115
+ isPrerenderRoute: reqCtx._classifiedRoute?.matched?.pr === true,
116
+ routeName: reqCtx._classifiedRoute?.routeKey,
117
+ ttl: pprConfig.ttl,
118
+ swr: pprConfig.swr,
119
+ tags: pprConfig.tags,
120
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
121
+ captureTimeout: pprConfig.captureTimeout,
122
+ };
123
+ }
124
+
125
+ function replayableShellSnapshot(
126
+ entry: ShellCacheEntry,
127
+ buildVersion: string,
128
+ ): ShellReplayDecision {
129
+ if (!isValidShellHit(entry, buildVersion)) {
130
+ return { reason: "invalid-version" };
131
+ }
132
+ if (!hasIntactShellPayload(entry)) return { reason: "corrupt-entry" };
133
+ if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
134
+ if (entry.transitionWhen) return { reason: "transition-when" };
135
+ const snapshot = entry.snapshot;
136
+ const hasSegments = snapshot?.some((record) => {
137
+ if (
138
+ !record ||
139
+ typeof record !== "object" ||
140
+ record.family !== "segment" ||
141
+ typeof record.value !== "object" ||
142
+ record.value === null
143
+ ) {
144
+ return false;
145
+ }
146
+ const segments = (record.value as { segments?: unknown }).segments;
147
+ return Array.isArray(segments) && segments.length > 0;
148
+ });
149
+ return hasSegments && snapshot
150
+ ? { snapshot }
151
+ : { reason: "no-segment-snapshot" };
152
+ }
66
153
 
67
154
  export function handleRscRendering<TEnv>(
68
155
  ctx: HandlerContext<TEnv>,
@@ -106,24 +193,14 @@ async function handleRscRenderingInner<TEnv>(
106
193
 
107
194
  let payload: RscPayload;
108
195
  let hasInterceptSlots = false;
196
+ let pprReplayStatus: PprReplayStatus | undefined;
109
197
 
110
198
  // --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
111
199
  //
112
- // COMMIT POINT. This function is the render pass executeRender wraps, so it runs
113
- // strictly AFTER the whole middleware chain the global router.use() chain AND
114
- // route DSL middleware() both wrap it. Any middleware rejection/redirect/401 has
115
- // already returned before this line, which is what makes a shared shell safe:
116
- // not a single shell byte can precede a guard decision, on MISS or HIT.
117
- //
118
- // PPR is opt-in per PAGE ROUTE via the `ppr` path option (read off the classified
119
- // route snapshot — the same matched entry match() will resolve). No `ppr` option
120
- // means pure axis 1: no store read, no capture, no logs, zero cost.
121
- //
122
- // On a valid HIT the composed response is committed HERE — the stored prelude
123
- // bytes flush immediately while match()/segment resolution/Flight render/resume
124
- // run behind them inside the response stream (ring-3 reads and render setup hide
125
- // behind wire bytes). On a MISS the request continues as plain axis 1 and a
126
- // background capture is scheduled after the response is built.
200
+ // COMMIT POINT: this render pass runs strictly AFTER the whole middleware
201
+ // chain (executeRender wraps it), so no shell byte can precede a guard
202
+ // decision that ordering is what makes a shared shell safe. Routes
203
+ // without the `ppr` option stay pure axis 1 at zero cost.
127
204
  let pprMiss: {
128
205
  descriptor: ShellCaptureDescriptor;
129
206
  ssrModule: SSRModule;
@@ -137,30 +214,18 @@ async function handleRscRenderingInner<TEnv>(
137
214
  ) {
138
215
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
139
216
  if (pprConfig) {
140
- // A per-request CSP nonce pins the route to axis 1: useNonce() (and any app
141
- // code reading the nonce) renders it into every nonced script/style/meta, so
142
- // a shell shared per host+URL would freeze one request's nonce for every
143
- // visitor and the browser's CSP would reject the frozen nonce for all but the
144
- // capture request. The nonce arrives two ways and BOTH must gate: the
145
- // createRouter({ nonce }) provider (threaded here as `nonce`), and a direct
146
- // token write in middleware (ctx.set(nonce, value)). The token is only
147
- // visible in the post-middleware request variables — and this commit point
148
- // runs AFTER the whole middleware chain (see the block header), so it is
149
- // present here. Reading it closes the gap the provider-only check left open
150
- // (issue #656). The threaded-param check stays first: the provider path is
151
- // resolved before any variable read and short-circuits cheaply.
217
+ // A per-request CSP nonce pins the route to axis 1: a shared shell would
218
+ // freeze the capture request's nonce and CSP would reject it for every
219
+ // other visitor. BOTH nonce sources must gate the createRouter({ nonce })
220
+ // provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
221
+ // the provider-only check missed the latter (issue #656).
152
222
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
153
223
  const store = reqCtx._cacheStore;
154
224
  const key = buildShellKey(url);
155
- // Dev Server-Timing mirror (issue #651): a capture runs AFTER its
225
+ // Dev Server-Timing mirror (issue #651): a capture completes AFTER its
156
226
  // triggering response committed, so its outcome can only ride a LATER
157
- // response's header. When the metrics surface is active
158
- // (debugPerformance), fold the buffered terminal capture event for this
159
- // key into THIS request's Server-Timing as `ppr-capture;dur=<attempt
160
- // ms>;desc="<outcome + sizes + waits>"`. Consuming (read-and-clear)
161
- // keeps one capture = one report. Dev-only: the buffer is only written
162
- // in dev (see takeCaptureDebugEventForTiming), and production folds the
163
- // whole branch away.
227
+ // response's header. Read-and-clear keeps one capture = one report;
228
+ // dev-only (see takeCaptureDebugEventForTiming).
164
229
  if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
165
230
  const lastCapture = takeCaptureDebugEventForTiming(key);
166
231
  if (lastCapture) {
@@ -303,16 +368,11 @@ async function handleRscRenderingInner<TEnv>(
303
368
  }
304
369
  }
305
370
  // Build-time shell read-through (producer B, #699): on a runtime
306
- // store MISS (or an invalid/corrupt runtime entry), a Prerender+ppr
307
- // route's shell was already produced at `vite build` — serve it
308
- // through the SAME serveShellHit, so the first-ever request after a
309
- // deploy is a HIT with zero runtime capture. lookupBuildShell owns
310
- // every gate (search-less request, versions, integrity, tag
311
- // markers) and fails to null — the ordinary MISS path below takes
312
- // over. Past ppr.ttl the baked entry still serves but a runtime
313
- // recapture is scheduled: SWR is the UPGRADE path from build entry
314
- // to fresher runtime entry (the runtime store read above wins once
315
- // the capture lands).
371
+ // store MISS a Prerender+ppr route serves its `vite build`-baked
372
+ // shell through the SAME serveShellHit. lookupBuildShell owns every
373
+ // gate and fails to null (ordinary MISS path takes over); past
374
+ // ppr.ttl the baked entry still serves while SWR recaptures — the
375
+ // upgrade path from build entry to runtime entry.
316
376
  const buildHit = await lookupBuildShell(
317
377
  url,
318
378
  ctx.version,
@@ -321,18 +381,7 @@ async function handleRscRenderingInner<TEnv>(
321
381
  // the dev server's /__rsc_shell endpoint for PRERENDERED routes
322
382
  // only (production's exact candidate set). Folded away in
323
383
  // production builds (NODE_ENV is a compile-time constant).
324
- process.env.NODE_ENV !== "production"
325
- ? {
326
- isPrerenderRoute:
327
- reqCtx._classifiedRoute?.matched?.pr === true,
328
- routeName: reqCtx._classifiedRoute?.routeKey,
329
- ttl: pprConfig.ttl,
330
- swr: pprConfig.swr,
331
- tags: pprConfig.tags,
332
- maxSnapshotBytes: pprConfig.maxSnapshotBytes,
333
- captureTimeout: pprConfig.captureTimeout,
334
- }
335
- : undefined,
384
+ resolveDevShellLookup(reqCtx, pprConfig),
336
385
  );
337
386
  if (buildHit) {
338
387
  // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
@@ -348,7 +397,16 @@ async function handleRscRenderingInner<TEnv>(
348
397
 
349
398
  if (isPartial) {
350
399
  // Partial render (navigation)
351
- const result = await ctx.router.matchPartial(request, { env });
400
+ const replay = await matchPartialWithPprReplay(
401
+ ctx,
402
+ request,
403
+ env,
404
+ url,
405
+ reqCtx,
406
+ nonce,
407
+ );
408
+ const result = replay.result;
409
+ pprReplayStatus = replay.status;
352
410
 
353
411
  if (!result) {
354
412
  // Fall back to full render
@@ -360,10 +418,17 @@ async function handleRscRenderingInner<TEnv>(
360
418
  // perform SPA navigation. A raw 308 would be auto-followed by
361
419
  // fetch, hitting the target without _rsc_partial. Resolve the
362
420
  // target server-side (same open-redirect policy as 3xx).
363
- return createSimpleRedirectResponse(match.redirect, {
421
+ const redirectResponse = createSimpleRedirectResponse(match.redirect, {
364
422
  requestOrigin: url.origin,
365
423
  basename: ctx.router.basename,
366
424
  });
425
+ if (pprReplayStatus) {
426
+ redirectResponse.headers.set(
427
+ PPR_REPLAY_STATUS_HEADER,
428
+ serializePprReplayStatus(pprReplayStatus),
429
+ );
430
+ }
431
+ return redirectResponse;
367
432
  }
368
433
 
369
434
  payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
@@ -466,6 +531,10 @@ async function handleRscRenderingInner<TEnv>(
466
531
  // are deliberately NOT stamped. See browser/response-adapter.ts.
467
532
  "X-RSC-Router-Id": ctx.router.id,
468
533
  };
534
+ if (pprReplayStatus) {
535
+ rscHeaders[PPR_REPLAY_STATUS_HEADER] =
536
+ serializePprReplayStatus(pprReplayStatus);
537
+ }
469
538
  // Tell the client's prefetch cache to scope this response to its source
470
539
  // URL (instead of the default source-agnostic wildcard). Intercept
471
540
  // responses depend on the source page matching an intercept rule, so
@@ -517,13 +586,9 @@ async function handleRscRenderingInner<TEnv>(
517
586
  );
518
587
 
519
588
  // --- Axis 2: PPR shell CAPTURE on MISS (background task; see design doc) ---
520
- // The ppr route missed its shell above. Schedule the background capture only
521
- // when the served response is a 200 HTML document (a 404/error render is not a
522
- // cacheable shell), and tag the response for observability either way. Capture
523
- // does NOT flow through the HTTP pipeline: scheduleShellCapture re-derives the
524
- // page via router.match() under a derived context (fresh handle store,
525
- // _shellCaptureRun: true) — middleware never re-runs; it already ran for this
526
- // request and guarding is serve-time.
589
+ // Capture only a 200 HTML document (a 404/error render is not a cacheable
590
+ // shell). Capture does not flow through the HTTP pipeline middleware never
591
+ // re-runs (it already ran for this request; guarding is serve-time).
527
592
  if (pprMiss && !reqCtx._dynamic) {
528
593
  if (
529
594
  response.status === 200 &&
@@ -546,16 +611,143 @@ async function handleRscRenderingInner<TEnv>(
546
611
  }
547
612
 
548
613
  /**
549
- * Neutralize the shell-HIT degradation redirect target.
550
- *
551
- * The inline `location.replace` emitted by serveShellHit when a shell HIT lands
552
- * on a URL whose route became redirecting mid-TTL is a document-native redirect
553
- * exit that BYPASSES the 3xx chokepoint (guardOutgoingRedirect acts only on 3xx
554
- * + Location responses, never a committed 200 body). So it reuses the ONE
555
- * same-origin resolver directly: a cross-origin/unparseable/unsafe target
556
- * neutralizes to the same safe same-origin landing as redirect-guard.ts
557
- * (basename root, or "/" when unset) rather than navigating the user off-host.
558
- * A safe same-origin/relative target passes through as its normalized href.
614
+ * Reuse a PPR capture's canonical segment record for a partial navigation.
615
+ * The ordinary matchPartial pipeline remains authoritative: it projects the
616
+ * cached target tree against the client's segment ids, evaluates revalidation,
617
+ * and resolves every loader fresh. Only segment-family records are seeded;
618
+ * captured item/response/loader values belong to document parity and must not
619
+ * pin navigation data.
620
+ */
621
+ async function matchPartialWithPprReplay<TEnv>(
622
+ ctx: HandlerContext<TEnv>,
623
+ request: Request,
624
+ env: TEnv,
625
+ url: URL,
626
+ reqCtx: RequestContext<any>,
627
+ nonce: string | undefined,
628
+ ) {
629
+ const replayStart = reqCtx._metricsStore ? performance.now() : 0;
630
+ const recordReplayStatus = (status: PprReplayStatus): void => {
631
+ if (!reqCtx._metricsStore) return;
632
+ appendMetric(
633
+ reqCtx._metricsStore,
634
+ "ppr:navigation-replay",
635
+ replayStart,
636
+ performance.now() - replayStart,
637
+ undefined,
638
+ describePprReplayStatus(status),
639
+ );
640
+ };
641
+ const runMatch = async (status?: PprReplayStatus) => {
642
+ const result = await ctx.router.matchPartial(request, { env });
643
+ if (status) recordReplayStatus(status);
644
+ return { result, status };
645
+ };
646
+ const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
647
+ const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
648
+ const store = reqCtx._cacheStore;
649
+
650
+ if (!pprConfig) return runMatch();
651
+ if (request.method !== "GET") {
652
+ return runMatch({ outcome: "BYPASS", reason: "method" });
653
+ }
654
+ if (reqCtx._dynamic) {
655
+ return runMatch({ outcome: "BYPASS", reason: "dynamic" });
656
+ }
657
+ if (activeNonce !== undefined) {
658
+ return runMatch({ outcome: "BYPASS", reason: "nonce" });
659
+ }
660
+ if (!hasShellFamily(store)) {
661
+ return runMatch({ outcome: "BYPASS", reason: "store-unavailable" });
662
+ }
663
+ if (store.supportsPassiveShellReads !== true) {
664
+ return runMatch({
665
+ outcome: "BYPASS",
666
+ reason: "passive-read-unsupported",
667
+ });
668
+ }
669
+
670
+ const key = buildShellKey(url);
671
+ let cached: Awaited<ReturnType<typeof store.getShell>> = null;
672
+ try {
673
+ cached = await store.getShell(key, { claimRevalidation: false });
674
+ } catch (error) {
675
+ reportCacheError(error, "cache-read", "[NavigationPPR] getShell");
676
+ return runMatch({ outcome: "BYPASS", reason: "read-error" });
677
+ }
678
+
679
+ let bypassReason: PprReplayBypassReason | undefined;
680
+ let snapshot: ShellSnapshotRecord[] | undefined;
681
+ let freshness: "fresh" | "stale" = "fresh";
682
+ if (cached) {
683
+ const decision = replayableShellSnapshot(cached.entry, ctx.version);
684
+ if ("snapshot" in decision) {
685
+ snapshot = decision.snapshot;
686
+ freshness = cached.shouldRevalidate ? "stale" : "fresh";
687
+ } else {
688
+ bypassReason = decision.reason;
689
+ }
690
+ }
691
+
692
+ if (!snapshot) {
693
+ // Production build manifests are local module data. In dev, resolving a
694
+ // missing build shell would foreground-fetch /__rsc_shell and block an
695
+ // otherwise ordinary navigation on capture, so replay remains runtime-only.
696
+ const buildHit = await lookupBuildShell(url, ctx.version, store);
697
+ if (buildHit?.stale) {
698
+ bypassReason ??= "stale-build-entry";
699
+ } else if (buildHit) {
700
+ const buildDecision = replayableShellSnapshot(
701
+ buildHit.entry,
702
+ ctx.version,
703
+ );
704
+ if ("snapshot" in buildDecision) {
705
+ snapshot = buildDecision.snapshot;
706
+ } else {
707
+ bypassReason ??= buildDecision.reason;
708
+ }
709
+ }
710
+ }
711
+
712
+ if (!snapshot) {
713
+ return runMatch({
714
+ outcome: "BYPASS",
715
+ reason: bypassReason ?? "no-entry",
716
+ });
717
+ }
718
+
719
+ const previousImplicitCache = reqCtx._shellImplicitCache;
720
+ let segmentReplayHit = false;
721
+ reqCtx._shellImplicitCache = {
722
+ ttl: pprConfig.ttl,
723
+ swr: pprConfig.swr,
724
+ store: new SeededShellStore(store, snapshot, {
725
+ segmentsOnly: true,
726
+ }),
727
+ keyPrefix: "doc",
728
+ onHit: () => {
729
+ segmentReplayHit = true;
730
+ },
731
+ };
732
+
733
+ try {
734
+ const result = await ctx.router.matchPartial(request, { env });
735
+ const status: PprReplayStatus = segmentReplayHit
736
+ ? { outcome: "HIT", freshness }
737
+ : { outcome: "BYPASS", reason: "snapshot-miss" };
738
+ recordReplayStatus(status);
739
+ return { result, status };
740
+ } finally {
741
+ reqCtx._shellImplicitCache = previousImplicitCache;
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Neutralize the shell-HIT degradation redirect target. The inline
747
+ * `location.replace` in a committed 200 body bypasses the 3xx chokepoint
748
+ * (guardOutgoingRedirect only sees 3xx + Location), so this reuses the same
749
+ * same-origin resolver directly: unsafe targets neutralize to the
750
+ * redirect-guard.ts landing instead of navigating the user off-host.
559
751
  */
560
752
  export function resolveShellHitRedirectTarget(
561
753
  rawTarget: string,
@@ -569,23 +761,13 @@ export function resolveShellHitRedirectTarget(
569
761
  }
570
762
 
571
763
  /**
572
- * Serve a validated shell HIT: commit the composed response NOW the stored
573
- * prelude bytes are the first thing on the wire and run the live tail
574
- * (match(), fresh loaders, full Flight render for hydration, fizz resume of just
575
- * the holes) BEHIND them inside the response stream. React relies on HTML-parser
576
- * foster-parenting for content streamed after the prelude's closing
577
- * `</body></html>`, so plain byte concatenation is the correct composition.
578
- *
579
- * Status and headers are committed at the flush: middleware already ran (their
580
- * ctx.res headers merge in via createResponseWithMergedHeaders), and route
581
- * middleware code after its next() can still adjust headers on the returned
582
- * Response object. A failing hole cannot become a 500/redirect after this point —
583
- * error UI renders inline via Suspense/error boundaries, the documented PPR
584
- * constraint.
585
- *
586
- * The tail promise is kicked off SYNCHRONOUSLY so match/Flight/resume run inside
587
- * the current ALS request-context frame (the stream may be pulled by the server
588
- * adapter outside it).
764
+ * Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
765
+ * live tail behind them inside the response stream. Plain byte concatenation is
766
+ * correct React foster-parents content streamed after the prelude's closing
767
+ * `</body></html>`. After the flush a failing hole cannot become a 500/redirect
768
+ * (error UI renders inline the documented PPR constraint). The tail promise
769
+ * is kicked off SYNCHRONOUSLY so it runs inside the current ALS request-context
770
+ * frame; the adapter may pull the stream outside it.
589
771
  */
590
772
  function serveShellHit(
591
773
  ctx: HandlerContext<any>,
@@ -612,6 +794,15 @@ function serveShellHit(
612
794
  : null;
613
795
  const tailT0 = tailTiming ? performance.now() : 0;
614
796
 
797
+ const createTailContext = (): RequestContext<any> => {
798
+ const tailCtx: RequestContext<any> = Object.create(reqCtx);
799
+ // Matching writes render state onto the derived context. Its barrier must
800
+ // close over that same context or a streaming tail inherits the base
801
+ // context's premature non-streaming handle snapshot.
802
+ wireRenderBarrier(tailCtx, handleStore);
803
+ return tailCtx;
804
+ };
805
+
615
806
  const renderTail = async (
616
807
  activeCtx: RequestContext<any>,
617
808
  ): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
@@ -628,14 +819,10 @@ function serveShellHit(
628
819
  if (match.redirect) return { redirect: match.redirect };
629
820
  setRequestContextParams(match.params, match.routeName);
630
821
  const payload = buildFullPayload(match, ctx, url, activeCtx, handleStore);
631
- // Theme fidelity for resume: initialTheme is per-request METADATA (the
632
- // visitor's cookie), but React resume requires the tree above the holes to
633
- // match the frozen prelude, which was rendered with the CAPTURE's
634
- // initialTheme. Replay the captured value into the payload (the SSR resume
635
- // tree AND client hydration both read it) so the trees agree by
636
- // construction. The visitor still sees THEIR theme: the FOUC script in the
637
- // prelude applies it pre-paint from the cookie, and ThemeProvider re-syncs
638
- // its state from the cookie post-mount.
822
+ // Theme fidelity for resume: replay the CAPTURE's initialTheme into the
823
+ // payload so the resume/hydration trees match the frozen prelude by
824
+ // construction. The visitor still sees THEIR theme the prelude's FOUC
825
+ // script applies the cookie pre-paint and ThemeProvider re-syncs post-mount.
639
826
  if (payload.metadata) {
640
827
  payload.metadata.initialTheme = entry.initialTheme as
641
828
  | import("../theme/types.js").Theme
@@ -687,18 +874,13 @@ function serveShellHit(
687
874
  const tailPromise: Promise<
688
875
  ReadableStream<Uint8Array> | { redirect: string }
689
876
  > = (async () => {
690
- // Capture data snapshot seeding (docs/design/ppr-shell-resume.md): the tail
691
- // is a FULL FRESH render whose payload must match the frozen prelude. If the
692
- // capture recorded a snapshot, run the tail through a SeededShellStore
693
- // overlay so every cache-store read the capture pinned returns its
694
- // capture-time value AS FRESH — the shell region reproduces byte-identically
695
- // even after the underlying cache entries drifted (expired/recomputed/
696
- // tag-invalidated). Everything not pinned (the holes — masked loaders were
697
- // never recorded) falls through to the real store and stays LIVE. The
698
- // overlay lives on a DERIVED context (own _cacheStore), so the shared reqCtx
699
- // is untouched; an entry without a snapshot keeps the pre-snapshot behavior.
877
+ // Snapshot seeding (docs/design/ppr-shell-resume.md): the tail render must
878
+ // match the frozen prelude, so pinned cache reads replay their capture-time
879
+ // values via a SeededShellStore overlay while unpinned reads (the holes)
880
+ // stay live. The overlay lives on a DERIVED context so the shared reqCtx is
881
+ // untouched.
700
882
  if (entry.snapshot && entry.snapshot.length > 0) {
701
- const seededCtx: RequestContext<any> = Object.create(reqCtx);
883
+ const seededCtx = createTailContext();
702
884
  if (reqCtx._cacheStore) {
703
885
  seededCtx._cacheStore = new SeededShellStore(
704
886
  reqCtx._cacheStore,
@@ -720,19 +902,16 @@ function serveShellHit(
720
902
  );
721
903
  }
722
904
  if (loaderSeed) seededCtx._shellLoaderSeed = loaderSeed;
723
- // Shell fast path (serve side): when the capture recorded the implicit
724
- // doc segment record and the handler layer declared no liveness, arm the
725
- // implicit scope on the seeded context — the tail match's cache lookup
726
- // then HITs the SeededShellStore's doc entry and the whole handler layer
727
- // is REPLAYED, not re-executed (loaders still run fresh via
728
- // resolveFreshLoadersAndYield; per-request payload metadata is rebuilt
729
- // by buildFullPayload as always). A route with handler-live holes, a
730
- // route-derived cache scope, or a missing/corrupt record degrades to
731
- // the full tail (handler re-run — today's behavior) automatically.
732
- if (!entry.handlerLiveHoles) {
905
+ // Shell fast path (serve side): if the capture recorded the implicit doc
906
+ // segment record and the handler layer declared no liveness, the tail's
907
+ // cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
908
+ // not re-executed (loaders still run fresh). Anything else degrades to
909
+ // the full tail automatically.
910
+ if (!entry.handlerLiveHoles && !entry.transitionWhen) {
733
911
  seededCtx._shellImplicitCache = {
734
912
  ttl: descriptor.ttl,
735
913
  swr: descriptor.swr,
914
+ keyPrefix: "doc",
736
915
  };
737
916
  if (INTERNAL_RANGO_DEBUG) {
738
917
  console.log(
@@ -741,25 +920,21 @@ function serveShellHit(
741
920
  }
742
921
  } else if (INTERNAL_RANGO_DEBUG) {
743
922
  console.log(
744
- `[Server][ppr] shell HIT: fast path declined — handler-live holes; tail re-runs handlers (abs ${Math.round(performance.now())})`,
923
+ `[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
745
924
  );
746
925
  }
747
- // Fragment splice (issue #700): cache/prerender-store hits inside THIS
748
- // tail render emit their stored segment fragments verbatim into the
749
- // payload; the SSR resume pass and browser hydration expand them
750
- // (segment-fragments.ts). Tail-only: the flag lives on the derived
751
- // context so it can never leak into a capture render (which serializes
752
- // segments and must see real elements).
926
+ // Fragment splice (issue #700): store hits in THIS tail emit their stored
927
+ // segment fragments verbatim (expanded by segment-fragments.ts). The flag
928
+ // lives on the derived context so it can never leak into a capture render,
929
+ // which serializes segments and must see real elements.
753
930
  seededCtx._shellFragmentPayload = true;
754
931
  return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
755
932
  }
756
- // No snapshot (e.g. a producer B entry whose capture hit only the
757
- // prerender store): still a shell-HIT tail, so arm the fragment splice on
758
- // a derived context the tail's prerender-store/cache hits (if any) then
759
- // splice; a route with neither serves exactly as before. Derived, never
760
- // the shared reqCtx: scheduleShellCapture derives the capture context from
761
- // reqCtx and the flag must not be inherited there.
762
- const fragmentCtx: RequestContext<any> = Object.create(reqCtx);
933
+ // No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
934
+ // fragment splice on a derived context never the shared reqCtx, from which
935
+ // scheduleShellCapture derives the capture context (the flag must not be
936
+ // inherited there).
937
+ const fragmentCtx = createTailContext();
763
938
  fragmentCtx._shellFragmentPayload = true;
764
939
  return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
765
940
  })();
@@ -853,15 +1028,12 @@ function serveShellHit(
853
1028
  if (tailTiming) publishShellTailTiming(tailTiming);
854
1029
  controller.close();
855
1030
  } catch (error) {
856
- // Self-heal on a failed tail: the pre-commit gates (isValidShellHit +
857
- // hasIntactShellPayload) cannot catch a parseable-but-mismatched
858
- // postponed blob or a hard render error above the holes those throw
859
- // here, AFTER the 200 + prelude flushed, and would otherwise re-fail on
860
- // every request until the entry ages out (nothing else evicts it).
861
- // Recapturing overwrites the entry with one the current server
862
- // produced. A client disconnect mid-stream also lands here and
863
- // schedules a spurious-but-idempotent recapture — bounded by the
864
- // stampede guard + backoff inside scheduleShellCapture.
1031
+ // Self-heal on a failed tail: errors the pre-commit gates cannot catch
1032
+ // (mismatched postponed blob, hard render error above the holes) throw
1033
+ // here AFTER the 200 + prelude flushed and would re-fail on every
1034
+ // request until the entry ages out recapture overwrites the entry.
1035
+ // Client disconnects land here too; the recapture is idempotent and
1036
+ // bounded by scheduleShellCapture's stampede guard + backoff.
865
1037
  scheduleShellCapture(
866
1038
  ctx,
867
1039
  request,