@rangojs/router 0.4.1 → 0.4.3

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 (47) hide show
  1. package/dist/types/browser/navigation-client.d.ts +1 -1
  2. package/dist/types/browser/prefetch/cache.d.ts +20 -8
  3. package/dist/types/cache/cf/cf-cache-store.d.ts +19 -8
  4. package/dist/types/cache/cf/cf-cache-types.d.ts +4 -2
  5. package/dist/types/cache/read-through-swr.d.ts +7 -6
  6. package/dist/types/cloudflare/tracing.d.ts +16 -12
  7. package/dist/types/router/instrument.d.ts +45 -15
  8. package/dist/types/router/telemetry-otel.d.ts +4 -4
  9. package/dist/types/router/timeout.d.ts +12 -1
  10. package/dist/types/router/tracing.d.ts +31 -17
  11. package/dist/types/rsc/capture-queue.d.ts +22 -1
  12. package/dist/types/rsc/shell-capture.d.ts +17 -9
  13. package/dist/types/rsc/stream-idle.d.ts +60 -0
  14. package/dist/types/segment-fragments.d.ts +27 -4
  15. package/dist/types/server/request-context.d.ts +28 -9
  16. package/dist/types/vercel/tracing.d.ts +9 -8
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +1 -1
  19. package/skills/cloudflare/SKILL.md +4 -2
  20. package/skills/observability/SKILL.md +33 -4
  21. package/skills/ppr/SKILL.md +25 -9
  22. package/src/browser/navigation-client.ts +77 -27
  23. package/src/browser/prefetch/cache.ts +40 -10
  24. package/src/browser/prefetch/fetch.ts +5 -0
  25. package/src/browser/rsc-router.tsx +12 -6
  26. package/src/cache/cache-runtime.ts +39 -25
  27. package/src/cache/cache-scope.ts +5 -1
  28. package/src/cache/cf/cf-cache-store.ts +423 -90
  29. package/src/cache/cf/cf-cache-types.ts +4 -2
  30. package/src/cache/document-cache.ts +63 -25
  31. package/src/cache/read-through-swr.ts +22 -16
  32. package/src/cloudflare/tracing.ts +16 -12
  33. package/src/router/instrument.ts +61 -15
  34. package/src/router/segment-resolution/loader-cache.ts +12 -1
  35. package/src/router/segment-resolution/revalidation.ts +16 -1
  36. package/src/router/telemetry-otel.ts +4 -4
  37. package/src/router/timeout.ts +12 -1
  38. package/src/router/tracing.ts +37 -17
  39. package/src/rsc/capture-queue.ts +72 -17
  40. package/src/rsc/handler.ts +171 -73
  41. package/src/rsc/rsc-rendering.ts +61 -6
  42. package/src/rsc/shell-capture.ts +83 -31
  43. package/src/rsc/stream-idle.ts +137 -0
  44. package/src/segment-fragments.ts +45 -4
  45. package/src/server/request-context.ts +29 -8
  46. package/src/testing/dispatch.ts +55 -1
  47. package/src/vercel/tracing.ts +9 -8
@@ -226,6 +226,12 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
226
226
  keyPrefix?: "doc";
227
227
  /** @internal Called only after the implicit cache hit decodes successfully. */
228
228
  onHit?: () => void;
229
+ /**
230
+ * @internal Called when the seeded document record fails server-side
231
+ * deserialization. Partial replay uses it to replace the enclosing shell
232
+ * snapshot after serving the fresh fallback.
233
+ */
234
+ onCorrupt?: () => void;
229
235
  /**
230
236
  * @internal The resolved key of the canonical document segment record.
231
237
  * Written during a CAPTURE render by CacheScope.cacheRoute when a
@@ -256,15 +262,22 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
256
262
  onExplicitBypass?: () => void;
257
263
  };
258
264
  /**
259
- * @internal Shell-HIT tail marker: cache/prerender-store hits during THIS
260
- * render emit stored segment fragments VERBATIM into the payload
265
+ * @internal Fragment-passthrough marker: cache/prerender-store hits during
266
+ * THIS render emit stored segment fragments VERBATIM into the payload
261
267
  * (segment-codec fragmentSegments) instead of deserialize -> re-serialize
262
- * per request; the payload consumers (SSR resume + browser hydration)
263
- * expand them (segment-fragments.ts, issue #700). Own property of
264
- * serveShellHit's derived tail context ONLY it must never be visible to a
265
- * capture render: the capture SSR-prerenders the payload AND serializes
266
- * segments into records (cacheRoute), and an envelope reaching
267
- * serializeSegments would store a double-encoded fragment.
268
+ * per request; the payload consumers (SSR resume, browser hydration, and
269
+ * the client partial-navigation decode chokepoints) expand them
270
+ * (segment-fragments.ts, issue #700). Partial requests arm only after the
271
+ * browser advertises `X-Rango-Fragment-Passthrough: 1`; a decode failure
272
+ * retries without it so CacheScope can evict the bad record. Two arming sites,
273
+ * both scoped to a
274
+ * render/match window: serveShellHit's derived tail context (document
275
+ * HIT), and matchPartialWithPprReplay's mutate-restore around the partial
276
+ * match (matchPartialForReplay). It must never be visible to a capture
277
+ * render: the capture SSR-prerenders the payload AND serializes segments
278
+ * into records (cacheRoute), and an envelope reaching serializeSegments
279
+ * would store a double-encoded fragment — deriveShellCaptureContext resets
280
+ * it as an own property for exactly that reason.
268
281
  */
