@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
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Global search-param cache-key filtering (`cache.searchParams` on the
3
+ * handler/createRouter cache config).
4
+ *
5
+ * Controls WHICH query params participate in default cache-key generation
6
+ * across every tier that keys by URL (segment, document, response, PPR shell,
7
+ * "use cache" ctx normalization, prerendered-shell manifest matching). The
8
+ * filter affects cache keys ONLY -- `ctx.searchParams` and the request URL are
9
+ * untouched, handlers and loaders still see the full query string.
10
+ *
11
+ * Design doc: docs/design/caching.md ("Search param filtering").
12
+ *
13
+ * The footgun this must not soften: excluding a param is a promise that
14
+ * rendered output does not depend on it. If it does, the first variant is
15
+ * cached and served to everyone (the classic CDN cache-key mistake). The
16
+ * default therefore stays "all" -- correct by default, opt into collapsing.
17
+ */
18
+
19
+ /**
20
+ * `cache.searchParams` config value.
21
+ *
22
+ * - `"all"` (default) -- every non-reserved param keys the cache (today's
23
+ * behavior; reserved = the router's own `_rsc*` / `__` allowlist params,
24
+ * see cache-key-utils.ts).
25
+ * - `"none"` -- query params never key the cache.
26
+ * - `{ include }` -- allowlist: only the named params key the cache.
27
+ * - `{ exclude }` -- denylist: every param except the named ones keys the cache.
28
+ *
29
+ * Names match exactly, plus a `*` SUFFIX wildcard (`"utm_*"` matches every
30
+ * param starting with `utm_`). No RegExp: keeps the config serializable and
31
+ * deterministic. `include` + `exclude` together is unrepresentable -- the
32
+ * union forces exactly one mode.
33
+ */
34
+ export type CacheSearchParams =
35
+ | "all"
36
+ | "none"
37
+ | { include: readonly string[]; exclude?: never }
38
+ | { exclude: readonly string[]; include?: never };
39
+
40
+ /**
41
+ * Compiled form of a `CacheSearchParams` config: returns true when the param
42
+ * name should participate in the cache key. `undefined` means "no filter"
43
+ * ("all") so the unfiltered path stays byte-identical to the pre-feature key
44
+ * format (cacheKeyBase output is byte-stable by contract).
45
+ */
46
+ export type SearchParamsFilter = (name: string) => boolean;
47
+
48
+ /**
49
+ * Well-known tracking/click-id params that fragment caches without changing
50
+ * rendered output for (almost) every app. Exported so the common case is one
51
+ * line: `searchParams: { exclude: TRACKING_SEARCH_PARAMS }`.
52
+ *
53
+ * Sources: Google (gclid/gclsrc/dclid/gbraid/wbraid + utm_*), Meta (fbclid),
54
+ * Microsoft (msclkid), TikTok (ttclid), Twitter/X (twclid), LinkedIn
55
+ * (li_fat_id), Mailchimp (mc_cid/mc_eid), Instagram (igshid), Yandex (yclid),
56
+ * HubSpot (_hsenc/_hsmi).
57
+ */
58
+ export const TRACKING_SEARCH_PARAMS: readonly string[] = [
59
+ "utm_*",
60
+ "gclid",
61
+ "gclsrc",
62
+ "dclid",
63
+ "gbraid",
64
+ "wbraid",
65
+ "fbclid",
66
+ "msclkid",
67
+ "ttclid",
68
+ "twclid",
69
+ "li_fat_id",
70
+ "mc_cid",
71
+ "mc_eid",
72
+ "igshid",
73
+ "yclid",
74
+ "_hsenc",
75
+ "_hsmi",
76
+ ];
77
+
78
+ const NONE_FILTER: SearchParamsFilter = () => false;
79
+
80
+ /**
81
+ * Build a name matcher from a pattern list: exact names into a Set, `*`-suffix
82
+ * patterns into prefix strings. Only a TRAILING `*` is a wildcard; a `*`
83
+ * anywhere else is matched literally (documented in CacheSearchParams).
84
+ */
85
+ function compileMatcher(
86
+ patterns: readonly string[],
87
+ ): (name: string) => boolean {
88
+ const exact = new Set<string>();
89
+ const prefixes: string[] = [];
90
+ for (const pattern of patterns) {
91
+ if (pattern.endsWith("*")) {
92
+ prefixes.push(pattern.slice(0, -1));
93
+ } else {
94
+ exact.add(pattern);
95
+ }
96
+ }
97
+ if (prefixes.length === 0) {
98
+ return (name) => exact.has(name);
99
+ }
100
+ return (name) =>
101
+ exact.has(name) || prefixes.some((prefix) => name.startsWith(prefix));
102
+ }
103
+
104
+ /**
105
+ * Compile a `cache.searchParams` config into a predicate, once per resolved
106
+ * cache config (handler.ts). Returns `undefined` for the default ("all") so
107
+ * every call site can cheaply skip filtering and keep the shipped key format
108
+ * byte-stable.
109
+ */
110
+ export function compileSearchParamsFilter(
111
+ config: CacheSearchParams | undefined,
112
+ ): SearchParamsFilter | undefined {
113
+ if (config === undefined || config === "all") return undefined;
114
+ if (config === "none") return NONE_FILTER;
115
+ if (config.include !== undefined) return compileMatcher(config.include);
116
+ const excluded = compileMatcher(config.exclude ?? []);
117
+ return (name) => !excluded(name);
118
+ }
@@ -34,7 +34,9 @@ import type {
34
34
  ShellSnapshotItemValue,
35
35
  ShellSnapshotResponseValue,
36
36
  ShellSnapshotLoaderValue,
37
+ CacheReadError,
37
38
  } from "./types.js";
