@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
@@ -4,9 +4,9 @@
4
4
  * Runs in the RSC realm of the build's temp server, AFTER all bundles are
5
5
  * written (the prelude embeds built client asset URLs — bootstrap module,
6
6
  * chunk preloads — that only exist post-client-build). The capture core is
7
- * producer A's, verbatim: deriveShellCaptureContext (mask funnel, liveness,
8
- * snapshot recording, implicit doc-cache scope) + captureAndStoreShell (gates,
9
- * quiesce, tags union, putShell barrier). Build capture first replays global
7
+ * producer A's, verbatim: deriveShellCaptureContext (push funnel, snapshot
8
+ * recording, implicit doc-cache scope) + settleCaptureRecord (the record-first
9
+ * step) + captureAndStoreShell (gates, quiesce, tags union, putShell barrier). Build capture first replays global
10
10
  * and route middleware with a synthetic build request context
11
11
  * (`ctx.build === true`, inert `ctx.waitUntil()`); middleware can seed vars or
12
12
  * call `ctx.dynamic()` to skip this URL. The sink is an entry collector instead
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  import type { ShellCacheEntry } from "../cache/types.js";
24
+ import type { MatchResult } from "../types.js";
24
25
  import { MemorySegmentCacheStore } from "../cache/memory-segment-store.js";
