@rangojs/router 0.1.0 → 0.1.1

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 (89) hide show
  1. package/dist/bin/rango.js +11 -1
  2. package/dist/types/browser/dev-discovery.d.ts +11 -0
  3. package/dist/types/browser/navigation-store.d.ts +3 -13
  4. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  5. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  6. package/dist/types/browser/types.d.ts +4 -16
  7. package/dist/types/build/generate-manifest.d.ts +6 -0
  8. package/dist/types/cache/cache-scope.d.ts +7 -5
  9. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  10. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  11. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  12. package/dist/types/cache/types.d.ts +24 -7
  13. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  14. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  15. package/dist/types/prerender/store.d.ts +1 -0
  16. package/dist/types/route-map-builder.d.ts +4 -29
  17. package/dist/types/router/prerender-match.d.ts +4 -1
  18. package/dist/types/router/router-interfaces.d.ts +3 -1
  19. package/dist/types/rsc/handler-context.d.ts +1 -0
  20. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  21. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  22. package/dist/types/rsc/shell-capture.d.ts +4 -8
  23. package/dist/types/rsc/types.d.ts +2 -0
  24. package/dist/types/server/context.d.ts +16 -0
  25. package/dist/types/server/request-context.d.ts +18 -8
  26. package/dist/types/server.d.ts +1 -1
  27. package/dist/types/types/route-entry.d.ts +3 -0
  28. package/dist/types/vite/discovery/state.d.ts +3 -2
  29. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  30. package/dist/vite/index.js +194 -107
  31. package/package.json +7 -2
  32. package/skills/caching/SKILL.md +1 -1
  33. package/skills/ppr/SKILL.md +48 -4
  34. package/src/browser/dev-discovery.ts +66 -0
  35. package/src/browser/navigation-client.ts +6 -0
  36. package/src/browser/navigation-store.ts +4 -271
  37. package/src/browser/partial-update.ts +7 -14
  38. package/src/browser/prefetch/cache.ts +1 -1
  39. package/src/browser/prefetch/loader.ts +110 -0
  40. package/src/browser/prefetch/runtime.ts +7 -0
  41. package/src/browser/react/Link.tsx +7 -10
  42. package/src/browser/react/NavigationProvider.tsx +1 -1
  43. package/src/browser/react/use-router.ts +1 -1
  44. package/src/browser/rsc-router.tsx +4 -2
  45. package/src/browser/types.ts +4 -27
  46. package/src/build/generate-manifest.ts +11 -0
  47. package/src/cache/cache-scope.ts +17 -6
  48. package/src/cache/cf/cf-cache-store.ts +63 -39
  49. package/src/cache/memory-segment-store.ts +17 -6
  50. package/src/cache/shell-snapshot.ts +45 -15
  51. package/src/cache/types.ts +23 -6
  52. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  53. package/src/dev-discovery-protocol.ts +11 -0
  54. package/src/prerender/build-shell-capture.ts +7 -1
  55. package/src/prerender/store.ts +5 -0
  56. package/src/route-definition/dsl-helpers.ts +7 -2
  57. package/src/route-map-builder.ts +56 -49
  58. package/src/router/handler-context.ts +13 -5
  59. package/src/router/lazy-includes.ts +1 -0
  60. package/src/router/match-api.ts +8 -4
  61. package/src/router/prerender-match.ts +19 -2
  62. package/src/router/router-interfaces.ts +4 -0
  63. package/src/router/segment-resolution/static-store.ts +36 -10
  64. package/src/router.ts +34 -3
  65. package/src/rsc/handler-context.ts +1 -0
  66. package/src/rsc/handler.ts +3 -1
  67. package/src/rsc/loader-fetch.ts +2 -2
  68. package/src/rsc/manifest-init.ts +4 -11
  69. package/src/rsc/render-pipeline.ts +10 -2
  70. package/src/rsc/rsc-rendering.ts +207 -146
  71. package/src/rsc/shell-capture.ts +34 -11
  72. package/src/rsc/types.ts +2 -0
  73. package/src/server/context.ts +28 -0
  74. package/src/server/request-context.ts +32 -10
  75. package/src/server.ts +0 -2
  76. package/src/testing/dispatch.ts +1 -0
  77. package/src/types/route-entry.ts +3 -0
  78. package/src/urls/include-helper.ts +1 -0
  79. package/src/urls/path-helper.ts +8 -4
  80. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  81. package/src/vite/discovery/discover-routers.ts +7 -66
  82. package/src/vite/discovery/prerender-collection.ts +13 -2
  83. package/src/vite/discovery/state.ts +4 -4
  84. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  85. package/src/vite/plugins/version-injector.ts +0 -1
  86. package/src/vite/plugins/virtual-entries.ts +11 -1
  87. package/src/vite/router-discovery.ts +132 -22
  88. package/src/vite/utils/manifest-utils.ts +1 -4
  89. 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,
