@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -20,7 +20,10 @@ import {
20
20
  encodeReply,
21
21
  createClientTemporaryReferenceSet,
22
22
  } from "@vitejs/plugin-rsc/rsc";
23
- import { getRequestContext } from "../server/request-context.js";
23
+ import {
24
+ getRequestContext,
25
+ runWithRequestContext,
26
+ } from "../server/request-context.js";
24
27
  import { isUnderTestRunner } from "../runtime-env.js";
25
28
  import {
26
29
  isTainted,
@@ -124,6 +127,86 @@ export async function replyToCacheKey(
124
127
  // the test-ergonomics warning fires once per fn rather than once per call.
125
128
  const warnedUncachedUnderTest = new Set<string>();
126
129
 
130
+ /**
131
+ * Fast-path cache-key builder for JSON-safe key args. Returns a deterministic
132
+ * string (object keys recursively sorted so insertion order can't change the
133
+ * key) when EVERY part is a primitive or a plain object/array of the same, and
134
+ * `undefined` otherwise so the caller falls back to the Flight reply encoder.
135
+ *
136
+ * Strings are always quoted via JSON.stringify, so they can never collide with
137
+ * the bareword encodings of null/true/false/undefined/numbers. Anything the
138
+ * reply encoder must handle instead — functions, symbols, bigint, non-finite
139
+ * numbers, Dates, Maps, class instances, promises, and React elements (whose
140
+ * `$$typeof` symbol value trips the symbol reject) — yields `undefined`.
141
+ */
142
+ function jsonSafeKeyPart(value: unknown): string | undefined {
143
+ if (value === null) return "null";
144
+ switch (typeof value) {
145
+ case "string":
146
+ return JSON.stringify(value);
147
+ case "boolean":
148
+ return value ? "true" : "false";
149
+ case "number":
150
+ return Number.isFinite(value) ? String(value) : undefined;
151
+ case "undefined":
152
+ return "undefined";
153
+ case "object": {
154
+ if (Array.isArray(value)) {
155
+ const parts: string[] = [];
156
+ for (const item of value) {
157
+ const encoded = jsonSafeKeyPart(item);
158
+ if (encoded === undefined) return undefined;
159
+ parts.push(encoded);
160
+ }
161
+ return `[${parts.join(",")}]`;
162
+ }
163
+ // Only plain objects (Object.prototype or null proto) are fast-path safe.
164
+ // Dates, Maps, class instances, promises, etc. carry a different prototype
165
+ // and fall back to the encoder.
166
+ const proto = Object.getPrototypeOf(value);
167
+ if (proto !== Object.prototype && proto !== null) return undefined;
168
+ const obj = value as Record<string, unknown>;
169
+ const parts: string[] = [];
170
+ for (const key of Object.keys(obj).sort()) {
171
+ const encoded = jsonSafeKeyPart(obj[key]);
172
+ if (encoded === undefined) return undefined;
173
+ parts.push(`${JSON.stringify(key)}:${encoded}`);
174
+ }
175
+ return `{${parts.join(",")}}`;
176
+ }
177
+ default:
178
+ // bigint, symbol, function
179
+ return undefined;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The serialized product of one "use cache" execution: exactly what the store
185
+ * write persists. Followers that dedup onto an in-flight execution (see
186
+ * inFlightExecutions) await this and serve it as a synthetic cache hit — each
187
+ * deserializing its OWN copy of `serialized` and replaying `handles`/`tags`
188
+ * against its OWN request. A deserialized result object is never shared.
189
+ */
190
+ interface CacheEnvelope {
191
+ /** RSC-serialized return value (never null — a null serialize rejects). */
192
+ serialized: string;
193
+ /** Merged profile/DSL + runtime cacheTag() tags. */
194
+ tags: string[];
195
+ /** RSC-encoded handle blob captured during execution, if any. */
196
+ handles?: string;
197
+ }
198
+
199
+ /**
200
+ * In-flight "use cache" executions keyed by cache key. When N concurrent calls
201
+ * miss on the same key, only the first (the leader) runs the function; the rest
202
+ * await its {@link CacheEnvelope} and serve it as a synthetic hit rather than
203
+ * re-running the function and re-writing the store. Isolate-scoped (module
204
+ * singleton), and cleared for a key as soon as the leader settles: a rejected
205
+ * leader (function threw, or the result was not serializable) propagates to
206
+ * current waiters, which then retry fresh.
207
+ */
208
+ const inFlightExecutions = new Map<string, Promise<CacheEnvelope>>();
209
+
127
210
  // ============================================================================
128
211
  // Core: registerCachedFunction
129
212
  // ============================================================================
@@ -250,12 +333,23 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
250
333
  let cacheKey: string;
251
334
  try {
252
335
  if (keyArgs.length > 0) {
253
- const tempRefs = createClientTemporaryReferenceSet();
254
- const encoded = await encodeReply(keyArgs as unknown[], {
255
- temporaryReferences: tempRefs,
256
- });
257
- const argsKey = await replyToCacheKey(encoded);
258
- cacheKey = `use-cache:${id}:${argsKey}`;
336
+ // Fast path: when every key arg is JSON-safe, build the key with a
337
+ // deterministic stable-stringify and skip encodeReply (the Flight reply
338
+ // encoder runs on EVERY call, including hits). The `:j:` namespace keeps
339
+ // these keys disjoint from the encoder path so the two can never collide
340
+ // for one fn id. Entries cached under the old encoder key for JSON-safe
341
+ // args cold-start once after this upgrade.
342
+ const fastKey = jsonSafeKeyPart(keyArgs);
343
+ if (fastKey !== undefined) {
344
+ cacheKey = `use-cache:${id}:j:${fastKey}`;
345
+ } else {
346
+ const tempRefs = createClientTemporaryReferenceSet();
347
+ const encoded = await encodeReply(keyArgs as unknown[], {
348
+ temporaryReferences: tempRefs,
349
+ });
350
+ const argsKey = await replyToCacheKey(encoded);
351
+ cacheKey = `use-cache:${id}:${argsKey}`;
352
+ }
259
353
  } else {
260
354
  cacheKey = `use-cache:${id}`;
261
355
  }
@@ -307,27 +401,37 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
307
401
  }
308
402
  }
309
403
 
310
- if (cached?.shouldRevalidate) {
404
+ // foregroundOnAction (opt-in; see CacheProfile.foregroundOnAction): during an
405
+ // action's revalidation render, a stale entry falls through to the foreground
406
+ // miss path below instead of SWR. The flag is set by revalidateAfterAction.
407
+ const foregroundOnActionRevalidation =
408
+ requestCtx?._inActionRevalidation === true &&
409
+ profile.foregroundOnAction === true;
410
+ if (cached?.shouldRevalidate && !foregroundOnActionRevalidation) {
311
411
  // Stale hit: return stale value, revalidate in background
312
412
  try {
313
413
  const result = await serveCached(cached);
314
414
  // Background revalidation — must capture handles if tainted args present.
315
- // Use an isolated handle store so background pushes don't pollute the
316
- // live response or throw LateHandlePushError on the completed store.
317
- // Same isolation pattern as route-level background-revalidation.ts.
318
415
  runBackground(requestCtx, async () => {
319
- // Reuse closure-captured requestCtx instead of calling
320
- // getRequestContext() ALS context may be gone inside waitUntil.
321
- let originalHandleStore:
322
- | ReturnType<typeof createHandleStore>
323
- | undefined;
324
- if (hasTaintedArgs && requestCtx) {
325
- originalHandleStore = requestCtx._handleStore;
326
- requestCtx._handleStore = createHandleStore();
327
- }
328
- const bgHandleStore = hasTaintedArgs
329
- ? requestCtx?._handleStore
330
- : undefined;
416
+ // The background body runs under a DERIVED context with an OWN
417
+ // _handleStore (the shell-capture isolation pattern
418
+ // shell-capture.ts attemptCapture): its handle pushes land in the
419
+ // isolated store (captured below, persisted with the entry) while
420
+ // the foreground keeps pushing into the ORIGINAL store, untouched.
421
+ // Derivation matters because the foreground is STILL RENDERING here
422
+ // runBackground/waitUntil starts the task on the next microtask,
423
+ // not after the response. The previous shape swapped
424
+ // requestCtx._handleStore in place (restore in finally), which
425
+ // routed the whole overlap window's foreground pushes into the
426
+ // background store: lost from the live document AND persisted into
427
+ // the revalidated entry (issue #684, plan 010).
428
+ const bgHandleStore =
429
+ hasTaintedArgs && requestCtx ? createHandleStore() : undefined;
430
+ const bgCtx: typeof requestCtx = bgHandleStore
431
+ ? Object.assign(Object.create(requestCtx), {
432
+ _handleStore: bgHandleStore,
433
+ })
434
+ : requestCtx;
331
435
  let bgCapture: HandleCapture | undefined;
332
436
  let bgStopCapture: (() => void) | undefined;
333
437
  if (bgHandleStore) {
@@ -336,31 +440,28 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
336
440
  bgStopCapture = c.stop;
337
441
  }
338
442
 
339
- // Stamp tainted ARGS only not requestCtx. The args stamp guards
340
- // direct ctx method calls (ctx.set, ctx.header, ctx.onResponse, etc.)
341
- // which is sufficient for correctness.
342
- //
343
- // We intentionally skip stamping requestCtx here because:
344
- // 1. runBackground starts the async task synchronously (before the
345
- // first await), so stampCacheExec would pollute the shared
346
- // requestCtx while the foreground pipeline is still running.
347
- // This causes assertNotInsideCacheExec to fire when cache-store
348
- // later calls requestCtx.onResponse().
349
- // 2. requestCtx methods are closure-bound to the original ctx, so
350
- // neither Object.create() nor a proxy can isolate the stamp.
351
- // 3. The foreground miss path already stamps requestCtx and catches
352
- // cookies()/headers() misuse on first execution. The background
353
- // re-runs the same function with the same request.
354
- const bgTaintedArgs: unknown[] = [];
355
- for (const arg of args) {
356
- if (isTainted(arg)) {
357
- stampCacheExec(arg as object);
358
- bgTaintedArgs.push(arg);
359
- }
360
- }
443
+ // Tainted args are NOT stamped here, in contrast to the foreground
444
+ // miss path below. The args include the live HandlerContext the
445
+ // still-rendering foreground holds, and INSIDE_CACHE_EXEC is a
446
+ // property stamped onto that SHARED object — so for the whole
447
+ // revalidation window a concurrent foreground ctx.set() /
448
+ // ctx.headers.*() would throw (issue #684, plan 010). requestCtx is
449
+ // not stamped for the same reason. In-fn misuse is already caught
450
+ // by the miss path's stamps on the function's FIRST execution — the
451
+ // background re-runs the same function with the same request.
361
452
 
362
453
  try {
363
- const scoped = runWithCacheTagScope(() => fn.apply(this, args));
454
+ // Re-establish the request-context ALS so a "use cache" body that
455
+ // reads the ambient getRequestContext() (e.g.
456
+ // getRequestContext().env.ApiKey) resolves during the background
457
+ // revalidation instead of throwing "called outside of a request
458
+ // context". runWithRequestContext sets the store for fn's
459
+ // synchronous kickoff; its async continuations inherit it. The
460
+ // DERIVED context goes in, so ambient _handleStore reads inside
461
+ // the body resolve to the isolated store.
462
+ const scoped = runWithRequestContext(bgCtx, () =>
463
+ runWithCacheTagScope(() => fn.apply(this, args)),
464
+ );
364
465
  const freshResult = await scoped.result;
365
466
  bgStopCapture?.();
366
467
  // Merge profile/DSL tags with runtime cacheTag() tags, read after
@@ -395,15 +496,9 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
395
496
  "[use cache] background revalidation failed",
396
497
  requestCtx,
397
498
  );
398
- } finally {
399
- for (const arg of bgTaintedArgs) {
400
- unstampCacheExec(arg as object);
401
- }
402
- // Restore original handle store
403
- if (originalHandleStore && requestCtx) {
404
- requestCtx._handleStore = originalHandleStore;
405
- }
406
499
  }
500
+ // No finally: nothing shared was mutated — the derived context and
501
+ // its handle store are garbage after the task settles.
407
502
  });
408
503
  return result;
409
504
  } catch (error) {
@@ -417,7 +512,65 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
417
512
  }
418
513
  }
419
514
 
420
- // Cache miss: execute, serialize, store
515
+ // Cache miss.
516
+ //
517
+ // In-flight dedup: if a concurrent call for this key is already executing,
518
+ // await its envelope and serve it as a synthetic hit rather than re-running
519
+ // the function. Each follower deserializes its OWN copy, replays handles
520
+ // against its OWN handle store (gated on ITS hasTaintedArgs), and records
521
+ // tags into its OWN request — no deserialized result is shared across
522
+ // requests. The store write stays exactly once (the leader's).
523
+ const existing = inFlightExecutions.get(cacheKey);
524
+ if (existing) {
525
+ let envelope: CacheEnvelope | undefined;
526
+ try {
527
+ envelope = await existing;
528
+ } catch {
529
+ // Leader rejected (function threw or its result was not serializable);
530
+ // its map entry is already cleared, so fall through to a fresh run.
531
+ envelope = undefined;
532
+ }
533
+ if (envelope) {
534
+ try {
535
+ return await serveCached({
536
+ value: envelope.serialized,
537
+ handles: envelope.handles,
538
+ tags: envelope.tags,
539
+ shouldRevalidate: false,
540
+ });
541
+ } catch (error) {
542
+ reportCacheError(
543
+ error,
544
+ "cache-corrupt",
545
+ `[use cache] "${id}" inflight-hit`,
546
+ );
547
+ // Fall through to a fresh execution below.
548
+ }
549
+ }
550
+ }
551
+
552
+ // This call becomes the leader. Register a deferred envelope so concurrent
553
+ // callers dedup onto it; it is resolved/rejected exactly once below (or on a
554
+ // function throw). clearSelf only deletes the map slot if it still holds
555
+ // THIS promise, so a fall-through retry (rare rejected-leader path) can't
556
+ // evict a newer leader's entry.
557
+ let resolveEnvelope!: (env: CacheEnvelope) => void;
558
+ let rejectEnvelope!: (err: unknown) => void;
559
+ const envelopePromise = new Promise<CacheEnvelope>((res, rej) => {
560
+ resolveEnvelope = res;
561
+ rejectEnvelope = rej;
562
+ });
563
+ // Followers attach their own catch; guard the map's own reference so a
564
+ // rejected envelope with no waiter is not an unhandled rejection.
565
+ envelopePromise.catch(() => {});
566
+ inFlightExecutions.set(cacheKey, envelopePromise);
567
+ const clearSelf = (): void => {
568
+ if (inFlightExecutions.get(cacheKey) === envelopePromise) {
569
+ inFlightExecutions.delete(cacheKey);
570
+ }
571
+ };
572
+
573
+ // execute, serialize, store
421
574
  const handleStore = hasTaintedArgs ? requestCtx?._handleStore : undefined;
422
575
  let capture: HandleCapture | undefined;
423
576
  let stopCapture: (() => void) | undefined;
@@ -431,6 +584,13 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
431
584
  // inside the cached function body (those side effects are lost on hit).
432
585
  // Uses ref-counted stamp/unstamp so overlapping executions
433
586
  // sharing the same ctx don't clear each other's guards.
587
+ //
588
+ // LOAD-BEARING for the stale-revalidation path above: the background
589
+ // re-execution deliberately does NOT re-stamp (the objects are live
590
+ // foreground state mid-render), relying on THIS stamp having caught in-fn
591
+ // misuse on the function's first execution — an entry only becomes
592
+ // stale-revalidatable because a stamped miss ran clean and stored it. Do
593
+ // not create a "use cache" entry via any path that skips this stamp.
434
594
  const taintedArgs: unknown[] = [];
435
595
  for (const arg of args) {
436
596
  if (isTainted(arg)) {
@@ -450,6 +610,12 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
450
610
  try {
451
611
  scoped = runWithCacheTagScope(() => fn.apply(this, args));
452
612
  result = await scoped.result;
613
+ } catch (execError) {
614
+ // The function threw: drop the in-flight entry and reject any waiters so
615
+ // they retry fresh, then propagate to this caller.
616
+ clearSelf();
617
+ rejectEnvelope(execError);
618
+ throw execError;
453
619
  } finally {
454
620
  // Decrement ref count; symbol is deleted when it reaches zero
455
621
  for (const arg of taintedArgs) {
@@ -470,28 +636,53 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
470
636
  ];
471
637
  recordRequestTags(allTags, requestCtx);
472
638
 
473
- // Serialize and store fully non-blocking when waitUntil is available.
474
- // The response does not need to wait for serialization or the store write.
475
- const cacheWrite = async () => {
639
+ // Serialize + encode handles ONCE, resolve the in-flight envelope so any
640
+ // concurrent followers can serve a synthetic hit, then persist to the store.
641
+ // Fully non-blocking when waitUntil is available — the leader's response
642
+ // waits on neither serialization nor the store write.
643
+ const finalizeAndWrite = async (): Promise<void> => {
644
+ let serialized: string | null;
645
+ let encodedHandles: string | undefined;
476
646
  try {
477
- const serialized = await serializeResult(result);
478
- if (serialized !== null) {
479
- const encodedHandles = capture?.data
480
- ? await encodeHandles(capture.data)
481
- : undefined;
482
- await store.setItem!(cacheKey, serialized, {
483
- handles: encodedHandles,
484
- ttl: profile.ttl,
485
- swr: profile.swr,
486
- tags: allTags.length > 0 ? allTags : undefined,
487
- });
488
- }
647
+ serialized = await serializeResult(result);
648
+ encodedHandles = capture?.data
649
+ ? await encodeHandles(capture.data)
650
+ : undefined;
651
+ } catch (buildError) {
652
+ // Serialize/handle-encode failed: no envelope for followers (they run
653
+ // fresh) and nothing to write.
654
+ clearSelf();
655
+ rejectEnvelope(buildError);
656
+ requestCtx?._reportBackgroundError?.(buildError, "cache-write");
657
+ return;
658
+ }
659
+ clearSelf();
660
+ if (serialized === null) {
661
+ // Non-serializable result: no store write (matches the prior silent
662
+ // skip); reject so any waiter falls through to a fresh execution.
663
+ rejectEnvelope(
664
+ new Error(
665
+ `[use cache] "${id}" result is not serializable; not cached`,
666
+ ),
667
+ );
668
+ return;
669
+ }
670
+ // Hand followers the envelope before the store write so a slow/failed
671
+ // write never stalls them.
672
+ resolveEnvelope({ serialized, tags: allTags, handles: encodedHandles });
673
+ try {
674
+ await store.setItem!(cacheKey, serialized, {
675
+ handles: encodedHandles,
676
+ ttl: profile.ttl,
677
+ swr: profile.swr,
678
+ tags: allTags.length > 0 ? allTags : undefined,
679
+ });
489
680
  } catch (writeError) {
490
681
  requestCtx?._reportBackgroundError?.(writeError, "cache-write");
491
682
  }
492
683
  };
493
684
 
494
- await runBackground(requestCtx, cacheWrite, true);
685
+ await runBackground(requestCtx, finalizeAndWrite, true);
495
686
 
496
687
  return result;
497
688
  };
@@ -31,7 +31,7 @@ import {
31
31
  encodeHandles,
32
32
  decodeHandles,
33
33
  } from "./handle-snapshot.js";
34
- import { sortedSearchString, sortedRouteParams } from "./cache-key-utils.js";
34
+ import { cacheKeyBase } from "./cache-key-utils.js";
35
35
  import {
36
36
  DEFAULT_ROUTE_TTL,
37
37
  isFiniteNonNegativeSeconds,
@@ -85,21 +85,6 @@ function validatedSwr(value: number | undefined): number | undefined {
85
85
  return isValidCacheSeconds(value, "swr") ? value : undefined;
86
86
  }
87
87
 
88
- function getCacheKeyBase(
89
- host: string,
90
- pathname: string,
91
- params?: Record<string, string>,
92
- searchParams?: URLSearchParams,
93
- ): string {
94
- const paramStr = sortedRouteParams(params);
95
- const searchStr = searchParams ? sortedSearchString(searchParams) : "";
96
-
97
- let key = `${host}${pathname}`;
98
- if (paramStr) key += `:${paramStr}`;
99
- if (searchStr) key += `?${searchStr}`;
100
- return key;
101
- }
102
-
103
88
  function getDefaultRouteCacheKey(
104
89
  pathname: string,
105
90
  params?: Record<string, string>,
@@ -113,7 +98,7 @@ function getDefaultRouteCacheKey(
113
98
  // Intercept navigations get their own cache namespace
114
99
  const prefix = isIntercept ? "intercept" : isPartial ? "partial" : "doc";
115
100
 
116
- return `${prefix}:${getCacheKeyBase(host, pathname, params, searchParams)}`;
101
+ return `${prefix}:${cacheKeyBase(host, pathname, searchParams, params)}`;
117
102
  }
118
103
 
119
104
  // ============================================================================
@@ -38,36 +38,82 @@ export function normalizeTags(tags: Iterable<string>): string[] {
38
38
  }
39
39
 
40
40
  /**
41
- * Tag the current "use cache" entry for later invalidation via
42
- * updateTag() / revalidateTag().
41
+ * Tag content for later invalidation via updateTag() / revalidateTag().
43
42
  *
44
- * Must be called inside a function marked with "use cache".
45
- * Tags are additive - multiple calls accumulate.
43
+ * cacheTag() serves two forms depending on what is active when it runs:
44
+ *
45
+ * 1. Inside a "use cache" function — the DEFAULT. The tags go to the current
46
+ * cache entry; `revalidateTag(tag)` drops that entry. Tags are additive
47
+ * (multiple calls accumulate), and normalizeTag() is the single chokepoint so
48
+ * a padded write matches an unpadded invalidate.
49
+ *
50
+ * 2. Render-callable (#648) — no "use cache" scope active, but a request context
51
+ * is present. The tags record onto the request's DOCUMENT artifact
52
+ * (ctx._requestTags) instead of throwing. The collection layers already exist:
53
+ * PPR shell capture unions _requestTags into the shell entry, the document
54
+ * cache tags the full-page entry with it, and prerender build contexts seed
55
+ * their own set — so a server component that renders into a shell makes
56
+ * `revalidateTag("campaign:spring")` evict that shell with ZERO cache()/"use
57
+ * cache" in its tree. This is PPR's DERIVATIVE invalidation: PPR is
58
+ * execution-PRESERVING (everything still runs underneath; only document bytes
59
+ * are shortcut), so its tags ride this existing instrument rather than a
60
+ * first-class ppr key/tag API. The shell-expiry invariant holds by
61
+ * construction — baked ⇒ evicts (bake-lane loaders execute during capture and
62
+ * record here), hole ⇒ fresh (masked loaders behind a renderable loading()
63
+ * never execute during capture, so nothing under a hole can tag the shell).
64
+ *
65
+ * Inside a cache() DSL segment the render-callable form records at the DOCUMENT
66
+ * level, not the segment (only the "use cache" runtime enters the tag scope) — a
67
+ * documented semantic, not a filtered one. An empty/whitespace-only tag is
68
+ * dropped in both forms (the render-callable form silently, via normalizeTags in
69
+ * recordRequestTags; the scope form with a dev warning).
70
+ *
71
+ * With neither a scope nor a request context, cacheTag() throws.
46
72
  *
47
73
  * @example
48
74
  * ```typescript
75
+ * // Form 1 — inside "use cache":
49
76
  * async function getProduct(ctx) {
50
77
  * "use cache";
51
78
  * cacheTag(`product:${ctx.params.id}`, "products");
52
79
  * return db.getProduct(ctx.params.id);
53
80
  * }
81
+ *
82
+ * // Form 2 — render-callable, tags the shell/document from a server component:
83
+ * function CampaignBanner() {
84
+ * cacheTag("campaign:spring");
85
+ * return <aside>Spring sale</aside>;
86
+ * }
54
87
  * ```
55
88
  */
56
89
  export function cacheTag(...tags: string[]): void {
57
90
  const store = cacheTagStorage.getStore();
58
- if (!store) {
59
- throw new Error('cacheTag() must be called inside a "use cache" function.');
60
- }
61
- for (const tag of tags) {
62
- const normalized = normalizeTag(tag);
63
- if (normalized === null) {
64
- if (process.env.NODE_ENV !== "production") {
65
- console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
91
+ if (store) {
92
+ // Form 1: "use cache" scope wins tag the cache entry (unchanged).
93
+ for (const tag of tags) {
94
+ const normalized = normalizeTag(tag);
95
+ if (normalized === null) {
96
+ if (process.env.NODE_ENV !== "production") {
97
+ console.warn(`[cacheTag] Ignoring empty or whitespace-only tag.`);
98
+ }
99
+ continue;
66
100
  }
67
- continue;
101
+ store.add(normalized);
68
102
  }
69
- store.add(normalized);
103
+ return;
70
104
  }
105
+
106
+ const reqCtx = _getRequestContext();
107
+ if (reqCtx?._requestTags) {
108
+ // Form 2: render-callable — tag the request's document artifact. See the
109
+ // JSDoc above for the composition doctrine and the baked/hole invariant.
110
+ recordRequestTags(tags, reqCtx);
111
+ return;
112
+ }
113
+
114
+ throw new Error(
115
+ 'cacheTag() must be called inside a "use cache" function or during a request render.',
116
+ );
71
117
  }
72
118
 
73
119
  export function recordRequestTags(