@rangojs/router 0.18.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.
Files changed (244) hide show
  1. package/dist/types/browser/event-controller.d.ts +3 -3
  2. package/dist/types/browser/history-state.d.ts +2 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  4. package/dist/types/browser/partial-update.d.ts +16 -1
  5. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  6. package/dist/types/browser/react/Link.d.ts +19 -9
  7. package/dist/types/browser/react/context.d.ts +2 -2
  8. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  9. package/dist/types/browser/react/location-state.d.ts +1 -1
  10. package/dist/types/browser/transition-when.d.ts +80 -0
  11. package/dist/types/browser/types.d.ts +104 -9
  12. package/dist/types/build/generate-manifest.d.ts +8 -0
  13. package/dist/types/cache/background-task.d.ts +24 -2
  14. package/dist/types/cache/cache-exec-scope.d.ts +31 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +44 -13
  16. package/dist/types/cache/cache-policy.d.ts +1 -1
  17. package/dist/types/cache/cache-runtime.d.ts +7 -0
  18. package/dist/types/cache/cache-scope.d.ts +117 -35
  19. package/dist/types/cache/cache-tag.d.ts +24 -4
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
  21. package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
  22. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
  23. package/dist/types/cache/document-cache.d.ts +10 -0
  24. package/dist/types/cache/handle-snapshot.d.ts +40 -8
  25. package/dist/types/cache/invalidation-order.d.ts +23 -0
  26. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +88 -141
  28. package/dist/types/cache/tag-invalidation.d.ts +38 -14
  29. package/dist/types/cache/types.d.ts +68 -61
  30. package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
  31. package/dist/types/client-urls/navigation.d.ts +28 -2
  32. package/dist/types/client-urls/server-projection.d.ts +5 -2
  33. package/dist/types/client-urls/types.d.ts +5 -5
  34. package/dist/types/client.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  38. package/dist/types/route-name.d.ts +7 -0
  39. package/dist/types/router/error-handling.d.ts +1 -1
  40. package/dist/types/router/intercept-resolution.d.ts +2 -0
  41. package/dist/types/router/match-context.d.ts +5 -0
  42. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  43. package/dist/types/router/router-options.d.ts +4 -1
  44. package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
  45. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  46. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
  47. package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
  48. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  49. package/dist/types/router/transition-when.d.ts +24 -10
  50. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  51. package/dist/types/rsc/full-payload.d.ts +14 -7
  52. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  53. package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
  54. package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
  55. package/dist/types/rsc/shell-capture.d.ts +78 -81
  56. package/dist/types/rsc/shell-serve.d.ts +19 -48
  57. package/dist/types/rsc/types.d.ts +2 -0
  58. package/dist/types/segment-system.d.ts +7 -0
  59. package/dist/types/server/context.d.ts +89 -61
  60. package/dist/types/server/cookie-store.d.ts +30 -18
  61. package/dist/types/server/handle-store.d.ts +52 -1
  62. package/dist/types/server/request-context.d.ts +66 -45
  63. package/dist/types/server/request-headers.d.ts +44 -0
  64. package/dist/types/server.d.ts +1 -0
  65. package/dist/types/testing/e2e/index.d.ts +1 -1
  66. package/dist/types/testing/index.d.ts +3 -2
  67. package/dist/types/testing/internal/action-id.d.ts +7 -0
  68. package/dist/types/testing/location-state-key.d.ts +24 -0
  69. package/dist/types/testing/render-route.d.ts +17 -1
  70. package/dist/types/testing/run-transition-when.d.ts +44 -69
  71. package/dist/types/testing/serve-shell-request.d.ts +7 -6
  72. package/dist/types/testing/shell-status.d.ts +34 -22
  73. package/dist/types/transition-when-ref.d.ts +79 -0
  74. package/dist/types/types/cache-types.d.ts +15 -3
  75. package/dist/types/types/index.d.ts +1 -1
  76. package/dist/types/types/segments.d.ts +86 -71
  77. package/dist/types/urls/path-helper-types.d.ts +3 -4
  78. package/dist/types/urls/pattern-types.d.ts +3 -4
  79. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  80. package/dist/vite/index.js +960 -97
  81. package/package.json +1 -1
  82. package/skills/cache-guide/SKILL.md +76 -49
  83. package/skills/caching/SKILL.md +167 -69
  84. package/skills/client-urls/SKILL.md +1 -1
  85. package/skills/comparison/references/framework-comparison.md +8 -5
  86. package/skills/document-cache/SKILL.md +24 -0
  87. package/skills/hooks/SKILL.md +3 -1
  88. package/skills/hooks/navigation.md +23 -2
  89. package/skills/hooks/state.md +98 -6
  90. package/skills/intercept/SKILL.md +29 -12
  91. package/skills/loader/SKILL.md +52 -21
  92. package/skills/middleware/SKILL.md +18 -0
  93. package/skills/migrate-nextjs/SKILL.md +3 -3
  94. package/skills/observability/SKILL.md +6 -5
  95. package/skills/parallel/SKILL.md +1 -1
  96. package/skills/ppr/SKILL.md +129 -77
  97. package/skills/prerender/SKILL.md +3 -3
  98. package/skills/rango/SKILL.md +25 -6
  99. package/skills/route/SKILL.md +2 -0
  100. package/skills/testing/SKILL.md +27 -25
  101. package/skills/testing/cache-prerender.md +9 -9
  102. package/skills/testing/client-components.md +45 -3
  103. package/skills/testing/loader.md +3 -2
  104. package/skills/testing/middleware.md +1 -0
  105. package/skills/testing/predicates.md +49 -49
  106. package/skills/testing/render-handler.md +1 -0
  107. package/skills/testing/serve-shell-request.md +14 -0
  108. package/skills/testing/server-actions.md +9 -3
  109. package/skills/theme/SKILL.md +8 -4
  110. package/skills/typesafety/env-and-bindings.md +8 -0
  111. package/skills/use-cache/SKILL.md +64 -19
  112. package/skills/view-transitions/SKILL.md +150 -29
  113. package/src/__augment-tests__/augmented.check.ts +23 -0
  114. package/src/browser/event-controller.ts +4 -4
  115. package/src/browser/history-state.ts +52 -1
  116. package/src/browser/link-interceptor.ts +4 -0
  117. package/src/browser/navigation-bridge.ts +77 -18
  118. package/src/browser/navigation-client.ts +4 -3
  119. package/src/browser/navigation-store.ts +43 -1
  120. package/src/browser/navigation-transaction.ts +31 -11
  121. package/src/browser/partial-update.ts +146 -35
  122. package/src/browser/prefetch/cache.ts +6 -6
  123. package/src/browser/prefetch/fetch.ts +2 -2
  124. package/src/browser/react/Link.tsx +38 -33
  125. package/src/browser/react/NavigationProvider.tsx +2 -2
  126. package/src/browser/react/context.ts +2 -2
  127. package/src/browser/react/location-state-shared.ts +241 -49
  128. package/src/browser/react/location-state.ts +5 -2
  129. package/src/browser/rsc-router.tsx +4 -0
  130. package/src/browser/server-action-bridge.ts +84 -15
  131. package/src/browser/transition-when.ts +205 -0
  132. package/src/browser/types.ts +124 -10
  133. package/src/build/generate-manifest.ts +77 -1
  134. package/src/cache/background-task.ts +55 -2
  135. package/src/cache/cache-exec-scope.ts +48 -1
  136. package/src/cache/cache-key-utils.ts +56 -16
  137. package/src/cache/cache-policy.ts +9 -4
  138. package/src/cache/cache-runtime.ts +197 -144
  139. package/src/cache/cache-scope.ts +304 -112
  140. package/src/cache/cache-tag.ts +105 -13
  141. package/src/cache/cf/cf-cache-store.ts +152 -76
  142. package/src/cache/cf/cf-shell-frame.ts +2 -2
  143. package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
  144. package/src/cache/document-cache.ts +53 -7
  145. package/src/cache/handle-snapshot.ts +52 -9
  146. package/src/cache/invalidation-order.ts +84 -0
  147. package/src/cache/request-tag-mask.ts +147 -0
  148. package/src/cache/shell-snapshot.ts +130 -348
  149. package/src/cache/tag-invalidation.ts +57 -51
  150. package/src/cache/types.ts +71 -67
  151. package/src/cache/vercel/vercel-cache-store.ts +61 -58
  152. package/src/client-urls/client-root.tsx +8 -4
  153. package/src/client-urls/client-urls.ts +8 -6
  154. package/src/client-urls/navigation.ts +55 -4
  155. package/src/client-urls/server-projection.ts +31 -10
  156. package/src/client-urls/types.ts +12 -5
  157. package/src/client.tsx +2 -0
  158. package/src/host/cookie-handler.ts +2 -1
  159. package/src/index.rsc.ts +3 -0
  160. package/src/index.ts +3 -0
  161. package/src/loader-redirect.tsx +7 -3
  162. package/src/prerender/build-shell-capture.ts +155 -146
  163. package/src/route-definition/dsl-helpers.ts +2 -0
  164. package/src/route-definition/helpers-types.ts +11 -11
  165. package/src/route-name.ts +12 -0
  166. package/src/router/content-negotiation.ts +4 -1
  167. package/src/router/error-handling.ts +5 -2
  168. package/src/router/handler-context.ts +10 -7
  169. package/src/router/intercept-resolution.ts +11 -1
  170. package/src/router/loader-resolution.ts +28 -3
  171. package/src/router/logging.ts +5 -3
  172. package/src/router/match-api.ts +62 -34
  173. package/src/router/match-context.ts +5 -0
  174. package/src/router/match-handlers.ts +9 -29
  175. package/src/router/match-middleware/background-revalidation.ts +3 -5
  176. package/src/router/match-middleware/cache-lookup.ts +35 -19
  177. package/src/router/match-middleware/cache-store.ts +15 -9
  178. package/src/router/match-result.ts +1 -0
  179. package/src/router/middleware.ts +2 -5
  180. package/src/router/navigation-snapshot.ts +6 -5
  181. package/src/router/prerender-match.ts +4 -0
  182. package/src/router/request-classification.ts +7 -4
  183. package/src/router/router-options.ts +4 -1
  184. package/src/router/segment-resolution/fresh.ts +0 -4
  185. package/src/router/segment-resolution/loader-cache.ts +150 -25
  186. package/src/router/segment-resolution/loader-mask.ts +3 -4
  187. package/src/router/segment-resolution/loader-snapshot.ts +15 -78
  188. package/src/router/segment-resolution/mask-nested.ts +47 -22
  189. package/src/router/segment-resolution/revalidation.ts +0 -3
  190. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  191. package/src/router/telemetry.ts +6 -3
  192. package/src/router/transition-when.ts +34 -52
  193. package/src/router.ts +2 -1
  194. package/src/rsc/attach-transition-when.ts +47 -0
  195. package/src/rsc/full-payload.ts +30 -10
  196. package/src/rsc/handler.ts +12 -10
  197. package/src/rsc/loader-fetch.ts +2 -1
  198. package/src/rsc/manifest-init.ts +4 -0
  199. package/src/rsc/origin-guard.ts +8 -5
  200. package/src/rsc/progressive-enhancement.ts +7 -30
  201. package/src/rsc/response-cache-serve.ts +68 -80
  202. package/src/rsc/response-route-handler.ts +2 -2
  203. package/src/rsc/rsc-rendering.ts +206 -252
  204. package/src/rsc/server-action.ts +8 -20
  205. package/src/rsc/shell-build-manifest.ts +22 -20
  206. package/src/rsc/shell-capture-constants.ts +136 -20
  207. package/src/rsc/shell-capture.ts +352 -589
  208. package/src/rsc/shell-serve.ts +73 -109
  209. package/src/rsc/ssr-setup.ts +3 -2
  210. package/src/rsc/types.ts +2 -0
  211. package/src/segment-system.tsx +40 -7
  212. package/src/server/context.ts +189 -124
  213. package/src/server/cookie-store.ts +153 -127
  214. package/src/server/handle-store.ts +245 -27
  215. package/src/server/request-context.ts +123 -84
  216. package/src/server/request-headers.ts +134 -0
  217. package/src/server.ts +4 -0
  218. package/src/testing/dispatch.ts +4 -7
  219. package/src/testing/e2e/index.ts +1 -0
  220. package/src/testing/index.ts +5 -0
  221. package/src/testing/internal/action-id.ts +25 -0
  222. package/src/testing/internal/context.ts +1 -1
  223. package/src/testing/location-state-key.ts +45 -0
  224. package/src/testing/render-handler.ts +1 -1
  225. package/src/testing/render-route.tsx +109 -16
  226. package/src/testing/run-client-revalidate.ts +3 -19
  227. package/src/testing/run-loader.ts +5 -1
  228. package/src/testing/run-transition-when.ts +109 -172
  229. package/src/testing/serve-shell-request.ts +10 -7
  230. package/src/testing/shell-status.ts +60 -56
  231. package/src/transition-when-ref.ts +220 -0
  232. package/src/types/cache-types.ts +15 -3
  233. package/src/types/index.ts +3 -0
  234. package/src/types/segments.ts +94 -91
  235. package/src/urls/path-helper-types.ts +3 -4
  236. package/src/urls/pattern-types.ts +3 -4
  237. package/src/vite/discovery/discover-routers.ts +3 -0
  238. package/src/vite/discovery/shell-prerender-phase.ts +0 -2
  239. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  240. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  241. package/src/vite/rango.ts +2 -0
  242. package/src/vite/router-discovery.ts +75 -5
  243. package/dist/types/rsc/transition-gate.d.ts +0 -23
  244. package/src/rsc/transition-gate.ts +0 -86
