@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
@@ -61,14 +61,28 @@ import {
61
61
  type DevShellLookup,
62
62
  } from "./shell-build-manifest.js";
63
63
  import { contextGet } from "../context-var.js";
64
+ import {
65
+ getNavigationContextHeader,
66
+ prerenderStoreShortCircuits,
67
+ } from "../router/navigation-snapshot.js";
68
+ import {
69
+ ensureFullRouteSnapshot,
70
+ type RouteSnapshot,
71
+ } from "../router/route-snapshot.js";
72
+ import { prerenderEntryExists } from "../router/match-middleware/cache-lookup.js";
64
73
  import {
65
74
  resolveSameOriginRedirect,
66
75
  safeSameOriginLanding,
67
76
  } from "../redirect-origin.js";
68
77
  import { nonce as nonceToken } from "./nonce.js";
69
78
  import { reportCacheError } from "../cache/cache-error.js";
79
+ import type { SearchParamsFilter } from "../cache/search-params-filter.js";
70
80
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
71
- import type { ShellCacheEntry, ShellSnapshotRecord } from "../cache/types.js";
81
+ import type {
82
+ SegmentCacheStore,
83
+ ShellCacheEntry,
84
+ ShellSnapshotRecord,
85
+ } from "../cache/types.js";
72
86
 
73
87
  type PprReplayBypassReason =
74
88
  | "method"
@@ -76,6 +90,10 @@ type PprReplayBypassReason =
76
90
  | "nonce"
77
91
  | "store-unavailable"
78
92
  | "passive-read-unsupported"
93
+ | "no-navigation-context"
94
+ | "prerender-store"
95
+ | "intercept"
96
+ | "cache-disabled"
79
97
  | "read-error"
80
98
  | "no-entry"
81
99
  | "invalid-version"
@@ -84,6 +102,7 @@ type PprReplayBypassReason =
84
102
  | "transition-when"
85
103
  | "no-segment-snapshot"
86
104
  | "snapshot-miss"
105
+ | "explicit-cache-hit"
87
106
  | "stale-build-entry";
88
107
 
89
108
  type PprReplayStatus =
@@ -106,6 +125,66 @@ function describePprReplayStatus(status: PprReplayStatus): string {
106
125
  : `bypass:${status.reason}`;
107
126
  }
108
127
 