@@ -51,9 +52,13 @@ import {
51
52
  describeShellTailTiming,
52
53
  publishShellTailTiming,
53
54
  takeShellTailTimingForServerTiming,
55
+ type ResolvedPprConfig,
54
56
  type ShellTailTiming,
55
57
  } from "./shell-serve.js";
56
- import { lookupBuildShell } from "./shell-build-manifest.js";
58
+ import {
59
+ lookupBuildShell,
60
+ type DevShellLookup,
61
+ } from "./shell-build-manifest.js";
57
62
  import { contextGet } from "../context-var.js";
58
63
  import {
59
64
  resolveSameOriginRedirect,
@@ -62,7 +67,52 @@ import {
62
67
  import { nonce as nonceToken } from "./nonce.js";
63
68
  import { reportCacheError } from "../cache/cache-error.js";
64
69
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
65
- import type { ShellCacheEntry } from "../cache/types.js";
70
+ import type { ShellCacheEntry, ShellSnapshotRecord } from "../cache/types.js";
71
+
72
+ function resolveDevShellLookup(
73
+ reqCtx: RequestContext<any>,
74
+ pprConfig: ResolvedPprConfig,
75
+ ): DevShellLookup | undefined {
76
+ if (process.env.NODE_ENV === "production") return undefined;
77
+ return {
78
+ isPrerenderRoute: reqCtx._classifiedRoute?.matched?.pr === true,
79
+ routeName: reqCtx._classifiedRoute?.routeKey,
80
+ ttl: pprConfig.ttl,
81
+ swr: pprConfig.swr,
82
+ tags: pprConfig.tags,
83
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
84
+ captureTimeout: pprConfig.captureTimeout,
85
+ };
86
+ }
87
+
88
+ function replayableShellSnapshot(
89
+ entry: ShellCacheEntry | undefined,
90
+ buildVersion: string,
91
+ ): ShellSnapshotRecord[] | undefined {
92
+ if (
93
+ !entry ||
94
+ !isValidShellHit(entry, buildVersion) ||
95
+ entry.handlerLiveHoles ||
96
+ entry.transitionWhen
97
+ ) {
98
+ return undefined;
99
+ }
100
+ const snapshot = entry.snapshot;
101
+ const hasSegments = snapshot?.some((record) => {
102
+ if (
103
+ !record ||
104
+ typeof record !== "object" ||
105
+ record.family !== "segment" ||
106
+ typeof record.value !== "object" ||
107
+ record.value === null
108
+ ) {
109
+ return false;
110
+ }
111
+ const segments = (record.value as { segments?: unknown }).segments;
112
+ return Array.isArray(segments) && segments.length > 0;
113
+ });
114
+ return hasSegments ? snapshot : undefined;
115
+ }
66
116
 
67
117
  export function handleRscRendering<TEnv>(
68
118
  ctx: HandlerContext<TEnv>,
@@ -109,21 +159,10 @@ async function handleRscRenderingInner<TEnv>(
109
159
 
110
160
  // --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
111
161
  //
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.
162
+ // COMMIT POINT: this render pass runs strictly AFTER the whole middleware
163
+ // chain (executeRender wraps it), so no shell byte can precede a guard
164
+ // decision that ordering is what makes a shared shell safe. Routes
165
+ // without the `ppr` option stay pure axis 1 at zero cost.
127
166
  let pprMiss: {
128
167
  descriptor: ShellCaptureDescriptor;
129
168
  ssrModule: SSRModule;
@@ -137,30 +176,18 @@ async function handleRscRenderingInner<TEnv>(
137
176
  ) {
138
177
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
139
178
  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.
179
+ // A per-request CSP nonce pins the route to axis 1: a shared shell would
180
+ // freeze the capture request's nonce and CSP would reject it for every
181
+ // other visitor. BOTH nonce sources must gate the createRouter({ nonce })
182
+ // provider (`nonce` param) and a middleware ctx.set(nonce, …) token write;
183
+ // the provider-only check missed the latter (issue #656).
152
184
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
153
185
  const store = reqCtx._cacheStore;
154
186
  const key = buildShellKey(url);
155
- // Dev Server-Timing mirror (issue #651): a capture runs AFTER its
187
+ // Dev Server-Timing mirror (issue #651): a capture completes AFTER its
156
188
  // 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.
189
+ // response's header. Read-and-clear keeps one capture = one report;
190
+ // dev-only (see takeCaptureDebugEventForTiming).
164
191
  if (process.env.NODE_ENV !== "production" && reqCtx._metricsStore) {
165
192
  const lastCapture = takeCaptureDebugEventForTiming(key);
166
193
  if (lastCapture) {
@@ -303,16 +330,11 @@ async function handleRscRenderingInner<TEnv>(
303
330
  }
304
331
  }
305
332
  // 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).
333
+ // store MISS a Prerender+ppr route serves its `vite build`-baked
334
+ // shell through the SAME serveShellHit. lookupBuildShell owns every
335
+ // gate and fails to null (ordinary MISS path takes over); past
336
+ // ppr.ttl the baked entry still serves while SWR recaptures — the
337
+ // upgrade path from build entry to runtime entry.
316
338
  const buildHit = await lookupBuildShell(
317
339
  url,
318
340
  ctx.version,
@@ -321,18 +343,7 @@ async function handleRscRenderingInner<TEnv>(
321
343
  // the dev server's /__rsc_shell endpoint for PRERENDERED routes
322
344
  // only (production's exact candidate set). Folded away in
323
345
  // 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,
346
+ resolveDevShellLookup(reqCtx, pprConfig),
336
347
  );
337
348
  if (buildHit) {
338
349
  // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
@@ -348,7 +359,14 @@ async function handleRscRenderingInner<TEnv>(
348
359
 
349
360
  if (isPartial) {
350
361
  // Partial render (navigation)
351
- const result = await ctx.router.matchPartial(request, { env });
362
+ const result = await matchPartialWithPprReplay(
363
+ ctx,
364
+ request,
365
+ env,
366
+ url,
367
+ reqCtx,
368
+ nonce,
369
+ );
352
370
 
353
371
  if (!result) {
354
372
  // Fall back to full render
@@ -517,13 +535,9 @@ async function handleRscRenderingInner<TEnv>(
517
535
  );
518
536
 
519
537
  // --- 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.
538
+ // Capture only a 200 HTML document (a 404/error render is not a cacheable
539
+ // shell). Capture does not flow through the HTTP pipeline middleware never
540
+ // re-runs (it already ran for this request; guarding is serve-time).
527
541
  if (pprMiss && !reqCtx._dynamic) {
528
542
  if (
529
543
  response.status === 200 &&
@@ -546,16 +560,83 @@ async function handleRscRenderingInner<TEnv>(
546
560
  }
547
561
 
548
562
  /**
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.
563
+ * Reuse a PPR capture's canonical segment record for a partial navigation.
564
+ * The ordinary matchPartial pipeline remains authoritative: it projects the
565
+ * cached target tree against the client's segment ids, evaluates revalidation,
566
+ * and resolves every loader fresh. Only segment-family records are seeded;
567
+ * captured item/response/loader values belong to document parity and must not
568
+ * pin navigation data.
569
+ */
570
+ async function matchPartialWithPprReplay<TEnv>(
571
+ ctx: HandlerContext<TEnv>,
572
+ request: Request,
573
+ env: TEnv,
574
+ url: URL,
575
+ reqCtx: RequestContext<any>,
576
+ nonce: string | undefined,
577
+ ) {
578
+ const runMatch = () => ctx.router.matchPartial(request, { env });
579
+ const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
580
+ const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
581
+ const store = reqCtx._cacheStore;
582
+
583
+ if (
584
+ request.method !== "GET" ||
585
+ !pprConfig ||
586
+ reqCtx._dynamic ||
587
+ activeNonce !== undefined ||
588
+ !hasShellFamily(store) ||
589
+ store.supportsPassiveShellReads !== true
590
+ ) {
591
+ return runMatch();
592
+ }
593
+
594
+ const key = buildShellKey(url);
595
+ let cached: Awaited<ReturnType<typeof store.getShell>> = null;
596
+ try {
597
+ cached = await store.getShell(key, { claimRevalidation: false });
598
+ } catch (error) {
599
+ reportCacheError(error, "cache-read", "[NavigationPPR] getShell");
600
+ return runMatch();
601
+ }
602
+
603
+ let snapshot = cached?.shouldRevalidate
604
+ ? undefined
605
+ : replayableShellSnapshot(cached?.entry, ctx.version);
606
+
607
+ if (!snapshot) {
608
+ // Production build manifests are local module data. In dev, resolving a
609
+ // missing build shell would foreground-fetch /__rsc_shell and block an
610
+ // otherwise ordinary navigation on capture, so replay remains runtime-only.
611
+ const buildHit = await lookupBuildShell(url, ctx.version, store);
612
+ if (!buildHit?.stale) {
613
+ snapshot = replayableShellSnapshot(buildHit?.entry, ctx.version);
614
+ }
615
+ }
616
+
617
+ if (!snapshot) return runMatch();
618
+
619
+ const previousImplicitCache = reqCtx._shellImplicitCache;
620
+ reqCtx._shellImplicitCache = {
621
+ ttl: pprConfig.ttl,
622
+ swr: pprConfig.swr,
623
+ store: new SeededShellStore(store, snapshot, { segmentsOnly: true }),
624
+ keyPrefix: "doc",
625
+ };
626
+
627
+ try {
628
+ return await runMatch();
629
+ } finally {
630
+ reqCtx._shellImplicitCache = previousImplicitCache;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Neutralize the shell-HIT degradation redirect target. The inline
636
+ * `location.replace` in a committed 200 body bypasses the 3xx chokepoint
637
+ * (guardOutgoingRedirect only sees 3xx + Location), so this reuses the same
638
+ * same-origin resolver directly: unsafe targets neutralize to the
639
+ * redirect-guard.ts landing instead of navigating the user off-host.
559
640
  */
560
641
  export function resolveShellHitRedirectTarget(
561
642
  rawTarget: string,
@@ -569,23 +650,13 @@ export function resolveShellHitRedirectTarget(
569
650
  }
570
651
 
571
652
  /**
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).
653
+ * Serve a validated shell HIT: commit the stored prelude bytes NOW and run the
654
+ * live tail behind them inside the response stream. Plain byte concatenation is
655
+ * correct React foster-parents content streamed after the prelude's closing
656
+ * `</body></html>`. After the flush a failing hole cannot become a 500/redirect
657
+ * (error UI renders inline the documented PPR constraint). The tail promise
658
+ * is kicked off SYNCHRONOUSLY so it runs inside the current ALS request-context
659
+ * frame; the adapter may pull the stream outside it.
589
660
  */
590
661
  function serveShellHit(
591
662
  ctx: HandlerContext<any>,
@@ -612,6 +683,15 @@ function serveShellHit(
612
683
  : null;
613
684
  const tailT0 = tailTiming ? performance.now() : 0;
614
685
 
686
+ const createTailContext = (): RequestContext<any> => {
687
+ const tailCtx: RequestContext<any> = Object.create(reqCtx);
688
+ // Matching writes render state onto the derived context. Its barrier must
689
+ // close over that same context or a streaming tail inherits the base
690
+ // context's premature non-streaming handle snapshot.
691
+ wireRenderBarrier(tailCtx, handleStore);
692
+ return tailCtx;
693
+ };
694
+
615
695
  const renderTail = async (
616
696
  activeCtx: RequestContext<any>,
617
697
  ): Promise<ReadableStream<Uint8Array> | { redirect: string }> => {
@@ -628,14 +708,10 @@ function serveShellHit(
628
708
  if (match.redirect) return { redirect: match.redirect };
629
709
  setRequestContextParams(match.params, match.routeName);
630
710
  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.
711
+ // Theme fidelity for resume: replay the CAPTURE's initialTheme into the
712
+ // payload so the resume/hydration trees match the frozen prelude by
713
+ // construction. The visitor still sees THEIR theme the prelude's FOUC
714
+ // script applies the cookie pre-paint and ThemeProvider re-syncs post-mount.
639
715
  if (payload.metadata) {
640
716
  payload.metadata.initialTheme = entry.initialTheme as
641
717
  | import("../theme/types.js").Theme
@@ -687,18 +763,13 @@ function serveShellHit(
687
763
  const tailPromise: Promise<
688
764
  ReadableStream<Uint8Array> | { redirect: string }
689
765
  > = (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.
766
+ // Snapshot seeding (docs/design/ppr-shell-resume.md): the tail render must
767
+ // match the frozen prelude, so pinned cache reads replay their capture-time
768
+ // values via a SeededShellStore overlay while unpinned reads (the holes)
769
+ // stay live. The overlay lives on a DERIVED context so the shared reqCtx is
770
+ // untouched.
700
771
  if (entry.snapshot && entry.snapshot.length > 0) {
701
- const seededCtx: RequestContext<any> = Object.create(reqCtx);
772
+ const seededCtx = createTailContext();
702
773
  if (reqCtx._cacheStore) {
703
774
  seededCtx._cacheStore = new SeededShellStore(
704
775
  reqCtx._cacheStore,
@@ -720,19 +791,16 @@ function serveShellHit(
720
791
  );
721
792
  }
722
793
  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) {
794
+ // Shell fast path (serve side): if the capture recorded the implicit doc
795
+ // segment record and the handler layer declared no liveness, the tail's
796
+ // cache lookup HITs the seeded doc entry — the handler layer is REPLAYED,
797
+ // not re-executed (loaders still run fresh). Anything else degrades to
798
+ // the full tail automatically.
799
+ if (!entry.handlerLiveHoles && !entry.transitionWhen) {
733
800
  seededCtx._shellImplicitCache = {
734
801
  ttl: descriptor.ttl,
735
802
  swr: descriptor.swr,
803
+ keyPrefix: "doc",
736
804
  };
737
805
  if (INTERNAL_RANGO_DEBUG) {
738
806
  console.log(
@@ -741,25 +809,21 @@ function serveShellHit(
741
809
  }
742
810
  } else if (INTERNAL_RANGO_DEBUG) {
743
811
  console.log(
744
- `[Server][ppr] shell HIT: fast path declined — handler-live holes; tail re-runs handlers (abs ${Math.round(performance.now())})`,
812
+ `[Server][ppr] shell HIT: fast path declined — request-dependent handler/transition state; tail re-runs handlers (abs ${Math.round(performance.now())})`,
745
813
  );
746
814
  }
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).
815
+ // Fragment splice (issue #700): store hits in THIS tail emit their stored
816
+ // segment fragments verbatim (expanded by segment-fragments.ts). The flag
817
+ // lives on the derived context so it can never leak into a capture render,
818
+ // which serializes segments and must see real elements.
753
819
  seededCtx._shellFragmentPayload = true;
754
820
  return runWithRequestContext(seededCtx, () => renderTail(seededCtx));
755
821
  }
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);
822
+ // No snapshot (e.g. a producer B entry): still a shell-HIT tail, so arm the
823
+ // fragment splice on a derived context never the shared reqCtx, from which
824
+ // scheduleShellCapture derives the capture context (the flag must not be
825
+ // inherited there).
826
+ const fragmentCtx = createTailContext();
763
827
  fragmentCtx._shellFragmentPayload = true;
764
828
  return runWithRequestContext(fragmentCtx, () => renderTail(fragmentCtx));
765
829
  })();
@@ -853,15 +917,12 @@ function serveShellHit(
853
917
  if (tailTiming) publishShellTailTiming(tailTiming);
854
918
  controller.close();
855
919
  } 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.
920
+ // Self-heal on a failed tail: errors the pre-commit gates cannot catch
921
+ // (mismatched postponed blob, hard render error above the holes) throw
922
+ // here AFTER the 200 + prelude flushed and would re-fail on every
923
+ // request until the entry ages out recapture overwrites the entry.
924
+ // Client disconnects land here too; the recapture is idempotent and
925
+ // bounded by scheduleShellCapture's stampede guard + backoff.
865
926
  scheduleShellCapture(
866
927
  ctx,
867
928
  request,
@@ -476,6 +476,8 @@ export interface ShellCaptureDebugEvent {
476
476
  snapshotBytes?: number;
477
477
  /** True when the snapshot exceeded maxSnapshotBytes and was dropped. */
478
478
  snapshotSkipped?: boolean;
479
+ /** Outcome reported by a store that supports shell-write acknowledgements. */
480
+ storeWrite?: "stored" | "invalidated";
479
481
  /** Consecutive failure count in the key's backoff entry, when one exists. */
480
482
  backoffFailures?: number;
481
483
  /** Ms remaining in the key's backoff window, when one exists. */
@@ -516,6 +518,9 @@ export function describeShellCaptureEvent(
516
518
  `snapshot=${event.snapshotBytes}b${event.snapshotSkipped ? " (over cap, skipped)" : ""}`,
517
519
  );
518
520
  }
521
+ if (event.storeWrite !== undefined) {
522
+ parts.push(`store-write=${event.storeWrite}`);
523
+ }
519
524
  if (event.backoffFailures !== undefined) {
520
525
  parts.push(`backoff-failures=${event.backoffFailures}`);
521
526
  }
@@ -941,13 +946,15 @@ type CaptureAttemptOutcome = "stored" | "redirect" | "no-shell" | "refused";
941
946
  * bag, not a return value: captureAndStoreShell's outcome type stays a string
942
947
  * union its existing callers (producer B, tests) consume unchanged.
943
948
  */
944
- interface CaptureAttemptStats {
945
- barrierWaitMs?: number;
946
- writeSettleMs?: number;
947
- preludeBytes?: number;
948
- snapshotBytes?: number;
949
- snapshotSkipped?: boolean;
950
- }
949
+ type CaptureAttemptStats = Pick<
950
+ ShellCaptureDebugEvent,
951
+ | "barrierWaitMs"
952
+ | "writeSettleMs"
953
+ | "preludeBytes"
954
+ | "snapshotBytes"
955
+ | "snapshotSkipped"
956
+ | "storeWrite"
957
+ >;
951
958
 
952
959
  /**
953
960
  * Run the shell capture with a single in-place retry, then store the result.
@@ -1116,6 +1123,7 @@ async function attemptCapture(
1116
1123
  reqCtx,
1117
1124
  descriptor,
1118
1125
  );
1126
+ const captureStartedAt = Date.now();
1119
1127
 
1120
1128
  return runWithRequestContext(derivedCtx, async () => {
1121
1129
  const match = await ctx.router.match(request, { env });
@@ -1157,6 +1165,7 @@ async function attemptCapture(
1157
1165
  derivedCtx,
1158
1166
  descriptor,
1159
1167
  stats,
1168
+ captureStartedAt,
1160
1169
  );
1161
1170
  });
1162
1171
  }
@@ -1329,6 +1338,7 @@ export function deriveShellCaptureContext(
1329
1338
  ttl: descriptor.ttl,
1330
1339
  swr: descriptor.swr,
1331
1340
  store: new SnapshotOnlySegmentStore(recordingStore),
1341
+ keyPrefix: "doc",
1332
1342
  };
1333
1343
  }
1334
1344
 
@@ -1355,6 +1365,7 @@ async function captureAndStoreShell(
1355
1365
  reqCtx: RequestContext<any>,
1356
1366
  capture: ShellCaptureDescriptor,
1357
1367
  stats?: CaptureAttemptStats,
1368
+ captureStartedAt: number = Date.now(),
1358
1369
  ): Promise<Exclude<CaptureAttemptOutcome, "redirect">> {
1359
1370
  const captureShellHTML = ssrModule.captureShellHTML!;
1360
1371
 
@@ -1661,15 +1672,26 @@ async function captureAndStoreShell(
1661
1672
  handlerLiveHoles: handlerLayerIsLive(
1662
1673
  reqCtx._shellCaptureHandleLiveness,
1663
1674
  ),
1664
- createdAt: Date.now(),
1675
+ transitionWhen: reqCtx._transitionWhen?.length ? true : undefined,
1676
+ createdAt: captureStartedAt,
1665
1677
  };
1666
- await store.putShell(
1678
+ const storeWrite = await store.putShell(
1667
1679
  capture.key,
1668
1680
  entry,
1669
1681
  capture.ttl,
1670
1682
  capture.swr,
1671
1683
  shellTags,
1672
1684
  );
1685
+ if (stats && storeWrite) stats.storeWrite = storeWrite;
1686
+ if (storeWrite === "invalidated") {
1687
+ warnCaptureRefusedOnce(
1688
+ capture.key,
1689
+ "one of the shell's tags was invalidated after this capture generation started, so the store rejected the write. " +
1690
+ "If capture code deterministically calls updateTag() on its own shell tag, move that mutation out of the render; " +
1691
+ "otherwise every generation is invalidated before it can be served.",
1692
+ );
1693
+ return "refused";
1694
+ }
1673
1695
  } catch (error) {
1674
1696
  // Best-effort: a failed put must never throw out of the background task.
1675
1697
  reportCacheError(
@@ -1680,8 +1702,9 @@ async function captureAndStoreShell(
1680
1702
  );
1681
1703
  }
1682
1704
  }
1683
- // A shell was captured (the store I/O may have failed, but that is reported,
1684
- // not retried) so this attempt is `stored` and the caller does not retry.
1705
+ // A shell was captured (ordinary store I/O failure is reported, not retried).
1706
+ // An acknowledged invalidation rejection returned `refused` above so the
1707
+ // scheduler backs the key off instead of recapturing on every request.
1685
1708
  return "stored";
1686
1709
  } finally {
1687
1710
  // Stop the hop loop for the pathological never-quiets path (quiesce never
package/src/rsc/types.ts CHANGED
@@ -41,6 +41,8 @@ export interface RscPayload {
41
41
  handles?: AsyncGenerator<HandleData, void, unknown>;
42
42
  /** RSC version string for cache invalidation */
43
43
  version?: string;
44
+ /** Cloudflare dev worker generation used for stale-document convergence. */
45
+ devDiscoveryEpoch?: number;
44
46
  /** TTL in milliseconds for the client-side in-memory prefetch cache */
45
47
  prefetchCacheTTL?: number;
46
48
  /** Max entries in the client-side in-memory prefetch cache (FIFO eviction) */