39
+ import { CACHE_READ_ERROR } from "./types.js";
38
40
  import { bufferToBase64, base64ToBuffer } from "./cf/cf-base64.js";
39
41
  import { isPerClientSignalHeader } from "../browser/cookie-name.js";
40
42
 
@@ -158,9 +160,11 @@ export class RecordingShellStore<
158
160
  return this.records.size > 0 ? [...this.records.values()] : undefined;
159
161
  }
160
162
 
161
- async get(key: string): Promise<CacheGetResult | null> {
163
+ async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
162
164
  const result = await this.inner.get(key);
163
- if (result) this.record("segment", key, result.data);
165
+ if (result && result !== CACHE_READ_ERROR) {
166
+ this.record("segment", key, result.data);
167
+ }
164
168
  return result;
165
169
  }
166
170
 
@@ -283,7 +287,7 @@ export class SnapshotOnlySegmentStore<
283
287
  return this.recording.keyGenerator;
284
288
  }
285
289
 
286
- async get(key: string): Promise<CacheGetResult | null> {
290
+ async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
287
291
  return this.recording.get(key);
288
292
  }
289
293
 
@@ -417,7 +421,7 @@ export class SeededShellStore<
417
421
  return this.inner.supportsPassiveShellReads;
418
422
  }
419
423
 