269
282
  _shellFragmentPayload?: boolean;
270
283
  /**
@@ -551,6 +564,12 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
551
564
  * to avoid a second resolveRoute call. Cleared on HMR invalidation.
552
565
  */
553
566
  _classifiedRoute?: import("../router/route-snapshot.js").RouteSnapshot;
567
+ /**
568
+ * @internal Classified request mode from classifyRequest, read by the
569
+ * rango.response span tail (rsc/handler.ts) for the rango.response.mode
570
+ * attribute. Unset when middleware short-circuits before core execution.
571
+ */
572
+ _requestMode?: import("../router/request-classification.js").RequestPlan["mode"];
554
573
  /**
555
574
  * @internal Coarse route-level cache signal for the X-Rango-Cache debug
556
575
  * header. Populated by match/matchPartial only when the debug cache signal
@@ -566,7 +585,7 @@ export interface RequestContext<TEnv = DefaultEnv, TParams = Record<string, stri
566
585
  * This is the type exported to library consumers. Internal code should
567
586
  * use the full RequestContext interface directly.
568
587
  */
569
- export type PublicRequestContext<TEnv = DefaultEnv, TParams = Record<string, string>> = Omit<RequestContext<TEnv, TParams>, "cookie" | "cookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_transitionWhen" | "_pprTransitionDecisions" | "_pprReplayPostMatchReason" | "_cacheStore" | "_searchParamsFilter" | "_shellCaptureRun" | "_shellImplicitCache" | "_shellLoaderSeed" | "_shellCaptureLoaderRecords" | "_shellCaptureHandleLiveness" | "_shellCaptureLoaderHandleValues" | "_shellFragmentPayload" | "_shellCaptureGuardTripped" | "_shellCaptureGuardTrippedLoaderId" | "_explicitTaggedStores" | "_requestTags" | "_cacheProfiles" | "_onResponseCallbacks" | "_pendingBackgroundTasks" | "_themeConfig" | "_locationState" | "_routeName" | "_prevRouteKey" | "_gateCurrentUrl" | "_gateCurrentParams" | "_gateActionId" | "_gateActionUrl" | "_gateActionResult" | "_gateFormData" | "_inActionRevalidation" | "_reportedErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_renderBarrierHandleSnapshot" | "_renderBarrierGuardClosed" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_renderForeground" | "_renderDiagnosticsEnabled" | "_handlerStart" | "_tracing" | "_basename" | "_routerId" | "_setStatus" | "_rotateStateCookie" | "_setKeepCacheDirective" | "_variables" | "_classifiedRoute" | "_cacheSignal" | "_dynamic" | "res">;
588
+ export type PublicRequestContext<TEnv = DefaultEnv, TParams = Record<string, string>> = Omit<RequestContext<TEnv, TParams>, "cookie" | "cookies" | "setCookie" | "deleteCookie" | "_handleStore" | "_transitionWhen" | "_pprTransitionDecisions" | "_pprReplayPostMatchReason" | "_cacheStore" | "_searchParamsFilter" | "_shellCaptureRun" | "_shellImplicitCache" | "_shellLoaderSeed" | "_shellCaptureLoaderRecords" | "_shellCaptureHandleLiveness" | "_shellCaptureLoaderHandleValues" | "_shellFragmentPayload" | "_shellCaptureGuardTripped" | "_shellCaptureGuardTrippedLoaderId" | "_explicitTaggedStores" | "_requestTags" | "_cacheProfiles" | "_onResponseCallbacks" | "_pendingBackgroundTasks" | "_themeConfig" | "_locationState" | "_routeName" | "_prevRouteKey" | "_gateCurrentUrl" | "_gateCurrentParams" | "_gateActionId" | "_gateActionUrl" | "_gateActionResult" | "_gateFormData" | "_inActionRevalidation" | "_reportedErrors" | "_renderBarrier" | "_resolveRenderBarrier" | "_renderBarrierSegmentOrder" | "_treeHasStreaming" | "_renderBarrierWaiters" | "_handlerLoaderDeps" | "_renderBarrierHandleSnapshot" | "_renderBarrierGuardClosed" | "_reportBackgroundError" | "_debugPerformance" | "_metricsStore" | "_renderForeground" | "_renderDiagnosticsEnabled" | "_handlerStart" | "_tracing" | "_basename" | "_routerId" | "_setStatus" | "_rotateStateCookie" | "_setKeepCacheDirective" | "_variables" | "_classifiedRoute" | "_requestMode" | "_cacheSignal" | "_dynamic" | "res">;
570
589
  /**
571
590
  * Marker for a waitUntil-scheduled fn whose task promise must NOT enter
572
591
  * _pendingBackgroundTasks. Used by the PPR shell capture for its own task:
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Vercel OpenTelemetry tracing integration.
3
3
  *
4
- * Bridges the router's performance phases (request, middleware, action,
5
- * loaders, handler, render, ssr) onto OpenTelemetry spans so they show up in
6
- * Vercel's trace waterfall next to the platform's automatic spans, with correct
7
- * nesting. Vercel exposes tracing through OpenTelemetry (not a native import-free
8
- * API like Cloudflare), so this is a thin convenience over `createOTelTracing`:
9
- * it reads the global OTel tracer that `@vercel/otel`'s `registerOTel()` installs.
4
+ * Bridges the router's observable phases (request, middleware, action, loaders,
5
+ * handler, render, ssr, response, background) onto OpenTelemetry spans so they
6
+ * show up in Vercel's trace waterfall next to the platform's automatic spans,
7
+ * with correct nesting. Vercel exposes tracing through OpenTelemetry (not a
8
+ * native import-free API like Cloudflare), so this is a thin convenience over
9
+ * `createOTelTracing`: it reads the global OTel tracer that `@vercel/otel`'s
10
+ * `registerOTel()` installs.
10
11
  *
11
12
  * Usage (vercel preset). A Rango/Vite app does NOT auto-load `instrumentation.ts`
12
13
  * the way Next.js does, so export the tracing config from there and import it —
@@ -59,8 +60,8 @@ export interface VercelTracingOptions extends TracingToggleOptions {
59
60
  /**
60
61
  * Create the tracing config for a Vercel router. Pass the result to
61
62
  * `createRouter({ tracing })`. Spans are emitted for the request, middleware,
62
- * action, loaders, handler, render, and ssr phases; pass `spans` to turn
63
- * individual phases off.
63
+ * action, loaders, handler, render, ssr, response, and background phases; pass
64
+ * `spans` to turn individual phases off.
64
65
  *
65
66
  * @see createOTelTracing (`@rangojs/router`) for the underlying adapter on any
66
67
  * platform with an OpenTelemetry SDK.
@@ -2530,7 +2530,7 @@ import { resolve } from "node:path";
2530
2530
  // package.json
2531
2531
  var package_default = {
2532
2532
  name: "@rangojs/router",
2533
- version: "0.4.1",
2533
+ version: "0.4.3",
2534
2534
  description: "Django-inspired RSC router with composable URL patterns",
2535
2535
  keywords: [
2536
2536
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.4.1",
3
+ "version": "0.4.3",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -129,8 +129,10 @@ export const router = createRouter<AppBindings>({
129
129
  });
130
130
  ```
131
131
 
132
- KV is required for durable PPR shells. `CFCacheStore` without `kv` can still
133
- cache segment/response entries in the Cache API, but its shell family is inert.
132
+ PPR shells use Cache API as the per-colo L1 and KV as the durable cross-colo
133
+ L2; a KV hit promotes the coupled shell envelope back into L1. KV remains
134
+ required for this family. `CFCacheStore` without `kv` can still cache
135
+ segment/response entries in Cache API, but its shell family is inert.
134
136
 
135
137
  ## Commands
136
138
 
@@ -145,10 +145,39 @@ const router = createRouter({ document: Document, urls: urlpatterns, tracing });
145
145
  These factories return a `RouterTracingConfig` for the same `tracing` slot;
146
146
  `telemetry` stays independent (events only, no phase spans). Phase spans:
147
147
  `rango.request`, `rango.middleware`, `rango.action`, `rango.loader`,
148
- `rango.render`, `rango.ssr` — the same phases the `debugPerformance` timeline
149
- shows, co-emitted from one site. Off-platform (no Cloudflare tracing destination
150
- / no OTel SDK) every span call is a transparent pass-through, so the request
151
- behaves as if tracing were off.
148
+ `rango.handler`, `rango.render`, `rango.ssr`, `rango.response`,
149
+ `rango.background`. All share the `PHASES` registry and `observePhase`
150
+ execution boundary with `debugPerformance`; phases with `metric: false` either
151
+ use finer-grained perf rows or, for `response` and `background`, remain
152
+ span-only. Off-platform (no Cloudflare tracing destination / no OTel SDK) every
153
+ span call is a transparent pass-through, so the request behaves as if tracing
154
+ were off.
155
+
156
+ `rango.response` is the explicit handoff marker: at most one per traced
157
+ request, a direct child of `rango.request`, wrapping only response finalization
158
+ (redirect interception/guarding, `Server-Timing` mutation, final response
159
+ selection) and ending immediately before the handler returns the response to
160
+ the host. It is handoff-bound, never drain-bound — it never reads or awaits
161
+ `response.body`. Attributes: `http.response.status_code`,
162
+ `rango.response.mode` (classified request mode, or `middleware-short-circuit`),
163
+ `rango.response.body_kind` (`stream`/`empty`/`websocket`). For request modes
164
+ that render nothing (fetchable `_rsc_loader`, response routes, middleware
165
+ short-circuits) it shows the trace is complete rather than truncated — those
166
+ requests legitimately have no `rango.render`/`rango.ssr`/`rango.handler` spans.
167
+ On deployed Workers it may read 0 ms (frozen non-I/O timers); its position and
168
+ attributes are the value. Disable per-response billing overhead at volume with
169
+ `spans: { response: false }`.
170
+
171
+ `rango.background` wraps detached waitUntil work — PPR shell captures and SWR
172
+ background revalidations — that runs after the foreground spans ended. Without
173
+ it, a capture or revalidation shows up as an unexplained wave of orphan
174
+ KV/fetch spans minutes into a trace. `rango.background.kind` names the lane
175
+ (`shell-capture` / `document-revalidation` / `loader-revalidation` /
176
+ `use-cache-revalidation`); the shell-capture lane also carries
177
+ `rango.shell_key`, `rango.background.outcome`, and
178
+ `rango.background.queue_wait_ms` (a capture parked behind the per-isolate
179
+ capture queue reads as span duration, not dead air). Toggle with
180
+ `spans: { background: false }`.
152
181
 
153
182
  Custom sinks implement `emit(event)`:
154
183
 
@@ -37,9 +37,9 @@ PPR is a DOCUMENT-level property declared on the page route via the `ppr` path
37
37
  option. Serving is **integral to the router** — there is nothing to mount. The
38
38
  only prerequisite is an app-level `createRouter({ cache })` store that
39
39
  implements the shell family (`getShell`/`putShell`): `MemorySegmentCacheStore`
40
- (dev/tests), `CFCacheStore` (Cloudflare KV), or `VercelCacheStore` (runtime
41
- cache). A ppr route on a store without the family stays on axis 1 with a
42
- once-per-key warning.
40
+ (dev/tests), `CFCacheStore` (Cache API L1 + KV L2), or `VercelCacheStore`
41
+ (runtime cache). A ppr route on a store without the family stays on axis 1 with
42
+ a once-per-key warning.
43
43
 
44
44
  ```typescript
45
45
  import { createRouter, urls } from "@rangojs/router";
@@ -258,11 +258,21 @@ Partial responses expose the actual decision as `x-rango-ppr-replay`:
258
258
  `HIT; freshness=fresh|stale` or `BYPASS; reason=<bounded-token>`. With
259
259
  performance metrics enabled, the same decision appears as
260
260
  `ppr-navigation-replay` in `Server-Timing`. `HIT` means matching consumed the
261
- seeded segment record after it decoded successfully, not merely that a snapshot
262
- existed. An explicit `cache()` scope that supplies the match cannot produce a
261
+ seeded segment record, not merely that a snapshot existed. Fragment-capable
262
+ clients decode its stored ReactNode fields after the response arrives; if that
263
+ decode fails, the client retries once through the server decode-and-evict path
264
+ and disables further passthrough for that browser document. The retry replaces
265
+ the fragment-capable response-cache slot, and a corrupt seeded snapshot is
266
+ recaptured at the key that supplied it. An explicit `cache()` scope that supplies
267
+ the match cannot produce a
263
268
  false HIT — it reports `explicit-cache-hit`. There is still no Flight resume
264
269
  API; this is segment replay followed by normal Flight streaming, not reuse of
265
- the HTML `prelude`/`postponed` bytes.
270
+ the HTML `prelude`/`postponed` bytes. Replayed segments do ride the partial
271
+ payload as verbatim `__rangoFragment` envelopes (#700 fragment splice — the
272
+ stored per-segment Flight strings are string-copied instead of
273
+ deserialize→re-serialize per request); the client expands them at its
274
+ navigation/prefetch decode chokepoints before anything renders, so they are
275
+ consumer-invisible.
266
276
 
267
277
  The bounded bypass tokens, grouped by when they are decided:
268
278
 
@@ -344,9 +354,10 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
344
354
  - A ppr-declared route that CANNOT be honored (missing shell store family,
345
355
  per-request nonce) serves plain axis 1 with NO header and warns once per
346
356
  key — no header + a declared `ppr` means look for that warning.
347
- - On Cloudflare, `CFCacheStore` WITHOUT a KV namespace has an inert shell
348
- family (the shell tier is KV-only): every ppr route stays `MISS` forever.
349
- The store warns once per isolate bind KV
357
+ - On Cloudflare, `CFCacheStore` reads PPR shells from the per-colo Cache API,
358
+ falls through to KV on a miss, and promotes the KV hit back into that colo.
359
+ WITHOUT a KV namespace its shell family remains inert: every ppr route stays
360
+ `MISS` forever. The store warns once per isolate — bind KV
350
361
  (`new CFCacheStore({ ctx, kv: env.CACHE_KV })`) or use another store.
351
362
  - Structured capture diagnostics: `createRouter({ debugShellCapture: true })`
352
363
  logs one line per capture attempt/skip (outcome, durations, prelude and
@@ -356,6 +367,11 @@ curl -s -D - -o /dev/null https://app.example.com/products/1 | grep -i x-rango-s
356
367
  later request. In dev, with `debugPerformance` on, the
357
368
  last capture outcome for a key also rides the next document GET's
358
369
  `Server-Timing` as `ppr-capture;desc="…"`.
370
+ - For deployed Cloudflare tier diagnostics, build with
371
+ `INTERNAL_RANGO_DEBUG=1` and run `wrangler tail`. `[CFCacheStore][shell]`
372
+ JSON events distinguish L1 hit/miss, KV fallback/promotion, tier writes, and
373
+ marker rejection, with `cf-ray`/colo and read timings. The flag is baked at
374
+ build time; setting only a Worker runtime variable is too late.
359
375
 
360
376
  ### Unit / integration testing (public primitives)
361
377
 
@@ -14,6 +14,12 @@ import {
14
14
  } from "./logging.js";
15
15
  import { getRangoState } from "./rango-state.js";
16
16
  import { isActionFenceActive } from "./action-fence.js";
17
+ import {
18
+ expandPayloadFragments,
19
+ SEGMENT_FRAGMENT_CAPABILITY_HEADER,
20
+ SEGMENT_FRAGMENT_RECOVERY_HEADER,
21
+ SegmentFragmentDecodeError,
22
+ } from "../segment-fragments.js";
17
23
  import {
18
24
  extractRscHeaderUrl,
19
25
  emptyResponse,
@@ -26,6 +32,8 @@ import {
26
32
  buildSourceKey,
27
33
  consumeInflightPrefetch,
28
34
  consumePrefetch,
35
+ disableFragmentPassthrough,
36
+ isFragmentPassthroughEnabled,
29
37
  type DecodedPrefetch,
30
38
  } from "./prefetch/cache.js";
31
39
  import { cancelAllPrefetches } from "./prefetch/loader.js";
@@ -46,7 +54,10 @@ import { cancelAllPrefetches } from "./prefetch/loader.js";
46
54
  * @returns NavigationClient instance
47
55
  */