@@ -24,6 +24,10 @@ import {
24
24
  runInSegmentTagScope,
25
25
  } from "./cache-tag.js";
26
26
  import { reportCacheError } from "./cache-error.js";
27
+ import {
28
+ predatesInvalidation,
29
+ type ExecutionStart,
30
+ } from "./tag-invalidation.js";
27
31
  // segment-codec is the only module on cache-scope's import graph that eagerly
28
32
  // pulls @vitejs/plugin-rsc (a virtual: module the plain node/vitest runner cannot
29
33
  // resolve). It is imported LAZILY at the two call sites below (deserializeSegments
@@ -35,6 +39,7 @@ import {
35
39
  captureHandles,
36
40
  captureHandleOwners,
37
41
  restoreHandles,
42
+ type OwnedPushDelivery,
38
43
  encodeHandles,
39
44
  decodeHandles,
40
45
  } from "./handle-snapshot.js";
@@ -47,14 +52,7 @@ import {
47
52
  resolveTagsOption,
48
53
  } from "./cache-policy.js";
49
54
  import type { RequestContext } from "../server/request-context.js";
50
-
51
- export function resolveCacheTags(
52
- config: PartialCacheOptions | false,
53
- ctx: RequestContext | undefined,
54
- ): string[] | undefined {
55
- if (config === false) return undefined;
56
- return resolveTagsOption(config.tags, ctx, "CacheScope");
57
- }
55
+ import { runIdentityExempt } from "./cache-exec-scope.js";
58
56
 
