@rangojs/router 0.17.0 → 0.18.0
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 +3 -2
- package/dist/types/browser/cookie-name.d.ts +6 -0
- package/dist/types/browser/history-state.d.ts +16 -0
- package/dist/types/cache/cache-key-utils.d.ts +17 -2
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +98 -14
- package/dist/types/cache/cache-tag.d.ts +92 -7
- package/dist/types/cache/cf/cf-base64.d.ts +6 -0
- package/dist/types/cache/cf/cf-cache-constants.d.ts +11 -0
- package/dist/types/cache/cf/cf-cache-store.d.ts +185 -44
- package/dist/types/cache/cf/cf-cache-types.d.ts +31 -4
- package/dist/types/cache/cf/cf-shell-frame.d.ts +95 -0
- package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +34 -0
- package/dist/types/cache/handle-snapshot.d.ts +4 -3
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +110 -21
- package/dist/types/cache/tag-invalidation.d.ts +30 -3
- package/dist/types/cache/types.d.ts +207 -43
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +111 -13
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +40 -11
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +21 -10
- package/dist/types/router/segment-resolution/mask-nested.d.ts +65 -24
- package/dist/types/rsc/full-payload.d.ts +14 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/shell-build-manifest.d.ts +8 -0
- package/dist/types/rsc/shell-capture-constants.d.ts +53 -6
- package/dist/types/rsc/shell-capture.d.ts +146 -15
- package/dist/types/rsc/shell-serve.d.ts +140 -17
- package/dist/types/rsc/types.d.ts +7 -0
- package/dist/types/server/context.d.ts +109 -15
- package/dist/types/server/cookie-store.d.ts +21 -0
- package/dist/types/server/request-context.d.ts +159 -33
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/serve-shell-request.d.ts +132 -0
- package/dist/types/testing/shell-status.d.ts +23 -10
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/urls/pattern-types.d.ts +22 -18
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +46 -9
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +123 -51
- package/skills/caching/SKILL.md +253 -23
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +11 -6
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +44 -32
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +8 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/state.md +3 -1
- package/skills/loader/SKILL.md +161 -39
- package/skills/middleware/SKILL.md +16 -9
- package/skills/migrate-nextjs/SKILL.md +67 -36
- package/skills/migrate-react-router/SKILL.md +2 -1
- package/skills/migrate-react-router/component-migration.md +3 -1
- package/skills/migrate-react-router/data-and-actions.md +3 -1
- package/skills/migrate-react-router/route-mapping.md +7 -4
- package/skills/observability/SKILL.md +64 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +655 -363
- package/skills/prerender/SKILL.md +27 -14
- package/skills/rango/SKILL.md +52 -33
- package/skills/route/SKILL.md +12 -7
- package/skills/router-setup/SKILL.md +4 -2
- package/skills/scripts/SKILL.md +8 -1
- package/skills/server-actions/SKILL.md +11 -0
- package/skills/shell-manifest/SKILL.md +21 -12
- package/skills/testing/SKILL.md +25 -21
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/loader.md +18 -1
- package/skills/testing/serve-shell-request.md +188 -0
- package/skills/theme/SKILL.md +52 -4
- package/skills/typesafety/env-and-bindings.md +10 -2
- package/skills/use-cache/SKILL.md +39 -16
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/history-state.ts +23 -0
- package/src/browser/rsc-router.tsx +5 -0
- package/src/cache/cache-key-utils.ts +22 -2
- package/src/cache/cache-policy.ts +22 -0
- package/src/cache/cache-runtime.ts +34 -7
- package/src/cache/cache-scope.ts +345 -33
- package/src/cache/cache-tag.ts +375 -11
- package/src/cache/cf/cf-base64.ts +36 -4
- package/src/cache/cf/cf-cache-constants.ts +12 -0
- package/src/cache/cf/cf-cache-store.ts +990 -348
- package/src/cache/cf/cf-cache-types.ts +32 -4
- package/src/cache/cf/cf-shell-frame.ts +258 -0
- package/src/cache/cf/cf-tag-marker-memo.ts +143 -39
- package/src/cache/handle-snapshot.ts +4 -3
- package/src/cache/index.ts +2 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +217 -27
- package/src/cache/tag-invalidation.ts +104 -19
- package/src/cache/types.ts +211 -43
- package/src/cache/vercel/vercel-cache-store.ts +545 -61
- package/src/prerender/build-shell-capture.ts +65 -11
- package/src/route-definition/dsl-helpers.ts +4 -3
- package/src/router/handler-context.ts +38 -4
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +19 -7
- package/src/router/loader-resolution.ts +63 -32
- package/src/router/match-api.ts +28 -9
- package/src/router/match-handlers.ts +5 -0
- package/src/router/match-middleware/cache-lookup.ts +17 -0
- package/src/router/match-middleware/cache-store.ts +31 -11
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +23 -4
- package/src/router/prerender-match.ts +6 -0
- package/src/router/segment-resolution/fresh.ts +18 -9
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +227 -56
- package/src/router/segment-resolution/loader-snapshot.ts +203 -51
- package/src/router/segment-resolution/mask-nested.ts +136 -42
- package/src/router/segment-resolution/revalidation.ts +27 -10
- package/src/rsc/full-payload.ts +25 -1
- package/src/rsc/handler.ts +14 -2
- package/src/rsc/progressive-enhancement.ts +3 -2
- package/src/rsc/response-cache-serve.ts +9 -6
- package/src/rsc/rsc-rendering.ts +601 -181
- package/src/rsc/shell-build-manifest.ts +27 -9
- package/src/rsc/shell-capture-constants.ts +60 -6
- package/src/rsc/shell-capture.ts +1001 -250
- package/src/rsc/shell-serve.ts +316 -34
- package/src/rsc/types.ts +7 -0
- package/src/server/context.ts +219 -28
- package/src/server/cookie-store.ts +87 -62
- package/src/server/request-context.ts +263 -38
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/run-loader.ts +63 -5
- package/src/testing/serve-shell-request.ts +378 -0
- package/src/testing/shell-status.ts +34 -12
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/types/handler-context.ts +14 -1
- package/src/urls/pattern-types.ts +22 -18
- package/src/vite/discovery/shell-prerender-phase.ts +9 -0
- package/src/vite/router-discovery.ts +14 -4
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
type CacheExecScope,
|
|
59
59
|
} from "./cache-exec-scope.js";
|
|
60
60
|
import { reportCacheError } from "./cache-error.js";
|
|
61
|
+
import { currentCacheSeq, invalidatedSince } from "./tag-invalidation.js";
|
|
61
62
|
import type { CacheItemResult } from "./types.js";
|
|
62
63
|
import type { InternalHandlerContext } from "../types.js";
|
|
63
64
|
|
|
@@ -292,7 +293,10 @@ interface CacheEnvelope {
|
|
|
292
293
|
*/
|
|
293
294
|
interface InFlightExecution {
|
|
294
295
|
promise: Promise<CacheEnvelope>;
|
|
296
|
+
/** Date.now() at registration, for IN_FLIGHT_LEADER_MAX_WAIT_MS. */
|
|
295
297
|
registeredAt: number;
|
|
298
|
+
/** currentCacheSeq() at registration, for invalidatedSince (#973). */
|
|
299
|
+
startSeq: number;
|
|
296
300
|
}
|
|
297
301
|
|
|
298
302
|
const inFlightExecutions = new Map<string, InFlightExecution>();
|
|
@@ -678,6 +682,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
678
682
|
PHASES.background("use-cache-revalidation"),
|
|
679
683
|
async () => {
|
|
680
684
|
const refreshArgs = args.map(refreshView);
|
|
685
|
+
// The refresh reads its data from here on (#973).
|
|
686
|
+
const startSeq = currentCacheSeq();
|
|
681
687
|
const scoped = runWithCacheTagScope(() =>
|
|
682
688
|
runWithCacheExecScope(
|
|
683
689
|
() => fn.apply(this, refreshArgs),
|
|
@@ -714,6 +720,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
714
720
|
// An error row would replace the stale entry; the catch
|
|
715
721
|
// below reports it and the stale entry keeps serving.
|
|
716
722
|
if (flightErrors.length > 0) throw flightErrors[0];
|
|
723
|
+
// The request invalidated one of the refreshed value's
|
|
724
|
+
// tags after the refresh started: the value may predate
|
|
725
|
+
// it, and written now it would outlive it.
|
|
726
|
+
if (invalidatedSince(requestCtx, freshTags, startSeq)) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
717
729
|
await store.setItem!(cacheKey, serialized, {
|
|
718
730
|
handles: encodedHandles,
|
|
719
731
|
ttl: profile.ttl,
|
|
@@ -757,8 +769,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
757
769
|
// against its OWN handle store (gated on ITS hasTaintedArgs), and records
|
|
758
770
|
// tags into its OWN request — no deserialized result is shared across
|
|
759
771
|
// requests. The store write stays exactly once (the leader's).
|
|
760
|
-
|
|
761
|
-
|
|
772
|
+
let existing = inFlightExecutions.get(cacheKey);
|
|
773
|
+
while (existing) {
|
|
762
774
|
const remainingMs =
|
|
763
775
|
IN_FLIGHT_LEADER_MAX_WAIT_MS - (Date.now() - existing.registeredAt);
|
|
764
776
|
const raced =
|
|
@@ -780,8 +792,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
780
792
|
"cache-read",
|
|
781
793
|
`[use cache] "${id}" inflight-timeout`,
|
|
782
794
|
);
|
|
783
|
-
//
|
|
784
|
-
}
|
|
795
|
+
break; // This call becomes the leader below.
|
|
796
|
+
}
|
|
797
|
+
// Leader rejected: its map entry is already cleared; run fresh.
|
|
798
|
+
if (!raced) break;
|
|
799
|
+
// Checked after the wait, so an invalidation made meanwhile counts too.
|
|
800
|
+
if (!invalidatedSince(requestCtx, raced.tags, existing.startSeq)) {
|
|
785
801
|
try {
|
|
786
802
|
return await serveCached({
|
|
787
803
|
value: raced.serialized,
|
|
@@ -795,11 +811,16 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
795
811
|
"cache-corrupt",
|
|
796
812
|
`[use cache] "${id}" inflight-hit`,
|
|
797
813
|
);
|
|
798
|
-
//
|
|
814
|
+
break; // Run fresh below.
|
|
799
815
|
}
|
|
800
816
|
}
|
|
801
|
-
//
|
|
802
|
-
//
|
|
817
|
+
// This request invalidated one of the leader's tags after it started
|
|
818
|
+
// (#973): its value may predate the invalidation. The leader cleared
|
|
819
|
+
// its entry before resolving, so an entry now is a newer execution
|
|
820
|
+
// (another caller that made this same decision): join it rather than
|
|
821
|
+
// start one more.
|
|
822
|
+
const next = inFlightExecutions.get(cacheKey);
|
|
823
|
+
existing = next !== existing ? next : undefined;
|
|
803
824
|
}
|
|
804
825
|
|
|
805
826
|
// This call becomes the leader. Register a deferred envelope so concurrent
|
|
@@ -816,9 +837,11 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
816
837
|
// Followers attach their own catch; guard the map's own reference so a
|
|
817
838
|
// rejected envelope with no waiter is not an unhandled rejection.
|
|
818
839
|
envelopePromise.catch(() => {});
|
|
840
|
+
const startSeq = currentCacheSeq();
|
|
819
841
|
inFlightExecutions.set(cacheKey, {
|
|
820
842
|
promise: envelopePromise,
|
|
821
843
|
registeredAt: Date.now(),
|
|
844
|
+
startSeq,
|
|
822
845
|
});
|
|
823
846
|
const clearSelf = (): void => {
|
|
824
847
|
if (inFlightExecutions.get(cacheKey)?.promise === envelopePromise) {
|
|
@@ -931,6 +954,10 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
|
|
|
931
954
|
// Hand followers the envelope before the store write so a slow/failed
|
|
932
955
|
// write never stalls them.
|
|
933
956
|
resolveEnvelope({ serialized, tags: allTags, handles: encodedHandles });
|
|
957
|
+
// This request invalidated one of the value's tags after the execution
|
|
958
|
+
// started (#973): the value may predate the invalidation, and written
|
|
959
|
+
// now it would outlive it. Skip the write; the next read runs fresh.
|
|
960
|
+
if (invalidatedSince(requestCtx, allTags, startSeq)) return;
|
|
934
961
|
try {
|
|
935
962
|
await store.setItem!(cacheKey, serialized, {
|
|
936
963
|
handles: encodedHandles,
|
package/src/cache/cache-scope.ts
CHANGED
|
@@ -17,7 +17,12 @@ import {
|
|
|
17
17
|
getRequestContext,
|
|
18
18
|
_getRequestContext,
|
|
19
19
|
} from "../server/request-context.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
getSegmentTags,
|
|
22
|
+
recordRequestTags,
|
|
23
|
+
recordSegmentTags,
|
|
24
|
+
runInSegmentTagScope,
|
|
25
|
+
} from "./cache-tag.js";
|
|
21
26
|
import { reportCacheError } from "./cache-error.js";
|
|
22
27
|
// segment-codec is the only module on cache-scope's import graph that eagerly
|
|
23
28
|
// pulls @vitejs/plugin-rsc (a virtual: module the plain node/vitest runner cannot
|
|
@@ -33,7 +38,7 @@ import {
|
|
|
33
38
|
encodeHandles,
|
|
34
39
|
decodeHandles,
|
|
35
40
|
} from "./handle-snapshot.js";
|
|
36
|
-
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
41
|
+
import { cacheKeyBase, composeCacheKeys } from "./cache-key-utils.js";
|
|
37
42
|
import {
|
|
38
43
|
DEFAULT_ROUTE_TTL,
|
|
39
44
|
isFiniteNonNegativeSeconds,
|
|
@@ -51,6 +56,38 @@ export function resolveCacheTags(
|
|
|
51
56
|
return resolveTagsOption(config.tags, ctx, "CacheScope");
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Narrow the request's route-record window (RequestContext._routeRecordWindow)
|
|
61
|
+
* to a record this request read or wrote: fresh until `freshUntil` (the
|
|
62
|
+
* record's `expiresAt`), then stale for `swr` seconds. A shell capture starts
|
|
63
|
+
* with no window of its own (shell-capture.ts deriveShellCaptureContext sets
|
|
64
|
+
* it undefined), so it narrows only to the records the capture itself read
|
|
65
|
+
* or wrote.
|
|
66
|
+
*/
|
|
67
|
+
function noteRouteRecordWindow(
|
|
68
|
+
requestCtx: RequestContext,
|
|
69
|
+
freshUntil: number,
|
|
70
|
+
ttl: number,
|
|
71
|
+
swr: number,
|
|
72
|
+
written: boolean,
|
|
73
|
+
): void {
|
|
74
|
+
const next = {
|
|
75
|
+
freshUntil,
|
|
76
|
+
staleUntil: freshUntil + swr * 1000,
|
|
77
|
+
ttl,
|
|
78
|
+
swr,
|
|
79
|
+
written,
|
|
80
|
+
};
|
|
81
|
+
const current = requestCtx._routeRecordWindow;
|
|
82
|
+
// The record that runs out first names the config; freshness is the least.
|
|
83
|
+
requestCtx._routeRecordWindow = current
|
|
84
|
+
? {
|
|
85
|
+
...(current.staleUntil <= next.staleUntil ? current : next),
|
|
86
|
+
freshUntil: Math.min(current.freshUntil, freshUntil),
|
|
87
|
+
}
|
|
88
|
+
: next;
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
function debugCacheLog(message: string): void {
|
|
55
92
|
if (INTERNAL_RANGO_DEBUG) {
|
|
56
93
|
console.log(message);
|
|
@@ -110,7 +147,43 @@ function getDefaultRouteCacheKey(
|
|
|
110
147
|
// CacheScope
|
|
111
148
|
// ============================================================================
|
|
112
149
|
|
|
113
|
-
const CACHE_HIT_OBSERVERS = new WeakMap<
|
|
150
|
+
const CACHE_HIT_OBSERVERS = new WeakMap<
|
|
151
|
+
CacheScope,
|
|
152
|
+
() => void | Promise<void>
|
|
153
|
+
>();
|
|
154
|
+
|
|
155
|
+
type CacheKeyFn = NonNullable<PartialCacheOptions["key"]>;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A configured `key()` cannot run without a request context. Falling back to
|
|
159
|
+
* the default key would store the key()'s partitioned output under the broad
|
|
160
|
+
* key and serve it across partitions, so key resolution fails instead and
|
|
161
|
+
* every caller degrades to an uncached render (as for a throwing `key()`).
|
|
162
|
+
*/
|
|
163
|
+
function keyWithoutContextError(): Error {
|
|
164
|
+
return new Error(
|
|
165
|
+
"[CacheScope] a cache() key() needs a request context; rendering uncached",
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* One key resolution per request, resolver (`by`: a `key()` or a store's
|
|
171
|
+
* keyGenerator) and `slot`, held on the request context (`_resolvedCacheKeys`,
|
|
172
|
+
* see CacheScope.resolveKeyFrom).
|
|
173
|
+
*/
|
|
174
|
+
function memoizedKey(
|
|
175
|
+
requestCtx: RequestContext,
|
|
176
|
+
by: unknown,
|
|
177
|
+
slot: string,
|
|
178
|
+
resolve: () => Promise<string>,
|
|
179
|
+
): Promise<string> {
|
|
180
|
+
const memo = (requestCtx._resolvedCacheKeys ??= new Map());
|
|
181
|
+
let resolved = memo.get(by);
|
|
182
|
+
if (!resolved) memo.set(by, (resolved = new Map()));
|
|
183
|
+
let key = resolved.get(slot);
|
|
184
|
+
if (!key) resolved.set(slot, (key = resolve()));
|
|
185
|
+
return key;
|
|
186
|
+
}
|
|
114
187
|
|
|
115
188
|
/**
|
|
116
189
|
* Discriminated outcome of a route cache lookup — see
|
|
@@ -145,6 +218,18 @@ export class CacheScope {
|
|
|
145
218
|
readonly parent: CacheScope | null;
|
|
146
219
|
/** Explicit store from cache() options, if specified */
|
|
147
220
|
private readonly explicitStore: SegmentCacheStore | undefined;
|
|
221
|
+
/**
|
|
222
|
+
* @internal The `key()` functions that partition this scope's records,
|
|
223
|
+
* outermost first: every cache() on the parent chain that sets one, this
|
|
224
|
+
* scope's last (#970). `cache(false)` adds none and does not cut the chain,
|
|
225
|
+
* so a cache() re-enabled under it stays in the partition. Before, the
|
|
226
|
+
* innermost config alone decided the key: an inner cache() without `key()`
|
|
227
|
+
* wrote under the default key and an inner `key()` dropped the outer one,
|
|
228
|
+
* so a request in one partition HIT another partition's inner records.
|
|
229
|
+
*/
|
|
230
|
+
readonly keyFns: readonly CacheKeyFn[];
|
|
231
|
+
/** Whether this scope's own config sets `key()` (the last of keyFns). */
|
|
232
|
+
private readonly hasOwnKey: boolean;
|
|
148
233
|
|
|
149
234
|
constructor(
|
|
150
235
|
config: PartialCacheOptions | false,
|
|
@@ -163,6 +248,15 @@ export class CacheScope {
|
|
|
163
248
|
this.parent = parent;
|
|
164
249
|
// Extract and store explicit store reference
|
|
165
250
|
this.explicitStore = config !== false ? config.store : undefined;
|
|
251
|
+
// Only a function counts: a conditional `key: flag ? fn : null` from
|
|
252
|
+
// untyped code is no key(), as resolveCacheKey always treated it.
|
|
253
|
+
const ownKey =
|
|
254
|
+
config !== false && typeof config.key === "function"
|
|
255
|
+
? config.key
|
|
256
|
+
: undefined;
|
|
257
|
+
const inherited = parent?.keyFns ?? [];
|
|
258
|
+
this.hasOwnKey = ownKey !== undefined;
|
|
259
|
+
this.keyFns = ownKey ? [...inherited, ownKey] : inherited;
|
|
166
260
|
}
|
|
167
261
|
|
|
168
262
|
/**
|
|
@@ -249,19 +343,151 @@ export class CacheScope {
|
|
|
249
343
|
* Resolve the cache key using the shared 3-tier priority.
|
|
250
344
|
* @internal
|
|
251
345
|
*/
|
|
252
|
-
private
|
|
346
|
+
private resolveKey(
|
|
253
347
|
pathname: string,
|
|
254
348
|
params: Record<string, string>,
|
|
255
349
|
isIntercept?: boolean,
|
|
256
350
|
): Promise<string> {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
351
|
+
return this.resolveKeyFrom(
|
|
352
|
+
getDefaultRouteCacheKey(
|
|
353
|
+
pathname,
|
|
354
|
+
params,
|
|
355
|
+
isIntercept,
|
|
356
|
+
this.defaultKeyPrefix,
|
|
357
|
+
),
|
|
262
358
|
);
|
|
263
|
-
|
|
264
|
-
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* A shell captured from a route record this request read or wrote must not
|
|
363
|
+
* outlive it (noteRouteRecordWindow). The implicit doc scope's own record
|
|
364
|
+
* is the shell's, so it notes nothing.
|
|
365
|
+
*/
|
|
366
|
+
private noteRecordWindow(
|
|
367
|
+
requestCtx: RequestContext,
|
|
368
|
+
freshUntil: number,
|
|
369
|
+
written: boolean,
|
|
370
|
+
): void {
|
|
371
|
+
if (!this.isShellImplicitDocScope) {
|
|
372
|
+
noteRouteRecordWindow(
|
|
373
|
+
requestCtx,
|
|
374
|
+
freshUntil,
|
|
375
|
+
this.ttl,
|
|
376
|
+
this.swr ?? 0,
|
|
377
|
+
written,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* @internal This scope's record key for `defaultKey`, resolved once per
|
|
384
|
+
* request: a ppr route's shell partition (resolvePartition) and the record
|
|
385
|
+
* lookup share the `key()` runs, and a rejection is shared too.
|
|
386
|
+
*
|
|
387
|
+
* With `key()` functions on the chain (keyFns) the parts are their results,
|
|
388
|
+
* outermost first, composed by composeCacheKeys (a single part raw), so a
|
|
389
|
+
* nested cache() keys its records within the enclosing partition (#970).
|
|
390
|
+
* A scope whose own config sets no `key()` appends its own default key
|
|
391
|
+
* (resolveDefaultKey). Reusing the outer `key()` result alone would give
|
|
392
|
+
* every route under the inner cache() one record: a `key()` replaces the
|
|
393
|
+
* whole default key, so an outer key that names no route (`tier:gold`)
|
|
394
|
+
* would make `/a` HIT `/b`'s record. A scope with its own `key()` adds no
|
|
395
|
+
* default key: a `key()` is a full override, and no keyGenerator runs.
|
|
396
|
+
* Without `key()` on the chain it is resolveDefaultKey alone. With one but
|
|
397
|
+
* no request context it rejects (keyWithoutContextError).
|
|
398
|
+
*
|
|
399
|
+
* Each `key()` result lives on the request context (`_resolvedCacheKeys`),
|
|
400
|
+
* by function: it runs once per request whatever the default key
|
|
401
|
+
* (document, partial or `doc` shell prefix) and however many scopes share
|
|
402
|
+
* it (sibling routes under one keyed cache()). A shell capture, whose
|
|
403
|
+
* context is `Object.create` of the request's (shell-capture.ts), reads the
|
|
404
|
+
* foreground's results through the prototype chain and never runs `key()`
|
|
405
|
+
* itself: a `key()` calling `cookies()` would trip the capture guard there,
|
|
406
|
+
* and a capture must store the partition its request resolved.
|
|
407
|
+
*/
|
|
408
|
+
resolveKeyFrom(defaultKey: string): Promise<string> {
|
|
409
|
+
const requestCtx = _getRequestContext();
|
|
410
|
+
if (this.keyFns.length === 0) {
|
|
411
|
+
return this.resolveDefaultKey(requestCtx, defaultKey);
|
|
412
|
+
}
|
|
413
|
+
if (!requestCtx) return Promise.reject(keyWithoutContextError());
|
|
414
|
+
const parts = this.resolveKeyResults(requestCtx, defaultKey);
|
|
415
|
+
if (!this.hasOwnKey) {
|
|
416
|
+
parts.push(this.resolveDefaultKey(requestCtx, defaultKey));
|
|
417
|
+
}
|
|
418
|
+
return parts.length === 1
|
|
419
|
+
? parts[0]
|
|
420
|
+
: Promise.all(parts).then(composeCacheKeys);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* @internal The request partition of a ppr route's shell under this scope
|
|
425
|
+
* (resolveShellPartition): the chain's `key()` results, composed like the
|
|
426
|
+
* record key but without the default key, which the URL-based shell key
|
|
427
|
+
* already carries. A scope without its own `key()` whose store has a
|
|
428
|
+
* keyGenerator adds that result when it differs from the `doc` default
|
|
429
|
+
* key, as an unkeyed scope's partition always did: the record is split by
|
|
430
|
+
* it, so the shell must be too. Undefined (synchronously, with no work)
|
|
431
|
+
* when nothing can partition; null when nothing does.
|
|
432
|
+
*/
|
|
433
|
+
resolvePartition(
|
|
434
|
+
pathname: string,
|
|
435
|
+
params: Record<string, string> | undefined,
|
|
436
|
+
): Promise<string | null> | undefined {
|
|
437
|
+
const keyGenerator = this.hasOwnKey
|
|
438
|
+
? undefined
|
|
439
|
+
: this.getStore()?.keyGenerator;
|
|
440
|
+
if (this.keyFns.length === 0 && !keyGenerator) return undefined;
|
|
441
|
+
const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
|
|
442
|
+
const requestCtx = _getRequestContext();
|
|
443
|
+
if (!requestCtx) {
|
|
444
|
+
return this.keyFns.length > 0
|
|
445
|
+
? Promise.reject(keyWithoutContextError())
|
|
446
|
+
: Promise.resolve(null);
|
|
447
|
+
}
|
|
448
|
+
const generated = keyGenerator
|
|
449
|
+
? this.resolveDefaultKey(requestCtx, defaultKey)
|
|
450
|
+
: undefined;
|
|
451
|
+
return Promise.all([
|
|
452
|
+
Promise.all(this.resolveKeyResults(requestCtx, defaultKey)),
|
|
453
|
+
generated,
|
|
454
|
+
]).then(([keys, generatedKey]) => {
|
|
455
|
+
const parts =
|
|
456
|
+
generatedKey === undefined || generatedKey === defaultKey
|
|
457
|
+
? keys
|
|
458
|
+
: [...keys, generatedKey];
|
|
459
|
+
const partition = parts.length === 0 ? null : composeCacheKeys(parts);
|
|
460
|
+
return partition === defaultKey ? null : partition;
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** The chain's `key()` results, each once per request (memoizedKey). */
|
|
465
|
+
private resolveKeyResults(
|
|
466
|
+
requestCtx: RequestContext,
|
|
467
|
+
defaultKey: string,
|
|
468
|
+
): Promise<string>[] {
|
|
469
|
+
return this.keyFns.map((keyFn) =>
|
|
470
|
+
memoizedKey(requestCtx, keyFn, "", () =>
|
|
471
|
+
resolveCacheKey(keyFn, null, defaultKey, "CacheScope"),
|
|
472
|
+
),
|
|
473
|
+
);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* This scope's store keyGenerator result for `defaultKey` (once per request
|
|
478
|
+
* and default key), else `defaultKey` (resolveCacheKey).
|
|
479
|
+
*/
|
|
480
|
+
private resolveDefaultKey(
|
|
481
|
+
requestCtx: RequestContext | undefined,
|
|
482
|
+
defaultKey: string,
|
|
483
|
+
): Promise<string> {
|
|
484
|
+
const store = this.getStore();
|
|
485
|
+
const keyGenerator = store?.keyGenerator;
|
|
486
|
+
const resolve = () =>
|
|
487
|
+
resolveCacheKey(undefined, store, defaultKey, "CacheScope");
|
|
488
|
+
return requestCtx && keyGenerator
|
|
489
|
+
? memoizedKey(requestCtx, keyGenerator, defaultKey, resolve)
|
|
490
|
+
: resolve();
|
|
265
491
|
}
|
|
266
492
|
|
|
267
493
|
/**
|
|
@@ -431,7 +657,8 @@ export class CacheScope {
|
|
|
431
657
|
// expand them (segment-fragments.ts). Read off the ambient context at the
|
|
432
658
|
// same point the cache key was resolved (getDefaultRouteCacheKey), so the
|
|
433
659
|
// flag shares fate with the key: a disrupted ALS already missed the
|
|
434
|
-
// seeded record
|
|
660
|
+
// seeded record, which a document tail turns into the degrade
|
|
661
|
+
// (ShellRecordUnavailableError).
|
|
435
662
|
let segments: ResolvedSegment[];
|
|
436
663
|
const ambientContext = _getRequestContext();
|
|
437
664
|
try {
|
|
@@ -467,8 +694,14 @@ export class CacheScope {
|
|
|
467
694
|
// A hit serves content that was tagged at write time, so the document
|
|
468
695
|
// tag union must include this entry's tags for updateTag()/revalidateTag()
|
|
469
696
|
// to invalidate any full-page entry built on top of it. The write path
|
|
470
|
-
// records via cacheRoute (resolveCacheTags); the hit path records here
|
|
697
|
+
// records via cacheRoute (resolveCacheTags); the hit path records here,
|
|
698
|
+
// onto the replayed segments too, for a record written from this replay
|
|
699
|
+
// (a shell capture's doc record).
|
|
471
700
|
recordRequestTags(cached.tags);
|
|
701
|
+
recordSegmentTags(
|
|
702
|
+
segments.map((s) => s.id),
|
|
703
|
+
cached.tags,
|
|
704
|
+
);
|
|
472
705
|
|
|
473
706
|
// Replay handle data. An empty string means the route pushed no handles —
|
|
474
707
|
// skip the decode entirely (the common case). Otherwise decode the
|
|
@@ -502,7 +735,10 @@ export class CacheScope {
|
|
|
502
735
|
);
|
|
503
736
|
}
|
|
504
737
|
|
|
505
|
-
|
|
738
|
+
if (ambientContext)
|
|
739
|
+
this.noteRecordWindow(ambientContext, cached.expiresAt, false);
|
|
740
|
+
|
|
741
|
+
await CACHE_HIT_OBSERVERS.get(this)?.();
|
|
506
742
|
return { status: "hit", result: { segments, shouldRevalidate } };
|
|
507
743
|
} catch (error) {
|
|
508
744
|
// Covers a store.get() failure AND a throwing consumer key()/keyGenerator
|
|
@@ -575,6 +811,7 @@ export class CacheScope {
|
|
|
575
811
|
|
|
576
812
|
const ttl = this.ttl;
|
|
577
813
|
const swr = this.swr;
|
|
814
|
+
this.noteRecordWindow(requestCtx, Date.now() + ttl * 1000, true);
|
|
578
815
|
|
|
579
816
|
// Resolve cache key early (while request context is available)
|
|
580
817
|
const key = await this.resolveKey(pathname, params, isIntercept);
|
|
@@ -657,9 +894,21 @@ export class CacheScope {
|
|
|
657
894
|
const onFlightError = (error: unknown): void => {
|
|
658
895
|
flightErrors.push(error);
|
|
659
896
|
};
|
|
897
|
+
// Each segment serializes inside its own tag scope, and the handles
|
|
898
|
+
// inside one for this write: Flight re-renders their server
|
|
899
|
+
// components, so their render-called tags land on the record.
|
|
900
|
+
const handlesOwnerId = `${key}#handles`;
|
|
660
901
|
const [serializedSegments, encodedHandles] = await Promise.all([
|
|
661
|
-
|
|
662
|
-
|
|
902
|
+
Promise.all(
|
|
903
|
+
nonLoaderSegments.map((segment) =>
|
|
904
|
+
runInSegmentTagScope(segment.id, () =>
|
|
905
|
+
serializeSegments([segment], onFlightError),
|
|
906
|
+
),
|
|
907
|
+
),
|
|
908
|
+
).then((parts) => parts.flat()),
|
|
909
|
+
runInSegmentTagScope(handlesOwnerId, () =>
|
|
910
|
+
encodeHandles(handles, onFlightError),
|
|
911
|
+
),
|
|
663
912
|
]);
|
|
664
913
|
// Flight encodes a component that throws (an async server component in
|
|
665
914
|
// the tree) or a rejected handle value as an error row and completes
|
|
@@ -670,7 +919,10 @@ export class CacheScope {
|
|
|
670
919
|
segments: serializedSegments,
|
|
671
920
|
handles: encodedHandles,
|
|
672
921
|
expiresAt: Date.now() + ttl * 1000,
|
|
673
|
-
tags,
|
|
922
|
+
tags: collectRecordTags(requestCtx, tags, [
|
|
923
|
+
...nonLoaderSegments.map((s) => s.id),
|
|
924
|
+
handlesOwnerId,
|
|
925
|
+
]),
|
|
674
926
|
};
|
|
675
927
|
if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
|
|
676
928
|
|
|
@@ -699,6 +951,55 @@ export class CacheScope {
|
|
|
699
951
|
}
|
|
700
952
|
}
|
|
701
953
|
|
|
954
|
+
/**
|
|
955
|
+
* A route cache() record's tags (#957): its config tags plus every tag its
|
|
956
|
+
* content recorded under `ownerIds` (cache-tag.ts getSegmentTags — the covered
|
|
957
|
+
* segments' handlers, the server components their Flight serialization
|
|
958
|
+
* re-rendered, the loaders those handlers consumed, and the handle values).
|
|
959
|
+
* A HIT replays all of that output, loading() subtrees included, so every one
|
|
960
|
+
* of these tags describes it.
|
|
961
|
+
*/
|
|
962
|
+
function collectRecordTags(
|
|
963
|
+
requestCtx: RequestContext,
|
|
964
|
+
configTags: string[] | undefined,
|
|
965
|
+
ownerIds: string[],
|
|
966
|
+
): string[] | undefined {
|
|
967
|
+
const tags = new Set(configTags);
|
|
968
|
+
const before = tags.size;
|
|
969
|
+
for (const id of ownerIds) {
|
|
970
|
+
for (const tag of getSegmentTags(requestCtx, id)) tags.add(tag);
|
|
971
|
+
}
|
|
972
|
+
return tags.size > before ? [...tags] : configTags;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* @internal The request partition of a ppr route's shell: with a route
|
|
977
|
+
* cache() scope, its CacheScope.resolvePartition (the chain's `key()`
|
|
978
|
+
* results, composed, plus a keyGenerator's contribution for a scope without
|
|
979
|
+
* its own `key()` (#970)); with none, the app store's keyGenerator result
|
|
980
|
+
* given the document default key. Undefined (synchronously, with no work)
|
|
981
|
+
* when neither applies: the shell key stays host + path + filtered search.
|
|
982
|
+
* Resolves to null when the partition is the default key itself (a
|
|
983
|
+
* keyGenerator that returns it unchanged): that partitions nothing, so the
|
|
984
|
+
* shell stays unpartitioned and keeps its build shell. The shell's key, its
|
|
985
|
+
* read and capture, and partial replay all use it, so a visitor is only
|
|
986
|
+
* served its own partition. A rejected promise is a failed key resolution:
|
|
987
|
+
* the caller serves no shell.
|
|
988
|
+
*/
|
|
989
|
+
export function resolveShellPartition(
|
|
990
|
+
routeScope: CacheScope | null | undefined,
|
|
991
|
+
appStore: SegmentCacheStore | null | undefined,
|
|
992
|
+
pathname: string,
|
|
993
|
+
params: Record<string, string> | undefined,
|
|
994
|
+
): Promise<string | null> | undefined {
|
|
995
|
+
if (routeScope?.enabled) return routeScope.resolvePartition(pathname, params);
|
|
996
|
+
if (!appStore?.keyGenerator) return undefined;
|
|
997
|
+
const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
|
|
998
|
+
return resolveCacheKey(undefined, appStore, defaultKey, "CacheScope").then(
|
|
999
|
+
(resolved) => (resolved === defaultKey ? null : resolved),
|
|
1000
|
+
);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
702
1003
|
/**
|
|
703
1004
|
* Create a cache scope from entry's cache config. `shortCode` is the cache()
|
|
704
1005
|
* entry's: a scope nested in an enabled parent keeps the parent's boundary
|
|
@@ -725,13 +1026,21 @@ type ShellImplicitCacheMarker = NonNullable<
|
|
|
725
1026
|
* {@link resolveShellImplicitCacheScope} (routes that derived no scope) and
|
|
726
1027
|
* the explicit-scope composition sites (capture doc record in the cache-store
|
|
727
1028
|
* middleware, seeded replay fallback in withCacheLookup) so both ends of the
|
|
728
|
-
* shell contract resolve the SAME canonical document key.
|
|
1029
|
+
* shell contract resolve the SAME canonical document key. A document HIT
|
|
1030
|
+
* tail's marker carries the entry's own key (`fixedDocKey`), which wins over
|
|
1031
|
+
* key resolution.
|
|
729
1032
|
*/
|
|
730
1033
|
export function createShellImplicitDocScope(
|
|
731
1034
|
marker: ShellImplicitCacheMarker,
|
|
732
1035
|
): CacheScope {
|
|
1036
|
+
const fixedDocKey = marker.fixedDocKey;
|
|
733
1037
|
const implicitScope = new CacheScope(
|
|
734
|
-
{
|
|
1038
|
+
{
|
|
1039
|
+
ttl: marker.ttl,
|
|
1040
|
+
swr: marker.swr,
|
|
1041
|
+
store: marker.store,
|
|
1042
|
+
...(fixedDocKey !== undefined && { key: () => fixedDocKey }),
|
|
1043
|
+
},
|
|
735
1044
|
null,
|
|
736
1045
|
marker.keyPrefix,
|
|
737
1046
|
);
|
|
@@ -740,26 +1049,29 @@ export function createShellImplicitDocScope(
|
|
|
740
1049
|
}
|
|
741
1050
|
|
|
742
1051
|
/**
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
* (resolveFreshLoadersAndYield).
|
|
1052
|
+
* The doc record scope: when the current request context carries the
|
|
1053
|
+
* `_shellImplicitCache` marker (a shell capture, a document HIT tail, or a
|
|
1054
|
+
* normal partial navigation replay), substitute an implicit doc-level scope
|
|
1055
|
+
* so withCacheLookup/withCacheStore treat the WHOLE matched route as a
|
|
1056
|
+
* cache() boundary — the shell entry IS a cache() of the handler layer, with
|
|
1057
|
+
* loaders as the live carve-outs (resolveFreshLoadersAndYield).
|
|
750
1058
|
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
1059
|
+
* A document HIT tail (`docTail`) always gets the implicit scope: it replays
|
|
1060
|
+
* the shell's own record and never consults a route-derived scope, whose
|
|
1061
|
+
* opt-outs the serve gate evaluated before the commit (shellServePlan). Every
|
|
1062
|
+
* other marker leaves an existing scope — including an explicit cache(false)
|
|
1063
|
+
* — in place: the consumer's cache() semantics (their ttl/swr/store/condition)
|
|
1064
|
+
* are never overridden. On the navigation-replay serve path the marker still
|
|
1065
|
+
* composes with an explicit scope downstream (withCacheLookup's seeded
|
|
1066
|
+
* fallback after an explicit-tier miss) — see `onExplicitHit` on
|
|
1067
|
+
* `_shellImplicitCache`; during a capture, recordShellCaptureDocRecord
|
|
1068
|
+
* (cache-store.ts) writes the record for such a scope.
|
|
757
1069
|
*/
|
|
758
1070
|
export function resolveShellImplicitCacheScope(
|
|
759
1071
|
scope: CacheScope | null,
|
|
760
1072
|
): CacheScope | null {
|
|
761
|
-
if (scope) return scope;
|
|
762
1073
|
const marker = getRequestContext()?._shellImplicitCache;
|
|
1074
|
+
if (scope && !marker?.docTail) return scope;
|
|
763
1075
|
if (!marker) return null;
|
|
764
1076
|
return createShellImplicitDocScope(marker);
|
|
765
1077
|
}
|