@rangojs/router 0.2.0 → 0.4.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 (105) hide show
  1. package/README.md +19 -1
  2. package/dist/types/browser/event-controller.d.ts +4 -0
  3. package/dist/types/browser/link-interceptor.d.ts +17 -7
  4. package/dist/types/browser/navigation-bridge.d.ts +13 -1
  5. package/dist/types/browser/notify-listeners.d.ts +2 -0
  6. package/dist/types/browser/prefetch/cache.d.ts +1 -1
  7. package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
  8. package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
  9. package/dist/types/browser/prefetch/loader.d.ts +3 -1
  10. package/dist/types/browser/prefetch/observer.d.ts +3 -6
  11. package/dist/types/browser/prefetch/runtime.d.ts +0 -1
  12. package/dist/types/browser/rango-state.d.ts +4 -0
  13. package/dist/types/browser/react/Link.d.ts +11 -19
  14. package/dist/types/browser/react/context.d.ts +3 -0
  15. package/dist/types/browser/rsc-router.d.ts +7 -2
  16. package/dist/types/browser/types.d.ts +6 -0
  17. package/dist/types/cache/cache-key-utils.d.ts +11 -3
  18. package/dist/types/cache/cache-scope.d.ts +82 -3
  19. package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
  20. package/dist/types/cache/index.d.ts +3 -1
  21. package/dist/types/cache/search-params-filter.d.ts +64 -0
  22. package/dist/types/cache/shell-snapshot.d.ts +4 -4
  23. package/dist/types/cache/types.d.ts +36 -2
  24. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types/index.rsc.d.ts +1 -0
  27. package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
  28. package/dist/types/router/navigation-snapshot.d.ts +29 -0
  29. package/dist/types/router/prefetch-default.d.ts +28 -0
  30. package/dist/types/router/router-interfaces.d.ts +7 -0
  31. package/dist/types/router/router-options.d.ts +54 -0
  32. package/dist/types/rsc/capture-queue.d.ts +6 -0
  33. package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
  34. package/dist/types/rsc/shell-capture.d.ts +15 -7
  35. package/dist/types/rsc/shell-serve.d.ts +11 -4
  36. package/dist/types/rsc/types.d.ts +19 -0
  37. package/dist/types/server/request-context.d.ts +57 -4
  38. package/dist/types/testing/e2e/index.d.ts +2 -2
  39. package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
  40. package/dist/types/testing/render-route.d.ts +10 -1
  41. package/dist/types/testing/shell-status.d.ts +6 -3
  42. package/dist/types/vite/plugin-types.d.ts +1 -1
  43. package/dist/vite/index.js +9 -6
  44. package/package.json +23 -22
  45. package/skills/caching/SKILL.md +39 -0
  46. package/skills/comparison/references/framework-comparison.md +9 -2
  47. package/skills/links/SKILL.md +24 -0
  48. package/skills/ppr/SKILL.md +80 -16
  49. package/skills/router-setup/SKILL.md +9 -0
  50. package/skills/testing/client-components.md +15 -14
  51. package/skills/vercel/SKILL.md +1 -1
  52. package/src/browser/event-controller.ts +110 -11
  53. package/src/browser/link-interceptor.ts +469 -20
  54. package/src/browser/navigation-bridge.ts +71 -2
  55. package/src/browser/navigation-store.ts +24 -1
  56. package/src/browser/notify-listeners.ts +22 -0
  57. package/src/browser/prefetch/cache.ts +4 -2
  58. package/src/browser/prefetch/default-strategy.ts +74 -0
  59. package/src/browser/prefetch/invalidation.ts +30 -0
  60. package/src/browser/prefetch/loader.ts +18 -17
  61. package/src/browser/prefetch/observer.ts +50 -22
  62. package/src/browser/prefetch/runtime.ts +0 -1
  63. package/src/browser/rango-state.ts +21 -0
  64. package/src/browser/react/Link.tsx +111 -101
  65. package/src/browser/react/NavigationProvider.tsx +1 -0
  66. package/src/browser/react/context.ts +4 -0
  67. package/src/browser/rsc-router.tsx +30 -9
  68. package/src/browser/types.ts +6 -0
  69. package/src/cache/cache-key-utils.ts +18 -4
  70. package/src/cache/cache-runtime.ts +7 -2
  71. package/src/cache/cache-scope.ts +152 -23
  72. package/src/cache/cf/cf-cache-store.ts +55 -16
  73. package/src/cache/document-cache.ts +7 -1
  74. package/src/cache/index.ts +7 -0
  75. package/src/cache/search-params-filter.ts +118 -0
  76. package/src/cache/shell-snapshot.ts +8 -4
  77. package/src/cache/types.ts +39 -2
  78. package/src/cache/vercel/vercel-cache-store.ts +22 -3
  79. package/src/index.rsc.ts +4 -0
  80. package/src/index.ts +6 -0
  81. package/src/router/match-middleware/cache-lookup.ts +146 -33
  82. package/src/router/match-middleware/cache-store.ts +70 -0
  83. package/src/router/navigation-snapshot.ts +46 -6
  84. package/src/router/prefetch-default.ts +59 -0
  85. package/src/router/router-interfaces.ts +8 -0
  86. package/src/router/router-options.ts +59 -1
  87. package/src/router.ts +7 -0
  88. package/src/rsc/capture-queue.ts +24 -1
  89. package/src/rsc/full-payload.ts +1 -0
  90. package/src/rsc/handler.ts +10 -0
  91. package/src/rsc/response-cache-serve.ts +1 -1
  92. package/src/rsc/rsc-rendering.ts +367 -40
  93. package/src/rsc/shell-build-manifest.ts +8 -1
  94. package/src/rsc/shell-capture.ts +99 -50
  95. package/src/rsc/shell-serve.ts +14 -6
  96. package/src/rsc/types.ts +19 -0
  97. package/src/server/request-context.ts +62 -3
  98. package/src/testing/dispatch.ts +21 -3
  99. package/src/testing/e2e/index.ts +6 -0
  100. package/src/testing/e2e/page-helpers.ts +47 -0
  101. package/src/testing/e2e/parity.ts +13 -0
  102. package/src/testing/render-route.tsx +86 -17
  103. package/src/testing/shell-status.ts +20 -3
  104. package/src/vite/plugin-types.ts +1 -1
  105. package/src/vite/plugins/vercel-output.ts +2 -2