420
- async get(key: string): Promise<CacheGetResult | null> {
424
+ async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
421
425
  const seeded = this.segments.get(key);
422
426
  if (seeded) return { data: seeded, shouldRevalidate: false };
423
427
  if (this.segmentsOnly) return null;
@@ -12,6 +12,22 @@
12
12
  import type { ResolvedSegment } from "../types.js";
13
13
  import type { RequestContext } from "../server/request-context.js";
14
14
 
15
+ /**
16
+ * Sentinel a `SegmentCacheStore.get` MAY return instead of `null` when the
17
+ * read FAILED (backend error) rather than genuinely missing. For the render
18
+ * outcome the two are identical — render fresh, re-cache — so hit/miss-only
19
+ * consumers can treat it as a miss. The PPR replay composition needs the
20
+ * distinction: an errored explicit-tier read must render uncached
21
+ * (`lookupRouteDetailed` classifies it `error`), never be substituted by the
22
+ * seeded doc record — the built-in stores swallow backend errors internally,
23
+ * so without this signal their failures read as replayable misses. Third-party
24
+ * stores returning plain `null` on error keep the miss classification.
25
+ */
26
+ export const CACHE_READ_ERROR: unique symbol = Symbol.for(
27
+ "rango.cache.readError",
28
+ );
29
+ export type CacheReadError = typeof CACHE_READ_ERROR;
30
+
15
31
  /**
16
32
  * Result from cache get() including data and revalidation status
17
33
  */
@@ -90,9 +106,10 @@ export interface SegmentCacheStore<TEnv = unknown> {
90
106
 
91
107
  /**
92
108
  * Get cached entry data by key
93
- * @returns Cache result with data and staleness, or null if not found/expired
109
+ * @returns Cache result with data and staleness, null if not found/expired,
110
+ * or CACHE_READ_ERROR when the read failed (optional — see the sentinel).
94
111
  */
95
- get(key: string): Promise<CacheGetResult | null>;
112
+ get(key: string): Promise<CacheGetResult | null | CacheReadError>;
96
113
 
97
114
  /**
98
115
  * Store entry data with TTL
@@ -305,6 +322,26 @@ export interface ShellCacheEntry {
305
322
  * heals it. See docs/design/ppr-shell-resume.md ("the capture data snapshot").
306
323
  */
307
324
  snapshot?: ShellSnapshotRecord[];
325
+ /**
326
+ * The key of the CANONICAL document segment record inside `snapshot` — the
327
+ * one navigation replay can actually consume (resolved under the implicit
328
+ * doc namespace at capture; see CacheScope.cacheRoute). Replay eligibility
329
+ * requires this exact record: the snapshot also carries incidentally
330
+ * recorded explicit-tier records (RecordingShellStore passthroughs) whose
331
+ * keys a partial lookup can never resolve, and counting those declared
332
+ * entries "replayable" that always missed (`snapshot-miss` flip-flop).
333
+ * Absent on entries captured before the field existed OR when the capture
334
+ * recorded no doc record (cache(false)/condition-false routes, prerender
335
+ * short-circuit) — both read as `no-segment-snapshot`; recapture heals the
336
+ * former.
337
+ */
338
+ docKey?: string;
339
+ /**
340
+ * The entry was captured from a partial request only to produce an eligible
341
+ * segment snapshot. Document serving must treat its HTML prelude as a miss;
342
+ * the partial request's headers and middleware state are not document state.
343
+ */
344
+ navigationOnly?: true;
308
345
  /**
309
346
  * True when the capture's HANDLER layer declared per-request liveness: a
310
347
  * handle pushed OUTSIDE a DSL loader scope carried a nested thenable (the
@@ -40,7 +40,9 @@ import type {
40
40
  CacheItemOptions,
41
41
  ShellCacheEntry,
42
42
  ShellSnapshotRecord,
43
+ CacheReadError,
43
44
  } from "../types.js";
45
+ import { CACHE_READ_ERROR } from "../types.js";
44
46
  import type { RequestContext } from "../../server/request-context.js";
45
47
  import { isPerClientSignalHeader } from "../../browser/cookie-name.js";
46
48
  import {
@@ -202,6 +204,13 @@ interface VercelShellEnvelope {
202
204
  i?: string;
203
205
  /** Capture data snapshot: recorded cache-store hits/writes for HIT parity. */
204
206
  sn?: ShellSnapshotRecord[];
207
+ /**
208
+ * ShellCacheEntry.docKey. Must round-trip: navigation-replay eligibility
209
+ * requires the exact canonical doc segment record named here — dropping the
210
+ * field reads back as "no consumable record" and every partial navigation
211
+ * reports `no-segment-snapshot` after a store round trip.
212
+ */
213
+ dk?: string;
205
214
  /**
206
215
  * ShellCacheEntry.handlerLiveHoles. Must round-trip: the serve side arms the
207
216
  * handler-free fast path on `!entry.handlerLiveHoles`, so dropping the flag
@@ -211,6 +220,8 @@ interface VercelShellEnvelope {
211
220
  lh?: boolean;
212
221
  /** ShellCacheEntry.transitionWhen; conditional transitions must re-run. */
213
222
  tw?: true;
223
+ /** ShellCacheEntry.navigationOnly; its partial-context prelude is not document-safe. */
224
+ no?: true;
214
225
  }
215
226
 
216
227
  /** Read-path outcome for the debug sink. */
@@ -389,7 +400,7 @@ export class VercelCacheStore<
389
400
 
390
401
  // --- Segment family (get/set/delete) ---
391
402
 
392
- async get(key: string): Promise<CacheGetResult | null> {
403
+ async get(key: string): Promise<CacheGetResult | null | CacheReadError> {
393
404
  const storeKey = this.toStoreKey(key, "s");
394
405
  const started = Date.now();
395
406
  let raw: unknown;
@@ -398,7 +409,9 @@ export class VercelCacheStore<
398
409
  } catch (error) {
399
410
  reportCacheError(error, "cache-read", "[VercelCacheStore] get");
400
411
  this.emitDebug({ op: "get", key, outcome: "error" });
401
- return null;
412
+ // Distinct from a miss so the PPR replay composition renders uncached
413
+ // instead of substituting the seeded doc record (CACHE_READ_ERROR).
414
+ return CACHE_READ_ERROR;
402
415
  }
403
416
  const readMs = Date.now() - started;
404
417
 
@@ -814,8 +827,10 @@ export class VercelCacheStore<
814
827
  buildVersion: env.bv,
815
828
  initialTheme: env.i,
816
829
  snapshot: env.sn,
830
+ docKey: env.dk,
817
831
  handlerLiveHoles: env.lh,
818
832
  transitionWhen: env.tw,
833
+ navigationOnly: env.no,
819
834
  createdAt: env.c,
820
835
  },
821
836
  shouldRevalidate,
@@ -863,8 +878,10 @@ export class VercelCacheStore<
863
878
  t: safeTags.length > 0 ? safeTags : undefined,
864
879
  i: entry.initialTheme,
865
880
  sn: entry.snapshot,
881
+ dk: entry.docKey,
866
882
  lh: entry.handlerLiveHoles,
867
883
  tw: entry.transitionWhen,
884
+ no: entry.navigationOnly,
868
885
  };
