@rangojs/router 0.17.0 → 0.19.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/event-controller.d.ts +3 -3
- package/dist/types/browser/history-state.d.ts +18 -0
- package/dist/types/browser/navigation-transaction.d.ts +20 -2
- package/dist/types/browser/partial-update.d.ts +16 -1
- package/dist/types/browser/prefetch/cache.d.ts +6 -6
- package/dist/types/browser/react/Link.d.ts +19 -9
- package/dist/types/browser/react/context.d.ts +2 -2
- package/dist/types/browser/react/location-state-shared.d.ts +95 -56
- package/dist/types/browser/react/location-state.d.ts +1 -1
- package/dist/types/browser/transition-when.d.ts +80 -0
- package/dist/types/browser/types.d.ts +104 -9
- package/dist/types/build/generate-manifest.d.ts +8 -0
- package/dist/types/cache/background-task.d.ts +24 -2
- package/dist/types/cache/cache-exec-scope.d.ts +31 -0
- package/dist/types/cache/cache-key-utils.d.ts +48 -2
- package/dist/types/cache/cache-policy.d.ts +18 -1
- package/dist/types/cache/cache-runtime.d.ts +7 -0
- package/dist/types/cache/cache-scope.d.ts +192 -26
- package/dist/types/cache/cache-tag.d.ts +116 -11
- 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 +224 -72
- 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 +19 -0
- package/dist/types/cache/document-cache.d.ts +10 -0
- package/dist/types/cache/handle-snapshot.d.ts +41 -8
- package/dist/types/cache/index.d.ts +1 -0
- package/dist/types/cache/invalidation-order.d.ts +23 -0
- package/dist/types/cache/isolate-tag-memo.d.ts +149 -0
- package/dist/types/cache/request-tag-mask.d.ts +54 -0
- package/dist/types/cache/shell-memo.d.ts +178 -0
- package/dist/types/cache/shell-snapshot.d.ts +141 -105
- package/dist/types/cache/tag-invalidation.d.ts +54 -3
- package/dist/types/cache/types.d.ts +267 -96
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +123 -18
- package/dist/types/client-urls/navigation.d.ts +28 -2
- package/dist/types/client-urls/server-projection.d.ts +5 -2
- package/dist/types/client-urls/types.d.ts +5 -5
- package/dist/types/client.d.ts +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/prerender/build-shell-capture.d.ts +11 -4
- package/dist/types/route-definition/helpers-types.d.ts +11 -11
- package/dist/types/route-name.d.ts +7 -0
- package/dist/types/router/error-handling.d.ts +1 -1
- package/dist/types/router/instrument.d.ts +8 -0
- package/dist/types/router/intercept-resolution.d.ts +2 -0
- package/dist/types/router/match-context.d.ts +5 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
- package/dist/types/router/middleware-types.d.ts +6 -1
- package/dist/types/router/router-options.d.ts +4 -1
- package/dist/types/router/segment-resolution/loader-cache.d.ts +58 -12
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
- package/dist/types/router/segment-resolution/loader-snapshot.d.ts +33 -48
- package/dist/types/router/segment-resolution/mask-nested.d.ts +73 -24
- package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
- package/dist/types/router/transition-when.d.ts +24 -10
- package/dist/types/rsc/attach-transition-when.d.ts +18 -0
- package/dist/types/rsc/full-payload.d.ts +21 -0
- package/dist/types/rsc/handler.d.ts +1 -1
- package/dist/types/rsc/response-cache-serve.d.ts +15 -12
- package/dist/types/rsc/shell-build-manifest.d.ts +12 -3
- package/dist/types/rsc/shell-capture-constants.d.ts +116 -12
- package/dist/types/rsc/shell-capture.d.ts +202 -74
- package/dist/types/rsc/shell-serve.d.ts +144 -50
- package/dist/types/rsc/types.d.ts +9 -0
- package/dist/types/segment-system.d.ts +7 -0
- package/dist/types/server/context.d.ts +155 -33
- package/dist/types/server/cookie-store.d.ts +33 -0
- package/dist/types/server/handle-store.d.ts +52 -1
- package/dist/types/server/request-context.d.ts +210 -63
- package/dist/types/server/request-headers.d.ts +44 -0
- package/dist/types/server.d.ts +1 -0
- package/dist/types/ssr/index.d.ts +9 -0
- package/dist/types/ssr/ssr-root.d.ts +9 -8
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/flight.entry.d.ts +7 -0
- package/dist/types/testing/index.d.ts +3 -2
- package/dist/types/testing/internal/action-id.d.ts +7 -0
- package/dist/types/testing/location-state-key.d.ts +24 -0
- package/dist/types/testing/render-route.d.ts +17 -1
- package/dist/types/testing/run-loader.d.ts +18 -3
- package/dist/types/testing/run-transition-when.d.ts +44 -69
- package/dist/types/testing/serve-shell-request.d.ts +133 -0
- package/dist/types/testing/shell-status.d.ts +44 -19
- package/dist/types/transition-when-ref.d.ts +79 -0
- package/dist/types/types/cache-types.d.ts +15 -3
- package/dist/types/types/handler-context.d.ts +14 -1
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/types/segments.d.ts +86 -71
- package/dist/types/urls/path-helper-types.d.ts +3 -4
- package/dist/types/urls/pattern-types.d.ts +21 -18
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
- package/dist/types/vite/router-discovery.d.ts +29 -2
- package/dist/vite/index.js +1005 -105
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +17 -7
- package/skills/cache-guide/SKILL.md +175 -76
- package/skills/caching/SKILL.md +361 -33
- package/skills/catalog.json +1 -1
- package/skills/client-urls/SKILL.md +12 -7
- package/skills/cloudflare/SKILL.md +15 -0
- package/skills/comparison/references/framework-comparison.md +52 -37
- package/skills/defer-hydration/SKILL.md +50 -30
- package/skills/deployment-caching/SKILL.md +5 -2
- package/skills/document-cache/SKILL.md +32 -2
- package/skills/handler-use/SKILL.md +2 -0
- package/skills/hooks/SKILL.md +3 -1
- package/skills/hooks/handle-and-actions.md +3 -1
- package/skills/hooks/navigation.md +23 -2
- package/skills/hooks/state.md +101 -7
- package/skills/intercept/SKILL.md +29 -12
- package/skills/loader/SKILL.md +197 -44
- package/skills/middleware/SKILL.md +34 -9
- package/skills/migrate-nextjs/SKILL.md +68 -37
- 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 +65 -0
- package/skills/parallel/SKILL.md +13 -8
- package/skills/ppr/SKILL.md +715 -371
- package/skills/prerender/SKILL.md +30 -17
- package/skills/rango/SKILL.md +72 -34
- package/skills/route/SKILL.md +14 -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 +49 -43
- package/skills/testing/cache-prerender.md +15 -14
- package/skills/testing/client-components.md +45 -3
- package/skills/testing/loader.md +19 -1
- package/skills/testing/middleware.md +1 -0
- package/skills/testing/predicates.md +49 -49
- package/skills/testing/render-handler.md +1 -0
- package/skills/testing/serve-shell-request.md +202 -0
- package/skills/testing/server-actions.md +9 -3
- package/skills/theme/SKILL.md +56 -4
- package/skills/typesafety/env-and-bindings.md +18 -2
- package/skills/use-cache/SKILL.md +91 -23
- package/skills/vercel/SKILL.md +14 -2
- package/skills/view-transitions/SKILL.md +150 -29
- package/src/__augment-tests__/augmented.check.ts +23 -0
- package/src/browser/cookie-name.ts +23 -0
- package/src/browser/event-controller.ts +4 -4
- package/src/browser/history-state.ts +75 -1
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +77 -18
- package/src/browser/navigation-client.ts +4 -3
- package/src/browser/navigation-store.ts +43 -1
- package/src/browser/navigation-transaction.ts +31 -11
- package/src/browser/partial-update.ts +146 -35
- package/src/browser/prefetch/cache.ts +6 -6
- package/src/browser/prefetch/fetch.ts +2 -2
- package/src/browser/react/Link.tsx +38 -33
- package/src/browser/react/NavigationProvider.tsx +2 -2
- package/src/browser/react/context.ts +2 -2
- package/src/browser/react/location-state-shared.ts +241 -49
- package/src/browser/react/location-state.ts +5 -2
- package/src/browser/rsc-router.tsx +9 -0
- package/src/browser/server-action-bridge.ts +84 -15
- package/src/browser/transition-when.ts +205 -0
- package/src/browser/types.ts +124 -10
- package/src/build/generate-manifest.ts +77 -1
- package/src/cache/background-task.ts +55 -2
- package/src/cache/cache-exec-scope.ts +48 -1
- package/src/cache/cache-key-utils.ts +62 -2
- package/src/cache/cache-policy.ts +31 -4
- package/src/cache/cache-runtime.ts +209 -129
- package/src/cache/cache-scope.ts +573 -69
- package/src/cache/cache-tag.ts +475 -19
- 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 +1094 -376
- 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 +55 -40
- package/src/cache/document-cache.ts +53 -7
- package/src/cache/handle-snapshot.ts +53 -9
- package/src/cache/index.ts +2 -0
- package/src/cache/invalidation-order.ts +84 -0
- package/src/cache/isolate-tag-memo.ts +308 -0
- package/src/cache/request-tag-mask.ts +147 -0
- package/src/cache/shell-memo.ts +341 -0
- package/src/cache/shell-snapshot.ts +249 -277
- package/src/cache/tag-invalidation.ts +109 -18
- package/src/cache/types.ts +274 -102
- package/src/cache/vercel/vercel-cache-store.ts +561 -74
- package/src/client-urls/client-root.tsx +8 -4
- package/src/client-urls/client-urls.ts +8 -6
- package/src/client-urls/navigation.ts +55 -4
- package/src/client-urls/server-projection.ts +31 -10
- package/src/client-urls/types.ts +12 -5
- package/src/client.tsx +2 -0
- package/src/host/cookie-handler.ts +2 -1
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +3 -0
- package/src/loader-redirect.tsx +7 -3
- package/src/prerender/build-shell-capture.ts +173 -110
- package/src/route-definition/dsl-helpers.ts +6 -3
- package/src/route-definition/helpers-types.ts +11 -11
- package/src/route-name.ts +12 -0
- package/src/router/content-negotiation.ts +4 -1
- package/src/router/error-handling.ts +5 -2
- package/src/router/handler-context.ts +42 -5
- package/src/router/instrument.ts +54 -2
- package/src/router/intercept-resolution.ts +30 -8
- package/src/router/loader-resolution.ts +89 -33
- package/src/router/logging.ts +5 -3
- package/src/router/match-api.ts +89 -42
- package/src/router/match-context.ts +5 -0
- package/src/router/match-handlers.ts +14 -29
- package/src/router/match-middleware/background-revalidation.ts +3 -5
- package/src/router/match-middleware/cache-lookup.ts +52 -19
- package/src/router/match-middleware/cache-store.ts +36 -10
- package/src/router/match-result.ts +1 -0
- package/src/router/middleware-types.ts +6 -1
- package/src/router/middleware.ts +20 -4
- package/src/router/navigation-snapshot.ts +6 -5
- package/src/router/prerender-match.ts +10 -0
- package/src/router/request-classification.ts +7 -4
- package/src/router/router-options.ts +4 -1
- package/src/router/segment-resolution/fresh.ts +18 -13
- package/src/router/segment-resolution/helpers.ts +4 -2
- package/src/router/segment-resolution/loader-cache.ts +366 -70
- package/src/router/segment-resolution/loader-mask.ts +3 -4
- package/src/router/segment-resolution/loader-snapshot.ts +215 -126
- package/src/router/segment-resolution/mask-nested.ts +161 -42
- package/src/router/segment-resolution/revalidation.ts +27 -13
- package/src/router/segment-resolution/view-transition-default.ts +6 -21
- package/src/router/telemetry.ts +6 -3
- package/src/router/transition-when.ts +34 -52
- package/src/router.ts +2 -1
- package/src/rsc/attach-transition-when.ts +47 -0
- package/src/rsc/full-payload.ts +47 -3
- package/src/rsc/handler.ts +24 -10
- package/src/rsc/loader-fetch.ts +2 -1
- package/src/rsc/manifest-init.ts +4 -0
- package/src/rsc/origin-guard.ts +8 -5
- package/src/rsc/progressive-enhancement.ts +10 -32
- package/src/rsc/response-cache-serve.ts +69 -78
- package/src/rsc/response-route-handler.ts +2 -2
- package/src/rsc/rsc-rendering.ts +712 -338
- package/src/rsc/server-action.ts +8 -20
- package/src/rsc/shell-build-manifest.ts +49 -29
- package/src/rsc/shell-capture-constants.ts +182 -12
- package/src/rsc/shell-capture.ts +1126 -612
- package/src/rsc/shell-serve.ts +331 -85
- package/src/rsc/ssr-setup.ts +3 -2
- package/src/rsc/types.ts +9 -0
- package/src/segment-system.tsx +40 -7
- package/src/server/context.ts +328 -72
- package/src/server/cookie-store.ts +184 -133
- package/src/server/handle-store.ts +245 -27
- package/src/server/request-context.ts +364 -100
- package/src/server/request-headers.ts +134 -0
- package/src/server.ts +4 -0
- package/src/ssr/index.tsx +96 -37
- package/src/ssr/ssr-root.tsx +9 -8
- package/src/testing/dispatch.ts +4 -7
- package/src/testing/e2e/index.ts +1 -0
- package/src/testing/flight.entry.ts +14 -0
- package/src/testing/index.ts +5 -0
- package/src/testing/internal/action-id.ts +25 -0
- package/src/testing/internal/context.ts +1 -1
- package/src/testing/location-state-key.ts +45 -0
- package/src/testing/render-handler.ts +1 -1
- package/src/testing/render-route.tsx +109 -16
- package/src/testing/run-client-revalidate.ts +3 -19
- package/src/testing/run-loader.ts +67 -5
- package/src/testing/run-transition-when.ts +109 -172
- package/src/testing/serve-shell-request.ts +381 -0
- package/src/testing/shell-status.ts +73 -47
- package/src/theme/ThemeProvider.tsx +7 -6
- package/src/transition-when-ref.ts +220 -0
- package/src/types/cache-types.ts +15 -3
- package/src/types/handler-context.ts +14 -1
- package/src/types/index.ts +3 -0
- package/src/types/segments.ts +94 -91
- package/src/urls/path-helper-types.ts +3 -4
- package/src/urls/pattern-types.ts +21 -18
- package/src/vite/discovery/discover-routers.ts +3 -0
- package/src/vite/discovery/shell-prerender-phase.ts +9 -2
- package/src/vite/discovery/virtual-module-codegen.ts +16 -6
- package/src/vite/plugins/transition-when-hoist.ts +1064 -0
- package/src/vite/rango.ts +2 -0
- package/src/vite/router-discovery.ts +89 -9
- package/dist/types/rsc/transition-gate.d.ts +0 -23
- package/src/rsc/transition-gate.ts +0 -86
package/src/cache/cache-scope.ts
CHANGED
|
@@ -17,8 +17,17 @@ 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";
|
|
27
|
+
import {
|
|
28
|
+
predatesInvalidation,
|
|
29
|
+
type ExecutionStart,
|
|
30
|
+
} from "./tag-invalidation.js";
|
|
22
31
|
// segment-codec is the only module on cache-scope's import graph that eagerly
|
|
23
32
|
// pulls @vitejs/plugin-rsc (a virtual: module the plain node/vitest runner cannot
|
|
24
33
|
// resolve). It is imported LAZILY at the two call sites below (deserializeSegments
|
|
@@ -30,10 +39,11 @@ import {
|
|
|
30
39
|
captureHandles,
|
|
31
40
|
captureHandleOwners,
|
|
32
41
|
restoreHandles,
|
|
42
|
+
type OwnedPushDelivery,
|
|
33
43
|
encodeHandles,
|
|
34
44
|
decodeHandles,
|
|
35
45
|
} from "./handle-snapshot.js";
|
|
36
|
-
import { cacheKeyBase } from "./cache-key-utils.js";
|
|
46
|
+
import { cacheKeyBase, composeCacheKeys } from "./cache-key-utils.js";
|
|
37
47
|
import {
|
|
38
48
|
DEFAULT_ROUTE_TTL,
|
|
39
49
|
isFiniteNonNegativeSeconds,
|
|
@@ -42,13 +52,38 @@ import {
|
|
|
42
52
|
resolveTagsOption,
|
|
43
53
|
} from "./cache-policy.js";
|
|
44
54
|
import type { RequestContext } from "../server/request-context.js";
|
|
55
|
+
import { runIdentityExempt } from "./cache-exec-scope.js";
|
|
45
56
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Narrow the request's route-record window (RequestContext._routeRecordWindow)
|
|
59
|
+
* to a record this request read or wrote: fresh until `freshUntil` (the
|
|
60
|
+
* record's `expiresAt`), then stale for `swr` seconds. A shell capture starts
|
|
61
|
+
* with no window of its own (shell-capture.ts deriveShellCaptureContext sets
|
|
62
|
+
* it undefined), so it narrows only to the records the capture itself read
|
|
63
|
+
* or wrote.
|
|
64
|
+
*/
|
|
65
|
+
function noteRouteRecordWindow(
|
|
66
|
+
requestCtx: RequestContext,
|
|
67
|
+
freshUntil: number,
|
|
68
|
+
ttl: number,
|
|
69
|
+
swr: number,
|
|
70
|
+
written: boolean,
|
|
71
|
+
): void {
|
|
72
|
+
const next = {
|
|
73
|
+
freshUntil,
|
|
74
|
+
staleUntil: freshUntil + swr * 1000,
|
|
75
|
+
ttl,
|
|
76
|
+
swr,
|
|
77
|
+
written,
|
|
78
|
+
};
|
|
79
|
+
const current = requestCtx._routeRecordWindow;
|
|
80
|
+
// The record that runs out first names the config; freshness is the least.
|
|
81
|
+
requestCtx._routeRecordWindow = current
|
|
82
|
+
? {
|
|
83
|
+
...(current.staleUntil <= next.staleUntil ? current : next),
|
|
84
|
+
freshUntil: Math.min(current.freshUntil, freshUntil),
|
|
85
|
+
}
|
|
86
|
+
: next;
|
|
52
87
|
}
|
|
53
88
|
|
|
54
89
|
function debugCacheLog(message: string): void {
|
|
@@ -110,7 +145,95 @@ function getDefaultRouteCacheKey(
|
|
|
110
145
|
// CacheScope
|
|
111
146
|
// ============================================================================
|
|
112
147
|
|
|
113
|
-
|
|
148
|
+
type CacheKeyFn = NonNullable<PartialCacheOptions["key"]>;
|
|
149
|
+
type CacheConditionFn = NonNullable<PartialCacheOptions["condition"]>;
|
|
150
|
+
type CacheTagsOption = NonNullable<PartialCacheOptions["tags"]>;
|
|
151
|
+
|
|
152
|
+
/** `inherited`, plus `own` when set. */
|
|
153
|
+
function appendOwn<T>(
|
|
154
|
+
inherited: readonly T[] | undefined,
|
|
155
|
+
own: T | undefined,
|
|
156
|
+
): readonly T[] {
|
|
157
|
+
if (own === undefined) return inherited ?? [];
|
|
158
|
+
return inherited ? [...inherited, own] : [own];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A configured `key()` cannot run without a request context. Falling back to
|
|
163
|
+
* the default key would store the key()'s partitioned output under the broad
|
|
164
|
+
* key and serve it across partitions, so key resolution fails instead and
|
|
165
|
+
* every caller degrades to an uncached render (as for a throwing `key()`).
|
|
166
|
+
*/
|
|
167
|
+
function keyWithoutContextError(): Error {
|
|
168
|
+
return new Error(
|
|
169
|
+
"[CacheScope] a cache() key() needs a request context; rendering uncached",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** keyGenerators already warned about returning an empty key. */
|
|
174
|
+
const EMPTY_KEY_WARNED = new WeakSet<object>();
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A keyGenerator result as a partition part: null when it is the default key.
|
|
178
|
+
* An empty result rejects (the request renders uncached, as for a throwing
|
|
179
|
+
* keyGenerator): positionalParts writes a default result as the empty part,
|
|
180
|
+
* so an empty result would name the partition of a store that returned the
|
|
181
|
+
* default key. Warned once per keyGenerator, naming its store.
|
|
182
|
+
*/
|
|
183
|
+
function generatedPart(
|
|
184
|
+
store: SegmentCacheStore | null,
|
|
185
|
+
generated: Promise<string>,
|
|
186
|
+
defaultKey: string,
|
|
187
|
+
): Promise<string | null> {
|
|
188
|
+
return generated.then((key) => {
|
|
189
|
+
if (key === defaultKey) return null;
|
|
190
|
+
if (key !== "") return key;
|
|
191
|
+
const name = store?.constructor?.name ?? "store";
|
|
192
|
+
const keyGenerator = store?.keyGenerator;
|
|
193
|
+
if (keyGenerator && !EMPTY_KEY_WARNED.has(keyGenerator)) {
|
|
194
|
+
EMPTY_KEY_WARNED.add(keyGenerator);
|
|
195
|
+
console.warn(
|
|
196
|
+
`[CacheScope] ${name}'s keyGenerator returned an empty key; rendering uncached. Return the default key to leave a request unpartitioned.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
throw new Error(
|
|
200
|
+
`[CacheScope] ${name}'s keyGenerator returned an empty key; rendering uncached`,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* keyGenerator parts by position: none when every one returned the default
|
|
207
|
+
* key (the key stays as it was), else all of them, an empty part for each
|
|
208
|
+
* default. Dropping only the defaults lost the positions: with a locale
|
|
209
|
+
* store that returns the default key for `en` and a region store that does
|
|
210
|
+
* for `us`, `{ en, de }` and `{ de, us }` both kept one part `…|de` and
|
|
211
|
+
* shared a record and a shell.
|
|
212
|
+
*/
|
|
213
|
+
function positionalParts(parts: readonly (string | null)[]): string[] {
|
|
214
|
+
return parts.every((part) => part === null)
|
|
215
|
+
? []
|
|
216
|
+
: parts.map((part) => part ?? "");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* One key resolution per request, resolver (`by`: a `key()` or a store's
|
|
221
|
+
* keyGenerator) and `slot`, held on the request context (`_resolvedCacheKeys`,
|
|
222
|
+
* see CacheScope.resolveKeyFrom).
|
|
223
|
+
*/
|
|
224
|
+
function memoizedKey(
|
|
225
|
+
requestCtx: RequestContext,
|
|
226
|
+
by: unknown,
|
|
227
|
+
slot: string,
|
|
228
|
+
resolve: () => Promise<string>,
|
|
229
|
+
): Promise<string> {
|
|
230
|
+
const memo = (requestCtx._resolvedCacheKeys ??= new Map());
|
|
231
|
+
let resolved = memo.get(by);
|
|
232
|
+
if (!resolved) memo.set(by, (resolved = new Map()));
|
|
233
|
+
let key = resolved.get(slot);
|
|
234
|
+
if (!key) resolved.set(slot, (key = resolve()));
|
|
235
|
+
return key;
|
|
236
|
+
}
|
|
114
237
|
|
|
115
238
|
/**
|
|
116
239
|
* Discriminated outcome of a route cache lookup — see
|
|
@@ -145,6 +268,41 @@ export class CacheScope {
|
|
|
145
268
|
readonly parent: CacheScope | null;
|
|
146
269
|
/** Explicit store from cache() options, if specified */
|
|
147
270
|
private readonly explicitStore: SegmentCacheStore | undefined;
|
|
271
|
+
/**
|
|
272
|
+
* @internal The `key()` functions that partition this scope's records,
|
|
273
|
+
* outermost first: every cache() on the parent chain that sets one, this
|
|
274
|
+
* scope's last (#970). `cache(false)` adds none and does not cut the chain,
|
|
275
|
+
* so a cache() re-enabled under it stays in the partition. Before, the
|
|
276
|
+
* innermost config alone decided the key: an inner cache() without `key()`
|
|
277
|
+
* wrote under the default key and an inner `key()` dropped the outer one,
|
|
278
|
+
* so a request in one partition HIT another partition's inner records.
|
|
279
|
+
*/
|
|
280
|
+
readonly keyFns: readonly CacheKeyFn[];
|
|
281
|
+
/** Whether this scope's own config sets `key()` (the last of keyFns). */
|
|
282
|
+
private readonly hasOwnKey: boolean;
|
|
283
|
+
/**
|
|
284
|
+
* The `condition()` predicates gating this scope, outermost first: every
|
|
285
|
+
* cache() on the parent chain that sets one, this scope's last (#974). A
|
|
286
|
+
* read or write needs all of them to allow it (conditionAllows). Before,
|
|
287
|
+
* only the innermost config's predicate ran, so an outer condition()
|
|
288
|
+
* refusing a request still let a nested cache() read and write.
|
|
289
|
+
*/
|
|
290
|
+
private readonly conditions: readonly CacheConditionFn[];
|
|
291
|
+
/**
|
|
292
|
+
* The `tags` options of this scope and every cache() on the parent chain,
|
|
293
|
+
* outermost first (#974): a nested record carries them all (resolveTags),
|
|
294
|
+
* so `updateTag(outerTag)` evicts it and the shells built from it.
|
|
295
|
+
*/
|
|
296
|
+
private readonly tagOptions: readonly CacheTagsOption[];
|
|
297
|
+
/**
|
|
298
|
+
* The enclosing enabled scopes without a `key()` of their own whose
|
|
299
|
+
* `store` option differs from this scope's, outermost first: a store
|
|
300
|
+
* keyGenerator of theirs partitions this scope's records too (#974,
|
|
301
|
+
* resolvePartitionParts). On the same store the keyGenerator already
|
|
302
|
+
* applies through this scope's own default key, or its own `key()`
|
|
303
|
+
* overrides it (#970).
|
|
304
|
+
*/
|
|
305
|
+
private readonly storeScopes: readonly CacheScope[];
|
|
148
306
|
|
|
149
307
|
constructor(
|
|
150
308
|
config: PartialCacheOptions | false,
|
|
@@ -158,11 +316,46 @@ export class CacheScope {
|
|
|
158
316
|
* a ppr route).
|
|
159
317
|
*/
|
|
160
318
|
readonly boundary?: string,
|
|
319
|
+
/**
|
|
320
|
+
* Awaited on every lookup HIT: the implicit doc scope's marker `onHit`
|
|
321
|
+
* (createShellImplicitDocScope), which arms a partial replay's bake-lane
|
|
322
|
+
* loader pins before the route's loaders resolve.
|
|
323
|
+
*/
|
|
324
|
+
private readonly onHit?: () => void | Promise<void>,
|
|
325
|
+
/**
|
|
326
|
+
* The record key of the implicit doc scope of a document HIT tail
|
|
327
|
+
* (createShellImplicitDocScope): the shell entry's own `docKey`, used as
|
|
328
|
+
* is. Not a `key()`, which would be namespaced (#975) and miss the
|
|
329
|
+
* record.
|
|
330
|
+
*/
|
|
331
|
+
private readonly fixedKey?: string,
|
|
161
332
|
) {
|
|
162
333
|
this.config = config;
|
|
163
334
|
this.parent = parent;
|
|
335
|
+
const own = config === false ? undefined : config;
|
|
164
336
|
// Extract and store explicit store reference
|
|
165
|
-
this.explicitStore =
|
|
337
|
+
this.explicitStore = own?.store;
|
|
338
|
+
// Only a function counts: a conditional `key: flag ? fn : null` from
|
|
339
|
+
// untyped code is no key(), as resolveCacheKey always treated it.
|
|
340
|
+
const ownKey = typeof own?.key === "function" ? own.key : undefined;
|
|
341
|
+
this.hasOwnKey = ownKey !== undefined;
|
|
342
|
+
this.keyFns = appendOwn(parent?.keyFns, ownKey);
|
|
343
|
+
this.conditions = appendOwn(
|
|
344
|
+
parent?.conditions,
|
|
345
|
+
own?.condition || undefined,
|
|
346
|
+
);
|
|
347
|
+
this.tagOptions = appendOwn(parent?.tagOptions, own?.tags || undefined);
|
|
348
|
+
const storeScopes: CacheScope[] = [];
|
|
349
|
+
for (let scope = parent; scope; scope = scope.parent) {
|
|
350
|
+
if (
|
|
351
|
+
scope.enabled &&
|
|
352
|
+
!scope.hasOwnKey &&
|
|
353
|
+
scope.explicitStore !== this.explicitStore
|
|
354
|
+
) {
|
|
355
|
+
storeScopes.unshift(scope);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
this.storeScopes = storeScopes;
|
|
166
359
|
}
|
|
167
360
|
|
|
168
361
|
/**
|
|
@@ -242,6 +435,15 @@ export class CacheScope {
|
|
|
242
435
|
* 2. App-level store from request context
|
|
243
436
|
*/
|
|
244
437
|
getStore(): SegmentCacheStore | null {
|
|
438
|
+
// The implicit doc scope's store is a per-request overlay
|
|
439
|
+
// (createShellImplicitDocScope: SeededShellStore on a HIT tail or a
|
|
440
|
+
// partial replay, SnapshotOnlySegmentStore at capture), never a tag
|
|
441
|
+
// invalidation target. Registered like a cache({ store }), every HIT
|
|
442
|
+
// left one in the handler's explicit-store registry, and each
|
|
443
|
+
// updateTag()/revalidateTag() then warned about it.
|
|
444
|
+
if (this.isShellImplicitDocScope && this.explicitStore) {
|
|
445
|
+
return this.explicitStore;
|
|
446
|
+
}
|
|
245
447
|
return resolveCacheStore(this.explicitStore);
|
|
246
448
|
}
|
|
247
449
|
|
|
@@ -249,19 +451,200 @@ export class CacheScope {
|
|
|
249
451
|
* Resolve the cache key using the shared 3-tier priority.
|
|
250
452
|
* @internal
|
|
251
453
|
*/
|
|
252
|
-
private
|
|
454
|
+
private resolveKey(
|
|
253
455
|
pathname: string,
|
|
254
456
|
params: Record<string, string>,
|
|
255
457
|
isIntercept?: boolean,
|
|
256
458
|
): Promise<string> {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
459
|
+
return this.resolveKeyFrom(
|
|
460
|
+
getDefaultRouteCacheKey(
|
|
461
|
+
pathname,
|
|
462
|
+
params,
|
|
463
|
+
isIntercept,
|
|
464
|
+
this.defaultKeyPrefix,
|
|
465
|
+
),
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* A shell captured from a route record this request read or wrote must not
|
|
471
|
+
* outlive it (noteRouteRecordWindow). The implicit doc scope's own record
|
|
472
|
+
* is the shell's, so it notes nothing.
|
|
473
|
+
*/
|
|
474
|
+
private noteRecordWindow(
|
|
475
|
+
requestCtx: RequestContext,
|
|
476
|
+
freshUntil: number,
|
|
477
|
+
written: boolean,
|
|
478
|
+
): void {
|
|
479
|
+
if (!this.isShellImplicitDocScope) {
|
|
480
|
+
noteRouteRecordWindow(
|
|
481
|
+
requestCtx,
|
|
482
|
+
freshUntil,
|
|
483
|
+
this.ttl,
|
|
484
|
+
this.swr ?? 0,
|
|
485
|
+
written,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* @internal This scope's record key for `defaultKey`, resolved once per
|
|
492
|
+
* request: a ppr route's shell partition (resolvePartition) and the record
|
|
493
|
+
* lookup share the `key()` runs, and a rejection is shared too.
|
|
494
|
+
*
|
|
495
|
+
* With partition parts on the chain (resolvePartitionParts: the `key()`
|
|
496
|
+
* results, and the keyGenerator results of enclosing scopes on another
|
|
497
|
+
* store), they are composed by composeCacheKeys, which namespaces every
|
|
498
|
+
* `key()` result (#975), so a nested cache() keys its records within the
|
|
499
|
+
* enclosing partition (#970, #974). A scope whose own config sets no
|
|
500
|
+
* `key()` appends its own default key (resolveDefaultKey). Reusing the
|
|
501
|
+
* outer `key()` result alone would give every route under the inner
|
|
502
|
+
* cache() one record: a `key()` replaces the whole default key, so an
|
|
503
|
+
* outer key that names no route (`tier:gold`) would make `/a` HIT `/b`'s
|
|
504
|
+
* record. A scope with its own `key()` adds no default key: a `key()` is a
|
|
505
|
+
* full override, and its store's keyGenerator does not run. With no
|
|
506
|
+
* partition part it is resolveDefaultKey alone. With a `key()` on the
|
|
507
|
+
* chain but no request context it rejects (keyWithoutContextError).
|
|
508
|
+
* `partitionedPrefix` prefixes a key built from partition parts only (the
|
|
509
|
+
* response family's `response:`).
|
|
510
|
+
*
|
|
511
|
+
* Each `key()` result lives on the request context (`_resolvedCacheKeys`),
|
|
512
|
+
* by function: it runs once per request whatever the default key
|
|
513
|
+
* (document, partial or `doc` shell prefix) and however many scopes share
|
|
514
|
+
* it (sibling routes under one keyed cache()). A shell capture, whose
|
|
515
|
+
* context is `Object.create` of the request's (shell-capture.ts), reads the
|
|
516
|
+
* foreground's results through the prototype chain and never runs `key()`
|
|
517
|
+
* itself: a `key()` calling `cookies()` would trip the capture guard there,
|
|
518
|
+
* and a capture must store the partition its request resolved.
|
|
519
|
+
*/
|
|
520
|
+
resolveKeyFrom(defaultKey: string, partitionedPrefix = ""): Promise<string> {
|
|
521
|
+
if (this.fixedKey !== undefined) return Promise.resolve(this.fixedKey);
|
|
522
|
+
const requestCtx = _getRequestContext();
|
|
523
|
+
if (!requestCtx || !this.mayPartition) {
|
|
524
|
+
return this.keyFns.length > 0
|
|
525
|
+
? Promise.reject(keyWithoutContextError())
|
|
526
|
+
: this.resolveDefaultKey(requestCtx, defaultKey);
|
|
527
|
+
}
|
|
528
|
+
return Promise.all([
|
|
529
|
+
this.resolvePartitionParts(requestCtx, defaultKey),
|
|
530
|
+
this.hasOwnKey
|
|
531
|
+
? undefined
|
|
532
|
+
: this.resolveDefaultKey(requestCtx, defaultKey),
|
|
533
|
+
]).then(([[keys, storeParts], ownDefault]) => {
|
|
534
|
+
const generated = positionalParts(storeParts);
|
|
535
|
+
if (keys.length === 0 && generated.length === 0) return ownDefault!;
|
|
536
|
+
if (ownDefault !== undefined) generated.push(ownDefault);
|
|
537
|
+
return partitionedPrefix + composeCacheKeys(keys, generated);
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* @internal The request partition of a ppr route's shell under this scope
|
|
543
|
+
* (resolveShellPartition): its partition parts (resolvePartitionParts),
|
|
544
|
+
* composed like the record key but without the default key, which the
|
|
545
|
+
* URL-based shell key already carries. A scope without its own `key()`
|
|
546
|
+
* whose store has a keyGenerator adds that result when it differs from the
|
|
547
|
+
* `doc` default key, as an unkeyed scope's partition always did: the
|
|
548
|
+
* record is split by it, so the shell must be too. Undefined
|
|
549
|
+
* (synchronously, with no work) when nothing can partition; null when
|
|
550
|
+
* nothing does.
|
|
551
|
+
*/
|
|
552
|
+
resolvePartition(
|
|
553
|
+
pathname: string,
|
|
554
|
+
params: Record<string, string> | undefined,
|
|
555
|
+
): Promise<string | null> | undefined {
|
|
556
|
+
const keyGenerator = this.hasOwnKey
|
|
557
|
+
? undefined
|
|
558
|
+
: this.getStore()?.keyGenerator;
|
|
559
|
+
if (!this.mayPartition && !keyGenerator) return undefined;
|
|
560
|
+
const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
|
|
561
|
+
const requestCtx = _getRequestContext();
|
|
562
|
+
if (!requestCtx) {
|
|
563
|
+
return this.keyFns.length > 0
|
|
564
|
+
? Promise.reject(keyWithoutContextError())
|
|
565
|
+
: Promise.resolve(null);
|
|
566
|
+
}
|
|
567
|
+
return Promise.all([
|
|
568
|
+
this.resolvePartitionParts(requestCtx, defaultKey),
|
|
569
|
+
keyGenerator
|
|
570
|
+
? generatedPart(
|
|
571
|
+
this.getStore(),
|
|
572
|
+
this.resolveDefaultKey(requestCtx, defaultKey),
|
|
573
|
+
defaultKey,
|
|
574
|
+
)
|
|
575
|
+
: null,
|
|
576
|
+
]).then(([[keys, storeParts], ownGenerated]) => {
|
|
577
|
+
const generated = positionalParts(
|
|
578
|
+
keyGenerator ? [...storeParts, ownGenerated] : storeParts,
|
|
579
|
+
);
|
|
580
|
+
return keys.length === 0 && generated.length === 0
|
|
581
|
+
? null
|
|
582
|
+
: composeCacheKeys(keys, generated);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** Whether a partition part can apply: a `key()` or a store scope above. */
|
|
587
|
+
private get mayPartition(): boolean {
|
|
588
|
+
return this.keyFns.length > 0 || this.storeScopes.length > 0;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The parts partitioning this scope's records, outermost first: the
|
|
593
|
+
* chain's `key()` results (each once per request, memoizedKey) and the
|
|
594
|
+
* keyGenerator result of each enclosing scope on another store (once per
|
|
595
|
+
* keyGenerator, #974), null where it is `defaultKey`. The callers keep those
|
|
596
|
+
* positions (positionalParts): a result equal to the default key
|
|
597
|
+
* partitions nothing only when every one does (#970).
|
|
598
|
+
*/
|
|
599
|
+
private resolvePartitionParts(
|
|
600
|
+
requestCtx: RequestContext,
|
|
601
|
+
defaultKey: string,
|
|
602
|
+
): Promise<[string[], (string | null)[]]> {
|
|
603
|
+
const keys = this.keyFns.map((keyFn) =>
|
|
604
|
+
memoizedKey(requestCtx, keyFn, "", () =>
|
|
605
|
+
resolveCacheKey(keyFn, null, defaultKey),
|
|
606
|
+
),
|
|
262
607
|
);
|
|
263
|
-
const
|
|
264
|
-
|
|
608
|
+
const generated: Promise<string | null>[] = [];
|
|
609
|
+
if (this.storeScopes.length > 0) {
|
|
610
|
+
// By keyGenerator, not by store: a capture wraps the app store
|
|
611
|
+
// (shell-capture.ts RecordingShellStore) but not an explicit one, and
|
|
612
|
+
// the wrapper hands back the inner store's keyGenerator. By store, a
|
|
613
|
+
// chain naming the app store explicitly added its keyGenerator a second
|
|
614
|
+
// time in the capture, and the capture's record key was not the
|
|
615
|
+
// request's.
|
|
616
|
+
const seen = new Set([this.getStore()?.keyGenerator]);
|
|
617
|
+
for (const scope of this.storeScopes) {
|
|
618
|
+
const store = scope.getStore();
|
|
619
|
+
const keyGenerator = store?.keyGenerator;
|
|
620
|
+
if (!keyGenerator || seen.has(keyGenerator)) continue;
|
|
621
|
+
seen.add(keyGenerator);
|
|
622
|
+
generated.push(
|
|
623
|
+
generatedPart(
|
|
624
|
+
store,
|
|
625
|
+
scope.resolveDefaultKey(requestCtx, defaultKey),
|
|
626
|
+
defaultKey,
|
|
627
|
+
),
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return Promise.all([Promise.all(keys), Promise.all(generated)]);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* This scope's store keyGenerator result for `defaultKey` (once per request
|
|
636
|
+
* and default key), else `defaultKey` (resolveCacheKey).
|
|
637
|
+
*/
|
|
638
|
+
private resolveDefaultKey(
|
|
639
|
+
requestCtx: RequestContext | undefined,
|
|
640
|
+
defaultKey: string,
|
|
641
|
+
): Promise<string> {
|
|
642
|
+
const store = this.getStore();
|
|
643
|
+
const keyGenerator = store?.keyGenerator;
|
|
644
|
+
const resolve = () => resolveCacheKey(undefined, store, defaultKey);
|
|
645
|
+
return requestCtx && keyGenerator
|
|
646
|
+
? memoizedKey(requestCtx, keyGenerator, defaultKey, resolve)
|
|
647
|
+
: resolve();
|
|
265
648
|
}
|
|
266
649
|
|
|
267
650
|
/**
|
|
@@ -292,11 +675,11 @@ export class CacheScope {
|
|
|
292
675
|
}
|
|
293
676
|
|
|
294
677
|
/**
|
|
295
|
-
* Evaluate the cache `condition`
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
|
|
299
|
-
|
|
678
|
+
* Evaluate the cache `condition` predicates of this scope and every
|
|
679
|
+
* enclosing cache() (`conditions`, #974), outermost first. Returns false
|
|
680
|
+
* (skip the cache operation) when one returns false or throws; returns
|
|
681
|
+
* true when there is none or no request context to evaluate them against.
|
|
682
|
+
*
|
|
300
683
|
* One WRITE decision per (scope, request), memoized on the request context.
|
|
301
684
|
* A capture render has TWO writers consulting the same predicate — the
|
|
302
685
|
* explicit tier's cacheRoute and the snapshot-only doc record gate
|
|
@@ -309,28 +692,37 @@ export class CacheScope {
|
|
|
309
692
|
*/
|
|
310
693
|
private readonly writeConditionMemo = new WeakMap<RequestContext, boolean>();
|
|
311
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Evaluate the cache `condition` predicate. Returns false (skip the cache
|
|
697
|
+
* operation) when the predicate returns false or throws; returns true when
|
|
698
|
+
* there is no condition or no request context to evaluate it against. A
|
|
699
|
+
* write decision is memoized (writeConditionMemo).
|
|
700
|
+
*/
|
|
312
701
|
private conditionAllows(op: "read" | "write"): boolean {
|
|
313
|
-
if (this.
|
|
314
|
-
const requestCtx =
|
|
702
|
+
if (this.conditions.length === 0) return true;
|
|
703
|
+
const requestCtx = _getRequestContext();
|
|
315
704
|
if (!requestCtx) return true;
|
|
316
705
|
if (op === "write") {
|
|
317
706
|
const memoized = this.writeConditionMemo.get(requestCtx);
|
|
318
707
|
if (memoized !== undefined) return memoized;
|
|
319
708
|
}
|
|
320
|
-
let allowed
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
709
|
+
let allowed = true;
|
|
710
|
+
for (const condition of this.conditions) {
|
|
711
|
+
try {
|
|
712
|
+
allowed = !!runIdentityExempt(() => condition(requestCtx));
|
|
713
|
+
if (!allowed) {
|
|
714
|
+
debugCacheLog(
|
|
715
|
+
`[CacheScope] condition returned false, skipping cache ${op}`,
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
} catch (error) {
|
|
719
|
+
console.error(
|
|
720
|
+
`[CacheScope] condition function threw, skipping cache ${op}:`,
|
|
721
|
+
error,
|
|
326
722
|
);
|
|
723
|
+
allowed = false;
|
|
327
724
|
}
|
|
328
|
-
|
|
329
|
-
console.error(
|
|
330
|
-
`[CacheScope] condition function threw, skipping cache ${op}:`,
|
|
331
|
-
error,
|
|
332
|
-
);
|
|
333
|
-
allowed = false;
|
|
725
|
+
if (!allowed) break;
|
|
334
726
|
}
|
|
335
727
|
if (op === "write") this.writeConditionMemo.set(requestCtx, allowed);
|
|
336
728
|
return allowed;
|
|
@@ -343,14 +735,15 @@ export class CacheScope {
|
|
|
343
735
|
* @param pathname - URL pathname for cache key generation
|
|
344
736
|
* @param params - Route params for cache key generation
|
|
345
737
|
* @param isIntercept - Whether this is an intercept navigation (uses different cache key)
|
|
346
|
-
* @param
|
|
347
|
-
*
|
|
738
|
+
* @param ownedPushes - How restoreHandles delivers the record's
|
|
739
|
+
* loader-owned values (withCacheLookup: the handler context's
|
|
740
|
+
* _claimLoaderPushes, and on a document HIT tail the owners it restores)
|
|
348
741
|
*/
|
|
349
742
|
async lookupRoute(
|
|
350
743
|
pathname: string,
|
|
351
744
|
params: Record<string, string>,
|
|
352
745
|
isIntercept?: boolean,
|
|
353
|
-
|
|
746
|
+
ownedPushes?: OwnedPushDelivery,
|
|
354
747
|
): Promise<{
|
|
355
748
|
segments: ResolvedSegment[];
|
|
356
749
|
shouldRevalidate: boolean;
|
|
@@ -359,7 +752,7 @@ export class CacheScope {
|
|
|
359
752
|
pathname,
|
|
360
753
|
params,
|
|
361
754
|
isIntercept,
|
|
362
|
-
|
|
755
|
+
ownedPushes,
|
|
363
756
|
);
|
|
364
757
|
return outcome.status === "hit" ? outcome.result : null;
|
|
365
758
|
}
|
|
@@ -386,7 +779,7 @@ export class CacheScope {
|
|
|
386
779
|
pathname: string,
|
|
387
780
|
params: Record<string, string>,
|
|
388
781
|
isIntercept?: boolean,
|
|
389
|
-
|
|
782
|
+
ownedPushes?: OwnedPushDelivery,
|
|
390
783
|
): Promise<CacheRouteLookupOutcome> {
|
|
391
784
|
if (!this.enabled) return { status: "bypass" };
|
|
392
785
|
if (!this.conditionAllows("read")) return { status: "bypass" };
|
|
@@ -431,7 +824,8 @@ export class CacheScope {
|
|
|
431
824
|
// expand them (segment-fragments.ts). Read off the ambient context at the
|
|
432
825
|
// same point the cache key was resolved (getDefaultRouteCacheKey), so the
|
|
433
826
|
// flag shares fate with the key: a disrupted ALS already missed the
|
|
434
|
-
// seeded record
|
|
827
|
+
// seeded record, which a document tail turns into the degrade
|
|
828
|
+
// (ShellRecordUnavailableError).
|
|
435
829
|
let segments: ResolvedSegment[];
|
|
436
830
|
const ambientContext = _getRequestContext();
|
|
437
831
|
try {
|
|
@@ -467,8 +861,14 @@ export class CacheScope {
|
|
|
467
861
|
// A hit serves content that was tagged at write time, so the document
|
|
468
862
|
// tag union must include this entry's tags for updateTag()/revalidateTag()
|
|
469
863
|
// to invalidate any full-page entry built on top of it. The write path
|
|
470
|
-
// records via cacheRoute (
|
|
864
|
+
// records via cacheRoute (resolveTags); the hit path records here,
|
|
865
|
+
// onto the replayed segments too, for a record written from this replay
|
|
866
|
+
// (a shell capture's doc record).
|
|
471
867
|
recordRequestTags(cached.tags);
|
|
868
|
+
recordSegmentTags(
|
|
869
|
+
segments.map((s) => s.id),
|
|
870
|
+
cached.tags,
|
|
871
|
+
);
|
|
472
872
|
|
|
473
873
|
// Replay handle data. An empty string means the route pushed no handles —
|
|
474
874
|
// skip the decode entirely (the common case). Otherwise decode the
|
|
@@ -488,7 +888,7 @@ export class CacheScope {
|
|
|
488
888
|
handlesRecord,
|
|
489
889
|
handleStore,
|
|
490
890
|
cached.handleOwners,
|
|
491
|
-
|
|
891
|
+
ownedPushes,
|
|
492
892
|
);
|
|
493
893
|
}
|
|
494
894
|
}
|
|
@@ -502,7 +902,10 @@ export class CacheScope {
|
|
|
502
902
|
);
|
|
503
903
|
}
|
|
504
904
|
|
|
505
|
-
|
|
905
|
+
if (ambientContext)
|
|
906
|
+
this.noteRecordWindow(ambientContext, cached.expiresAt, false);
|
|
907
|
+
|
|
908
|
+
await this.onHit?.();
|
|
506
909
|
return { status: "hit", result: { segments, shouldRevalidate } };
|
|
507
910
|
} catch (error) {
|
|
508
911
|
// Covers a store.get() failure AND a throwing consumer key()/keyGenerator
|
|
@@ -535,7 +938,26 @@ export class CacheScope {
|
|
|
535
938
|
recordTags(requestCtx: RequestContext | undefined): void {
|
|
536
939
|
if (!this.enabled) return;
|
|
537
940
|
if (!this.conditionAllows("write")) return;
|
|
538
|
-
recordRequestTags(
|
|
941
|
+
recordRequestTags(this.resolveTags(requestCtx), requestCtx);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* @internal The `tags` of this scope and every enclosing cache()
|
|
946
|
+
* (`tagOptions`, #974), static and function forms, for `ctx`: the tags its
|
|
947
|
+
* records carry. Undefined when none resolve (a disabled scope has none).
|
|
948
|
+
*/
|
|
949
|
+
resolveTags(ctx: RequestContext | undefined): string[] | undefined {
|
|
950
|
+
if (!this.enabled || this.tagOptions.length === 0) return undefined;
|
|
951
|
+
if (this.tagOptions.length === 1) {
|
|
952
|
+
return resolveTagsOption(this.tagOptions[0], ctx, "CacheScope");
|
|
953
|
+
}
|
|
954
|
+
const tags = new Set<string>();
|
|
955
|
+
for (const option of this.tagOptions) {
|
|
956
|
+
for (const tag of resolveTagsOption(option, ctx, "CacheScope") ?? []) {
|
|
957
|
+
tags.add(tag);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return tags.size > 0 ? [...tags] : undefined;
|
|
539
961
|
}
|
|
540
962
|
|
|
541
963
|
/**
|
|
@@ -547,12 +969,17 @@ export class CacheScope {
|
|
|
547
969
|
* @param params - Route params for cache key generation
|
|
548
970
|
* @param segments - All resolved segments to cache
|
|
549
971
|
* @param isIntercept - Whether this is an intercept navigation (uses different cache key)
|
|
972
|
+
* @param start - Where the render of `segments` started: the record is not
|
|
973
|
+
* written when one of its tags was invalidated since (#977,
|
|
974
|
+
* predatesInvalidation). A shell capture's doc record passes none: it
|
|
975
|
+
* lives only in the shell entry, which putShell gates by its own start.
|
|
550
976
|
*/
|
|
551
977
|
async cacheRoute(
|
|
552
978
|
pathname: string,
|
|
553
979
|
params: Record<string, string>,
|
|
554
980
|
segments: ResolvedSegment[],
|
|
555
981
|
isIntercept?: boolean,
|
|
982
|
+
start?: ExecutionStart,
|
|
556
983
|
): Promise<void> {
|
|
557
984
|
if (!this.enabled || segments.length === 0) return;
|
|
558
985
|
if (!this.conditionAllows("write")) return;
|
|
@@ -575,6 +1002,7 @@ export class CacheScope {
|
|
|
575
1002
|
|
|
576
1003
|
const ttl = this.ttl;
|
|
577
1004
|
const swr = this.swr;
|
|
1005
|
+
this.noteRecordWindow(requestCtx, Date.now() + ttl * 1000, true);
|
|
578
1006
|
|
|
579
1007
|
// Resolve cache key early (while request context is available)
|
|
580
1008
|
const key = await this.resolveKey(pathname, params, isIntercept);
|
|
@@ -589,7 +1017,7 @@ export class CacheScope {
|
|
|
589
1017
|
}
|
|
590
1018
|
|
|
591
1019
|
// Resolve tags early (while request context is available, before waitUntil)
|
|
592
|
-
const tags =
|
|
1020
|
+
const tags = this.resolveTags(requestCtx);
|
|
593
1021
|
recordRequestTags(tags, requestCtx);
|
|
594
1022
|
|
|
595
1023
|
// Check if this is a partial request (navigation) vs document request
|
|
@@ -657,9 +1085,21 @@ export class CacheScope {
|
|
|
657
1085
|
const onFlightError = (error: unknown): void => {
|
|
658
1086
|
flightErrors.push(error);
|
|
659
1087
|
};
|
|
1088
|
+
// Each segment serializes inside its own tag scope, and the handles
|
|
1089
|
+
// inside one for this write: Flight re-renders their server
|
|
1090
|
+
// components, so their render-called tags land on the record.
|
|
1091
|
+
const handlesOwnerId = `${key}#handles`;
|
|
660
1092
|
const [serializedSegments, encodedHandles] = await Promise.all([
|
|
661
|
-
|
|
662
|
-
|
|
1093
|
+
Promise.all(
|
|
1094
|
+
nonLoaderSegments.map((segment) =>
|
|
1095
|
+
runInSegmentTagScope(segment.id, () =>
|
|
1096
|
+
serializeSegments([segment], onFlightError),
|
|
1097
|
+
),
|
|
1098
|
+
),
|
|
1099
|
+
).then((parts) => parts.flat()),
|
|
1100
|
+
runInSegmentTagScope(handlesOwnerId, () =>
|
|
1101
|
+
encodeHandles(handles, onFlightError),
|
|
1102
|
+
),
|
|
663
1103
|
]);
|
|
664
1104
|
// Flight encodes a component that throws (an async server component in
|
|
665
1105
|
// the tree) or a rejected handle value as an error row and completes
|
|
@@ -670,10 +1110,18 @@ export class CacheScope {
|
|
|
670
1110
|
segments: serializedSegments,
|
|
671
1111
|
handles: encodedHandles,
|
|
672
1112
|
expiresAt: Date.now() + ttl * 1000,
|
|
673
|
-
tags,
|
|
1113
|
+
tags: collectRecordTags(requestCtx, tags, [
|
|
1114
|
+
...nonLoaderSegments.map((s) => s.id),
|
|
1115
|
+
handlesOwnerId,
|
|
1116
|
+
]),
|
|
674
1117
|
};
|
|
675
1118
|
if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
|
|
676
1119
|
|
|
1120
|
+
if (start && (await predatesInvalidation(store, data.tags, start))) {
|
|
1121
|
+
debugCacheLog(`[CacheScope] ${key}: invalidated since render`);
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
677
1125
|
if (INTERNAL_RANGO_DEBUG) {
|
|
678
1126
|
debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
|
|
679
1127
|
}
|
|
@@ -699,6 +1147,56 @@ export class CacheScope {
|
|
|
699
1147
|
}
|
|
700
1148
|
}
|
|
701
1149
|
|
|
1150
|
+
/**
|
|
1151
|
+
* A route cache() record's tags (#957): its config tags plus every tag its
|
|
1152
|
+
* content recorded under `ownerIds` (cache-tag.ts getSegmentTags — the covered
|
|
1153
|
+
* segments' handlers, the server components their Flight serialization
|
|
1154
|
+
* re-rendered, the loaders those handlers consumed, and the handle values).
|
|
1155
|
+
* A HIT replays all of that output, loading() subtrees included, so every one
|
|
1156
|
+
* of these tags describes it.
|
|
1157
|
+
*/
|
|
1158
|
+
function collectRecordTags(
|
|
1159
|
+
requestCtx: RequestContext,
|
|
1160
|
+
configTags: string[] | undefined,
|
|
1161
|
+
ownerIds: string[],
|
|
1162
|
+
): string[] | undefined {
|
|
1163
|
+
const tags = new Set(configTags);
|
|
1164
|
+
const before = tags.size;
|
|
1165
|
+
for (const id of ownerIds) {
|
|
1166
|
+
for (const tag of getSegmentTags(requestCtx, id)) tags.add(tag);
|
|
1167
|
+
}
|
|
1168
|
+
return tags.size > before ? [...tags] : configTags;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* @internal The request partition of a ppr route's shell: with a route
|
|
1173
|
+
* cache() scope, its CacheScope.resolvePartition (the chain's `key()`
|
|
1174
|
+
* results, namespaced and composed (#975), plus the keyGenerator results of
|
|
1175
|
+
* enclosing scopes on another store (#974) and of a scope without its own
|
|
1176
|
+
* `key()` (#970)); with none, the app store's keyGenerator result
|
|
1177
|
+
* given the document default key. Undefined (synchronously, with no work)
|
|
1178
|
+
* when neither applies: the shell key stays host + path + filtered search.
|
|
1179
|
+
* Resolves to null when the partition is the default key itself (a
|
|
1180
|
+
* keyGenerator that returns it unchanged): that partitions nothing, so the
|
|
1181
|
+
* shell stays unpartitioned and keeps its build shell. The shell's key, its
|
|
1182
|
+
* read and capture, and partial replay all use it, so a visitor is only
|
|
1183
|
+
* served its own partition. A rejected promise is a failed key resolution:
|
|
1184
|
+
* the caller serves no shell.
|
|
1185
|
+
*/
|
|
1186
|
+
export function resolveShellPartition(
|
|
1187
|
+
routeScope: CacheScope | null | undefined,
|
|
1188
|
+
appStore: SegmentCacheStore | null | undefined,
|
|
1189
|
+
pathname: string,
|
|
1190
|
+
params: Record<string, string> | undefined,
|
|
1191
|
+
): Promise<string | null> | undefined {
|
|
1192
|
+
if (routeScope?.enabled) return routeScope.resolvePartition(pathname, params);
|
|
1193
|
+
if (!appStore?.keyGenerator) return undefined;
|
|
1194
|
+
const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
|
|
1195
|
+
return resolveCacheKey(undefined, appStore, defaultKey).then((resolved) =>
|
|
1196
|
+
resolved === defaultKey ? null : resolved,
|
|
1197
|
+
);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
702
1200
|
/**
|
|
703
1201
|
* Create a cache scope from entry's cache config. `shortCode` is the cache()
|
|
704
1202
|
* entry's: a scope nested in an enabled parent keeps the parent's boundary
|
|
@@ -725,41 +1223,47 @@ type ShellImplicitCacheMarker = NonNullable<
|
|
|
725
1223
|
* {@link resolveShellImplicitCacheScope} (routes that derived no scope) and
|
|
726
1224
|
* the explicit-scope composition sites (capture doc record in the cache-store
|
|
727
1225
|
* middleware, seeded replay fallback in withCacheLookup) so both ends of the
|
|
728
|
-
* shell contract resolve the SAME canonical document key.
|
|
1226
|
+
* shell contract resolve the SAME canonical document key. A document HIT
|
|
1227
|
+
* tail's marker carries the entry's own key (`fixedDocKey`), which wins over
|
|
1228
|
+
* key resolution.
|
|
729
1229
|
*/
|
|
730
1230
|
export function createShellImplicitDocScope(
|
|
731
1231
|
marker: ShellImplicitCacheMarker,
|
|
732
1232
|
): CacheScope {
|
|
733
|
-
|
|
1233
|
+
return new CacheScope(
|
|
734
1234
|
{ ttl: marker.ttl, swr: marker.swr, store: marker.store },
|
|
735
1235
|
null,
|
|
736
1236
|
marker.keyPrefix,
|
|
1237
|
+
undefined,
|
|
1238
|
+
marker.onHit,
|
|
1239
|
+
marker.fixedDocKey,
|
|
737
1240
|
);
|
|
738
|
-
if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
|
|
739
|
-
return implicitScope;
|
|
740
1241
|
}
|
|
741
1242
|
|
|
742
1243
|
/**
|
|
743
|
-
*
|
|
744
|
-
*
|
|
745
|
-
*
|
|
746
|
-
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
* (resolveFreshLoadersAndYield).
|
|
1244
|
+
* The doc record scope: when the current request context carries the
|
|
1245
|
+
* `_shellImplicitCache` marker (a shell capture, a document HIT tail, or a
|
|
1246
|
+
* normal partial navigation replay), substitute an implicit doc-level scope
|
|
1247
|
+
* so withCacheLookup/withCacheStore treat the WHOLE matched route as a
|
|
1248
|
+
* cache() boundary — the shell entry IS a cache() of the handler layer, with
|
|
1249
|
+
* loaders as the live carve-outs (resolveFreshLoadersAndYield).
|
|
750
1250
|
*
|
|
751
|
-
*
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
1251
|
+
* A document HIT tail (`docTail`) always gets the implicit scope: it replays
|
|
1252
|
+
* the shell's own record and never consults a route-derived scope, whose
|
|
1253
|
+
* opt-outs the serve gate evaluated before the commit (shellServePlan). Every
|
|
1254
|
+
* other marker leaves an existing scope — including an explicit cache(false)
|
|
1255
|
+
* — in place: the consumer's cache() semantics (their ttl/swr/store/condition)
|
|
1256
|
+
* are never overridden. On the navigation-replay serve path the marker still
|
|
1257
|
+
* composes with an explicit scope downstream (withCacheLookup's seeded
|
|
1258
|
+
* fallback after an explicit-tier miss) — see `onExplicitHit` on
|
|
1259
|
+
* `_shellImplicitCache`; during a capture, recordShellCaptureDocRecord
|
|
1260
|
+
* (cache-store.ts) writes the record for such a scope.
|
|
757
1261
|
*/
|
|
758
1262
|
export function resolveShellImplicitCacheScope(
|
|
759
1263
|
scope: CacheScope | null,
|
|
760
1264
|
): CacheScope | null {
|
|
761
|
-
if (scope) return scope;
|
|
762
1265
|
const marker = getRequestContext()?._shellImplicitCache;
|
|
1266
|
+
if (scope && !marker?.docTail) return scope;
|
|
763
1267
|
if (!marker) return null;
|
|
764
1268
|
return createShellImplicitDocScope(marker);
|
|
765
1269
|
}
|