128
+ function buildNavigationShellKey(
129
+ url: URL,
130
+ filter?: SearchParamsFilter,
131
+ ): string {
132
+ return `${buildShellKey(url, filter)}:navigation`;
133
+ }
134
+
135
+ function createShellCaptureDescriptor(
136
+ ctx: HandlerContext<any>,
137
+ key: string,
138
+ pprConfig: ResolvedPprConfig,
139
+ store: SegmentCacheStore<any>,
140
+ navigationOnly?: true,
141
+ ): ShellCaptureDescriptor {
142
+ return {
143
+ key,
144
+ buildVersion: ctx.version,
145
+ ttl: pprConfig.ttl,
146
+ swr: pprConfig.swr,
147
+ tags: pprConfig.tags,
148
+ captureTimeout: pprConfig.captureTimeout,
149
+ store,
150
+ debug: INTERNAL_RANGO_DEBUG,
151
+ maxSnapshotBytes: pprConfig.maxSnapshotBytes,
152
+ debugSink: resolveShellCaptureDebugSink(ctx.router.debugShellCapture),
153
+ navigationOnly,
154
+ };
155
+ }
156
+
157
+ function shouldHealReplayMiss(
158
+ reason: PprReplayBypassReason | undefined,
159
+ ): boolean {
160
+ return (
161
+ reason === undefined ||
162
+ reason === "invalid-version" ||
163
+ reason === "corrupt-entry" ||
164
+ reason === "stale-build-entry"
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Post-match truth override for a pre-match BYPASS classification. The gate
170
+ * decides before matchPartial runs, but two facts only the match can settle:
171
+ * whether the prerender store ACTUALLY served (either artifact variant — the
172
+ * gate's probe reads only the non-intercept one), and whether the navigation
173
+ * resolved to an intercept (findInterceptForRoute runs during the match; the
174
+ * intercept-source header proves nothing in either direction). The resulting
175
+ * reason is stamped on the request context by withCacheLookup. A replay HIT is
176
+ * never overridden — the seeded record was demonstrably consumed, so neither
177
+ * fact can be true.
178
+ */
179
+ function reclassifyReplayStatus(
180
+ reqCtx: RequestContext<any>,
181
+ status: PprReplayStatus,
182
+ ): PprReplayStatus {
183
+ if (status.outcome === "HIT") return status;
184
+ const reason = reqCtx._pprReplayPostMatchReason;
185
+ return reason ? { outcome: "BYPASS", reason } : status;
186
+ }
187
+
109
188
  function resolveDevShellLookup(
110
189
  reqCtx: RequestContext<any>,
111
190
  pprConfig: ResolvedPprConfig,
@@ -132,21 +211,32 @@ function replayableShellSnapshot(
132
211
  if (!hasIntactShellPayload(entry)) return { reason: "corrupt-entry" };
133
212
  if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
134
213
  if (entry.transitionWhen) return { reason: "transition-when" };
214
+ // Eligibility requires the CANONICAL doc segment record (`docKey`), not just
215
+ // any segment record: captures under an explicit cache() scope also record
216
+ // that tier's reads/writes under keys a partial lookup can never resolve,
217
+ // and counting those declared entries "replayable" that then always missed
218
+ // (a timing-dependent snapshot-miss/no-segment-snapshot flip-flop). Entries
219
+ // captured before the field existed read as no-segment-snapshot and heal on
220
+ // recapture (pre-release, same treatment as the buildVersion field).
221
+ const docKey = entry.docKey;
135
222
  const snapshot = entry.snapshot;
136
- const hasSegments = snapshot?.some((record) => {
137
- if (
138
- !record ||
139
- typeof record !== "object" ||
140
- record.family !== "segment" ||
141
- typeof record.value !== "object" ||
142
- record.value === null
143
- ) {
144
- return false;
145
- }
146
- const segments = (record.value as { segments?: unknown }).segments;
147
- return Array.isArray(segments) && segments.length > 0;
148
- });
149
- return hasSegments && snapshot
223
+ const hasDocRecord =
224
+ docKey !== undefined &&
225
+ snapshot?.some((record) => {
226
+ if (
227
+ !record ||
228
+ typeof record !== "object" ||
229
+ record.family !== "segment" ||
230
+ record.key !== docKey ||
231
+ typeof record.value !== "object" ||
232
+ record.value === null
233
+ ) {
234
+ return false;
235
+ }
236
+ const segments = (record.value as { segments?: unknown }).segments;
237
+ return Array.isArray(segments) && segments.length > 0;
238
+ });
239
+ return hasDocRecord && snapshot
150
240
  ? { snapshot }
151
241
  : { reason: "no-segment-snapshot" };
152
242
  }
@@ -194,6 +284,7 @@ async function handleRscRenderingInner<TEnv>(
194
284
  let payload: RscPayload;
195
285
  let hasInterceptSlots = false;
196
286
  let pprReplayStatus: PprReplayStatus | undefined;
287
+ let partialCaptureNeeded = false;
197
288
 
198
289
  // --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
199
290
  //
@@ -221,7 +312,7 @@ async function handleRscRenderingInner<TEnv>(
221
312
  // the provider-only check missed the latter (issue #656).
222
313
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
223
314
  const store = reqCtx._cacheStore;
224
- const key = buildShellKey(url);
315
+ const key = buildShellKey(url, reqCtx._searchParamsFilter);
225
316
  // Dev Server-Timing mirror (issue #651): a capture completes AFTER its
226
317
  // triggering response committed, so its outcome can only ride a LATER
227
318
  // response's header. Read-and-clear keeps one capture = one report;
@@ -280,23 +371,12 @@ async function handleRscRenderingInner<TEnv>(
280
371
  ssrModule.resumeShellHTML &&
281
372
  ssrModule.captureShellHTML
282
373
  ) {
283
- const descriptor: ShellCaptureDescriptor = {
374
+ const descriptor = createShellCaptureDescriptor(
375
+ ctx,
284
376
  key,
285
- buildVersion: ctx.version,
286
- ttl: pprConfig.ttl,
287
- swr: pprConfig.swr,
288
- tags: pprConfig.tags,
289
- captureTimeout: pprConfig.captureTimeout,
377
+ pprConfig,
290
378
  store,
291
- debug: INTERNAL_RANGO_DEBUG,
292
- maxSnapshotBytes: pprConfig.maxSnapshotBytes,
293
- // The resolver owns the whole policy: option wins, the
294
- // INTERNAL_RANGO_DEBUG env flag lights the events up when no
295
- // option is set, an explicit `false` stays off.
296
- debugSink: resolveShellCaptureDebugSink(
297
- ctx.router.debugShellCapture,
298
- ),
299
- };
379
+ );
300
380
  // One serve funnel for BOTH entry sources (runtime store hit below,
301
381
  // build-manifest hit further down): schedule the background
302
382
  // recapture when asked, then commit the composed response.
@@ -347,7 +427,11 @@ async function handleRscRenderingInner<TEnv>(
347
427
  cached ? "hit" : "miss",
348
428
  );
349
429
  }
350
- if (cached && isValidShellHit(cached.entry, ctx.version)) {
430
+ if (
431
+ cached &&
432
+ !cached.entry.navigationOnly &&
433
+ isValidShellHit(cached.entry, ctx.version)
434
+ ) {
351
435
  if (!hasIntactShellPayload(cached.entry)) {
352
436
  // Corrupt stored payload (undecodable prelude / unparseable
353
437
  // postponed): a store-layer fault worth a diagnostic, unlike the
@@ -382,6 +466,7 @@ async function handleRscRenderingInner<TEnv>(
382
466
  // only (production's exact candidate set). Folded away in
383
467
  // production builds (NODE_ENV is a compile-time constant).
384
468
  resolveDevShellLookup(reqCtx, pprConfig),
469
+ reqCtx._searchParamsFilter,
385
470
  );
386
471
  if (buildHit) {
387
472
  // Past ppr.ttl: still serve the baked entry, recapture upgrades it.
@@ -407,6 +492,8 @@ async function handleRscRenderingInner<TEnv>(
407
492
  );
408
493
  const result = replay.result;
409
494
  pprReplayStatus = replay.status;
495
+ partialCaptureNeeded =
496
+ "captureNeeded" in replay && replay.captureNeeded === true;
410
497
 
411
498
  if (!result) {
412
499
  // Fall back to full render
@@ -462,6 +549,7 @@ async function handleRscRenderingInner<TEnv>(
462
549
  prefetchCacheTTL: ctx.router.prefetchCacheTTL,
463
550
  prefetchCacheSize: ctx.router.prefetchCacheSize,
464
551
  prefetchConcurrency: ctx.router.prefetchConcurrency,
552
+ defaultPrefetch: ctx.router.defaultPrefetch,
465
553
  stateCookieName: ctx.router.resolvedStateCookieName,
466
554
  },
467
555
  };
@@ -607,6 +695,40 @@ async function handleRscRenderingInner<TEnv>(
607
695
  response.headers.set(SHELL_STATUS_HEADER, "MISS");
608
696
  }
609
697
 
698
+ if (
699
+ isPartial &&
700
+ partialCaptureNeeded &&
701
+ !reqCtx._dynamic &&
702
+ response.status === 200
703
+ ) {
704
+ const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry)!;
705
+ const store = reqCtx._cacheStore!;
706
+ scheduleShellCapture(
707
+ ctx,
708
+ request,
709
+ env,
710
+ url,
711
+ reqCtx,
712
+ async (captureRequest, captureUrl) => {
713
+ const [ssrModule, streamMode] = await getSSRSetup(
714
+ ctx,
715
+ captureRequest,
716
+ env,
717
+ captureUrl,
718
+ undefined,
719
+ );
720
+ return streamMode === "allReady" ? null : ssrModule;
721
+ },
722
+ createShellCaptureDescriptor(
723
+ ctx,
724
+ buildNavigationShellKey(url, reqCtx._searchParamsFilter),
725
+ pprConfig,
726
+ store,
727
+ true,
728
+ ),
729
+ );
730
+ }
731
+
610
732
  return response;
611
733
  }
612
734
 
@@ -638,10 +760,17 @@ async function matchPartialWithPprReplay<TEnv>(
638
760
  describePprReplayStatus(status),
639
761
  );
640
762
  };
763
+ const finalizeReplayStatus = (status: PprReplayStatus): PprReplayStatus => {
764
+ const finalStatus = reclassifyReplayStatus(reqCtx, status);
765
+ recordReplayStatus(finalStatus);
766
+ return finalStatus;
767
+ };
641
768
  const runMatch = async (status?: PprReplayStatus) => {
642
769
  const result = await ctx.router.matchPartial(request, { env });
643
- if (status) recordReplayStatus(status);
644
- return { result, status };
770
+ // The match may have settled a fact the pre-match gate could only guess
771
+ // at (prerender serve, intercept resolution) — report the truth.
772
+ const finalStatus = status ? finalizeReplayStatus(status) : undefined;
773
+ return { result, status: finalStatus };
645
774
  };
646
775
  const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
647
776
  const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
@@ -666,8 +795,64 @@ async function matchPartialWithPprReplay<TEnv>(
666
795
  reason: "passive-read-unsupported",
667
796
  });
668
797
  }
798
+ // A partial without navigation context (curl probes, synthetic monitors)
799
+ // can never produce a partial match — createMatchContextForPartial returns
800
+ // null and the seeded record could not have been consulted, which used to
801
+ // read as a misleading `snapshot-miss` AFTER two wasted getShell reads.
802
+ // Same predicate as resolveNavigation, decided before any store I/O.
803
+ if (!getNavigationContextHeader(request)) {
804
+ return runMatch({ outcome: "BYPASS", reason: "no-navigation-context" });
805
+ }
806
+ // The prerender probe and the cache-opt-out gate below both need the
807
+ // classified snapshot; resolve (and persist) it once.
808
+ const routeSnapshot = classifiedRouteSnapshot(reqCtx);
809
+ // Prerender routes short-circuit in withCacheLookup and serve the partial
810
+ // from build-time segments — a better-than-HIT outcome. Their captures
811
+ // record no doc segment record (withCacheStore skips on the prerender hit),
812
+ // so seeding could never succeed; skip the reads and report the store that
813
+ // actually serves instead of blaming a "broken" capture. Bypass ONLY when
814
+ // the baked artifact actually exists — the trie's pr flag alone is not a
815
+ // serve guarantee: a Passthrough(Prerender()) route with an unbaked or
816
+ // ctx.passthrough()-skipped param misses the store and renders live, and
817
+ // replay (including its heal capture) must stay available for it. The
818
+ // probe goes through the same memoized store the middleware serves from.
819
+ // Shared predicate with the middleware (it declines on dev HMR — such
820
+ // partials fall through to the ordinary replay decision here too).
821
+ //
822
+ // The probe is a PRE-match fast path, not the truth: it reads only the
823
+ // non-intercept artifact, and whether the navigation IS an intercept (and
824
+ // therefore whether tryPrerenderLookup reads `paramHash` or
825
+ // `paramHash + "/i"`) is resolved during the match by match-api's
826
+ // findInterceptForRoute — an intercept-source header proves nothing in
827
+ // either direction. runMatch reclassifies every BYPASS from the stamped
828
+ // post-match facts (reclassifyReplayStatus), so a wrong guess here can
829
+ // skip the two getShell reads but never mis-report or mis-heal.
830
+ if (
831
+ prerenderStoreShortCircuits(routeSnapshot?.matched?.pr, request) &&
832
+ (await prerenderEntryExists(
833
+ routeSnapshot?.matched?.routeKey,
834
+ routeSnapshot?.params ?? {},
835
+ url.pathname,
836
+ routeSnapshot?.entries ?? [],
837
+ ))
838
+ ) {
839
+ return runMatch({ outcome: "BYPASS", reason: "prerender-store" });
840
+ }
841
+ // Consumer cache opt-outs are absolute: a STATICALLY disabled scope
842
+ // (cache(false)) bypasses before any shell read — no heal capture (its
843
+ // snapshot would be equally unusable). A `condition()` predicate is
844
+ // request-time state and must NOT be pre-decided here: evaluating it at the
845
+ // gate and again at the lookup lets a false-then-true flap report
846
+ // cache-disabled while the explicit tier serves. The lookup's own
847
+ // evaluation governs (withCacheLookup fires the marker's onExplicitBypass,
848
+ // reported as cache-disabled post-match).
849
+ const routeCacheScope = routeSnapshot?.cacheScope ?? null;
850
+ if (routeCacheScope && !routeCacheScope.enabled) {
851
+ return runMatch({ outcome: "BYPASS", reason: "cache-disabled" });
852
+ }
669
853
 
670
- const key = buildShellKey(url);
854
+ const key = buildShellKey(url, reqCtx._searchParamsFilter);
855
+ const navigationKey = `${key}:navigation`;
671
856
  let cached: Awaited<ReturnType<typeof store.getShell>> = null;
672
857
  try {
673
858
  cached = await store.getShell(key, { claimRevalidation: false });
@@ -689,11 +874,45 @@ async function matchPartialWithPprReplay<TEnv>(
689
874
  }
690
875
  }
691
876
 
877
+ if (
878
+ !snapshot &&
879
+ bypassReason !== "handler-live-holes" &&
880
+ bypassReason !== "transition-when"
881
+ ) {
882
+ let navigationCached: Awaited<ReturnType<typeof store.getShell>> = null;
883
+ try {
884
+ navigationCached = await store.getShell(navigationKey, {
885
+ claimRevalidation: false,
886
+ });
887
+ } catch (error) {
888
+ reportCacheError(error, "cache-read", "[NavigationPPR] getShell");
889
+ return runMatch({ outcome: "BYPASS", reason: "read-error" });
890
+ }
891
+ if (navigationCached) {
892
+ const decision = replayableShellSnapshot(
893
+ navigationCached.entry,
894
+ ctx.version,
895
+ );
896
+ if ("snapshot" in decision) {
897
+ snapshot = decision.snapshot;
898
+ freshness = navigationCached.shouldRevalidate ? "stale" : "fresh";
899
+ } else {
900
+ bypassReason = decision.reason;
901
+ }
902
+ }
903
+ }
904
+
692
905
  if (!snapshot) {
693
906
  // Production build manifests are local module data. In dev, resolving a
694
907
  // missing build shell would foreground-fetch /__rsc_shell and block an
695
908
  // otherwise ordinary navigation on capture, so replay remains runtime-only.
696
- const buildHit = await lookupBuildShell(url, ctx.version, store);
909
+ const buildHit = await lookupBuildShell(
910
+ url,
911
+ ctx.version,
912
+ store,
913
+ undefined,
914
+ reqCtx._searchParamsFilter,
915
+ );
697
916
  if (buildHit?.stale) {
698
917
  bypassReason ??= "stale-build-entry";
699
918
  } else if (buildHit) {
@@ -710,14 +929,78 @@ async function matchPartialWithPprReplay<TEnv>(
710
929
  }
711
930
 
712
931
  if (!snapshot) {
713
- return runMatch({
932
+ // Report-only marker for routes with an ENABLED route-derived scope: the
933
+ // lookup's own outcome must stay reportable even without a replayable
934
+ // snapshot. An always-false condition() route never produces a doc
935
+ // record (the consumer's write refusal is absolute — see
936
+ // recordShellCaptureDocRecord), so without this its lookup-time refusal
937
+ // would be misreported as the eligibility reason and heal captures would
938
+ // be scheduled for snapshots that can never become consumable. The
939
+ // marker carries NO store: the seeded fallback in withCacheLookup
940
+ // requires one, so nothing can be served through it — it only reports.
941
+ // Bare routes never see it (an installed marker would otherwise mint an
942
+ // implicit scope over the REAL doc: partition in
943
+ // resolveShellImplicitCacheScope).
944
+ if (routeCacheScope?.enabled) {
945
+ let explicitCacheHit = false;
946
+ let explicitCacheBypass = false;
947
+ const previousImplicitCache = reqCtx._shellImplicitCache;
948
+ reqCtx._shellImplicitCache = {
949
+ onExplicitHit: () => {
950
+ explicitCacheHit = true;
951
+ },
952
+ onExplicitBypass: () => {
953
+ explicitCacheBypass = true;
954
+ },
955
+ };
956
+ try {
957
+ const result = await ctx.router.matchPartial(request, { env });
958
+ const provisionalStatus: PprReplayStatus = explicitCacheBypass
959
+ ? { outcome: "BYPASS", reason: "cache-disabled" }
960
+ : explicitCacheHit
961
+ ? { outcome: "BYPASS", reason: "explicit-cache-hit" }
962
+ : { outcome: "BYPASS", reason: bypassReason ?? "no-entry" };
963
+ const status = finalizeReplayStatus(provisionalStatus);
964
+ return {
965
+ result,
966
+ status,
967
+ // A lookup-time opt-out makes every heal snapshot unusable
968
+ // (recordShellCaptureDocRecord refuses under the same predicate),
969
+ // and a prerender-served or intercept match never consults a heal
970
+ // snapshot at all. Otherwise heal the shouldHealReplayMiss set PLUS
971
+ // `no-segment-snapshot`: an entry captured while condition() was
972
+ // false legitimately lacks a snapshot, and a later request whose
973
+ // lookup did NOT refuse (condition true now) can heal it — the
974
+ // capture derives from THIS request's context, so its doc record
975
+ // records. Excluding it left replay dead until the document
976
+ // recaptured. The always-false route stays protected: its every
977
+ // lookup refuses, so explicitCacheBypass suppresses the heal.
978
+ captureNeeded:
979
+ !explicitCacheBypass &&
980
+ reqCtx._pprReplayPostMatchReason === undefined &&
981
+ (shouldHealReplayMiss(bypassReason) ||
982
+ bypassReason === "no-segment-snapshot"),
983
+ };
984
+ } finally {
985
+ reqCtx._shellImplicitCache = previousImplicitCache;
986
+ }
987
+ }
988
+ const match = await runMatch({
714
989
  outcome: "BYPASS",
715
990
  reason: bypassReason ?? "no-entry",
716
991
  });
992
+ return {
993
+ ...match,
994
+ captureNeeded:
995
+ reqCtx._pprReplayPostMatchReason === undefined &&
996
+ shouldHealReplayMiss(bypassReason),
997
+ };
717
998
  }
718
999
 
719
1000
  const previousImplicitCache = reqCtx._shellImplicitCache;
720
1001
  let segmentReplayHit = false;
1002
+ let explicitCacheHit = false;
1003
+ let explicitCacheBypass = false;
721
1004
  reqCtx._shellImplicitCache = {
722
1005
  ttl: pprConfig.ttl,
723
1006
  swr: pprConfig.swr,
@@ -728,20 +1011,64 @@ async function matchPartialWithPprReplay<TEnv>(
728
1011
  onHit: () => {
729
1012
  segmentReplayHit = true;
730
1013
  },
1014
+ // Arms the explicit-scope composition in withCacheLookup: a route-derived
1015
+ // cache() scope stays authoritative, the seeded doc record supplies the
1016
+ // match only on its miss. An explicit-tier hit must NOT report a replay
1017
+ // HIT — it served under the consumer's own semantics.
1018
+ onExplicitHit: () => {
1019
+ explicitCacheHit = true;
1020
+ },
1021
+ // Lookup-time condition() refusal (or a scope with no store): the gate
1022
+ // deliberately does not pre-decide predicates, so the truthful
1023
+ // cache-disabled report comes from the lookup that actually refused.
1024
+ onExplicitBypass: () => {
1025
+ explicitCacheBypass = true;
1026
+ },
731
1027
  };
732
1028
 
733
1029
  try {
734
1030
  const result = await ctx.router.matchPartial(request, { env });
735
- const status: PprReplayStatus = segmentReplayHit
1031
+ const provisionalStatus: PprReplayStatus = segmentReplayHit
736
1032
  ? { outcome: "HIT", freshness }
737
- : { outcome: "BYPASS", reason: "snapshot-miss" };
738
- recordReplayStatus(status);
1033
+ : explicitCacheHit
1034
+ ? { outcome: "BYPASS", reason: "explicit-cache-hit" }
1035
+ : explicitCacheBypass
1036
+ ? { outcome: "BYPASS", reason: "cache-disabled" }
1037
+ : { outcome: "BYPASS", reason: "snapshot-miss" };
1038
+ // A prerender serve or intercept resolution leaves the seeded record
1039
+ // unconsulted (prerender short-circuits before the scope; intercepts
1040
+ // keep snapshot.cacheScope) — the "snapshot-miss" guess would blame the
1041
+ // capture for a lane that was never in play.
1042
+ const status = finalizeReplayStatus(provisionalStatus);
739
1043
  return { result, status };
740
1044
  } finally {
741
1045
  reqCtx._shellImplicitCache = previousImplicitCache;
742
1046
  }
743
1047
  }
744
1048
 
1049
+ /**
1050
+ * The full route snapshot for the classified route, or null when
1051
+ * classification is unavailable (the gates then fall open to the ordinary
1052
+ * replay decision). Persists the enriched snapshot back onto
1053
+ * `_classifiedRoute` so the manifest-chain walk runs once per request:
1054
+ * matchPartial's createMatchContextForPartial re-reads `_classifiedRoute` and
1055
+ * calls ensureFullRouteSnapshot again, and the write-back lets that call hit
1056
+ * the entries-already-built fast path and reuse this same scope chain instead
1057
+ * of rebuilding it.
1058
+ */
1059
+ function classifiedRouteSnapshot(
1060
+ reqCtx: RequestContext<any>,
1061
+ ): RouteSnapshot<any> | null {
1062
+ const classified = reqCtx._classifiedRoute;
1063
+ if (!classified?.manifestEntry) return null;
1064
+ const full = ensureFullRouteSnapshot({
1065
+ ...classified,
1066
+ entries: classified.entries ?? [],
1067
+ });
1068
+ reqCtx._classifiedRoute = full;
1069
+ return full;
1070
+ }
1071
+
745
1072
  /**
746
1073
  * Neutralize the shell-HIT degradation redirect target. The inline
747
1074
  * `location.replace` in a committed 200 body bypasses the 3xx chokepoint
@@ -27,6 +27,7 @@
27
27
 
28
28
  import type { SegmentCacheStore, ShellCacheEntry } from "../cache/types.js";
29
29
  import { sortedSearchString } from "../cache/cache-key-utils.js";
30
+ import type { SearchParamsFilter } from "../cache/search-params-filter.js";
30
31
  import {
31
32
  DEV_SHELL_PROBE_TIMEOUT_MS,
32
33
  hasIntactShellPayload,
@@ -252,12 +253,18 @@ async function fetchDevShellEntry(
252
253
  * version validity, payload integrity, and tag-invalidation markers. Returns
253
254
  * null on any gate failure — the caller degrades to the ordinary MISS path
254
255
  * (axis 1 + runtime capture), never a broken serve.
256
+ *
257
+ * "Search-less" is evaluated AFTER the request's `cache.searchParams` filter:
258
+ * a URL whose only params are excluded ones (`?fbclid=…` under a tracking
259
+ * exclusion) matches the baked shell — ad-click traffic is exactly the
260
+ * traffic the shell was prerendered for.
255
261
  */
256
262
  export async function lookupBuildShell(
257
263
  url: URL,
258
264
  buildVersion: string,
259
265
  store: SegmentCacheStore,
260
266
  dev?: DevShellLookup,
267
+ filter?: SearchParamsFilter,
261
268
  ): Promise<BuildShellHit | null> {
262
269
  try {
263
270
  // Source-presence first: with no manifest and no dev context this is the
@@ -265,7 +272,7 @@ export async function lookupBuildShell(
265
272
  // the searchParams sort/allocation below.
266
273
  const hasManifest = globalThis.__loadShellManifestModule !== undefined;
267
274
  if (!hasManifest && !dev) return null;
268
- if (sortedSearchString(url.searchParams) !== "") return null;
275
+ if (sortedSearchString(url.searchParams, filter) !== "") return null;
269
276
  let record: BuildShellEntry | undefined;
270
277
  if (hasManifest) {
271
278
  const mod = await loadManifest();