869
886
  // write() enforces the 2 MB per-item ceiling (withinSizeLimit): an
870
887
  // oversized shell prelude is reported and skipped (fail-open to a full
@@ -1192,7 +1209,7 @@ export class VercelCacheStore<
1192
1209
 
1193
1210
  private asShellEnvelope(raw: unknown): VercelShellEnvelope | null {
1194
1211
  if (!isRecord(raw)) return null;
1195
- const { p, po, rv, bv, c, s, e, t, i, sn, lh, tw } = raw;
1212
+ const { p, po, rv, bv, c, s, e, t, i, sn, dk, lh, tw, no } = raw;
1196
1213
  if (typeof p !== "string" || typeof rv !== "string") return null;
1197
1214
  if (po !== null && typeof po !== "string") return null;
1198
1215
  if (typeof c !== "number") return null;
@@ -1208,8 +1225,10 @@ export class VercelCacheStore<
1208
1225
  t: Array.isArray(t) ? (t as string[]) : undefined,
1209
1226
  i: typeof i === "string" ? i : undefined,
1210
1227
  sn: Array.isArray(sn) ? (sn as ShellSnapshotRecord[]) : undefined,
1228
+ dk: typeof dk === "string" ? dk : undefined,
1211
1229
  lh: lh === true ? true : undefined,
1212
1230
  tw: tw === true ? true : undefined,
1231
+ no: no === true ? true : undefined,
1213
1232
  };
1214
1233
  }
1215
1234
 