25
26
  import {
26
27
  createRequestContext,
@@ -31,7 +32,10 @@ import {
31
32
  import {
32
33
  deriveShellCaptureContext,
33
34
  captureAndStoreShell,
35
+ settleCaptureRecord,
36
+ withinDeadline,
34
37
  delay,
38
+ SHELL_CAPTURE_MAX_WAIT_MS,
35
39
  SHELL_CAPTURE_RETRY_DELAY_MS,
36
40
  type ShellCaptureDescriptor,
37
41
  } from "../rsc/shell-capture.js";
@@ -134,18 +138,25 @@ export interface BuildShellCaptureResult {
134
138
  tags?: string[];
135
139
  /** On route-mismatch: what this router's match actually landed on. */
136
140
  matchedRouteName?: string;
141
+ /**
142
+ * On no-shell: the attempt ran out of ppr.captureTimeout. It is not
143
+ * retried: its match may still be running, and the retry would start the
144
+ * same work beside it (runtime twin: shell-capture.ts runShellCapture).
145
+ */
146
+ timedOut?: true;
137
147
  }
138
148
 
139
149
  /**
140
150
  * Capture the PPR shell for one prerendered URL at build time. Retries once
141
151
  * in place on `no-shell` (the first attempt warms the temp server's SSR/Flight
142
- * transform graph, mirroring producer A's cold-start retry — same delay).
152
+ * transform graph, mirroring producer A's cold-start retry — same delay),
153
+ * unless the attempt ran out of ppr.captureTimeout (`timedOut`).
143
154
  */
144
155
  export async function captureShellForBuild(
145
156
  opts: BuildShellCaptureOptions,
146
157
  ): Promise<BuildShellCaptureResult> {
147
158
  const first = await attemptBuildCapture(opts);
148
- if (first.outcome !== "no-shell") return first;
159
+ if (first.outcome !== "no-shell" || first.timedOut) return first;
149
160
  if (opts.debug) {
150
161
  console.log(
151
162
  `[rango] shell capture attempt 1/2 for ${opts.urlPath} produced no shell (cold graph?) — retrying`,
@@ -215,6 +226,7 @@ async function attemptBuildCapture(
215
226
  };
216
227
 
217
228
  let mismatchedRouteName: string | undefined;
229
+ let timedOut = false;
218
230
  const result = await runWithRequestContext(baseCtx, async () => {
219
231
  const preview =
220
232
  typeof router.previewMatch === "function"
@@ -252,6 +264,9 @@ async function attemptBuildCapture(
252
264
  setMismatchedRouteName: (routeName) => {
253
265
  mismatchedRouteName = routeName;
254
266
  },
267
+ markTimedOut: () => {
268
+ timedOut = true;
269
+ },
255
270
  });
256
271
 
257
272
  const routeMiddleware =
@@ -291,6 +306,7 @@ async function attemptBuildCapture(
291
306
  if (outcome === "route-mismatch") {
292
307
  return { outcome, matchedRouteName: mismatchedRouteName };
293
308
  }
309
+ if (outcome === "no-shell" && timedOut) return { outcome, timedOut: true };
294
310
  return { outcome };
295
311
  }
296
312
 
@@ -310,6 +326,8 @@ interface BuildCaptureFinalOptions {
310
326
  router: any;
311
327
  url: URL;
312
328
  setMismatchedRouteName(routeName: string | undefined): void;
329
+ /** The attempt ran out of ppr.captureTimeout (BuildShellCaptureResult.timedOut). */
330
+ markTimedOut(): void;
313
331
  }
314
332
 
315
333
  async function runBuildMiddlewareEnvelope<TEnv>(
@@ -369,15 +387,28 @@ async function runBuildCaptureFinal(
369
387
  // invoking this when baseCtx._dynamic is set. A loader/handler opting out
370
388
  // DURING the capture render is caught by the derivedCtx._dynamic check below.
371
389
 
372
- const { derivedCtx, freshHandleStore } = deriveShellCaptureContext(baseCtx, {
390
+ const derivation = deriveShellCaptureContext(baseCtx, {
373
391
  ttl: opts.ttl,
374
392
  swr: opts.swr,
375
393
  });
394
+ const { derivedCtx, freshHandleStore } = derivation;
376
395
 
377
396
  const outcome = await runWithRequestContext(derivedCtx, async () => {
378
- let match;
397
+ // Same one deadline as the runtime capture: the match, the record-first
398
+ // step, then the prerender with what is left.
399
+ const deadline =
400
+ Date.now() + (opts.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS);
401
+ let match: MatchResult;
379
402
  try {
380
- match = await router.match(request, { env });
403
+ const matched = await withinDeadline<MatchResult>(
404
+ router.match(request, { env }),
405
+ deadline,
406
+ );
407
+ if (!matched.done) {
408
+ options.markTimedOut();
409
+ return "no-shell" as const;
410
+ }
411
+ match = matched.value;
381
412
  } catch (error) {
382
413
  if (isPlainPathMiss(error, opts.urlPath)) {
383
414
  return "route-mismatch" as const;
@@ -392,8 +423,25 @@ async function runBuildCaptureFinal(
392
423
 
393
424
  setRequestContextParams(match.params, match.routeName);
394
425
 
395
- const payload = buildFullPayload(
426
+ // Same record-first step as the runtime capture. A prerendered URL's
427
+ // match comes from the prerender store (no handler runs, no doc record),
428
+ // which settleCaptureRecord reports as `prerender`.
429
+ const settled = await settleCaptureRecord(
396
430
  match,
431
+ derivation,
432
+ descriptor,
433
+ deadline,
434
+ );
435
+ if (settled.kind === "refused") {
436
+ return derivedCtx._dynamic ? ("dynamic" as const) : ("refused" as const);
437
+ }
438
+ if (settled.kind === "timeout") {
439
+ options.markTimedOut();
440
+ return "no-shell" as const;
441
+ }
442
+
443
+ const payload = buildFullPayload(
444
+ settled.match,
397
445
  // buildFullPayload reads only ctx.router.* and ctx.version.
398
446
  { router, version: opts.buildVersion } as unknown as HandlerContext<any>,
399
447
  url,
@@ -416,9 +464,15 @@ async function runBuildCaptureFinal(
416
464
  rscStream,
417
465
  freshHandleStore,
418
466
  derivedCtx,
419
- descriptor,
467
+ { ...descriptor, captureTimeout: Math.max(1, deadline - Date.now()) },
420
468
  );
421
- return derivedCtx._dynamic ? "dynamic" : captureOutcome;
469
+ if (captureOutcome === "no-shell" && Date.now() >= deadline) {
470
+ options.markTimedOut();
471
+ }
472
+ if (derivedCtx._dynamic) return "dynamic";
473
+ // A route cache() record that ran out mid-capture: the in-place retry
474
+ // (captureShellForBuild) reads or renders a newer one.
475
+ return captureOutcome === "expired" ? "no-shell" : captureOutcome;
422
476
  });
423
477
 
424
478
  return {
@@ -909,12 +909,13 @@ const loader: RouteHelpers<any, any>["loader"] = (
909
909
  // resolved here, at DSL-evaluation time, for the same reason
910
910
  // loading({ ssr: false }) is (below) — per-isSSR entry caching makes the
911
911
  // flag request-mode-correct with no isSSR threading; see LoaderEntry.
912
+ // `bake` is the lane, the same on both evaluations.
912
913
  const loaderEntry: LoaderEntry = {
913
914
  loader: loaderDef,
914
915
  revalidate: [] as ShouldRevalidateFn<any, any>[],
915
- ...(optionsGiven?.ssr === false && ctx.isSSR
916
- ? { awaitBeforeFlush: true as const }
917
- : {}),
916
+ ...(optionsGiven?.ssr === false && { bake: true as const }),
917
+ ...(optionsGiven?.ssr === false &&
918
+ ctx.isSSR && { awaitBeforeFlush: true as const }),
918
919
  };
919
920
 
920
921
  // Merge handler.use defaults (attached to the loader definition) with explicit use
@@ -5,7 +5,10 @@
5
5
  */
6
6
 
7
7
  import type { HandlerContext, InternalHandlerContext } from "../types";
8
- import { _getRequestContext } from "../server/request-context.js";
8
+ import {
9
+ _getRequestContext,
10
+ type RequestContext,
11
+ } from "../server/request-context.js";
9
12
  import { getSearchSchema, isRouteRootScoped } from "../route-map-builder.js";
10
13
  import { parseSearchParams, serializeSearchParams } from "../search-params.js";
11
14
  import {
@@ -18,6 +21,7 @@ import {
18
21
  assertCachedHeaderWriteAllowed,
19
22
  assertNonCacheableReadAllowed,
20
23
  } from "../server/context.js";
24
+ import { assertThemeReadAllowed } from "../server/cookie-store.js";
21
25
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
22
26
  import { isAutoGeneratedRouteName } from "../route-name.js";
23
27
  import { PRERENDER_PASSTHROUGH } from "../prerender.js";
@@ -29,6 +33,34 @@ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
29
33
  // once at module load instead of per createHandlerContext (per-request) call.
30
34
  const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]);
31
35
 
36
+ /** The request context a handler ctx's `theme` getter reads (THEME_DESCRIPTOR). */
37
+ const THEME_REQUEST_CONTEXT = Symbol("rango:theme-request-context");
38
+
39
+ /**
40
+ * ctx.theme is the visitor's theme cookie, guarded like cookies() (#971,
41
+ * assertThemeReadAllowed): a getter, so creating the ctx reads nothing and
42
+ * only a handler that reads the theme refuses a ppr capture or throws on a
43
+ * cache() miss. Non-enumerable, so serializing the ctx does not read it:
44
+ * React's dev Flight debug info JSON-stringifies server component props and
45
+ * replayed console.log args, and an enumerable getter read there refused the
46
+ * capture of a route that never read the theme. The trade: a spread or
47
+ * Object.assign copy of the ctx does not carry `theme`. One shared getter
48
+ * with the request context on a symbol key: a getter closure per ctx gives
49
+ * every ctx its own property shape (V8 dictionary mode). The guard reads the
50
+ * ambient context, as cookies() does, and falls back to the ctx's own when
51
+ * there is none.
52
+ */
53
+ const THEME_DESCRIPTOR: PropertyDescriptor = {
54
+ get(this: { [THEME_REQUEST_CONTEXT]?: RequestContext }) {
55
+ const requestContext = this[THEME_REQUEST_CONTEXT];
56
+ if (!requestContext?._themeConfig) return undefined;
57
+ assertThemeReadAllowed(_getRequestContext() ?? requestContext, "ctx.theme");
58
+ return requestContext._readTheme();
59
+ },
60
+ enumerable: false,
61
+ configurable: true,
62
+ };
63
+
32
64
  /**
33
65
  * `headers` whose mutating methods run `assertWrite(method)` first. Shared by
34
66
  * the handler ctx (createHandlerContext) and the middleware ctx
@@ -279,7 +311,7 @@ export function createHandlerContext<TEnv>(
279
311
  // Read-time guard: non-cacheable var inside "use cache" or cache() →
280
312
  // throw. Works for both ContextVar tokens and string keys.
281
313
  if (isNonCacheable(variables, keyOrVar)) {
282
- assertNonCacheableReadAllowed(keyOrVar);
314
+ assertNonCacheableReadAllowed(keyOrVar, _getRequestContext());
283
315
  }
284
316
  return contextGet(variables, keyOrVar);
285
317
  }) as HandlerContext<any, TEnv>["get"],
@@ -295,8 +327,7 @@ export function createHandlerContext<TEnv>(
295
327
  use: () => {
296
328
  throw new Error("ctx.use() called before loaders were initialized");
297
329
  },
298
- // Theme support (when enabled via router config)
299
- theme: requestContext?.theme,
330
+ // theme: THEME_DESCRIPTOR, defined after this literal.
300
331
  setTheme: requestContext?.setTheme,
301
332
  // Location state support (delegates to request context)
302
333
  setLocationState(entries) {
@@ -325,6 +356,9 @@ export function createHandlerContext<TEnv>(
325
356
  _responseType: responseType,
326
357
  _routeName: routeName,
327
358
  };
359
+ (ctx as { [THEME_REQUEST_CONTEXT]?: unknown })[THEME_REQUEST_CONTEXT] =
360
+ requestContext;
361
+ Object.defineProperty(ctx, "theme", THEME_DESCRIPTOR);
328
362
  // Brand with taint symbol so "use cache" excludes ctx from cache keys
329
363
  (ctx as any)[NOCACHE_SYMBOL] = true;
330
364
  return ctx;
@@ -28,7 +28,14 @@
28
28
  * is a direct call — no wrapper, no timestamp, no allocation.
29
29
  */
30
30
 
31
- import { _getRequestContext } from "../server/request-context.js";
31
+ import {
32
+ _getRequestContext,
33
+ type RequestContext,
34
+ } from "../server/request-context.js";
35
+ import {
36
+ recordTagOwnersArmed,
37
+ runInSegmentTagScope,
38
+ } from "../cache/cache-tag.js";
32
39
  import { isAutoGeneratedRouteName } from "../route-name.js";
33
40
  import { getRouterContext } from "./router-context.js";
34
41
  import { resolveSink, safeEmit, type TelemetryEvent } from "./telemetry.js";
@@ -352,11 +359,56 @@ export function observeHandler<C, R>(
352
359
  handler: (ctx: C) => R,
353
360
  ctx: C,
354
361
  ): R {
355
- const tracing = _getRequestContext()?._tracing;
362
+ return observeHandlerIn(_getRequestContext(), id, handler, ctx);
363
+ }
364
+
365
+ function observeHandlerIn<C, R>(
366
+ reqCtx: RequestContext | undefined,
367
+ id: string,
368
+ handler: (ctx: C) => R,
369
+ ctx: C,
370
+ ): R {
371
+ const tracing = reqCtx?._tracing;
356
372
  if (!tracing || tracing.phases.handler === false) return handler(ctx);
357
373
  return observePhase(PHASES.handler(id), () => handler(ctx));
358
374
  }
359
375
 
376
+ /**
377
+ * observeHandler, inside segment `segmentId`'s tag scope, so the tags the
378
+ * handler records reach the route's cache() record (#957, cache-tag.ts
379
+ * runInSegmentTagScope). `spanId` is observeHandler's `id`. A request that
380
+ * cannot write a record takes observeHandler's path as is, with the same one
381
+ * request-context read: no scope, no closure.
382
+ */
383
+ export function observeSegmentHandler<C, R>(
384
+ segmentId: string,
385
+ spanId: string,
386
+ handler: (ctx: C) => R,
387
+ ctx: C,
388
+ ): R {
389
+ const reqCtx = _getRequestContext();
390
+ if (!recordTagOwnersArmed(reqCtx)) {
391
+ return observeHandlerIn(reqCtx, spanId, handler, ctx);
392
+ }
393
+ return observeInSegmentScope(segmentId, reqCtx, spanId, handler, ctx);
394
+ }
395
+
396
+ // Separate from observeSegmentHandler: a closure there would make V8
397
+ // allocate its captured-variable context on every call, armed or not.
398
+ function observeInSegmentScope<C, R>(
399
+ segmentId: string,
400
+ reqCtx: RequestContext | undefined,
401
+ spanId: string,
402
+ handler: (ctx: C) => R,
403
+ ctx: C,
404
+ ): R {
405
+ return runInSegmentTagScope(
406
+ segmentId,
407
+ (c) => observeHandlerIn(reqCtx, spanId, handler, c),
408
+ ctx,
409
+ );
410
+ }
411
+
360
412
  /**
361
413
  * Emit one discrete telemetry event (the event-shaped counterpart to
362
414
  * observePhase). Resolves the sink from the active router context and stamps the
@@ -32,6 +32,7 @@ import { resolveLoaderData } from "./segment-resolution/loader-cache.js";
32
32
  import type { SegmentResolutionDeps } from "./types.js";
33
33
  import { debugLog } from "./logging.js";
34
34
  import { getRouterContext } from "./router-context.js";
35
+ import { runInSegmentTagScope } from "../cache/cache-tag.js";
35
36
  import {
36
37
  RangoContext,
37
38
  latchPprHeaderScopeForEntries,
@@ -332,12 +333,18 @@ export async function resolveInterceptEntry<TEnv>(
332
333
  );
333
334
  }
334
335
 
336
+ // The intercept segment's id, and the tag scope its handler and layout
337
+ // record in (#957, cache-tag.ts runInSegmentTagScope).
338
+ const slotSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
335
339
  let handlerResult: ReactNode;
336
340
  try {
341
+ const handler = interceptEntry.handler;
337
342
  handlerResult =
338
- typeof interceptEntry.handler === "function"
339
- ? handleHandlerResult(interceptEntry.handler(context))
340
- : interceptEntry.handler;
343
+ typeof handler === "function"
344
+ ? handleHandlerResult(
345
+ runInSegmentTagScope(slotSegmentId, handler, context),
346
+ )
347
+ : handler;
341
348
  } catch (error) {
342
349
  handlerResult = renderInterceptHandlerError(
343
350
  error,
@@ -355,14 +362,19 @@ export async function resolveInterceptEntry<TEnv>(
355
362
 
356
363
  let layoutElement: ReactNode | undefined;
357
364
  if (interceptEntry.layout) {
358
- if (typeof interceptEntry.layout === "function") {
359
- const layoutResult = await interceptEntry.layout(context);
365
+ const layout = interceptEntry.layout;
366
+ if (typeof layout === "function") {
367
+ const layoutResult = await runInSegmentTagScope(
368
+ slotSegmentId,
369
+ layout,
370
+ context,
371
+ );
360
372
  if (layoutResult instanceof Response) {
361
373
  throw layoutResult;
362
374
  }
363
375
  layoutElement = layoutResult;
364
376
  } else {
365
- layoutElement = interceptEntry.layout;
377
+ layoutElement = layout;
366
378
  }
367
379
  }
368
380
 
@@ -413,7 +425,7 @@ export async function resolveInterceptEntry<TEnv>(
413
425
  }
414
426
 
415
427
  const interceptSegment = {
416
- id: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
428
+ id: slotSegmentId,
417
429
  namespace: `intercept:${interceptEntry.routeName}`,
418
430
  type: "parallel" as const,
419
431
  index: 0,
@@ -9,6 +9,14 @@ import type { EntryData } from "../server/context";
9
9
  import { observePhase, PHASES } from "./instrument.js";
10
10
  import { contextGet, isNonCacheable } from "../context-var.js";
11
11
  import { NOCACHE_SYMBOL } from "../cache/taint.js";
12
+ import {
13
+ linkLoaderTags,
14
+ linkRecordedTags,
15
+ linkValueTags,
16
+ loaderTagSetsArmed,
17
+ readValueTags,
18
+ tagLoaderValue,
19
+ } from "../cache/cache-tag.js";
12
20
  import type {
13
21
  ResolvedSegment,
14
22
  HandlerContext,
@@ -388,6 +396,12 @@ function createLoaderExecutor<TEnv>(
388
396
  * payload's handle-stream lifetime.
389
397
  */
390
398
  silentHandles?: boolean;
399
+ /**
400
+ * loaderId -> the cache() tags of that loader's binding in this request
401
+ * (setupLoaderAccess _bindLoaderCacheTags): an execution of it outside
402
+ * the binding answers for them too (#964).
403
+ */
404
+ bindingTags?: ReadonlyMap<string, Set<string>>;
391
405
  },
392
406
  ): (
393
407
  loader: LoaderDefinition<any, any>,
@@ -438,8 +452,24 @@ function createLoaderExecutor<TEnv>(
438
452
  // loader's cache() read-through lives in loader-cache.ts
439
453
  // (executeLoaderData), whose ctx.use override serves a later handler read
440
454
  // the binding's result.
441
- if (loaderPromises.has(loader.$$id)) {
442
- return loaderPromises.get(loader.$$id)!;
455
+ //
456
+ // A read is a consumption: the value bakes into the reader's output, so
457
+ // the loader's tags follow the reader onto a cache() record (#957) and
458
+ // into the reader's own loader-cache entry (#964, readValueTags; armed
459
+ // only where a loader cache() can read them).
460
+ const reqCtx = reqCtxRef ?? _getRequestContext();
461
+ linkLoaderTags(loader.$$id, reqCtx);
462
+ const recordTags = loaderTagSetsArmed(reqCtx);
463
+ // A loader body reads a cache()-bound loader through its binding too, so
464
+ // every reader in the request gets the one value a HIT served. The
465
+ // binding's own MISS (callerLoaderId null) runs the loader below.
466
+ const bound =
467
+ callerLoaderId !== null &&
468
+ internal._loaderCacheOverrides?.get(loader.$$id);
469
+ const memo = bound || loaderPromises.get(loader.$$id);
470
+ if (memo) {
471
+ if (recordTags) readValueTags(memo);
472
+ return memo;
443
473
  }
444
474
 
445
475
  // Get loader function - either from loader object or fetchable registry
@@ -534,7 +564,7 @@ function createLoaderExecutor<TEnv>(
534
564
  return collectHandleData(keyOrVar, snapshot, segmentOrder);
535
565
  }
536
566
  if (isNonCacheable(variables, keyOrVar)) {
537
- assertNonCacheableReadAllowed(keyOrVar);
567
+ assertNonCacheableReadAllowed(keyOrVar, _getRequestContext());
538
568
  }
539
569
  return contextGet(variables, keyOrVar);
540
570
  }) as typeof ctx.get,
@@ -680,42 +710,29 @@ function createLoaderExecutor<TEnv>(
680
710
  // does not grant rendered() to handler-invoked loaders. Uses a body-only
681
711
  // scope, so isInsideLoaderScope() / barrier / deadlock gating is unchanged.
682
712
  //
683
- // `handlerInvoked` (!isDslLoader) rides on the scope for the CONSUMPTION-
684
- // LANE RULE: a handler-consumed loader's value is a BAKED copy in every
685
- // shared artifact (cache(), "use cache", the PPR shell), so its identity
686
- // reads are exempt from the shell-capture guard — same allowance the
687
- // cache-purity guards give it. DSL segment loaders keep their lane
688
- // machinery (live = masked at capture, bake = guarded). A DSL loader's
689
- // nested deps inherit isDslLoader=false only when the CHAIN started in a
690
- // handler; a chain started by the segment funnel stays DSL (the loader
691
- // scope ALS survives the body's awaits).
692
- // Shell fast path eligibility: a HANDLER-invoked loader executing during a
693
- // capture is handler-layer dynamism — on a handler-free (replayed) HIT it
694
- // would never re-run, freezing its consumption-lane value (#672's "fresh
695
- // per serve" slot shape). Mark the capture; the entry declines the fast
696
- // path and keeps the full tail. DSL loaders re-run on every HIT and never
697
- // set this.
698
- if (!isDslLoader) {
699
- const captureCtx = _getRequestContext();
700
- if (
701
- captureCtx?._shellCaptureRun &&
702
- captureCtx._shellCaptureHandleLiveness
703
- ) {
704
- captureCtx._shellCaptureHandleLiveness.handlerInvokedLoader = true;
705
- }
706
- }
713
+ // The CONSUMPTION-LANE RULE: a handler-consumed loader's value is a
714
+ // BAKED copy in every shared artifact (cache(), "use cache", the PPR
715
+ // shell). The cache-purity guards permit its identity reads; the PPR
716
+ // capture guard does not (server/context.ts assertNotInsideShellCapture).
707
717
 
718
+ const recordedTags = recordTags ? new Set<string>() : undefined;
708
719
  const promise = observePhase(PHASES.loader(loader.$$id), () =>
709
720
  Promise.resolve(
710
721
  runInsideLoaderBodyScope(
711
722
  () => loaderFn(loaderCtx as LoaderContext<any, TEnv>),
712
723
  loader.$$id,
713
- !isDslLoader,
724
+ recordedTags,
714
725
  ),
715
726
  ).finally(() => {
716
727
  pendingLoaders.delete(loader.$$id);
717
728
  }),
718
729
  );
730
+ if (recordedTags) {
731
+ tagLoaderValue(promise, recordedTags);
732
+ readValueTags(promise);
733
+ const bindingTags = executorOptions?.bindingTags?.get(loader.$$id);
734
+ if (bindingTags) linkRecordedTags(recordedTags, bindingTags);
735
+ }
719
736
 
720
737
  // Auxiliary-lane tracking: keeps the handle store open for this body's
721
738
  // ctx.handle() pushes without joining `settled` (the handler barrier that
@@ -759,8 +776,6 @@ export function setupLoaderAccess<TEnv>(
759
776
  const reqCtxRef = _getRequestContext();
760
777
  const handleStoreRef = reqCtxRef?._handleStore;
761
778
 
762
- const useLoader = createLoaderExecutor(ctx, loaderPromises);
763
-
764
779
  // Loader-cache HIT replay (loader-cache.ts replayLoaderHandles): a cached
765
780
  // entry records its ctx.use dependencies' pushes too, and a dependency is
766
781
  // memoized here and shared with live readers. A loader that already ran
@@ -773,12 +788,28 @@ export function setupLoaderAccess<TEnv>(
773
788
  claimed.add(loaderId);
774
789
  return true;
775
790
  };
791
+ // A loader cache() binding's tags reach every execution of that loader
792
+ // outside the binding: one a reader started before the binding (linked
793
+ // here), a later one, or a refresh's (bindingTags).
794
+ const executor: { bindingTags?: Map<string, Set<string>> } = {};
795
+ internal._bindLoaderCacheTags = (loaderId, tags) => {
796
+ (executor.bindingTags ??= new Map()).set(loaderId, tags);
797
+ const started = loaderPromises.get(loaderId);
798
+ if (started) linkValueTags(started, tags);
799
+ };
800
+ const useLoader = createLoaderExecutor(ctx, loaderPromises, executor);
776
801
  // A refresh's pushes are diverted, so its loader ctxs never claim
777
- // (cache-runtime.ts refreshView).
802
+ // (cache-runtime.ts refreshView). It reads each loader fresh, not through
803
+ // the page's bindings: a stale refresh must not rebuild its entry from
804
+ // another stale entry.
778
805
  internal._runLoaderIsolated = (loader) =>
779
806
  createLoaderExecutor(
780
- Object.create(ctx, { _claimLoaderPushes: { value: undefined } }),
807
+ Object.create(ctx, {
808
+ _claimLoaderPushes: { value: undefined },
809
+ _loaderCacheOverrides: { value: undefined },
810
+ }),
781
811
  new Map(),
812
+ executor,
782
813
  )(loader, null);
783
814
 
784
815
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
@@ -3,6 +3,8 @@ import {
3
3
  createCacheScope,
4
4
  resolveShellImplicitCacheScope,
5
5
  } from "../cache/cache-scope.js";
6
+ import { armLoaderTagSets, armRecordTagOwners } from "../cache/cache-tag.js";
7
+ import { bindsLoaderCache } from "./segment-resolution/loader-cache.js";
6
8
  import { RouteNotFoundError } from "../errors";
7
9
  import {
8
10
  createErrorInfo,
@@ -150,6 +152,20 @@ export async function createMatchContextForFull<TEnv>(
150
152
  Store.metrics = metricsStore;
151
153
  }
152
154
 
155
+ // A shell capture's implicit doc scope, or a document HIT tail's (which
156
+ // replaces any route-derived scope: the tail replays the shell's doc
157
+ // record), or else the route's own scope.
158
+ const cacheScope = resolveShellImplicitCacheScope(snapshot.cacheScope);
159
+ // A scope means a route cache() record may be written: arm its tag owners
160
+ // before any handler runs (#957). A document HIT tail writes no record and
161
+ // runs no handler, so it arms nothing. A loader bound with its own cache()
162
+ // needs the per-execution loader tag sets before any loader runs (#964):
163
+ // loaders still run on a HIT.
164
+ if (cacheScope && !_getRequestContext()?._shellImplicitCache?.docTail) {
165
+ armRecordTagOwners();
166
+ }
167
+ if (bindsLoaderCache(snapshot.entries)) armLoaderTagSets();
168
+
153
169
  return {
154
170
  request,
155
171
  url: cleanUrl,
@@ -186,9 +202,7 @@ export async function createMatchContextForFull<TEnv>(
186
202
  },
187
203
  isSameRouteNavigation: false,
188
204
  interceptResult: null,
189
- // Shell fast path: a capture or an eligible document/navigation HIT may
190
- // substitute an implicit doc-level scope (a route-derived scope wins).
191
- cacheScope: resolveShellImplicitCacheScope(snapshot.cacheScope),
205
+ cacheScope,
192
206
  isIntercept: false,
193
207
  actionContext: undefined,
194
208
  isAction: false,
@@ -401,6 +415,16 @@ export async function createMatchContextForPartial<TEnv>(
401
415
  }
402
416
 
403
417
  const isIntercept = !!interceptResult;
418
+ // A normal-route navigation may replay a PPR shell's canonical segment
419
+ // snapshot. Intercepts remain source-dependent and always use their normal
420
+ // cache path.
421
+ const cacheScope = isIntercept
422
+ ? snapshot.cacheScope
423
+ : resolveShellImplicitCacheScope(snapshot.cacheScope);
424
+ // A scope means a route cache() record may be written: arm its tag owners
425
+ // before any handler runs (#957); see the full-match site for #964's arm.
426
+ if (cacheScope) armRecordTagOwners();
427
+ if (bindsLoaderCache(snapshot.entries)) armLoaderTagSets();
404
428
 
405
429
  return {
406
430
  request,
@@ -427,12 +451,7 @@ export async function createMatchContextForPartial<TEnv>(
427
451
  interceptSelectorContext,
428
452
  isSameRouteNavigation: nav.isSameRouteNavigation,
429
453
  interceptResult,
430
- // A normal-route navigation may replay a PPR shell's canonical segment
431
- // snapshot. Intercepts remain source-dependent and always use their normal
432
- // cache path.
433
- cacheScope: isIntercept
434
- ? snapshot.cacheScope
435
- : resolveShellImplicitCacheScope(snapshot.cacheScope),
454
+ cacheScope,
436
455
  isIntercept,
437
456
  actionContext,
438
457
  isAction,
@@ -47,6 +47,7 @@ import {
47
47
  buildCacheSignalSegments,
48
48
  } from "./telemetry.js";
49
49
  import { _getRequestContext } from "../server/request-context.js";
50
+ import { ShellRecordUnavailableError } from "../cache/shell-snapshot.js";
50
51
  import { evaluatePprTransitionWhen } from "./transition-when.js";
51
52
 
52
53
  /**
@@ -380,6 +381,10 @@ export function createMatchHandlers<TEnv = any>(
380
381
  emitter.end(0, false, error.status);
381
382
  throw error;
382
383
  }
384
+ // A document shell HIT tail's doc record could not supply the
385
+ // handler layer: a serve-path signal serveShellHit degrades, not a
386
+ // routing error (the record's decode failure was already reported).
387
+ if (error instanceof ShellRecordUnavailableError) throw error;
383
388
  emitter.error(
384
389
  error instanceof Error ? error : new Error(String(error)),
385
390
  "routing",