@@ -30,6 +30,7 @@ import {
30
30
  setPrefetchConcurrency,
31
31
  setPrefetchDecoder,
32
32
  } from "./prefetch/loader.js";
33
+ import { setDefaultPrefetchStrategy } from "./prefetch/default-strategy.js";
33
34
  import { setAppVersion } from "./app-version.js";
34
35
  import {
35
36
  isInterceptSegment,
@@ -67,8 +68,11 @@ export interface InitBrowserAppOptions {
67
68
 
68
69
  /**
69
70
  * Enable global link interception for SPA navigation.
70
- * When enabled, clicks on same-origin anchor elements are intercepted
71
- * and handled via client-side navigation instead of full page loads.
71
+ * When enabled, clicks on eligible same-origin HTML anchor elements are intercepted
72
+ * and handled via client-side navigation instead of full page loads. Plain
73
+ * anchors inside the router basename also follow its default prefetch strategy
74
+ * after hydration. `data-prefetch="false"`/`"none"` opts out; `"true"` allows
75
+ * an application route with a common static-resource suffix.
72
76
  *
73
77
  * Links rendered with the Link component handle their own navigation
74
78
  * regardless of this setting.
@@ -123,6 +127,8 @@ export interface BrowserAppContext {
123
127
  warmupEnabled?: boolean;
124
128
  /** Whether the hydrated tree should be wrapped in React.StrictMode */
125
129
  strictMode?: boolean;
130
+ /** Whether plain-anchor click interception and delegated prefetch are enabled */
131
+ linkInterceptionEnabled?: boolean;
126
132
  /** App version for prefetch version mismatch detection */
127
133
  version?: string;
128
134
  /**
@@ -195,6 +201,12 @@ export async function initBrowserApp(
195
201
  }
196
202
  const initialHistoryKey = generateHistoryKey(window.location.href);
197
203
 
204
+ // Resolve the state namespace before the store installs its BroadcastChannel
205
+ // listener. A streaming handle payload can delay hydration below; leaving the
206
+ // default name active during that wait would discard this router's messages.
207
+ const version = initialPayload.metadata?.version;
208
+ initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
209
+
198
210
  // Create navigation store with history-based caching
199
211
  const store = createNavigationStore({
200
212
  initialLocation: window.location,
@@ -270,7 +282,6 @@ export async function initBrowserApp(
270
282
  // It is set once from the initial payload and not swapped within a session:
271
283
  // a cross-app navigation is a full document load (X-RSC-Reload), so the
272
284
  // target app establishes its own shell on load.
273
- const version = initialPayload.metadata?.version;
274
285
  const appShellRef = createAppShellRef({
275
286
  routerId: initialPayload.metadata?.routerId,
276
287
  rootLayout: initialPayload.metadata?.rootLayout,
@@ -278,11 +289,6 @@ export async function initBrowserApp(
278
289
  version,
279
290
  });
280
291
 
281
- // Initialize the rango state cookie for cache invalidation. The build version
282
- // busts cached prefetches on deploy; the server-resolved cookie name
283
- // namespaces the cookie so sibling apps on the same origin don't collide
284
- // (falls back to the bare default prefix if metadata lacks the name).
285
- initRangoState(version ?? "0", initialPayload.metadata?.stateCookieName);
286
292
  setAppVersion(version);
287
293
 
288
294
  // Initialize the in-memory prefetch cache (TTL + max size) and the prefetch
@@ -297,6 +303,13 @@ export async function initBrowserApp(
297
303
  if (prefetchConcurrency !== undefined) {
298
304
  setPrefetchConcurrency(prefetchConcurrency);
299
305
  }
306
+ // Apply the router-wide default Link prefetch strategy. Undefined (older
307
+ // server payload) keeps the module's environment-aware default, which equals
308
+ // the server resolver's default by contract — see default-strategy.ts.
309
+ const defaultPrefetch = initialPayload.metadata?.defaultPrefetch;
310
+ if (defaultPrefetch !== undefined) {
311
+ setDefaultPrefetchStrategy(defaultPrefetch);
312
+ }
300
313
 
301
314
  // Wire the RSC decoder so prefetches decode eagerly and warm the route's
302
315
  // client chunks (same createFromFetch the navigation client uses).
@@ -351,6 +364,8 @@ export async function initBrowserApp(
351
364
  onUpdate: (update) => store.emitUpdate(update),
352
365
  renderSegments,
353
366
  version: version,
367
+ defaultPrefetch,
368
+ basename: initialPayload.metadata?.basename,
354
369
  });
355
370
 
356
371
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -533,6 +548,7 @@ export async function initBrowserApp(
533
548
  initialTheme: effectiveInitialTheme,
534
549
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
535
550
  strictMode: initialPayload.metadata?.strictMode ?? true,
551
+ linkInterceptionEnabled: linkInterception,
536
552
  version,
537
553
  appShellRef,
538
554
  };
@@ -606,6 +622,7 @@ export function Rango(_props: RangoProps): React.ReactElement {
606
622
  warmupEnabled,
607
623
  version,
608
624
  appShellRef,
625
+ linkInterceptionEnabled,
609
626
  } = getBrowserAppContext();
610
627
 
611
628
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -613,11 +630,15 @@ export function Rango(_props: RangoProps): React.ReactElement {
613
630
  // that does not depend on React internals like __reactFiber.
614
631
  React.useEffect(() => {
615
632
  document.documentElement.dataset.hydrated = "";
633
+ const cleanupPrefetch = linkInterceptionEnabled
634
+ ? bridge.registerDelegatedPrefetch()
635
+ : undefined;
616
636
  if (IS_BROWSER_DEBUG && !hydrationCommitLogged) {
617
637
  hydrationCommitLogged = true;
618
638
  bootLog("hydration commit (root effect flushed)");
619
639
  }
620
- }, []);
640
+ return cleanupPrefetch;
641
+ }, [bridge, linkInterceptionEnabled]);
621
642
 
622
643
  return (
623
644
  <NavigationProvider
@@ -82,6 +82,11 @@ export interface RscMetadata {
82
82
  * Sent on initial render so the browser can configure its prefetch queue.
83
83
  */
84
84
  prefetchConcurrency?: number;
85
+ /**
86
+ * Router-wide default prefetch strategy for Links without a `prefetch` prop.
87
+ * Sent on initial render; applied once at init (default-strategy.ts).
88
+ */
89
+ defaultPrefetch?: import("../router/prefetch-default.js").PrefetchStrategy;
85
90
  /**
86
91
  * Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
87
92
  * reads it verbatim and binds the rango state cookie to it; composition
@@ -608,6 +613,7 @@ export interface NavigationBridge {
608
613
  refresh(): Promise<void>;
609
614
  handlePopstate(): Promise<void>;
610
615
  registerLinkInterception(): () => void;
616
+ registerDelegatedPrefetch(): () => void;
611
617
  /** Current RSC version (live, reflects the latest updateVersion). */
612
618
  getVersion(): string | undefined;
613
619
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { encodeKV } from "../encode-kv.js";
10
+ import type { SearchParamsFilter } from "./search-params-filter.js";
10
11
 
11
12
  /**
12
13
  * Reserved URL query params that the router owns and must never key the cache
@@ -34,12 +35,22 @@ function isReservedSearchParam(key: string): boolean {
34
35
  * Build a sorted, deterministic query string from URLSearchParams,
35
36
  * excluding the router's reserved params (see isReservedSearchParam).
36
37
  *
37
- * Returns empty string when no user-facing params exist.
38
+ * `filter` is the compiled `cache.searchParams` config (search-params-filter.ts),
39
+ * applied AFTER the reserved-param exclusion and BEFORE the sort, so reserved
40
+ * params can never be re-included (`include: ["__no_cache"]` is a no-op) and
41
+ * surviving params stay order-insensitive. `undefined` means no filtering --
42
+ * that path must stay byte-identical to the pre-filter format (cacheKeyBase
43
+ * output is byte-stable by contract).
44
+ *
45
+ * Returns empty string when no user-facing params survive.
38
46
  */
39
- export function sortedSearchString(searchParams: URLSearchParams): string {
47
+ export function sortedSearchString(
48
+ searchParams: URLSearchParams,
49
+ filter?: SearchParamsFilter,
50
+ ): string {
40
51
  const pairs: [string, string][] = [];
41
52
  for (const [k, v] of searchParams) {
42
- if (!isReservedSearchParam(k)) {
53
+ if (!isReservedSearchParam(k) && (filter === undefined || filter(k))) {
43
54
  pairs.push([k, v]);
44
55
  }
45
56
  }
@@ -77,9 +88,12 @@ export function cacheKeyBase(
77
88
  pathname: string,
78
89
  searchParams?: URLSearchParams,
79
90
  params?: Record<string, string>,
91
+ filter?: SearchParamsFilter,
80
92
  ): string {
81
93
  const paramStr = sortedRouteParams(params);
82
- const searchStr = searchParams ? sortedSearchString(searchParams) : "";
94
+ const searchStr = searchParams
95
+ ? sortedSearchString(searchParams, filter)
96
+ : "";
83
97
 
84
98
  let key = `${host}${pathname}`;
85
99
  if (paramStr) key += `:${paramStr}`;
@@ -302,9 +302,14 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
302
302
  if (ctx._responseType) {
303
303
  keyArgs.push(ctx._responseType);
304
304
  }
305
- // Include user-facing search params (exclude internal _rsc*/__ params)
305
+ // Include user-facing search params (exclude internal _rsc*/__
306
+ // params, plus the request's cache.searchParams filter -- same
307
+ // normalization as the URL-keyed tiers).
306
308
  if (ctx.searchParams instanceof URLSearchParams) {
307
- const normalized = sortedSearchString(ctx.searchParams);
309
+ const normalized = sortedSearchString(
310
+ ctx.searchParams,
311
+ requestCtx?._searchParamsFilter,
312
+ );
308
313
  if (normalized) {
309
314
  keyArgs.push(normalized);
310
315
  }
@@ -11,6 +11,7 @@
11
11
  import type { PartialCacheOptions } from "../types.js";
12
12
  import type { ResolvedSegment } from "../types.js";
13
13
  import type { SegmentCacheStore, CachedEntryData } from "./types.js";
14
+ import { CACHE_READ_ERROR } from "./types.js";
14
15
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
15
16
  import {
16
17
  getRequestContext,
@@ -101,7 +102,7 @@ function getDefaultRouteCacheKey(
101
102
  ? "intercept"
102
103
  : (prefixOverride ?? (isPartial ? "partial" : "doc"));
103
104
 
104
- return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
105
+ return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params, ctx?._searchParamsFilter)}`;
105
106
  }
106
107
 
107
108
  // ============================================================================
@@ -110,6 +111,17 @@ function getDefaultRouteCacheKey(
110
111
 
111
112
  const CACHE_HIT_OBSERVERS = new WeakMap<CacheScope, () => void>();
112
113
 
114
+ /**
115
+ * Discriminated outcome of a route cache lookup — see
116
+ * {@link CacheScope.lookupRouteDetailed} for what each status licenses.
117
+ */
118
+ export type CacheRouteLookupOutcome =
119
+ | {
120
+ status: "hit";
121
+ result: { segments: ResolvedSegment[]; shouldRevalidate: boolean };
122
+ }
123
+ | { status: "miss" | "bypass" | "error" };
124
+
113
125
  /**
114
126
  * CacheScope represents a cache boundary in the route tree.
115
127
  *
@@ -225,30 +237,76 @@ export class CacheScope {
225
237
  return resolveCacheKey(keyFn, this.getStore(), defaultKey, "CacheScope");
226
238
  }
227
239
 
240
+ /**
241
+ * @internal Whether a cache read/write is allowed for the current request:
242
+ * the scope is enabled AND its `condition` (if any) returns true. "read"
243
+ * is consulted by the PPR navigation-replay gate BEFORE any shell-store
244
+ * read so a cache(false)/condition-false route reports `cache-disabled`
245
+ * without spending getShell I/O; "write" gates the capture's snapshot-only
246
+ * doc record (cache-store middleware) under the same semantics as the
247
+ * scope's own store write. Consumer opt-outs are absolute — the seeded
248
+ * fallback must never serve where the consumer refused cached serves.
249
+ */
250
+ allowsCache(op: "read" | "write"): boolean {
251
+ return this.enabled && this.conditionAllows(op);
252
+ }
253
+
254
+ /**
255
+ * @internal True for scopes minted from the `_shellImplicitCache` marker
256
+ * (createShellImplicitDocScope) — the only construction path that passes
257
+ * the `doc` defaultKeyPrefix; route-derived scopes (createCacheScope) never
258
+ * do. withCacheLookup/withCacheStore use this to tell the implicit doc
259
+ * scope from a route-derived scope: the two compose on the replay serve
260
+ * path and only the route-derived kind gets the seeded fallback /
261
+ * doc-record treatment.
262
+ */
263
+ get isShellImplicitDocScope(): boolean {
264
+ return this.defaultKeyPrefix === "doc";
265
+ }
266
+
228
267
  /**
229
268
  * Evaluate the cache `condition` predicate. Returns false (skip the cache
230
269
  * operation) when the predicate returns false or throws; returns true when
231
270
  * there is no condition or no request context to evaluate it against.
232
271
  */
272
+ /**
273
+ * One WRITE decision per (scope, request), memoized on the request context.
274
+ * A capture render has TWO writers consulting the same predicate — the
275
+ * explicit tier's cacheRoute and the snapshot-only doc record gate
276
+ * (recordShellCaptureDocRecord) — and a true→false flap between the two
277
+ * evaluations recorded a REPLAYABLE canonical snapshot for a render whose
278
+ * real write was refused. The first evaluation pins the answer for the
279
+ * whole render (the capture's derived context during captures). READ
280
+ * decisions stay per-lookup by design: pre-deciding a flappable predicate
281
+ * at the replay gate was the round-2 regression.
282
+ */
283
+ private readonly writeConditionMemo = new WeakMap<RequestContext, boolean>();
284
+
233
285
  private conditionAllows(op: "read" | "write"): boolean {
234
286
  if (this.config === false || !this.config.condition) return true;
235
287
  const requestCtx = getRequestContext();
236
288
  if (!requestCtx) return true;
289
+ if (op === "write") {
290
+ const memoized = this.writeConditionMemo.get(requestCtx);
291
+ if (memoized !== undefined) return memoized;
292
+ }
293
+ let allowed: boolean;
237
294
  try {
238
- if (!this.config.condition(requestCtx)) {
295
+ allowed = !!this.config.condition(requestCtx);
296
+ if (!allowed) {
239
297
  debugCacheLog(
240
298
  `[CacheScope] condition returned false, skipping cache ${op}`,
241
299
  );
242
- return false;
243
300
  }
244
- return true;
245
301
  } catch (error) {
246
302
  console.error(
247
303
  `[CacheScope] condition function threw, skipping cache ${op}:`,
248
304
  error,
249
305
  );
250
- return false;
306
+ allowed = false;
251
307
  }
308
+ if (op === "write") this.writeConditionMemo.set(requestCtx, allowed);
309
+ return allowed;
252
310
  }
253
311
 
254
312
  /**
@@ -267,11 +325,42 @@ export class CacheScope {
267
325
  segments: ResolvedSegment[];
268
326
  shouldRevalidate: boolean;
269
327
  } | null> {
270
- if (!this.enabled) return null;
271
- if (!this.conditionAllows("read")) return null;
328
+ const outcome = await this.lookupRouteDetailed(
329
+ pathname,
330
+ params,
331
+ isIntercept,
332
+ );
333
+ return outcome.status === "hit" ? outcome.result : null;
334
+ }
335
+
336
+ /**
337
+ * @internal lookupRoute with a discriminated outcome. The PPR replay
338
+ * composition (withCacheLookup) may substitute the seeded doc record ONLY on
339
+ * a true `miss` — the other non-hit outcomes must not be papered over:
340
+ *
341
+ * - `bypass`: the scope refused the read — cache(false), a false
342
+ * `condition()`, or no resolvable store. Consumer opt-outs are absolute;
343
+ * a fallback here would serve cached segments to a request the consumer
344
+ * said must render fresh.
345
+ * - `error`: a throwing consumer key()/store keyGenerator/store.get. The
346
+ * contract on that failure is "render uncached" (see the catch below) —
347
+ * falling back would serve the canonical doc record across a broken key
348
+ * partition, exactly the collision resolveCacheKey's no-default-fallback
349
+ * rule exists to prevent.
350
+ *
351
+ * A corrupt entry that was evicted reads as `miss`: the store was consulted
352
+ * and holds nothing servable, which is the post-eviction truth.
353
+ */
354
+ async lookupRouteDetailed(
355
+ pathname: string,
356
+ params: Record<string, string>,
357
+ isIntercept?: boolean,
358
+ ): Promise<CacheRouteLookupOutcome> {
359
+ if (!this.enabled) return { status: "bypass" };
360
+ if (!this.conditionAllows("read")) return { status: "bypass" };
272
361
 
273
362
  const store = this.getStore();
274
- if (!store) return null;
363
+ if (!store) return { status: "bypass" };
275
364
 
276
365
  // Resolve cache key INSIDE the try so a throwing consumer key() (or a
277
366
  // store.keyGenerator) degrades to a cache miss (return null -> render
@@ -285,9 +374,17 @@ export class CacheScope {
285
374
 
286
375
  const result = await store.get(key);
287
376
 
377
+ // Built-in stores swallow backend read failures internally and signal
378
+ // them with CACHE_READ_ERROR — classify as `error`, not `miss`, so the
379
+ // replay composition renders uncached instead of substituting the
380
+ // seeded doc record for a tier whose backend never answered.
381
+ if (result === CACHE_READ_ERROR) {
382
+ return { status: "error" };
383
+ }
384
+
288
385
  if (!result) {
289
386
  debugCacheLog(`[CacheScope] MISS: ${key}`);
290
- return null;
387
+ return { status: "miss" };
291
388
  }
292
389
 
293
390
  const { data: cached, shouldRevalidate } = result;
@@ -320,7 +417,7 @@ export class CacheScope {
320
417
  .catch((e) =>
321
418
  reportCacheError(e, "cache-delete", `[CacheScope] ${key}: evict`),
322
419
  );
323
- return null;
420
+ return { status: "miss" };
324
421
  }
325
422
 
326
423
  // A hit serves content that was tagged at write time, so the document
@@ -351,16 +448,18 @@ export class CacheScope {
351
448
  }
352
449
 
353
450
  CACHE_HIT_OBSERVERS.get(this)?.();
354
- return { segments, shouldRevalidate };
451
+ return { status: "hit", result: { segments, shouldRevalidate } };
355
452
  } catch (error) {
356
453
  // Covers a store.get() failure AND a throwing consumer key()/keyGenerator
357
- // (resolveKey). Either way degrade to a cache miss so the render proceeds.
454
+ // (resolveKey). Either way degrade to an uncached render reported as
455
+ // `error`, not `miss`, so the replay composition cannot substitute the
456
+ // seeded doc record for a lookup that never resolved its key partition.
358
457
  reportCacheError(
359
458
  error,
360
459
  "cache-read",
361
460
  `[CacheScope] lookup ${key ?? "(key resolution failed)"}`,
362
461
  );
363
- return null;
462
+ return { status: "error" };
364
463
  }
365
464
  }
366
465
 
@@ -422,6 +521,15 @@ export class CacheScope {
422
521
  // Resolve cache key early (while request context is available)
423
522
  const key = await this.resolveKey(pathname, params, isIntercept);
424
523
 
524
+ // Doc-namespaced scopes (the shell implicit scope and the capture's
525
+ // composed doc scope) publish the canonical document segment key so
526
+ // captureAndStoreShell can stamp it onto the shell entry (`docKey`).
527
+ // Replay eligibility then requires this exact record — "any segment
528
+ // record" previously counted unusable explicit-tier-keyed records too.
529
+ if (this.defaultKeyPrefix === "doc" && requestCtx._shellImplicitCache) {
530
+ requestCtx._shellImplicitCache.docKey = key;
531
+ }
532
+
425
533
  // Resolve tags early (while request context is available, before waitUntil)
426
534
  const tags = resolveCacheTags(this.config, requestCtx);
427
535
  recordRequestTags(tags, requestCtx);
@@ -538,6 +646,31 @@ export function createCacheScope(
538
646
  return new CacheScope(config.options, parent);
539
647
  }
540
648
 
649
+ type ShellImplicitCacheMarker = NonNullable<
650
+ RequestContext["_shellImplicitCache"]
651
+ >;
652
+
653
+ /**
654
+ * Mint the implicit doc-level scope for a `_shellImplicitCache` marker: key
655
+ * resolution under the marker's `doc` namespace against the marker's store,
656
+ * with the marker's onHit wired as the hit observer. Shared by
657
+ * {@link resolveShellImplicitCacheScope} (routes that derived no scope) and
658
+ * the explicit-scope composition sites (capture doc record in the cache-store
659
+ * middleware, seeded replay fallback in withCacheLookup) so both ends of the
660
+ * shell contract resolve the SAME canonical document key.
661
+ */
662
+ export function createShellImplicitDocScope(
663
+ marker: ShellImplicitCacheMarker,
664
+ ): CacheScope {
665
+ const implicitScope = new CacheScope(
666
+ { ttl: marker.ttl, swr: marker.swr, store: marker.store },
667
+ null,
668
+ marker.keyPrefix,
669
+ );
670
+ if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
671
+ return implicitScope;
672
+ }
673
+
541
674
  /**
542
675
  * Shell fast path: when the route tree derived NO cache scope and the current
543
676
  * request context carries the `_shellImplicitCache` marker (a shell capture,
@@ -548,9 +681,11 @@ export function createCacheScope(
548
681
  * (resolveFreshLoadersAndYield).
549
682
  *
550
683
  * An existing scope — including an explicit cache(false) opt-out — always
551
- * wins: the consumer's cache() semantics (their ttl/swr/store/condition) are
552
- * never overridden, and cache(false) keeps the tail on the full handler
553
- * re-run path.
684
+ * wins HERE: the consumer's cache() semantics (their ttl/swr/store/condition)
685
+ * are never overridden, and cache(false) keeps the tail on the full handler
686
+ * re-run path. On the navigation-replay serve path the marker still composes
687
+ * with an explicit scope downstream (withCacheLookup's seeded fallback after
688
+ * an explicit-tier miss) — see `onExplicitHit` on `_shellImplicitCache`.
554
689
  */
555
690
  export function resolveShellImplicitCacheScope(
556
691
  scope: CacheScope | null,
@@ -558,11 +693,5 @@ export function resolveShellImplicitCacheScope(
558
693
  if (scope) return scope;
559
694
  const marker = getRequestContext()?._shellImplicitCache;
560
695
  if (!marker) return null;
561
- const implicitScope = new CacheScope(
562
- { ttl: marker.ttl, swr: marker.swr, store: marker.store },
563
- null,
564
- marker.keyPrefix,
565
- );
566
- if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
567
- return implicitScope;
696
+ return createShellImplicitDocScope(marker);
568
697
  }
@@ -35,7 +35,9 @@ import type {
35
35
  CacheItemResult,
36
36
  CacheItemOptions,
37
37
  ShellCacheEntry,
38
+ CacheReadError,
38
39
  } from "../types.js";
40
+ import { CACHE_READ_ERROR } from "../types.js";
39
41
  import {
40
42
  _getRequestContext,
41
43
  type RequestContext,
@@ -286,6 +288,14 @@ interface KVShellEnvelope {
286
288
  i?: string;
287
289
  /** Capture data snapshot: recorded cache-store hits/writes for HIT parity */
288
290
  sn?: import("../types.js").ShellSnapshotRecord[];
291
+ /**
292
+ * ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
293
+ * requires the exact canonical doc segment record named here — dropping the
294
+ * field reads back as "no consumable record" and every partial navigation
295
+ * reports `no-segment-snapshot` after a KV round trip (the memory store
296
+ * passes the entry by reference, so only envelope stores can lose it).
297
+ */
298
+ dk?: string;
289
299
  /**
290
300
  * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
291
301
  * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
@@ -295,6 +305,8 @@ interface KVShellEnvelope {
295
305
  lh?: boolean;
296
306
  /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
297
307
  tw?: true;
308
+ /** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
309
+ no?: true;
298
310
  }
299
311
 
300
312
  /**
@@ -881,7 +893,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
881
893
  * On L1 miss, falls back to KV (L2) if configured.
882
894
  * KV hits are promoted to L1 in the background.
883
895
  */
884
- async get(key: string): Promise<CacheGetResult | null> {
896
+ async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
885
897
  if (this.isReservedSegmentKey(key, "cache-read")) return null;
886
898
  try {
887
899
  const cache = await this.getCache();
@@ -895,27 +907,40 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
895
907
  const matchMs = Date.now() - matchStart;
896
908
 
897
909
  if (!response) {
898
- // A transient L1 match error (matchError set) is reported as cache-read
899
- // but, like a genuine miss or an abandoned slow match (timedOut), still
900
- // degrades to L2/KV rather than failing the read.
901
- if (matchError)
910
+ if (matchError) {
911
+ // A match REJECTION is reported as cache-read and still degrades to
912
+ // L2/KV -- a real KV value (or KV's own CACHE_READ_ERROR) stands on
913
+ // its own. But a null KV result (unconfigured namespace, kv-miss,
914
+ // kv-timeout) is NOT proof of absence under a rejected L1 match:
915
+ // the only real signal this read produced is the failure, so
916
+ // surface CACHE_READ_ERROR instead of a replayable miss (the PPR
917
+ // seeded fallback must render uncached, not substitute the doc
918
+ // record for a partition the store could not actually read).
902
919
  reportCacheError(
903
920
  matchError,
904
921
  "cache-read",
905
922
  "[CFCacheStore] get L1 match",
906
923
  );
924
+ if (this.debug)
925
+ this.emitDebug({
926
+ op: "get",
927
+ key,
928
+ // Distinct from a genuine absence: surface it as match-error so
929
+ // debug agrees with the cache-read already routed to onError,
930
+ // instead of masquerading as l1-miss.
931
+ outcome: "match-error",
932
+ matchMs,
933
+ });
934
+ const kvResult = await this.kvGetSegment(key);
935
+ return kvResult ?? CACHE_READ_ERROR;
936
+ }
937
+ // An abandoned slow match (timedOut) keeps the fail-open latency-budget
938
+ // policy: degrade to L2/KV, and a KV null stays a miss.
907
939
  if (this.debug)
908
940
  this.emitDebug({
909
941
  op: "get",
910
942
  key,
911
- // A match REJECTION (matchError) is distinct from a genuine absence:
912
- // surface it as match-error so debug agrees with the cache-read
913
- // already routed to onError, instead of masquerading as l1-miss.
914
- outcome: matchError
915
- ? "match-error"
916
- : timedOut
917
- ? "match-timeout"
918
- : "l1-miss",
943
+ outcome: timedOut ? "match-timeout" : "l1-miss",
919
944
  matchMs,
920
945
  });
921
946
  return this.kvGetSegment(key);
@@ -1096,7 +1121,9 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1096
1121
  // emit is the separate wrangler-tail signal. Keep both observability paths.
1097
1122
  reportCacheError(error, "cache-read", "[CFCacheStore] get");
1098
1123
  if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
1099
- return null;
1124
+ // Distinct from a miss so the PPR replay composition renders uncached
1125
+ // instead of substituting the seeded doc record (CACHE_READ_ERROR).
1126
+ return CACHE_READ_ERROR;
1100
1127
  }
1101
1128
  }
1102
1129
 
@@ -1817,8 +1844,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1817
1844
  buildVersion: envelope.bv,
1818
1845
  initialTheme: envelope.i,
1819
1846
  snapshot: envelope.sn,
1847
+ docKey: envelope.dk,
1820
1848
  handlerLiveHoles: envelope.lh,
1821
1849
  transitionWhen: envelope.tw,
1850
+ navigationOnly: envelope.no,
1822
1851
  createdAt: envelope.c,
1823
1852
  },
1824
1853
  shouldRevalidate,
@@ -1905,8 +1934,10 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
1905
1934
  ta: taggedAt,
1906
1935
  i: entry.initialTheme,
1907
1936
  sn: entry.snapshot,
1937
+ dk: entry.docKey,
1908
1938
  lh: entry.handlerLiveHoles,
1909
1939
  tw: entry.transitionWhen,
1940
+ no: entry.navigationOnly,
1910
1941
  };
1911
1942
  await this.kv!.put(kvKey, JSON.stringify(envelope), {
1912
1943
  expirationTtl: retentionTtl,
@@ -2813,7 +2844,7 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2813
2844
  private async kvGetSegment(
2814
2845
  key: string,
2815
2846
  opts?: { suppressRevalidate?: boolean },
2816
- ): Promise<CacheGetResult | null> {
2847
+ ): Promise<CacheGetResult | null | CacheReadError> {
2817
2848
  if (!this.kv) return null;
2818
2849
 
2819
2850
  try {
@@ -2882,7 +2913,15 @@ export class CFCacheStore<TEnv = unknown> implements SegmentCacheStore<TEnv> {
2882
2913
  } catch (error) {
2883
2914
  reportCacheError(error, "cache-read", "[CFCacheStore] kvGetSegment");
2884
2915
  if (this.debug) this.emitDebug({ op: "get", key, outcome: "error" });
2885
- return null;
2916
+ // A KV failure is NOT proof of absence: returning null classified it a
2917
+ // real miss and let the PPR seeded fallback substitute the doc record
2918
+ // for a key partition the store could not actually read. Same sentinel
2919
+ // as get()'s own catch — lookupRouteDetailed classifies it `error` and
2920
+ // the render stays uncached. (A kvGetOrEvict TIMEOUT above stays null
2921
+ // by design: it is a bounded-latency degrade of a likely-healthy read,
2922
+ // and serving the equivalent seeded record there is the fallback
2923
+ // working as intended, not a masked failure.)
2924
+ return CACHE_READ_ERROR;
2886
2925
  }
2887
2926
  }
2888
2927
 
@@ -327,7 +327,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
327
327
  // existing keys and double any host they already include).
328
328
  const cacheKey = keyGenerator
329
329
  ? keyGenerator(url) + segmentHash + typeSuffix
330
- : cacheKeyBase(url.host, url.pathname, url.searchParams) +
330
+ : cacheKeyBase(
331
+ url.host,
332
+ url.pathname,
333
+ url.searchParams,
334
+ undefined,
335
+ requestCtx?._searchParamsFilter,
336
+ ) +
331
337
  segmentHash +
332
338
  typeSuffix;
333
339
  // 1. Check cache
@@ -7,7 +7,9 @@ export type {
7
7
  ShellCacheEntry,
8
8
  SerializedSegmentData,
9
9
  SegmentHandleData,
10
+ CacheReadError,
10
11
  } from "./types.js";
12
+ export { CACHE_READ_ERROR } from "./types.js";
11
13
 
12
14
  export { MemorySegmentCacheStore } from "./memory-segment-store.js";
13
15
 
@@ -39,6 +41,11 @@ export {
39
41
  VERCEL_MAX_TAG_BYTES,
40
42
  } from "./vercel/index.js";
41
43
 
44
+ export {
45
+ TRACKING_SEARCH_PARAMS,
46
+ type CacheSearchParams,
47
+ } from "./search-params-filter.js";
48
+
42
49
  export { CacheScope, createCacheScope } from "./cache-scope.js";
43
50
 
44
51
  export {