package/src/index.rsc.ts CHANGED
@@ -189,6 +189,10 @@ export {
189
189
 
190
190
  // RSC handler types (server-side)
191
191
  export type { HandlerCacheConfig } from "./rsc/types.js";
192
+ export {
193
+ TRACKING_SEARCH_PARAMS,
194
+ type CacheSearchParams,
195
+ } from "./cache/search-params-filter.js";
192
196
 
193
197
  // Built-in handles (server-side)
194
198
  export { Meta } from "./handles/meta.js";
package/src/index.ts CHANGED
@@ -76,6 +76,12 @@ export type {
76
76
  RouteParams,
77
77
  } from "./search-params.js";
78
78
 
79
+ // Universal cache-key filtering config and tracking-param preset.
80
+ export {
81
+ TRACKING_SEARCH_PARAMS,
82
+ type CacheSearchParams,
83
+ } from "./cache/search-params-filter.js";
84
+
79
85
  // Client-safe createLoader - only stores the $$id, function is not included
80
86
  // Use this when defining loaders that will be imported by client components
81
87
  export { createLoader } from "./loader.js";
@@ -92,17 +92,19 @@
92
92
  * - Action context (if POST)
93
93
  */
94
94
  import type { ResolvedSegment } from "../../types.js";
95
+ import type { EntryData } from "../../server/context.js";
95
96
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
96
97
  import { getRouterContext, type RouterContext } from "../router-context.js";
97
98
  import { observeEvent } from "../instrument.js";
98
99
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
99
100
  import { treeHasStreaming } from "./segment-resolution.js";
100
101
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
101
- import type { HandleStore } from "../../server/handle-store.js";
102
102
  import {
103
- getRequestContext,
104
103
  _getRequestContext,
104
+ type RequestContext,
105
105
  } from "../../server/request-context.js";
106
+ import { createShellImplicitDocScope } from "../../cache/cache-scope.js";
107
+ import { prerenderStoreShortCircuits } from "../navigation-snapshot.js";
106
108
  import { paramsEqual } from "../params-util.js";
107
109
 
108
110
  // Lazily initialized prerender store singleton and dynamically imported deps.
@@ -124,17 +126,13 @@ let _decodeHandles:
124
126
  let _hashParams:
125
127
  | typeof import("../../prerender/param-hash.js").hashParams
126
128
  | undefined;
127
- let _lazyGetRequestContext:
128
- | typeof import("../../server/request-context.js").getRequestContext
129
- | undefined;
130
129
 
131
130
  async function ensurePrerenderDeps() {
132
131
  if (!_deserializeSegments) {
133
- const [codec, snapshot, paramHash, reqCtx, store] = await Promise.all([
132
+ const [codec, snapshot, paramHash, store] = await Promise.all([
134
133
  import("../../cache/segment-codec.js"),
135
134
  import("../../cache/handle-snapshot.js"),
136
135
  import("../../prerender/param-hash.js"),
137
- import("../../server/request-context.js"),
138
136
  import("../../prerender/store.js"),
139
137
  ]);
140
138
  _deserializeSegments = codec.deserializeSegments;
@@ -142,7 +140,6 @@ async function ensurePrerenderDeps() {
142
140
  _restoreHandles = snapshot.restoreHandles;
143
141
  _decodeHandles = snapshot.decodeHandles;
144
142
  _hashParams = paramHash.hashParams;
145
- _lazyGetRequestContext = reqCtx.getRequestContext;
146
143
  if (prerenderStoreInstance === undefined) {
147
144
  prerenderStoreInstance = store.createPrerenderStore();
148
145
  }
@@ -234,18 +231,16 @@ async function* yieldFromStore<TEnv>(
234
231
  ctx: MatchContext<TEnv>,
235
232
  state: MatchPipelineState,
236
233
  pipelineStart: number,
237
- handleStoreRef?: HandleStore,
234
+ reqCtx: RequestContext<TEnv> | undefined,
235
+ resolveLoadersOnly: RouterContext<TEnv>["resolveLoadersOnly"],
236
+ resolveLoadersOnlyWithRevalidation: RouterContext<TEnv>["resolveLoadersOnlyWithRevalidation"],
238
237
  ): AsyncGenerator<ResolvedSegment> {
239
- const { resolveLoadersOnlyWithRevalidation, resolveLoadersOnly } =
240
- getRouterContext<TEnv>();
241
-
242
238
  if (
243
239
  !_deserializeSegments ||
244
240
  !_fragmentSegments ||
245
241
  !_restoreHandles ||
246
242
  !_decodeHandles ||
247
- !_hashParams ||
248
- !_lazyGetRequestContext
243
+ !_hashParams
249
244
  ) {
250
245
  throw new Error("yieldFromStore called before ensurePrerenderDeps");
251
246
  }
@@ -254,14 +249,14 @@ async function* yieldFromStore<TEnv>(
254
249
  // store (the prerender lookup runs before the cache scope), so the fragment
255
250
  // splice must apply here too — otherwise producer B entries re-serialize the
256
251
  // whole tree per request while producer A entries do not.
257
- const segments = _getRequestContext()?._shellFragmentPayload
252
+ const segments = reqCtx?._shellFragmentPayload
258
253
  ? await _fragmentSegments(entry.segments)
259
254
  : await _deserializeSegments(entry.segments);
260
255
 
261
256
  // Replay handle data (same as runtime cache hit path). entry.handles is a
262
257
  // Flight-encoded string ("" when none) — decode before restore so
263
258
  // Promise/ReactNode handle values are revived, not the corrupted JSON form.
264
- const handleStore = handleStoreRef ?? _lazyGetRequestContext()?._handleStore;
259
+ const handleStore = reqCtx?._handleStore;
265
260
  if (handleStore && entry.handles) {
266
261
  const handlesRecord = await _decodeHandles(entry.handles);
267
262
  if (handlesRecord) {
@@ -275,13 +270,14 @@ async function* yieldFromStore<TEnv>(
275
270
  state.cachedMatchedIds = segments.map((s) => s.id);
276
271
 
277
272
  // Set streaming flag (once) and resolve render barrier.
278
- const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
279
- const barrierReqCtx = reqCtx ?? _getRequestContext();
280
- if (barrierReqCtx) {
281
- if (barrierReqCtx._treeHasStreaming === undefined) {
282
- barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
273
+ // Post-match serve-source truth for the PPR replay reporter. This overwrites
274
+ // `intercept` because the prerender store is the response source.
275
+ if (reqCtx) {
276
+ reqCtx._pprReplayPostMatchReason = "prerender-store";
277
+ if (reqCtx._treeHasStreaming === undefined) {
278
+ reqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
283
279
  }
284
- barrierReqCtx._resolveRenderBarrier(segments);
280
+ reqCtx._resolveRenderBarrier(segments);
285
281
  }
286
282
 
287
283
  // For partial navigation, nullify components the client already has
@@ -313,6 +309,54 @@ async function* yieldFromStore<TEnv>(
313
309
  );
314
310
  }
315
311
 
312
+ /**
313
+ * Whether the prerender store holds a baked entry for this route + params.
314
+ * Consulted by the PPR replay gate (matchPartialWithPprReplay), which must
315
+ * only report `prerender-store` when the short-circuit below will actually
316
+ * serve: a Passthrough(Prerender()) route with an unbaked/passthrough param
317
+ * misses the store and renders live, and replay — including its heal
318
+ * capture — must stay available for it (withCacheStore records the doc
319
+ * record on that path; state.cacheSource is not "prerender"). The store
320
+ * memoizes per routeKey/paramHash, so this probe and tryPrerenderLookup's
321
+ * subsequent get() share one underlying load.
322
+ */
323
+ export async function prerenderEntryExists(
324
+ routeKey: string | undefined,
325
+ params: Record<string, string>,
326
+ pathname: string,
327
+ entries: EntryData[],
328
+ ): Promise<boolean> {
329
+ if (!routeKey) return false;
330
+ // Deliberately NOT ensurePrerenderDeps(): the probe needs only the store
331
+ // and the param hasher — pulling segment-codec here would drag the
332
+ // @vitejs/plugin-rsc virtual module onto a path that never deserializes.
333
+ if (!_hashParams) {
334
+ _hashParams = (await import("../../prerender/param-hash.js")).hashParams;
335
+ }
336
+ if (prerenderStoreInstance === undefined) {
337
+ prerenderStoreInstance = (
338
+ await import("../../prerender/store.js")
339
+ ).createPrerenderStore();
340
+ }
341
+ if (!prerenderStoreInstance) return false;
342
+ // Non-intercept variant only: whether the navigation IS an intercept (and
343
+ // therefore whether tryPrerenderLookup reads `paramHash + "/i"`) resolves
344
+ // during the match, so this pre-match fast path can only guess the normal
345
+ // artifact. A wrong guess is reclassified post-match from the match
346
+ // pipeline's `_pprReplayPostMatchReason` stamp.
347
+ const entry = await prerenderStoreInstance.get(
348
+ routeKey,
349
+ _hashParams!(params),
350
+ {
351
+ pathname,
352
+ isPassthroughRoute: entries.some(
353
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
354
+ ),
355
+ },
356
+ );
357
+ return entry != null;
358
+ }
359
+
316
360
  /**
317
361
  * Look up a prerendered (build-time cached) entry for the current route and, on
318
362
  * a hit, yield its segments. Returns true when an entry was served (the caller
@@ -325,7 +369,9 @@ async function* tryPrerenderLookup<TEnv>(
325
369
  ctx: MatchContext<TEnv>,
326
370
  state: MatchPipelineState,
327
371
  pipelineStart: number,
328
- handleStoreRef?: HandleStore,
372
+ reqCtx: RequestContext<TEnv> | undefined,
373
+ resolveLoadersOnly: RouterContext<TEnv>["resolveLoadersOnly"],
374
+ resolveLoadersOnlyWithRevalidation: RouterContext<TEnv>["resolveLoadersOnlyWithRevalidation"],
329
375
  ): AsyncGenerator<ResolvedSegment, boolean> {
330
376
  const paramHash = _hashParams!(ctx.matched.params);
331
377
  const isPassthroughPrerenderRoute = ctx.entries.some(
@@ -341,7 +387,15 @@ async function* tryPrerenderLookup<TEnv>(
341
387
  },
342
388
  );
343
389
  if (!entry) return false;
344
- yield* yieldFromStore(entry, ctx, state, pipelineStart, handleStoreRef);
390
+ yield* yieldFromStore(
391
+ entry,
392
+ ctx,
393
+ state,
394
+ pipelineStart,
395
+ reqCtx,
396
+ resolveLoadersOnly,
397
+ resolveLoadersOnlyWithRevalidation,
398
+ );
345
399
  return true;
346
400
  }
347
401
 
@@ -380,7 +434,14 @@ export function withCacheLookup<TEnv>(
380
434
  // can disrupt AsyncLocalStorage, causing getRequestContext() to return
381
435
  // undefined afterward. Capturing the reference early ensures handle replay
382
436
  // and handler handle-push work regardless of ALS state.
383
- const handleStoreRef = _getRequestContext()?._handleStore;
437
+ const pipelineReqCtx = _getRequestContext<TEnv>();
438
+ // Only the match can determine interception; the source header proves
439
+ // nothing in either direction. Clear a stale reason on normal matches.
440
+ if (pipelineReqCtx) {
441
+ pipelineReqCtx._pprReplayPostMatchReason = ctx.isIntercept
442
+ ? "intercept"
443
+ : undefined;
444
+ }
384
445
 
385
446
  const {
386
447
  evaluateRevalidation,
@@ -389,15 +450,19 @@ export function withCacheLookup<TEnv>(
389
450
  resolveLoadersOnly,
390
451
  } = getRouterContext<TEnv>();
391
452
 
392
- const isHmr = !!ctx.request.headers.get("X-RSC-HMR");
393
- if (!ctx.isAction && !isHmr && ctx.matched.pr) {
453
+ if (
454
+ !ctx.isAction &&
455
+ prerenderStoreShortCircuits(ctx.matched.pr, ctx.request)
456
+ ) {
394
457
  await ensurePrerenderDeps();
395
458
  if (prerenderStoreInstance) {
396
459
  const served = yield* tryPrerenderLookup(
397
460
  ctx,
398
461
  state,
399
462
  pipelineStart,
400
- handleStoreRef,
463
+ pipelineReqCtx,
464
+ resolveLoadersOnly,
465
+ resolveLoadersOnlyWithRevalidation,
401
466
  );
402
467
  if (served) return;
403
468
  }
@@ -418,7 +483,9 @@ export function withCacheLookup<TEnv>(
418
483
  ctx,
419
484
  state,
420
485
  pipelineStart,
421
- handleStoreRef,
486
+ pipelineReqCtx,
487
+ resolveLoadersOnly,
488
+ resolveLoadersOnlyWithRevalidation,
422
489
  );
423
490
  if (served) return;
424
491
  }
@@ -437,11 +504,58 @@ export function withCacheLookup<TEnv>(
437
504
  return;
438
505
  }
439
506
 
440
- const cacheResult = await ctx.cacheScope.lookupRoute(
507
+ const explicitLookup = await ctx.cacheScope.lookupRouteDetailed(
441
508
  ctx.pathname,
442
509
  ctx.matched.params,
443
510
  ctx.isIntercept,
444
511
  );
512
+ let cacheResult =
513
+ explicitLookup.status === "hit" ? explicitLookup.result : null;
514
+
515
+ // PPR navigation replay composed with a route-derived cache() scope. The
516
+ // explicit tier stays authoritative: its hit serves under its own
517
+ // key/ttl/swr semantics and reports `explicit-cache-hit` — never a false
518
+ // replay HIT. ONLY a true `miss` lets the seeded doc record supply the
519
+ // match (the marker's onHit observer then reports the true HIT). The
520
+ // other outcomes render fresh: `bypass` (cache(false), a false
521
+ // condition() — absolute opt-outs even when the pre-read gate saw a
522
+ // different condition() result — or no store) and `error` (a throwing
523
+ // key()/keyGenerator/store.get keeps lookupRoute's render-uncached
524
+ // contract; the canonical record must not serve across a broken key
525
+ // partition). The outcome comes from the lookup itself, not a re-run of
526
+ // the condition, so a flapping predicate cannot re-admit the fallback.
527
+ // Gated on the marker's `onExplicitHit`, set ONLY on the
528
+ // navigation-replay serve path: a CAPTURE render must never fall back
529
+ // here — its marker store reads through to the real store, and a
530
+ // doc-keyed hit would replay the previous generation's segments instead
531
+ // of re-running handlers (breaking SWR recapture freshness). Intercepts
532
+ // stay source-dependent on their normal cache path (match-api never arms
533
+ // replay for them).
534
+ const replayMarker = pipelineReqCtx?._shellImplicitCache;
535
+ if (
536
+ replayMarker?.onExplicitHit &&
537
+ !ctx.isIntercept &&
538
+ !ctx.cacheScope.isShellImplicitDocScope
539
+ ) {
540
+ if (explicitLookup.status === "hit") {
541
+ replayMarker.onExplicitHit();
542
+ } else if (explicitLookup.status === "miss" && replayMarker.store) {
543
+ // The store gate keeps report-only markers (installed on the
544
+ // no-eligible-snapshot path purely for truthful status) inert: a
545
+ // store-less marker minting a doc scope here would resolve the APP
546
+ // store and read the REAL doc: partition — a cross-partition serve.
547
+ cacheResult = await createShellImplicitDocScope(
548
+ replayMarker,
549
+ ).lookupRoute(ctx.pathname, ctx.matched.params, ctx.isIntercept);
550
+ } else if (explicitLookup.status === "bypass") {
551
+ // condition() refused at lookup time (the gate only pre-decides the
552
+ // static cache(false) case) — report cache-disabled truthfully.
553
+ replayMarker.onExplicitBypass?.();
554
+ }
555
+ // "error" stays unreported: the render is fresh and the seeded record
556
+ // was not consulted, which is exactly what snapshot-miss describes; the
557
+ // store already routed the failure through reportCacheError.
558
+ }
445
559
 
446
560
  if (!cacheResult) {
447
561
  yield* source;
@@ -460,8 +574,7 @@ export function withCacheLookup<TEnv>(
460
574
  state.shouldRevalidate = cacheResult.shouldRevalidate;
461
575
  state.cachedSegments = cacheResult.segments;
462
576
  state.cachedMatchedIds = cacheResult.segments.map((s) => s.id);
463
- const pprTransitionDecisions =
464
- _getRequestContext()?._pprTransitionDecisions;
577
+ const pprTransitionDecisions = pipelineReqCtx?._pprTransitionDecisions;
465
578
 
466
579
  const canCheckSegmentRevalidation =
467
580
  !ctx.isFullMatch &&
@@ -581,7 +694,7 @@ export function withCacheLookup<TEnv>(
581
694
  yield segment;
582
695
  }
583
696
 
584
- const barrierReqCtx = _getRequestContext();
697
+ const barrierReqCtx = pipelineReqCtx;
585
698
  if (barrierReqCtx) {
586
699
  if (barrierReqCtx._treeHasStreaming === undefined) {
587
700
  barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);