48
56
  export function createNavigationClient(
49
- deps: Pick<RscBrowserDependencies, "createFromFetch">,
57
+ deps: Pick<
58
+ RscBrowserDependencies,
59
+ "createFromFetch" | "createFromReadableStream"
60
+ >,
50
61
  ): NavigationClient {
51
62
  return {
52
63
  /**
@@ -150,12 +161,6 @@ export function createNavigationClient(
150
161
  if (inflightEntryPromise) hitKey = wildcardKey;
151
162
  }
152
163
  }
153
- // Track when the stream completes
154
- let resolveStreamComplete: () => void;
155
- const streamComplete = new Promise<void>((resolve) => {
156
- resolveStreamComplete = resolve;
157
- });
158
-
159
164
  /**
160
165
  * Validate RSC control headers on any response (fresh, cached, or
161
166
  * in-flight). Handles version-mismatch reloads and server redirects.
@@ -164,6 +169,7 @@ export function createNavigationClient(
164
169
  const validateRscHeaders = (
165
170
  response: Response,
166
171
  source: string,
172
+ resolveStreamComplete: () => void,
167
173
  ): Response | Promise<Response> => {
168
174
  // Version mismatch — server wants a full page reload
169
175
  const reloadResult = handleReloadHeader(response, {
@@ -213,20 +219,35 @@ export function createNavigationClient(
213
219
  return response;
214
220
  };
215
221
 
216
- /** Start a fresh navigation fetch (no cache / inflight hit). */
217
- const doFreshFetch = (): Promise<Response> => {
222
+ /** Start and decode one fresh navigation fetch. */
223
+ const freshResult = (
224
+ fragmentPassthrough = isFragmentPassthroughEnabled(),
225
+ fragmentRecovery = false,
226
+ ): {
227
+ payload: Promise<RscPayload>;
228
+ streamComplete: Promise<void>;
229
+ } => {
230
+ let resolveStreamComplete!: () => void;
231
+ const streamComplete = new Promise<void>((resolve) => {
232
+ resolveStreamComplete = resolve;
233
+ });
234
+
218
235
  if (tx) {
219
236
  browserDebugLog(tx, "fetching", {
220
237
  path: `${fetchUrl.pathname}${fetchUrl.search}`,
238
+ fragmentPassthrough,
221
239
  });
222
240
  }
223
241
 
224
- return fetch(fetchUrl, {
242
+ const responsePromise = fetch(fetchUrl, {
225
243
  // During an action's flight the state is not rotated, so the old
226
244
  // X-Rango-State still matches the Vary-keyed HTTP-cache entry; bypass
227
245
  // it so a genuine mid-action navigation fetches fresh instead of being
228
- // served the stale prefetched bytes.
229
- ...(isActionFenceActive() && { cache: "no-store" as RequestCache }),
246
+ // served the stale prefetched bytes. Fragment recovery also bypasses
247
+ // HTTP caches so it reaches the server's decode-and-evict path.
248
+ ...((isActionFenceActive() || fragmentRecovery) && {
249
+ cache: "no-store" as RequestCache,
250
+ }),
230
251
  headers: {
231
252
  "X-RSC-Router-Client-Path": previousUrl,
232
253
  // Reuse the single per-operation read (see rangoState above): the
@@ -234,6 +255,12 @@ export function createNavigationClient(
234
255
  // cookie read has side effects (external-rotation notify) we do not
235
256
  // want to fire twice per navigation.
236
257
  "X-Rango-State": rangoState,
258
+ ...(fragmentPassthrough && {
259
+ [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
260
+ }),
261
+ ...(fragmentRecovery && {
262
+ [SEGMENT_FRAGMENT_RECOVERY_HEADER]: "1",
263
+ }),
237
264
  ...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
238
265
  ...(interceptSourceUrl && {
239
266
  "X-RSC-Router-Intercept-Source": interceptSourceUrl,
@@ -242,7 +269,11 @@ export function createNavigationClient(
242
269
  },
243
270
  signal,
244
271
  }).then((response) => {
245
- const validated = validateRscHeaders(response, "fetch");
272
+ const validated = validateRscHeaders(
273
+ response,
274
+ "fetch",
275
+ resolveStreamComplete,
276
+ );
246
277
  if (validated instanceof Promise) return validated;
247
278
 
248
279
  return teeWithCompletion(
@@ -254,20 +285,12 @@ export function createNavigationClient(
254
285
  signal,
255
286
  );
256
287
  });
257
- };
258
288
 
259
- // A warm prefetch hit returns its eagerly-decoded payload directly: the
260
- // route's chunks were imported during the prefetch, so this click runs
261
- // no decode and no network. Only the fresh path runs createFromFetch and
262
- // resolves the local streamComplete (via doFreshFetch's teeWithCompletion
263
- // and the control-header short-circuits in validateRscHeaders).
264
- const freshResult = (): {
265
- payload: Promise<RscPayload>;
266
- streamComplete: Promise<void>;
267
- } => ({
268
- payload: deps.createFromFetch<RscPayload>(doFreshFetch()),
269
- streamComplete,
270
- });
289
+ return {
290
+ payload: deps.createFromFetch<RscPayload>(responsePromise),
291
+ streamComplete,
292
+ };
293
+ };
271
294
 
272
295
  let payloadPromise: Promise<RscPayload>;
273
296
  let streamCompletePromise: Promise<void>;
@@ -332,7 +355,34 @@ export function createNavigationClient(
332
355
  // model is not flushed early (server/runtime buffering, e.g. wrangler
333
356
  // dev gzip); fast means the block, if any, is downstream in render.
334
357
  const vtDebugStart = isBrowserDebugEnabled() ? performance.now() : 0;
335
- const payload = await payloadPromise;
358
+ let payload: RscPayload;
359
+ try {
360
+ payload = await payloadPromise;
361
+ // Fragment envelopes (#700): expand replay-HIT envelopes before any
362
+ // consumer renders — this await is the single point all three payload
363
+ // sources (fresh fetch, warm prefetch, adopted inflight) flow through.
364
+ await expandPayloadFragments(payload, deps.createFromReadableStream);
365
+ } catch (error) {
366
+ if (
367
+ !(error instanceof SegmentFragmentDecodeError) ||
368
+ signal?.aborted
369
+ ) {
370
+ throw error;
371
+ }
372
+
373
+ // A fragment-only failure means the outer payload was valid but one
374
+ // stored segment was not. Retry once without the capability header:
375
+ // the server then takes CacheScope's decode-and-evict path and renders
376
+ // fresh. Never recurse; a failed recovery remains an ordinary
377
+ // unprocessable navigation response.
378
+ if (tx) browserDebugLog(tx, "fragment decode failed, retrying");
379
+ disableFragmentPassthrough();
380
+ const recovery = freshResult(false, true);
381
+ streamCompletePromise = recovery.streamComplete;
382
+ fullyPrefetched = false;
383
+ payload = await recovery.payload;
384
+ await expandPayloadFragments(payload, deps.createFromReadableStream);
385
+ }
336
386
  if (isBrowserDebugEnabled()) {
337
387
  debugLog("[VT-DIAG] payloadResolved", {
338
388
  ms: Math.round(performance.now() - vtDebugStart),
@@ -10,12 +10,12 @@
10
10
  * scopes are in play:
11
11
  * - Wildcard (default): built by `buildPrefetchKey(rangoState, target)` —
12
12
  * shape `rangoState\0/target?...`. Shared across all source pages and
13
- * invalidated automatically when Rango state bumps (deploy or
14
- * server-action invalidation).
13
+ * source segment trees; `_rsc_segments` is omitted from this key. Invalidated
14
+ * automatically when Rango state bumps (deploy or server-action invalidation).
15
15
  * - Source-scoped: built by `buildSourceKey(rangoState, sourceHref, target)`
16
16
  * — shape `rangoState\0sourceHref\0/target?...`. Embeds the Rango state
17
- * (so rotation invalidates source-scoped entries too) plus the source
18
- * href (so each originating page gets its own slot). Populated when the
17
+ * (so rotation invalidates source-scoped entries too), the source href, and
18
+ * the exact source segment tree. Populated when the
19
19
  * server tags a response with `X-RSC-Prefetch-Scope: source` (intercept
20
20
  * modals etc.), OR when a Link opts in with `prefetchKey=":source"` — in
21
21
  * both cases so source-sensitive responses cannot bleed into navigations
@@ -77,6 +77,7 @@ export interface DecodedPrefetch {
77
77
  }
78
78
 
79
79
  let cacheTTL = 300_000;
80
+ let fragmentPassthroughEnabled = true;
80
81
 
81
82
  // Max stored entries before FIFO eviction. Mirrors DEFAULT_PREFETCH_CACHE_SIZE
82
83
  // (router/prefetch-limits.ts); kept as a local literal so the client bundle
@@ -140,7 +141,9 @@ let generation = 0;
140
141
  * - Wildcard (source-agnostic): prefix is the Rango state value from
141
142
  * `getRangoState()`. Shared across all source pages. Invalidated
142
143
  * automatically when Rango state bumps (deploy or server-action).
143
- * Key shape: `rangoState\0/target?...`.
144
+ * `_rsc_segments` is omitted so sibling payloads prefetched from one page
145
+ * remain reusable after another prefetched navigation commits. Key shape:
146
+ * `rangoState\0/target?...`.
144
147
  * - Source-scoped: use `buildSourceKey()`. Key shape:
145
148
  * `rangoState\0sourceHref\0/target?...` — embeds the Rango state so
146
149
  * rotation invalidates source-scoped entries alongside wildcard ones,
@@ -149,12 +152,15 @@ let generation = 0;
149
152
  * `X-RSC-Prefetch-Scope: source` (intercept modals, etc.) or when a
150
153
  * Link opts in via `prefetchKey=":source"`.
151
154
  *
152
- * The `_rsc_segments` query param that travels in the target URL means
153
- * clients with different mounted segment trees naturally get different
154
- * keys so segment-level diffs remain consistent across both scopes.
155
+ * Source-scoped keys retain `_rsc_segments` because their exact source tree is
156
+ * part of the opt-in contract. Wildcard responses are source-agnostic by
157
+ * contract; callers use source scope when a custom revalidation decision makes
158
+ * the response depend on the current URL or segment tree.
155
159
  */
156
160
  export function buildPrefetchKey(prefix: string, targetUrl: URL): string {
157
- return prefix + "\0" + targetUrl.pathname + targetUrl.search;
161
+ const normalizedTarget = new URL(targetUrl);
162
+ normalizedTarget.searchParams.delete("_rsc_segments");
163
+ return prefix + "\0" + normalizedTarget.pathname + normalizedTarget.search;
158
164
  }
159
165
 
160
166
  /**
@@ -170,7 +176,14 @@ export function buildSourceKey(
170
176
  sourceHref: string,
171
177
  targetUrl: URL,
172
178
  ): string {
173
- return buildPrefetchKey(rangoState + "\0" + sourceHref, targetUrl);
179
+ return (
180
+ rangoState +
181
+ "\0" +
182
+ sourceHref +
183
+ "\0" +
184
+ targetUrl.pathname +
185
+ targetUrl.search
186
+ );
174
187
  }
175
188
 
176
189
  /**
@@ -369,6 +382,23 @@ export function clearPrefetchInflight(key: string): void {
369
382
  });
370
383
  }
371
384
 
385
+ /** Whether this document may request stored segment fragment envelopes. */
386
+ export function isFragmentPassthroughEnabled(): boolean {
387
+ return fragmentPassthroughEnabled;
388
+ }
389
+
390
+ /**
391
+ * Disable fragment passthrough for the rest of this document after one stored
392
+ * fragment fails to decode. The recovery replaces shared cache entries, while
393
+ * this guard prevents the current tab's HTTP cache from serving its already-
394
+ * cached corrupt variant again.
395
+ */
396
+ export function disableFragmentPassthrough(): void {
397
+ if (!fragmentPassthroughEnabled) return;
398
+ fragmentPassthroughEnabled = false;
399
+ clearPrefetchCache(false);
400
+ }
401
+
372
402
  export function clearPrefetchCache(rotateRangoState = true): void {
373
403
  generation++;
374
404
  inflight.clear();
@@ -25,6 +25,7 @@ import {
25
25
  removePrefetch,
26
26
  clearPrefetchInflight,
27
27
  currentGeneration,
28
+ isFragmentPassthroughEnabled,
28
29
  type DecodedPrefetch,
29
30
  } from "./cache.js";
30
31
  import { getRangoState } from "../rango-state.js";
@@ -33,6 +34,7 @@ import { enqueuePrefetch } from "./queue.js";
33
34
  import { shouldPrefetch } from "./policy.js";
34
35
  import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
35
36
  import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
37
+ import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
36
38
  import type { RscPayload } from "../types.js";
37
39
 
38
40
  /**
@@ -219,6 +221,9 @@ function executePrefetchFetch(
219
221
  "X-Rango-State": rangoState,
220
222
  "X-RSC-Router-Client-Path": window.location.href,
221
223
  "X-Rango-Prefetch": "1",
224
+ ...(isFragmentPassthroughEnabled() && {
225
+ [SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
226
+ }),
222
227
  },
223
228
  })
224
229
  .then((response) => {
@@ -22,7 +22,7 @@ import type {
22
22
  } from "./types.js";
23
23
  import type { EventController } from "./event-controller.js";
24
24
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
25
- import { expandSegmentFragments } from "../segment-fragments.js";
25
+ import { expandPayloadFragments } from "../segment-fragments.js";
26
26
  import { initRangoState } from "./rango-state.js";
27
27
  import { registerNavigationStore } from "./navigation-store-handle.js";
28
28
  import { initPrefetchCache } from "./prefetch/cache.js";
@@ -177,9 +177,7 @@ export async function initBrowserApp(
177
177
  // renderSegments, history cache). Non-HIT payloads have no envelopes and pay
178
178
  // one field scan. The SSR resume pass ran the same expansion (ssr-root.tsx),
179
179
  // so the hydrated tree matches the server-rendered one by construction.
180
- await expandSegmentFragments(initialPayload.metadata?.segments, (stream) =>
181
- deps.createFromReadableStream(stream),
182
- );
180
+ await expandPayloadFragments(initialPayload, deps.createFromReadableStream);
183
181
 
184
182
  // Extract themeConfig and initialTheme from payload if not explicitly provided
185
183
  // This allows virtual entries to work without importing the router
@@ -312,8 +310,16 @@ export async function initBrowserApp(
312
310
  }
313
311
 
314
312
  // Wire the RSC decoder so prefetches decode eagerly and warm the route's
315
- // client chunks (same createFromFetch the navigation client uses).
316
- setPrefetchDecoder((response) => deps.createFromFetch<RscPayload>(response));
313
+ // client chunks (same createFromFetch the navigation client uses). Fragment
314
+ // envelopes (#700) expand BEFORE the decoded payload enters the prefetch
315
+ // cache, so cached entries only ever resolve to expanded segments; a
316
+ // fragment-decode failure rejects the entry's payload and rides the cache's
317
+ // existing eviction path.
318
+ setPrefetchDecoder(async (response) => {
319
+ const payload = await deps.createFromFetch<RscPayload>(response);
320
+ await expandPayloadFragments(payload, deps.createFromReadableStream);
321
+ return payload;
322
+ });
317
323
 
318
324
  // Create a bound renderSegments that reads rootLayout through the shell ref.
319
325
  // The shell is set once at init and not swapped within a session (a cross-app