@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124

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 (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
@@ -4,6 +4,8 @@ import type {
4
4
  RscPayload,
5
5
  } from "./types.js";
6
6
  import { createPartialUpdater } from "./partial-update.js";
7
+ import { enterActionFence, exitActionFence } from "./action-fence.js";
8
+ import { KEEP_CACHE_HEADER } from "./cookie-name.js";
7
9
  import { createNavigationTransaction } from "./navigation-transaction.js";
8
10
  import {
9
11
  reconcileSegments,
@@ -156,12 +158,40 @@ export function createServerActionBridge(
156
158
 
157
159
  // Start action in event controller - handles lifecycle tracking
158
160
  const handle = eventController.startAction(id, args);
161
+ // Whether the action's response carried the keepClientCache() directive.
162
+ // Set when the response arrives; gates the deferred invalidation below.
163
+ let keepCache = false;
164
+ // Single deferred invalidation + fence release, run exactly ONCE however the
165
+ // action terminates (normal, redirect, error, abort, intercept, concurrent).
166
+ // This replaces main's eager clear at action start: every directive-free
167
+ // action invalidates once; keepClientCache() suppresses only the automatic
168
+ // invalidation, so a concurrent directive-free action still invalidates via
169
+ // its own latch. Latched so the finally AND the early SPA-redirect returns
170
+ // (whose Flight stream never settles) can both call it safely.
171
+ let actionFinalized = false;
172
+ // skipInvalidation: the version-mismatch reload terminal released nothing
173
+ // server-side, so it releases the fence without invalidating.
174
+ const finalizeAction = (skipInvalidation = false): void => {
175
+ if (actionFinalized) return;
176
+ actionFinalized = true;
177
+ // finally so a throw in invalidation cannot leak the fence (latch is set).
178
+ try {
179
+ if (!keepCache && !skipInvalidation) {
180
+ store.markCacheAsStaleAndBroadcast();
181
+ }
182
+ } finally {
183
+ exitActionFence();
184
+ }
185
+ };
159
186
  try {
160
187
  const segmentState = store.getSegmentState();
161
188
 
162
- // Mark cache as stale immediately when action starts
163
- // This ensures SWR pattern kicks in if user navigates away during action
164
- store.markCacheAsStaleAndBroadcast();
189
+ // Raise the action fence (replaces the old eager clear). Nothing is wiped,
190
+ // rotated, or broadcast yet: navigations during the flight fetch fresh
191
+ // (no-store) and popstate is treated as SWR, but the decision to
192
+ // invalidate is deferred to the response so a no-op action (keepClientCache)
193
+ // can leave the caches and the jar untouched.
194
+ enterActionFence();
165
195
 
166
196
  // Create temporary references for serialization
167
197
  const temporaryReferences = deps.createTemporaryReferenceSet();
@@ -237,11 +267,22 @@ export function createServerActionBridge(
237
267
  // abortAllActions() doesn't disrupt the in-progress Flight stream.
238
268
  handle.signal.removeEventListener("abort", onHandleAbort);
239
269
 
270
+ // Did the action call keepClientCache()? If so the deferred invalidation
271
+ // below is suppressed for THIS action (a concurrent directive-free
272
+ // action still invalidates via its own response).
273
+ keepCache = response.headers.get(KEEP_CACHE_HEADER) === "1";
274
+
240
275
  // Check for version mismatch - server wants us to reload
241
276
  const reloadResult = handleReloadHeader(response, {
242
277
  onBlocked: resolveStreamComplete,
243
- onReload: (url) =>
244
- log("version mismatch on action, reloading", { reloadUrl: url }),
278
+ onReload: (url) => {
279
+ log("version mismatch on action, reloading", { reloadUrl: url });
280
+ // Never-settling terminal (navigates away), so the finally never
281
+ // runs: release the fence here. skipInvalidation — the mismatch
282
+ // short-circuits the action server-side, so nothing mutated and a
283
+ // broadcast would only risk hard-reloading a sibling mid-task.
284
+ finalizeAction(true);
285
+ },
245
286
  });
246
287
  if (reloadResult) return reloadResult;
247
288
 
@@ -253,6 +294,10 @@ export function createServerActionBridge(
253
294
  if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
254
295
  log("action simple redirect", { url: redirect.url });
255
296
  handle.complete(undefined);
297
+ // This path returns a never-settling promise, so the finally never
298
+ // runs: invalidate + release the fence here (the mutation committed
299
+ // and we're navigating away). Latched, so the finally is a no-op.
300
+ finalizeAction();
256
301
  await dispatchRedirect(redirect.url);
257
302
  return new Promise<Response>(() => {});
258
303
  }
@@ -277,6 +322,9 @@ export function createServerActionBridge(
277
322
  log("action router id mismatch, reloading to re-sync");
278
323
  handle.complete(undefined);
279
324
  resolveStreamComplete();
325
+ // Never-settling return: release the fence before the reload (the
326
+ // reload resets module state anyway, but stay balanced). Latched.
327
+ finalizeAction();
280
328
  window.location.reload();
281
329
  return new Promise<Response>(() => {});
282
330
  }
@@ -542,8 +590,9 @@ export function createServerActionBridge(
542
590
  handle.clearConsolidation();
543
591
 
544
592
  if (scenario.historyKeyChanged) {
545
- if (!scenario.onInterceptRoute) {
546
- store.markCacheAsStaleAndBroadcast();
593
+ // Invalidation is deferred to finalizeAction(); here we only trigger
594
+ // the revalidation refetch of the new route (suppressed on keep).
595
+ if (!scenario.onInterceptRoute && !keepCache) {
547
596
  refetchRoute().catch((error) => {
548
597
  if (isBackgroundSuppressible(error)) return;
549
598
  console.error(
@@ -555,11 +604,14 @@ export function createServerActionBridge(
555
604
  break;
556
605
  }
557
606
 
558
- // Same history key but different pathname - safe to refetch current route
559
- store.markCacheAsStaleAndBroadcast();
560
- await refetchRoute({
561
- interceptSourceUrl: store.getInterceptSourceUrl(),
562
- });
607
+ // Same history key but different pathname - safe to refetch current
608
+ // route. Invalidation is deferred to finalizeAction(); here we only
609
+ // trigger the revalidation refetch (suppressed on keep).
610
+ if (!keepCache) {
611
+ await refetchRoute({
612
+ interceptSourceUrl: store.getInterceptSourceUrl(),
613
+ });
614
+ }
563
615
  break;
564
616
  }
565
617
 
@@ -567,8 +619,11 @@ export function createServerActionBridge(
567
619
  console.warn(
568
620
  `[Browser] Missing segments after action (HMR detected), refetching...`,
569
621
  );
622
+ // Repair (not revalidation), so ungated on keepCache: a keep action
623
+ // resolving last must discharge a directive-free sibling's repair.
624
+ // See the keep row in docs/design/rango-state-cookie.md (the all-keep
625
+ // edge, and the benign re-mark-stale-after-refetch end-state delta).
570
626
  await refetchRoute({ interceptSourceUrl });
571
- store.broadcastCacheInvalidation();
572
627
  break;
573
628
  }
574
629
 
@@ -585,11 +640,11 @@ export function createServerActionBridge(
585
640
  // Clear consolidation tracking before fetch
586
641
  handle.clearConsolidation();
587
642
 
643
+ // Ungated on keepCache, same as hmr-missing above (see the keep row).
588
644
  await refetchRoute({
589
645
  segments: segmentsToSend,
590
646
  interceptSourceUrl,
591
647
  });
592
- store.broadcastCacheInvalidation();
593
648
  break;
594
649
  }
595
650
 
@@ -653,7 +708,9 @@ export function createServerActionBridge(
653
708
  fullSegments,
654
709
  currentHandleData,
655
710
  );
656
- store.markCacheAsStaleAndBroadcast();
711
+ // Invalidation deferred to finalizeAction() (runs after this caches
712
+ // the fresh segments), suppressed when the action called
713
+ // keepClientCache().
657
714
  break;
658
715
  }
659
716
  }
@@ -661,6 +718,11 @@ export function createServerActionBridge(
661
718
  handle.complete(returnData);
662
719
  return returnData;
663
720
  } finally {
721
+ // The single deferred invalidation + fence release for this action. Runs
722
+ // for every terminal that settles (normal, navigated-away, error, abort,
723
+ // intercept, concurrent); the SPA-redirect paths above already ran it.
724
+ // Latched, so it fires exactly once.
725
+ finalizeAction();
664
726
  handle[Symbol.dispose]();
665
727
  }
666
728
  }
@@ -71,6 +71,12 @@ export interface RscMetadata {
71
71
  * Sent on initial render so the browser can configure its cache duration.
72
72
  */
73
73
  prefetchCacheTTL?: number;
74
+ /**
75
+ * Server-resolved rango state cookie name (`{prefix}_{routerId}`). The client
76
+ * reads it verbatim and binds the rango state cookie to it; composition
77
+ * happens only server-side.
78
+ */
79
+ stateCookieName?: string;
74
80
  /**
75
81
  * Theme configuration from router.
76
82
  * Included when theme is enabled in router config.
@@ -433,9 +439,9 @@ export interface NavigationStore {
433
439
  hasHistoryCache(historyKey: string): boolean;
434
440
  updateCacheHandleData(historyKey: string, handleData: HandleData): void;
435
441
  markCacheAsStale(): void;
442
+ markHistoryCacheStale(): void;
436
443
  markCacheAsStaleAndBroadcast(): void;
437
444
  clearHistoryCache(): void;
438
- broadcastCacheInvalidation(): void;
439
445
 
440
446
  // Cross-tab refresh callback (set by navigation bridge)
441
447
  setCrossTabRefreshCallback(callback: () => void): void;
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Cache error reporting.
3
+ *
4
+ * Caches are best-effort: a read failure degrades to a miss (render fresh) and a
5
+ * write failure degrades to a no-op - they MUST NOT throw up and fail the
6
+ * request. But the failure must still be LOUD: it is logged to the console (so
7
+ * it is visible even in a background waitUntil task or when no hook is wired)
8
+ * AND routed through the router's onError callback (via the request context's
9
+ * deduped _reportBackgroundError) so consumers can observe cache degradation in
10
+ * their own telemetry.
11
+ *
12
+ * The one deliberate exception is the invalidation WRITE verb (updateTag ->
13
+ * store.invalidateTags): a failed durable marker write is rejected so an awaited
14
+ * updateTag() surfaces it (read-your-own-writes honesty). That is not a data
15
+ * read/write and does not go through this helper's swallow-and-degrade path.
16
+ */
17
+
18
+ import { _getRequestContext } from "../server/request-context.js";
19
+
20
+ /**
21
+ * Minimal shape of a request context for error reporting. Passed explicitly by
22
+ * background tasks (waitUntil) where the ALS context is already gone, so the
23
+ * error can still reach the router's onError. Structural to avoid importing the
24
+ * full RequestContext type (request-context.ts imports CacheErrorCategory from
25
+ * here - a mutual type-only reference).
26
+ */
27
+ export interface CacheErrorReporter {
28
+ _reportBackgroundError?: (
29
+ error: unknown,
30
+ category: CacheErrorCategory,
31
+ ) => void;
32
+ }
33
+
34
+ export type CacheErrorCategory =
35
+ /** A read failed (transient infra: KV/Cache API error). Degrade to a miss. */
36
+ | "cache-read"
37
+ /** A write failed. Degrade to a no-op (entry simply not cached). */
38
+ | "cache-write"
39
+ /** A delete/eviction failed. Best-effort. */
40
+ | "cache-delete"
41
+ /**
42
+ * A STORED entry could not be parsed/deserialized (partial KV read, truncated
43
+ * Cache API body, malformed envelope/RSC payload). The entry is faulty and is
44
+ * evicted so subsequent reads do not keep failing on it. Distinct from
45
+ * cache-read so consumers can tell corruption from a transient outage.
46
+ */
47
+ | "cache-corrupt"
48
+ /** A tag-invalidation side effect failed (e.g. the eager CDN purge hook). */
49
+ | "cache-invalidate"
50
+ /**
51
+ * A background stale-while-revalidate refresh failed (the `"use cache"`
52
+ * read-through path). The stale value was already served; the refresh that
53
+ * would have replaced it errored.
54
+ */
55
+ | "stale-revalidation";
56
+
57
+ /**
58
+ * Report a non-fatal cache error loudly without failing the request: always logs
59
+ * (label + error) and, when a request context is available, routes the error
60
+ * through the router's onError callback. Never throws.
61
+ *
62
+ * `ctx` is for callers running in a detached background task (waitUntil), where
63
+ * the ALS request context is already gone (so `_getRequestContext()` is null):
64
+ * they capture the context up front and pass it here so onError still fires.
65
+ * Foreground callers omit it and fall back to the ALS context.
66
+ */
67
+ export function reportCacheError(
68
+ error: unknown,
69
+ category: CacheErrorCategory,
70
+ label: string,
71
+ ctx?: CacheErrorReporter,
72
+ ): void {
73
+ console.error(`${label}:`, error);
74
+ try {
75
+ const target = ctx ?? _getRequestContext();
76
+ target?._reportBackgroundError?.(error, category);
77
+ } catch {
78
+ // Reporting must never itself break the cache path.
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Run a best-effort async cache task (typically scheduled via waitUntil), catching
84
+ * any rejection and routing it through reportCacheError so background cache work
85
+ * (non-blocking L1 writes, KV persistence, L1 promotion) reports failures via
86
+ * onError instead of throwing or silently swallowing. Never rejects.
87
+ *
88
+ * Pass `ctx` when the task runs detached (the ALS context is gone) and the
89
+ * failure should still reach onError; omit it to fall back to the ALS context.
90
+ *
91
+ * @example this.waitUntil(() => reportingAsync(() => cache.put(req, res), "cache-write", "[CFCacheStore] L1 write"))
92
+ */
93
+ export async function reportingAsync(
94
+ task: () => Promise<unknown>,
95
+ category: CacheErrorCategory,
96
+ label: string,
97
+ ctx?: CacheErrorReporter,
98
+ ): Promise<void> {
99
+ try {
100
+ await task();
101
+ } catch (error) {
102
+ reportCacheError(error, category, label, ctx);
103
+ }
104
+ }
@@ -9,6 +9,7 @@
9
9
  import type { CacheDefaults, SegmentCacheStore } from "./types.js";
10
10
  import { _getRequestContext } from "../server/request-context.js";
11
11
  import type { RequestContext } from "../server/request-context.js";
12
+ import { normalizeTags } from "./cache-tag.js";
12
13
 
13
14
  /**
14
15
  * Default TTL for route-level cache() DSL and loader cache.
@@ -108,6 +109,64 @@ export async function resolveCacheKey(
108
109
  return defaultKey;
109
110
  }
110
111
 
112
+ // ============================================================================
113
+ // Cache Tag Resolution
114
+ // ============================================================================
115
+
116
+ /**
117
+ * Resolve cache tags from a tags option (static array or function of ctx).
118
+ *
119
+ * Fails open: a thrown tag callback falls back to no tags rather than
120
+ * aborting the request. Tags are additive metadata (not identity), so a
121
+ * missing tag does not cause cache collisions, only a missed invalidation.
122
+ *
123
+ * Shared by the cache() DSL (cache-scope) and loader caching (loader-cache)
124
+ * so tag resolution behaves identically across every cache axis.
125
+ */
126
+ export function resolveTagsOption<TEnv>(
127
+ tags: string[] | ((ctx: RequestContext<TEnv>) => string[]) | undefined,
128
+ ctx: RequestContext<TEnv> | undefined,
129
+ label: string,
130
+ ): string[] | undefined {
131
+ if (!tags) return undefined;
132
+ if (typeof tags === "function") {
133
+ if (!ctx) {
134
+ // A dynamic tags function needs the request context to run. Without it
135
+ // (e.g. resolved outside a request, at build/prerender time) the entry is
136
+ // cached UNTAGGED and can never be invalidated - surface that rather than
137
+ // silently dropping the tags, matching the thrown-callback branch below.
138
+ console.warn(
139
+ `[${label}] Dynamic tags function present but no request context; ` +
140
+ `caching without tags (this entry will not be tag-invalidatable).`,
141
+ );
142
+ return undefined;
143
+ }
144
+ try {
145
+ return normalizeTagList(tags(ctx));
146
+ } catch (error) {
147
+ console.error(
148
+ `[${label}] Tags function failed, caching without tags:`,
149
+ error,
150
+ );
151
+ return undefined;
152
+ }
153
+ }
154
+ return normalizeTagList(tags);
155
+ }
156
+
157
+ /**
158
+ * Normalize a resolved tags array so the WRITE path matches the invalidate path:
159
+ * updateTag()/revalidateTag()/cacheTag() all drop empty/whitespace-only tags via
160
+ * normalizeTag(). Without this, an empty tag attached at write time would enter
161
+ * the store index but could never be invalidated (the verbs normalize it away),
162
+ * and on CFCacheStore would also cost a wasted KV marker read per request.
163
+ * Returns undefined when nothing usable remains, keeping the entry header-free.
164
+ */
165
+ function normalizeTagList(tags: string[]): string[] | undefined {
166
+ const out = normalizeTags(tags);
167
+ return out.length > 0 ? out : undefined;
168
+ }
169
+
111
170
  // ============================================================================
112
171
  // Cache Store Resolution
113
172
  // ============================================================================
@@ -120,6 +179,41 @@ export async function resolveCacheKey(
120
179
  export function resolveCacheStore(
121
180
  explicitStore: SegmentCacheStore | undefined,
122
181
  ): SegmentCacheStore | null {
123
- if (explicitStore) return explicitStore;
182
+ if (explicitStore) {
183
+ // Register explicit per-scope stores so updateTag()/revalidateTag() can
184
+ // reach them. This is the single chokepoint every cache axis (segment,
185
+ // response, loader) resolves through, so registering here covers them all
186
+ // eagerly - no dependence on whether a tagged write has happened yet. The
187
+ // app-level store is intentionally not registered (always reachable via
188
+ // ctx._cacheStore).
189
+ registerExplicitTaggedStore(explicitStore);
190
+ return explicitStore;
191
+ }
124
192
  return _getRequestContext()?._cacheStore ?? null;
125
193
  }
194
+
195
+ /**
196
+ * Upper bound on the per-handler explicit-store registry. A module-singleton
197
+ * store (the recommended pattern) dedupes to a single entry and never approaches
198
+ * this. The cap bounds the niche case of an explicit store constructed PER
199
+ * request/boundary (e.g. a ctx-bound CFCacheStore, which must take a per-request
200
+ * ctx): without it the registry - which intentionally persists across requests so
201
+ * a server action's updateTag() can reach stores a prior render registered -
202
+ * would accumulate one dead instance per request and fan invalidation out to
203
+ * finished execution contexts. LRU-touch on re-resolution keeps a live, re-used
204
+ * store from being evicted by that churn.
205
+ */
206
+ const EXPLICIT_STORE_REGISTRY_CAP = 64;
207
+
208
+ function registerExplicitTaggedStore(store: SegmentCacheStore): void {
209
+ const set = _getRequestContext()?._explicitTaggedStores;
210
+ if (!set) return;
211
+ // LRU touch: move an already-present store to the most-recent position (Set
212
+ // preserves insertion order) so a store re-resolved every request stays live.
213
+ set.delete(store);
214
+ set.add(store);
215
+ if (set.size > EXPLICIT_STORE_REGISTRY_CAP) {
216
+ const oldest = set.values().next().value;
217
+ if (oldest !== undefined) set.delete(oldest);
218
+ }
219
+ }
@@ -40,6 +40,12 @@ import {
40
40
  import { startHandleCapture, type HandleCapture } from "./handle-capture.js";
41
41
  import { sortedSearchString } from "./cache-key-utils.js";
42
42
  import { runBackground } from "./background-task.js";
43
+ import {
44
+ normalizeTags,
45
+ recordRequestTags,
46
+ runWithCacheTagScope,
47
+ } from "./cache-tag.js";
48
+ import { reportCacheError } from "./cache-error.js";
43
49
 
44
50
  /**
45
51
  * Convert encodeReply result to a stable string key.
@@ -74,9 +80,17 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
74
80
  const store = requestCtx?._cacheStore;
75
81
  const resolvedProfileName = profileName || "default";
76
82
 
77
- // Bypass: no store or no getItem support
83
+ // Bypass: no store or no getItem support. Still run inside a tag scope so a
84
+ // cacheTag() call inside the function degrades to a no-op rather than
85
+ // throwing "must be called inside a use cache function" - adopting cacheTag()
86
+ // must not hard-fail in apps/tests without an item-capable cache configured.
78
87
  if (!store?.getItem) {
79
- return fn.apply(this, args);
88
+ const scoped = runWithCacheTagScope(() => fn.apply(this, args));
89
+ const result = await scoped.result;
90
+ // Still record the runtime tags into the request set so a cacheTag() in an
91
+ // uncached function tags the document, even with no item-capable store.
92
+ recordRequestTags(scoped.tags, requestCtx);
93
+ return result;
80
94
  }
81
95
 
82
96
  // Resolve profile strictly from request-scoped config (set by the
@@ -159,8 +173,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
159
173
  cacheKey = `use-cache:${id}`;
160
174
  }
161
175
  } catch {
162
- // Non-serializable args: run uncached
163
- return fn.apply(this, args);
176
+ // Non-serializable args: run uncached (within a tag scope so cacheTag()
177
+ // still does not throw). Record runtime tags so the document union still
178
+ // sees them even though this call is not itself cached.
179
+ const scoped = runWithCacheTagScope(() => fn.apply(this, args));
180
+ const result = await scoped.result;
181
+ recordRequestTags(scoped.tags, requestCtx);
182
+ return result;
164
183
  }
165
184
 
166
185
  // Cache lookup
@@ -178,9 +197,20 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
178
197
  if (r) restoreHandles(r, handleStore);
179
198
  }
180
199
  }
200
+ // Surface the hit's tags to the request set so a document built from a
201
+ // cached item is still tagged (the function did not re-run, so its
202
+ // runtime cacheTag() tags are only available from the stored entry).
203
+ recordRequestTags(cached.tags, requestCtx);
181
204
  return result;
182
- } catch {
183
- // Deserialization failed, fall through to fresh execution
205
+ } catch (error) {
206
+ // The stored value is corrupt/partial (failed RSC deserialize). Report
207
+ // it, then fall through to fresh execution - the miss path below re-runs
208
+ // and setItem() overwrites the faulty entry under the same key (self-heal).
209
+ reportCacheError(
210
+ error,
211
+ "cache-corrupt",
212
+ `[use cache] "${id}" fresh-hit`,
213
+ );
184
214
  }
185
215
  }
186
216
 
@@ -195,6 +225,8 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
195
225
  if (r) restoreHandles(r, handleStore);
196
226
  }
197
227
  }
228
+ // Tag the request with the stale entry's tags (see fresh-hit note).
229
+ recordRequestTags(cached.tags, requestCtx);
198
230
  // Background revalidation — must capture handles if tainted args present.
199
231
  // Use an isolated handle store so background pushes don't pollute the
200
232
  // live response or throw LateHandlePushError on the completed store.
@@ -244,8 +276,18 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
244
276
  }
245
277
 
246
278
  try {
247
- const freshResult = await fn.apply(this, args);
279
+ const scoped = runWithCacheTagScope(() => fn.apply(this, args));
280
+ const freshResult = await scoped.result;
248
281
  bgStopCapture?.();
282
+ // Merge profile/DSL tags with runtime cacheTag() tags, read after
283
+ // awaiting so post-await cacheTag() calls are included. Normalize
284
+ // (drops empty profile tags, matching the invalidate path) + dedupe.
285
+ const freshTags = [
286
+ ...new Set(
287
+ normalizeTags([...(profile.tags ?? []), ...scoped.tags]),
288
+ ),
289
+ ];
290
+ recordRequestTags(freshTags, requestCtx);
249
291
  const serialized = await serializeResult(freshResult);
250
292
  if (serialized !== null) {
251
293
  const encodedHandles = bgCapture?.data
@@ -255,12 +297,20 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
255
297
  handles: encodedHandles,
256
298
  ttl: profile.ttl,
257
299
  swr: profile.swr,
258
- tags: profile.tags,
300
+ tags: freshTags.length > 0 ? freshTags : undefined,
259
301
  });
260
302
  }
261
303
  } catch (bgError) {
262
304
  bgStopCapture?.();
263
- requestCtx?._reportBackgroundError?.(bgError, "stale-revalidation");
305
+ // Pass requestCtx explicitly: this runs in a detached background
306
+ // task where the ALS context is gone, so onError can only fire if
307
+ // we hand it the context captured up front.
308
+ reportCacheError(
309
+ bgError,
310
+ "stale-revalidation",
311
+ "[use cache] background revalidation failed",
312
+ requestCtx,
313
+ );
264
314
  } finally {
265
315
  for (const arg of bgTaintedArgs) {
266
316
  unstampCacheExec(arg as object);
@@ -272,8 +322,14 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
272
322
  }
273
323
  });
274
324
  return result;
275
- } catch {
276
- // Deserialization of stale value failed, fall through
325
+ } catch (error) {
326
+ // Stale value is corrupt/partial; report and fall through to a fresh
327
+ // execution, which overwrites the faulty entry under the same key.
328
+ reportCacheError(
329
+ error,
330
+ "cache-corrupt",
331
+ `[use cache] "${id}" stale-hit`,
332
+ );
277
333
  }
278
334
  }
279
335
 
@@ -306,8 +362,10 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
306
362
  }
307
363
 
308
364
  let result: any;
365
+ let scoped: ReturnType<typeof runWithCacheTagScope>;
309
366
  try {
310
- result = await fn.apply(this, args);
367
+ scoped = runWithCacheTagScope(() => fn.apply(this, args));
368
+ result = await scoped.result;
311
369
  } finally {
312
370
  // Decrement ref count; symbol is deleted when it reaches zero
313
371
  for (const arg of taintedArgs) {
@@ -320,6 +378,14 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
320
378
  stopCapture?.();
321
379
  }
322
380
 
381
+ // Merge profile/DSL tags with runtime cacheTag() tags. Read scoped.tags
382
+ // after awaiting result so post-await cacheTag() calls are included.
383
+ // Normalize (drops empty profile tags, matching the invalidate path) + dedupe.
384
+ const allTags = [
385
+ ...new Set(normalizeTags([...(profile.tags ?? []), ...scoped!.tags])),
386
+ ];
387
+ recordRequestTags(allTags, requestCtx);
388
+
323
389
  // Serialize and store — fully non-blocking when waitUntil is available.
324
390
  // The response does not need to wait for serialization or the store write.
325
391
  const cacheWrite = async () => {
@@ -333,7 +399,7 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
333
399
  handles: encodedHandles,
334
400
  ttl: profile.ttl,
335
401
  swr: profile.swr,
336
- tags: profile.tags,
402
+ tags: allTags.length > 0 ? allTags : undefined,
337
403
  });
338
404
  }
339
405
  } catch (writeError) {