59
57
  /**
60
58
  * Narrow the request's route-record window (RequestContext._routeRecordWindow)
@@ -147,12 +145,18 @@ function getDefaultRouteCacheKey(
147
145
  // CacheScope
148
146
  // ============================================================================
149
147
 
150
- const CACHE_HIT_OBSERVERS = new WeakMap<
151
- CacheScope,
152
- () => void | Promise<void>
153
- >();
154
-
155
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
+ }
156
160
 
157
161
  /**
158
162
  * A configured `key()` cannot run without a request context. Falling back to
@@ -166,6 +170,52 @@ function keyWithoutContextError(): Error {
166
170
  );
167
171
  }
168
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
+
169
219
  /**
170
220
  * One key resolution per request, resolver (`by`: a `key()` or a store's
171
221
  * keyGenerator) and `slot`, held on the request context (`_resolvedCacheKeys`,
@@ -230,6 +280,29 @@ export class CacheScope {
230
280
  readonly keyFns: readonly CacheKeyFn[];
231
281
  /** Whether this scope's own config sets `key()` (the last of keyFns). */
232
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[];
233
306
 
234
307
  constructor(
235
308
  config: PartialCacheOptions | false,
@@ -243,20 +316,46 @@ export class CacheScope {
243
316
  * a ppr route).
244
317
  */
245
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,
246
332
  ) {
247
333
  this.config = config;
248
334
  this.parent = parent;
335
+ const own = config === false ? undefined : config;
249
336
  // Extract and store explicit store reference
250
- this.explicitStore = config !== false ? config.store : undefined;
337
+ this.explicitStore = own?.store;
251
338
  // Only a function counts: a conditional `key: flag ? fn : null` from
252
339
  // 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 ?? [];
340
+ const ownKey = typeof own?.key === "function" ? own.key : undefined;
258
341
  this.hasOwnKey = ownKey !== undefined;
259
- this.keyFns = ownKey ? [...inherited, ownKey] : inherited;
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;
260
359
  }
