@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.
- package/README.md +19 -1
- package/dist/types/browser/event-controller.d.ts +4 -0
- package/dist/types/browser/link-interceptor.d.ts +17 -7
- package/dist/types/browser/navigation-bridge.d.ts +13 -1
- package/dist/types/browser/notify-listeners.d.ts +2 -0
- package/dist/types/browser/prefetch/cache.d.ts +1 -1
- package/dist/types/browser/prefetch/default-strategy.d.ts +31 -0
- package/dist/types/browser/prefetch/invalidation.d.ts +6 -0
- package/dist/types/browser/prefetch/loader.d.ts +3 -1
- package/dist/types/browser/prefetch/observer.d.ts +3 -6
- package/dist/types/browser/prefetch/runtime.d.ts +0 -1
- package/dist/types/browser/rango-state.d.ts +4 -0
- package/dist/types/browser/react/Link.d.ts +11 -19
- package/dist/types/browser/react/context.d.ts +3 -0
- package/dist/types/browser/rsc-router.d.ts +7 -2
- package/dist/types/browser/types.d.ts +6 -0
- package/dist/types/cache/cache-key-utils.d.ts +11 -3
- package/dist/types/cache/cache-scope.d.ts +82 -3
- package/dist/types/cache/cf/cf-cache-store.d.ts +2 -2
- package/dist/types/cache/index.d.ts +3 -1
- package/dist/types/cache/search-params-filter.d.ts +64 -0
- package/dist/types/cache/shell-snapshot.d.ts +4 -4
- package/dist/types/cache/types.d.ts +36 -2
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.rsc.d.ts +1 -0
- package/dist/types/router/match-middleware/cache-lookup.d.ts +13 -0
- package/dist/types/router/navigation-snapshot.d.ts +29 -0
- package/dist/types/router/prefetch-default.d.ts +28 -0
- package/dist/types/router/router-interfaces.d.ts +7 -0
- package/dist/types/router/router-options.d.ts +54 -0
- package/dist/types/rsc/capture-queue.d.ts +6 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +7 -1
- package/dist/types/rsc/shell-capture.d.ts +15 -7
- package/dist/types/rsc/shell-serve.d.ts +11 -4
- package/dist/types/rsc/types.d.ts +19 -0
- package/dist/types/server/request-context.d.ts +57 -4
- package/dist/types/testing/e2e/index.d.ts +2 -2
- package/dist/types/testing/e2e/page-helpers.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +10 -1
- package/dist/types/testing/shell-status.d.ts +6 -3
- package/dist/types/vite/plugin-types.d.ts +1 -1
- package/dist/vite/index.js +9 -6
- package/package.json +23 -22
- package/skills/caching/SKILL.md +39 -0
- package/skills/comparison/references/framework-comparison.md +9 -2
- package/skills/links/SKILL.md +24 -0
- package/skills/ppr/SKILL.md +80 -16
- package/skills/router-setup/SKILL.md +9 -0
- package/skills/testing/client-components.md +15 -14
- package/skills/vercel/SKILL.md +1 -1
- package/src/browser/event-controller.ts +110 -11
- package/src/browser/link-interceptor.ts +469 -20
- package/src/browser/navigation-bridge.ts +71 -2
- package/src/browser/navigation-store.ts +24 -1
- package/src/browser/notify-listeners.ts +22 -0
- package/src/browser/prefetch/cache.ts +4 -2
- package/src/browser/prefetch/default-strategy.ts +74 -0
- package/src/browser/prefetch/invalidation.ts +30 -0
- package/src/browser/prefetch/loader.ts +18 -17
- package/src/browser/prefetch/observer.ts +50 -22
- package/src/browser/prefetch/runtime.ts +0 -1
- package/src/browser/rango-state.ts +21 -0
- package/src/browser/react/Link.tsx +111 -101
- package/src/browser/react/NavigationProvider.tsx +1 -0
- package/src/browser/react/context.ts +4 -0
- package/src/browser/rsc-router.tsx +30 -9
- package/src/browser/types.ts +6 -0
- package/src/cache/cache-key-utils.ts +18 -4
- package/src/cache/cache-runtime.ts +7 -2
- package/src/cache/cache-scope.ts +152 -23
- package/src/cache/cf/cf-cache-store.ts +55 -16
- package/src/cache/document-cache.ts +7 -1
- package/src/cache/index.ts +7 -0
- package/src/cache/search-params-filter.ts +118 -0
- package/src/cache/shell-snapshot.ts +8 -4
- package/src/cache/types.ts +39 -2
- package/src/cache/vercel/vercel-cache-store.ts +22 -3
- package/src/index.rsc.ts +4 -0
- package/src/index.ts +6 -0
- package/src/router/match-middleware/cache-lookup.ts +146 -33
- package/src/router/match-middleware/cache-store.ts +70 -0
- package/src/router/navigation-snapshot.ts +46 -6
- package/src/router/prefetch-default.ts +59 -0
- package/src/router/router-interfaces.ts +8 -0
- package/src/router/router-options.ts +59 -1
- package/src/router.ts +7 -0
- package/src/rsc/capture-queue.ts +24 -1
- package/src/rsc/full-payload.ts +1 -0
- package/src/rsc/handler.ts +10 -0
- package/src/rsc/response-cache-serve.ts +1 -1
- package/src/rsc/rsc-rendering.ts +367 -40
- package/src/rsc/shell-build-manifest.ts +8 -1
- package/src/rsc/shell-capture.ts +99 -50
- package/src/rsc/shell-serve.ts +14 -6
- package/src/rsc/types.ts +19 -0
- package/src/server/request-context.ts +62 -3
- package/src/testing/dispatch.ts +21 -3
- package/src/testing/e2e/index.ts +6 -0
- package/src/testing/e2e/page-helpers.ts +47 -0
- package/src/testing/e2e/parity.ts +13 -0
- package/src/testing/render-route.tsx +86 -17
- package/src/testing/shell-status.ts +20 -3
- package/src/vite/plugin-types.ts +1 -1
- package/src/vite/plugins/vercel-output.ts +2 -2
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
374
|
+
const descriptor = createShellCaptureDescriptor(
|
|
375
|
+
ctx,
|
|
284
376
|
key,
|
|
285
|
-
|
|
286
|
-
ttl: pprConfig.ttl,
|
|
287
|
-
swr: pprConfig.swr,
|
|
288
|
-
tags: pprConfig.tags,
|
|
289
|
-
captureTimeout: pprConfig.captureTimeout,
|
|
377
|
+
pprConfig,
|
|
290
378
|
store,
|
|
291
|
-
|
|
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 (
|
|
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
|
-
|
|
644
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
1031
|
+
const provisionalStatus: PprReplayStatus = segmentReplayHit
|
|
736
1032
|
? { outcome: "HIT", freshness }
|
|
737
|
-
:
|
|
738
|
-
|
|
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();
|