@rangojs/router 0.17.0 → 0.18.0

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