261
360
 
262
361
  /**
@@ -336,6 +435,15 @@ export class CacheScope {
336
435
  * 2. App-level store from request context
337
436
  */
338
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
+ }
339
447
  return resolveCacheStore(this.explicitStore);
340
448
  }
341
449
 
@@ -384,17 +492,21 @@ export class CacheScope {
384
492
  * request: a ppr route's shell partition (resolvePartition) and the record
385
493
  * lookup share the `key()` runs, and a rejection is shared too.
386
494
  *
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).
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:`).
398
510
  *
399
511
  * Each `key()` result lives on the request context (`_resolvedCacheKeys`),
400
512
  * by function: it runs once per request whatever the default key
@@ -405,30 +517,37 @@ export class CacheScope {
405
517
  * itself: a `key()` calling `cookies()` would trip the capture guard there,
406
518
  * and a capture must store the partition its request resolved.
407
519
  */
408
- resolveKeyFrom(defaultKey: string): Promise<string> {
520
+ resolveKeyFrom(defaultKey: string, partitionedPrefix = ""): Promise<string> {
521
+ if (this.fixedKey !== undefined) return Promise.resolve(this.fixedKey);
409
522
  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));
523
+ if (!requestCtx || !this.mayPartition) {
524
+ return this.keyFns.length > 0
525
+ ? Promise.reject(keyWithoutContextError())
526
+ : this.resolveDefaultKey(requestCtx, defaultKey);
417
527
  }
418
- return parts.length === 1
419
- ? parts[0]
420
- : Promise.all(parts).then(composeCacheKeys);
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
+ });
421
539
  }
422
540
 
423
541
  /**
424
542
  * @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.
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.
432
551
  */
433
552
  resolvePartition(
434
553
  pathname: string,
@@ -437,7 +556,7 @@ export class CacheScope {
437
556
  const keyGenerator = this.hasOwnKey
438
557
  ? undefined
439
558
  : this.getStore()?.keyGenerator;
440
- if (this.keyFns.length === 0 && !keyGenerator) return undefined;
559
+ if (!this.mayPartition && !keyGenerator) return undefined;
441
560
  const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
442
561
  const requestCtx = _getRequestContext();
443
562
  if (!requestCtx) {
@@ -445,32 +564,71 @@ export class CacheScope {
445
564
  ? Promise.reject(keyWithoutContextError())
446
565
  : Promise.resolve(null);
447
566
  }
448
- const generated = keyGenerator
449
- ? this.resolveDefaultKey(requestCtx, defaultKey)
450
- : undefined;
451
567
  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;
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);
461
583
  });
462
584
  }
463
585
 
464
- /** The chain's `key()` results, each once per request (memoizedKey). */
465
- private resolveKeyResults(
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(
466
600
  requestCtx: RequestContext,
467
601
  defaultKey: string,
468
- ): Promise<string>[] {
469
- return this.keyFns.map((keyFn) =>
602
+ ): Promise<[string[], (string | null)[]]> {
603
+ const keys = this.keyFns.map((keyFn) =>
470
604
  memoizedKey(requestCtx, keyFn, "", () =>
471
- resolveCacheKey(keyFn, null, defaultKey, "CacheScope"),
605
+ resolveCacheKey(keyFn, null, defaultKey),
472
606
  ),
473
607
  );
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)]);
474
632
  }
475
633
 
476
634
  /**
@@ -483,8 +641,7 @@ export class CacheScope {
483
641
  ): Promise<string> {
484
642
  const store = this.getStore();
485
643
  const keyGenerator = store?.keyGenerator;
486
- const resolve = () =>
487
- resolveCacheKey(undefined, store, defaultKey, "CacheScope");
644
+ const resolve = () => resolveCacheKey(undefined, store, defaultKey);
488
645
  return requestCtx && keyGenerator
489
646
  ? memoizedKey(requestCtx, keyGenerator, defaultKey, resolve)
490
647
  : resolve();
@@ -518,11 +675,11 @@ export class CacheScope {
518
675
  }
519
676
 
520
677
  /**
521
- * Evaluate the cache `condition` predicate. Returns false (skip the cache
522
- * operation) when the predicate returns false or throws; returns true when
523
- * there is no condition or no request context to evaluate it against.
524
- */
525
- /**
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
+ *
526
683
  * One WRITE decision per (scope, request), memoized on the request context.
527
684
  * A capture render has TWO writers consulting the same predicate — the
528
685
  * explicit tier's cacheRoute and the snapshot-only doc record gate
@@ -535,28 +692,37 @@ export class CacheScope {
535
692
  */
536
693
  private readonly writeConditionMemo = new WeakMap<RequestContext, boolean>();
537
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
+ */
538
701
  private conditionAllows(op: "read" | "write"): boolean {
539
- if (this.config === false || !this.config.condition) return true;
540
- const requestCtx = getRequestContext();
702
+ if (this.conditions.length === 0) return true;
703
+ const requestCtx = _getRequestContext();
541
704
  if (!requestCtx) return true;
542
705
  if (op === "write") {
543
706
  const memoized = this.writeConditionMemo.get(requestCtx);
544
707
  if (memoized !== undefined) return memoized;
545
708
  }
546
- let allowed: boolean;
547
- try {
548
- allowed = !!this.config.condition(requestCtx);
549
- if (!allowed) {
550
- debugCacheLog(
551
- `[CacheScope] condition returned false, skipping cache ${op}`,
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,
552
722
  );
723
+ allowed = false;
553
724
  }
554
- } catch (error) {
555
- console.error(
556
- `[CacheScope] condition function threw, skipping cache ${op}:`,
557
- error,
558
- );
559
- allowed = false;
725
+ if (!allowed) break;
560
726
  }
561
727
  if (op === "write") this.writeConditionMemo.set(requestCtx, allowed);
562
728
  return allowed;
@@ -569,14 +735,15 @@ export class CacheScope {
569
735
  * @param pathname - URL pathname for cache key generation
570
736
  * @param params - Route params for cache key generation
571
737
  * @param isIntercept - Whether this is an intercept navigation (uses different cache key)
572
- * @param claimLoaderPushes - The handler context's _claimLoaderPushes, for
573
- * restoreHandles' loader-owned values
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)
574
741
  */
575
742
  async lookupRoute(
576
743
  pathname: string,
577
744
  params: Record<string, string>,
578
745
  isIntercept?: boolean,
579
- claimLoaderPushes?: (loaderId: string) => boolean,
746
+ ownedPushes?: OwnedPushDelivery,
580
747
  ): Promise<{
581
748
  segments: ResolvedSegment[];
582
749
  shouldRevalidate: boolean;
@@ -585,7 +752,7 @@ export class CacheScope {
585
752
  pathname,
586
753
  params,
587
754
  isIntercept,
588
- claimLoaderPushes,
755
+ ownedPushes,
589
756
  );
590
757
  return outcome.status === "hit" ? outcome.result : null;
591
758
  }
@@ -612,7 +779,7 @@ export class CacheScope {
612
779
  pathname: string,
613
780
  params: Record<string, string>,
614
781
  isIntercept?: boolean,
615
- claimLoaderPushes?: (loaderId: string) => boolean,
782
+ ownedPushes?: OwnedPushDelivery,
616
783
  ): Promise<CacheRouteLookupOutcome> {
617
784
  if (!this.enabled) return { status: "bypass" };
618
785
  if (!this.conditionAllows("read")) return { status: "bypass" };
@@ -694,7 +861,7 @@ export class CacheScope {
694
861
  // A hit serves content that was tagged at write time, so the document
695
862
  // tag union must include this entry's tags for updateTag()/revalidateTag()
696
863
  // to invalidate any full-page entry built on top of it. The write path
697
- // records via cacheRoute (resolveCacheTags); the hit path records here,
864
+ // records via cacheRoute (resolveTags); the hit path records here,
698
865
  // onto the replayed segments too, for a record written from this replay
699
866
  // (a shell capture's doc record).
700
867
  recordRequestTags(cached.tags);
@@ -721,7 +888,7 @@ export class CacheScope {
721
888
  handlesRecord,
722
889
  handleStore,
723
890
  cached.handleOwners,
724
- claimLoaderPushes,
891
+ ownedPushes,
725
892
  );
726
893
  }
727
894
  }
@@ -738,7 +905,7 @@ export class CacheScope {
738
905
  if (ambientContext)
739
906
  this.noteRecordWindow(ambientContext, cached.expiresAt, false);
740
907
 
741
- await CACHE_HIT_OBSERVERS.get(this)?.();
908
+ await this.onHit?.();
742
909
  return { status: "hit", result: { segments, shouldRevalidate } };
743
910
  } catch (error) {
744
911
  // Covers a store.get() failure AND a throwing consumer key()/keyGenerator
@@ -771,7 +938,26 @@ export class CacheScope {
771
938
  recordTags(requestCtx: RequestContext | undefined): void {
772
939
  if (!this.enabled) return;
773
940
  if (!this.conditionAllows("write")) return;
774
- recordRequestTags(resolveCacheTags(this.config, requestCtx), requestCtx);
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;
775
961
  }
776
962
 
777
963
  /**
@@ -783,12 +969,17 @@ export class CacheScope {
783
969
  * @param params - Route params for cache key generation
784
970
  * @param segments - All resolved segments to cache
785
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.
786
976
  */
787
977
  async cacheRoute(
788
978
  pathname: string,
789
979
  params: Record<string, string>,
790
980
  segments: ResolvedSegment[],
791
981
  isIntercept?: boolean,
982
+ start?: ExecutionStart,
792
983
  ): Promise<void> {
793
984
  if (!this.enabled || segments.length === 0) return;
794
985
  if (!this.conditionAllows("write")) return;
@@ -826,7 +1017,7 @@ export class CacheScope {
826
1017
  }
827
1018
 
828
1019
  // Resolve tags early (while request context is available, before waitUntil)
829
- const tags = resolveCacheTags(this.config, requestCtx);
1020
+ const tags = this.resolveTags(requestCtx);
830
1021
  recordRequestTags(tags, requestCtx);
831
1022
 
832
1023
  // Check if this is a partial request (navigation) vs document request
@@ -926,6 +1117,11 @@ export class CacheScope {
926
1117
  };
927
1118
  if (handleOwners && encodedHandles) data.handleOwners = handleOwners;
928
1119
 
1120
+ if (start && (await predatesInvalidation(store, data.tags, start))) {
1121
+ debugCacheLog(`[CacheScope] ${key}: invalidated since render`);
1122
+ return;
1123
+ }
1124
+
929
1125
  if (INTERNAL_RANGO_DEBUG) {
930
1126
  debugCacheLog(`[CacheScope] waitUntil: calling store.set for ${key}`);
931
1127
  }
@@ -975,8 +1171,9 @@ function collectRecordTags(
975
1171
  /**
976
1172
  * @internal The request partition of a ppr route's shell: with a route
977
1173
  * 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
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
980
1177
  * given the document default key. Undefined (synchronously, with no work)
981
1178
  * when neither applies: the shell key stays host + path + filtered search.
982
1179
  * Resolves to null when the partition is the default key itself (a
@@ -995,8 +1192,8 @@ export function resolveShellPartition(
995
1192
  if (routeScope?.enabled) return routeScope.resolvePartition(pathname, params);
996
1193
  if (!appStore?.keyGenerator) return undefined;
997
1194
  const defaultKey = getDefaultRouteCacheKey(pathname, params, false, "doc");
998
- return resolveCacheKey(undefined, appStore, defaultKey, "CacheScope").then(
999
- (resolved) => (resolved === defaultKey ? null : resolved),
1195
+ return resolveCacheKey(undefined, appStore, defaultKey).then((resolved) =>
1196
+ resolved === defaultKey ? null : resolved,
1000
1197
  );
1001
1198
  }
1002
1199
 
@@ -1033,19 +1230,14 @@ type ShellImplicitCacheMarker = NonNullable<
1033
1230
  export function createShellImplicitDocScope(
1034
1231
  marker: ShellImplicitCacheMarker,
1035
1232
  ): CacheScope {
1036
- const fixedDocKey = marker.fixedDocKey;
1037
- const implicitScope = new CacheScope(
1038
- {
1039
- ttl: marker.ttl,
1040
- swr: marker.swr,
1041
- store: marker.store,
1042
- ...(fixedDocKey !== undefined && { key: () => fixedDocKey }),
1043
- },
1233
+ return new CacheScope(
1234
+ { ttl: marker.ttl, swr: marker.swr, store: marker.store },
1044
1235
  null,
1045
1236
  marker.keyPrefix,
1237
+ undefined,
1238
+ marker.onHit,
1239
+ marker.fixedDocKey,
1046
1240
  );
1047
- if (marker.onHit) CACHE_HIT_OBSERVERS.set(implicitScope, marker.onHit);
1048
- return implicitScope;
1049
1241
  }
1050
1242
 
1051
1243
  /**