@rangojs/router 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (244) hide show
  1. package/dist/types/browser/event-controller.d.ts +3 -3
  2. package/dist/types/browser/history-state.d.ts +2 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +20 -2
  4. package/dist/types/browser/partial-update.d.ts +16 -1
  5. package/dist/types/browser/prefetch/cache.d.ts +6 -6
  6. package/dist/types/browser/react/Link.d.ts +19 -9
  7. package/dist/types/browser/react/context.d.ts +2 -2
  8. package/dist/types/browser/react/location-state-shared.d.ts +95 -56
  9. package/dist/types/browser/react/location-state.d.ts +1 -1
  10. package/dist/types/browser/transition-when.d.ts +80 -0
  11. package/dist/types/browser/types.d.ts +104 -9
  12. package/dist/types/build/generate-manifest.d.ts +8 -0
  13. package/dist/types/cache/background-task.d.ts +24 -2
  14. package/dist/types/cache/cache-exec-scope.d.ts +37 -0
  15. package/dist/types/cache/cache-key-utils.d.ts +44 -13
  16. package/dist/types/cache/cache-policy.d.ts +1 -1
  17. package/dist/types/cache/cache-runtime.d.ts +7 -0
  18. package/dist/types/cache/cache-scope.d.ts +117 -35
  19. package/dist/types/cache/cache-tag.d.ts +48 -5
  20. package/dist/types/cache/cf/cf-cache-store.d.ts +55 -44
  21. package/dist/types/cache/cf/cf-shell-frame.d.ts +1 -1
  22. package/dist/types/cache/cf/cf-tag-marker-memo.d.ts +0 -15
  23. package/dist/types/cache/document-cache.d.ts +10 -0
  24. package/dist/types/cache/handle-snapshot.d.ts +40 -8
  25. package/dist/types/cache/invalidation-order.d.ts +23 -0
  26. package/dist/types/cache/request-tag-mask.d.ts +54 -0
  27. package/dist/types/cache/shell-snapshot.d.ts +88 -141
  28. package/dist/types/cache/tag-invalidation.d.ts +38 -14
  29. package/dist/types/cache/types.d.ts +68 -61
  30. package/dist/types/cache/vercel/vercel-cache-store.d.ts +23 -16
  31. package/dist/types/client-urls/navigation.d.ts +28 -2
  32. package/dist/types/client-urls/server-projection.d.ts +5 -2
  33. package/dist/types/client-urls/types.d.ts +5 -5
  34. package/dist/types/client.d.ts +1 -1
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.rsc.d.ts +1 -1
  37. package/dist/types/route-definition/helpers-types.d.ts +11 -11
  38. package/dist/types/route-name.d.ts +7 -0
  39. package/dist/types/router/error-handling.d.ts +1 -1
  40. package/dist/types/router/intercept-resolution.d.ts +2 -0
  41. package/dist/types/router/match-context.d.ts +5 -0
  42. package/dist/types/router/match-middleware/background-revalidation.d.ts +0 -4
  43. package/dist/types/router/router-options.d.ts +4 -1
  44. package/dist/types/router/segment-resolution/loader-cache.d.ts +18 -1
  45. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -4
  46. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +14 -40
  47. package/dist/types/router/segment-resolution/mask-nested.d.ts +11 -3
  48. package/dist/types/router/segment-resolution/view-transition-default.d.ts +7 -5
  49. package/dist/types/router/transition-when.d.ts +24 -10
  50. package/dist/types/rsc/attach-transition-when.d.ts +18 -0
  51. package/dist/types/rsc/full-payload.d.ts +14 -7
  52. package/dist/types/rsc/response-cache-serve.d.ts +15 -12
  53. package/dist/types/rsc/shell-build-manifest.d.ts +4 -3
  54. package/dist/types/rsc/shell-capture-constants.d.ts +77 -20
  55. package/dist/types/rsc/shell-capture.d.ts +78 -81
  56. package/dist/types/rsc/shell-serve.d.ts +19 -48
  57. package/dist/types/rsc/types.d.ts +2 -0
  58. package/dist/types/segment-system.d.ts +7 -0
  59. package/dist/types/server/context.d.ts +126 -61
  60. package/dist/types/server/cookie-store.d.ts +30 -18
  61. package/dist/types/server/handle-store.d.ts +52 -1
  62. package/dist/types/server/request-context.d.ts +66 -45
  63. package/dist/types/server/request-headers.d.ts +44 -0
  64. package/dist/types/server.d.ts +1 -0
  65. package/dist/types/testing/e2e/index.d.ts +1 -1
  66. package/dist/types/testing/index.d.ts +3 -2
  67. package/dist/types/testing/internal/action-id.d.ts +7 -0
  68. package/dist/types/testing/location-state-key.d.ts +24 -0
  69. package/dist/types/testing/render-route.d.ts +17 -1
  70. package/dist/types/testing/run-transition-when.d.ts +44 -69
  71. package/dist/types/testing/serve-shell-request.d.ts +7 -6
  72. package/dist/types/testing/shell-status.d.ts +34 -22
  73. package/dist/types/transition-when-ref.d.ts +79 -0
  74. package/dist/types/types/cache-types.d.ts +15 -3
  75. package/dist/types/types/index.d.ts +1 -1
  76. package/dist/types/types/segments.d.ts +86 -71
  77. package/dist/types/urls/path-helper-types.d.ts +3 -4
  78. package/dist/types/urls/pattern-types.d.ts +3 -4
  79. package/dist/types/vite/plugins/transition-when-hoist.d.ts +45 -0
  80. package/dist/vite/index.js +962 -97
  81. package/package.json +1 -1
  82. package/skills/cache-guide/SKILL.md +76 -49
  83. package/skills/caching/SKILL.md +167 -69
  84. package/skills/client-urls/SKILL.md +1 -1
  85. package/skills/comparison/references/framework-comparison.md +8 -5
  86. package/skills/document-cache/SKILL.md +24 -0
  87. package/skills/hooks/SKILL.md +3 -1
  88. package/skills/hooks/navigation.md +23 -2
  89. package/skills/hooks/state.md +98 -6
  90. package/skills/intercept/SKILL.md +29 -12
  91. package/skills/loader/SKILL.md +52 -21
  92. package/skills/middleware/SKILL.md +18 -0
  93. package/skills/migrate-nextjs/SKILL.md +3 -3
  94. package/skills/observability/SKILL.md +6 -5
  95. package/skills/parallel/SKILL.md +1 -1
  96. package/skills/ppr/SKILL.md +129 -77
  97. package/skills/prerender/SKILL.md +3 -3
  98. package/skills/rango/SKILL.md +25 -6
  99. package/skills/route/SKILL.md +2 -0
  100. package/skills/testing/SKILL.md +27 -25
  101. package/skills/testing/cache-prerender.md +9 -9
  102. package/skills/testing/client-components.md +45 -3
  103. package/skills/testing/loader.md +3 -2
  104. package/skills/testing/middleware.md +1 -0
  105. package/skills/testing/predicates.md +49 -49
  106. package/skills/testing/render-handler.md +1 -0
  107. package/skills/testing/serve-shell-request.md +14 -0
  108. package/skills/testing/server-actions.md +9 -3
  109. package/skills/theme/SKILL.md +8 -4
  110. package/skills/typesafety/env-and-bindings.md +8 -0
  111. package/skills/use-cache/SKILL.md +67 -19
  112. package/skills/view-transitions/SKILL.md +150 -29
  113. package/src/__augment-tests__/augmented.check.ts +23 -0
  114. package/src/browser/event-controller.ts +4 -4
  115. package/src/browser/history-state.ts +52 -1
  116. package/src/browser/link-interceptor.ts +4 -0
  117. package/src/browser/navigation-bridge.ts +77 -18
  118. package/src/browser/navigation-client.ts +4 -3
  119. package/src/browser/navigation-store.ts +43 -1
  120. package/src/browser/navigation-transaction.ts +31 -11
  121. package/src/browser/partial-update.ts +146 -35
  122. package/src/browser/prefetch/cache.ts +6 -6
  123. package/src/browser/prefetch/fetch.ts +2 -2
  124. package/src/browser/react/Link.tsx +38 -33
  125. package/src/browser/react/NavigationProvider.tsx +2 -2
  126. package/src/browser/react/context.ts +2 -2
  127. package/src/browser/react/location-state-shared.ts +241 -49
  128. package/src/browser/react/location-state.ts +5 -2
  129. package/src/browser/rsc-router.tsx +4 -0
  130. package/src/browser/server-action-bridge.ts +84 -15
  131. package/src/browser/transition-when.ts +205 -0
  132. package/src/browser/types.ts +124 -10
  133. package/src/build/generate-manifest.ts +77 -1
  134. package/src/cache/background-task.ts +55 -2
  135. package/src/cache/cache-exec-scope.ts +54 -1
  136. package/src/cache/cache-key-utils.ts +56 -16
  137. package/src/cache/cache-policy.ts +9 -4
  138. package/src/cache/cache-runtime.ts +202 -144
  139. package/src/cache/cache-scope.ts +304 -112
  140. package/src/cache/cache-tag.ts +209 -13
  141. package/src/cache/cf/cf-cache-store.ts +152 -76
  142. package/src/cache/cf/cf-shell-frame.ts +2 -2
  143. package/src/cache/cf/cf-tag-marker-memo.ts +11 -100
  144. package/src/cache/document-cache.ts +53 -7
  145. package/src/cache/handle-snapshot.ts +52 -9
  146. package/src/cache/invalidation-order.ts +84 -0
  147. package/src/cache/request-tag-mask.ts +147 -0
  148. package/src/cache/shell-snapshot.ts +130 -348
  149. package/src/cache/tag-invalidation.ts +57 -51
  150. package/src/cache/types.ts +71 -67
  151. package/src/cache/vercel/vercel-cache-store.ts +61 -58
  152. package/src/client-urls/client-root.tsx +8 -4
  153. package/src/client-urls/client-urls.ts +8 -6
  154. package/src/client-urls/navigation.ts +55 -4
  155. package/src/client-urls/server-projection.ts +31 -10
  156. package/src/client-urls/types.ts +12 -5
  157. package/src/client.tsx +2 -0
  158. package/src/host/cookie-handler.ts +2 -1
  159. package/src/index.rsc.ts +3 -0
  160. package/src/index.ts +3 -0
  161. package/src/loader-redirect.tsx +7 -3
  162. package/src/prerender/build-shell-capture.ts +155 -146
  163. package/src/route-definition/dsl-helpers.ts +2 -0
  164. package/src/route-definition/helpers-types.ts +11 -11
  165. package/src/route-name.ts +12 -0
  166. package/src/router/content-negotiation.ts +4 -1
  167. package/src/router/error-handling.ts +5 -2
  168. package/src/router/handler-context.ts +10 -7
  169. package/src/router/intercept-resolution.ts +11 -1
  170. package/src/router/loader-resolution.ts +42 -5
  171. package/src/router/logging.ts +5 -3
  172. package/src/router/match-api.ts +62 -34
  173. package/src/router/match-context.ts +5 -0
  174. package/src/router/match-handlers.ts +9 -29
  175. package/src/router/match-middleware/background-revalidation.ts +3 -5
  176. package/src/router/match-middleware/cache-lookup.ts +35 -19
  177. package/src/router/match-middleware/cache-store.ts +15 -9
  178. package/src/router/match-result.ts +1 -0
  179. package/src/router/middleware.ts +2 -5
  180. package/src/router/navigation-snapshot.ts +6 -5
  181. package/src/router/prerender-match.ts +4 -0
  182. package/src/router/request-classification.ts +7 -4
  183. package/src/router/router-options.ts +4 -1
  184. package/src/router/segment-resolution/fresh.ts +0 -4
  185. package/src/router/segment-resolution/loader-cache.ts +152 -26
  186. package/src/router/segment-resolution/loader-mask.ts +3 -4
  187. package/src/router/segment-resolution/loader-snapshot.ts +15 -78
  188. package/src/router/segment-resolution/mask-nested.ts +47 -22
  189. package/src/router/segment-resolution/revalidation.ts +0 -3
  190. package/src/router/segment-resolution/view-transition-default.ts +6 -21
  191. package/src/router/telemetry.ts +6 -3
  192. package/src/router/transition-when.ts +34 -52
  193. package/src/router.ts +2 -1
  194. package/src/rsc/attach-transition-when.ts +47 -0
  195. package/src/rsc/full-payload.ts +30 -10
  196. package/src/rsc/handler.ts +12 -10
  197. package/src/rsc/loader-fetch.ts +2 -1
  198. package/src/rsc/manifest-init.ts +4 -0
  199. package/src/rsc/origin-guard.ts +8 -5
  200. package/src/rsc/progressive-enhancement.ts +7 -30
  201. package/src/rsc/response-cache-serve.ts +68 -80
  202. package/src/rsc/response-route-handler.ts +2 -2
  203. package/src/rsc/rsc-rendering.ts +206 -252
  204. package/src/rsc/server-action.ts +8 -20
  205. package/src/rsc/shell-build-manifest.ts +22 -20
  206. package/src/rsc/shell-capture-constants.ts +136 -20
  207. package/src/rsc/shell-capture.ts +352 -589
  208. package/src/rsc/shell-serve.ts +73 -109
  209. package/src/rsc/ssr-setup.ts +3 -2
  210. package/src/rsc/types.ts +2 -0
  211. package/src/segment-system.tsx +40 -7
  212. package/src/server/context.ts +263 -124
  213. package/src/server/cookie-store.ts +153 -127
  214. package/src/server/handle-store.ts +245 -27
  215. package/src/server/request-context.ts +134 -87
  216. package/src/server/request-headers.ts +134 -0
  217. package/src/server.ts +4 -0
  218. package/src/testing/dispatch.ts +4 -7
  219. package/src/testing/e2e/index.ts +1 -0
  220. package/src/testing/index.ts +5 -0
  221. package/src/testing/internal/action-id.ts +25 -0
  222. package/src/testing/internal/context.ts +1 -1
  223. package/src/testing/location-state-key.ts +45 -0
  224. package/src/testing/render-handler.ts +1 -1
  225. package/src/testing/render-route.tsx +109 -16
  226. package/src/testing/run-client-revalidate.ts +3 -19
  227. package/src/testing/run-loader.ts +5 -1
  228. package/src/testing/run-transition-when.ts +109 -172
  229. package/src/testing/serve-shell-request.ts +10 -7
  230. package/src/testing/shell-status.ts +60 -56
  231. package/src/transition-when-ref.ts +220 -0
  232. package/src/types/cache-types.ts +15 -3
  233. package/src/types/index.ts +3 -0
  234. package/src/types/segments.ts +94 -91
  235. package/src/urls/path-helper-types.ts +3 -4
  236. package/src/urls/pattern-types.ts +3 -4
  237. package/src/vite/discovery/discover-routers.ts +3 -0
  238. package/src/vite/discovery/shell-prerender-phase.ts +0 -2
  239. package/src/vite/discovery/virtual-module-codegen.ts +16 -6
  240. package/src/vite/plugins/transition-when-hoist.ts +1064 -0
  241. package/src/vite/rango.ts +2 -0
  242. package/src/vite/router-discovery.ts +75 -5
  243. package/dist/types/rsc/transition-gate.d.ts +0 -23
  244. package/src/rsc/transition-gate.ts +0 -86
@@ -223,6 +223,8 @@ export async function matchForPrerender<TEnv = any>(
223
223
  res: stubRes,
224
224
  cookie: () => undefined,
225
225
  cookies: () => ({}),
226
+ _readCookie: () => undefined,
227
+ _readCookies: () => ({}),
226
228
  setCookie: () => {},
227
229
  deleteCookie: () => {},
228
230
  header: () => {},
@@ -526,6 +528,8 @@ export async function renderStaticSegment<TEnv = any>(
526
528
  res: stubRes,
527
529
  cookie: () => undefined,
528
530
  cookies: () => ({}),
531
+ _readCookie: () => undefined,
532
+ _readCookies: () => ({}),
529
533
  setCookie: () => {},
530
534
  deleteCookie: () => {},
531
535
  header: () => {},
@@ -15,6 +15,7 @@
15
15
  * Builds on RouteSnapshot from route-snapshot.ts.
16
16
  */
17
17
 
18
+ import { requestHeaders } from "../server/request-headers.js";
18
19
  import { RouteNotFoundError } from "../errors.js";
19
20
  import type { EntryData } from "../server/context.js";
20
21
  import type { CollectedMiddleware } from "./middleware-types.js";
@@ -149,10 +150,11 @@ export async function classifyRequest<TEnv = any>(
149
150
  ): Promise<RequestPlan<TEnv>> {
150
151
  const pathname = url.pathname;
151
152
  const isAction =
152
- request.headers.has("rsc-action") || url.searchParams.has("_rsc_action");
153
+ requestHeaders(request).has("rsc-action") ||
154
+ url.searchParams.has("_rsc_action");
153
155
  const isLoaderFetch = url.searchParams.has("_rsc_loader");
154
156
  const isPartialReq = url.searchParams.has("_rsc_partial");
155
- const contentType = request.headers.get("content-type") || "";
157
+ const contentType = requestHeaders(request).get("content-type") || "";
156
158
  const isFormSubmission =
157
159
  contentType.includes("multipart/form-data") ||
158
160
  contentType.includes("application/x-www-form-urlencoded");
@@ -176,7 +178,7 @@ export async function classifyRequest<TEnv = any>(
176
178
  ) {
177
179
  let reloadUrl = stripInternalParams(url).toString();
178
180
  if (isAction) {
179
- const referer = request.headers.get("referer");
181
+ const referer = requestHeaders(request).get("referer");
180
182
  if (referer) {
181
183
  try {
182
184
  const refererUrl = new URL(referer);
@@ -248,7 +250,8 @@ export async function classifyRequest<TEnv = any>(
248
250
  }
249
251
 
250
252
  const actionId =
251
- request.headers.get("rsc-action") || url.searchParams.get("_rsc_action");
253
+ requestHeaders(request).get("rsc-action") ||
254
+ url.searchParams.get("_rsc_action");
252
255
 
253
256
  const hasVariants =
254
257
  snapshot.matched.negotiateVariants &&
@@ -518,7 +518,10 @@ export interface RangoOptions<TEnv = any> {
518
518
  *
519
519
  * Controls how long prefetch responses are kept in the client-side
520
520
  * in-memory cache and sets `Cache-Control: private, max-age=<ttl>`
521
- * on server responses for CDN/edge caching.
521
+ * on prefetch responses, so the browser's HTTP cache may reuse them
522
+ * (`private`: shared caches such as a CDN do not store them). A route an
523
+ * intercept targets gets no prefetch `Cache-Control`: whether its
524
+ * response is the modal or the full page depends on the source page.
522
525
  *
523
526
  * The cache is automatically invalidated on server actions regardless
524
527
  * of TTL, so this is primarily a staleness safety net.
@@ -324,7 +324,6 @@ export async function resolveSegment<TEnv>(
324
324
  transition: applyViewTransitionDefault(
325
325
  entry.transition,
326
326
  deps.viewTransitionDefault,
327
- entry.shortCode,
328
327
  ),
329
328
  params,
330
329
  belongsToRoute: false,
@@ -474,7 +473,6 @@ export async function resolveSegment<TEnv>(
474
473
  transition: applyViewTransitionDefault(
475
474
  entry.transition,
476
475
  deps.viewTransitionDefault,
477
- entry.shortCode,
478
476
  ),
479
477
  params,
480
478
  belongsToRoute: true,
@@ -563,7 +561,6 @@ export async function resolveOrphanLayout<TEnv>(
563
561
  transition: applyViewTransitionDefault(
564
562
  orphan.transition,
565
563
  deps.viewTransitionDefault,
566
- orphan.shortCode,
567
564
  ),
568
565
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
569
566
  });
@@ -723,7 +720,6 @@ export async function resolveParallelEntry<TEnv>(
723
720
  transition: applyViewTransitionDefault(
724
721
  parallelEntry.transition,
725
722
  deps.viewTransitionDefault,
726
- `${parentShortCode}.${slot}`,
727
723
  ),
728
724
  params,
729
725
  slot,
@@ -6,7 +6,8 @@
6
6
  * getItem()/setItem() methods on SegmentCacheStore.
7
7
  *
8
8
  * Cache key resolution (3-tier, matching CacheScope.resolveKey):
9
- * 1. options.key(requestCtx) — full override
9
+ * 1. options.key(requestCtx) — full override, stored namespaced as
10
+ * loader:{loaderId}:key:{encodeURIComponent(result)} (#1009)
10
11
  * 2. store.keyGenerator(requestCtx, defaultKey) — store-level modification
11
12
  * 3. loader:{loaderId}:{host}{pathname}:{sortedParams} — default
12
13
  *
@@ -57,13 +58,17 @@ import {
57
58
  } from "../../cache/handle-capture.js";
58
59
  import { encodeHandles, decodeHandles } from "../../cache/handle-snapshot.js";
59
60
  import { INTERNAL_RANGO_DEBUG } from "../../internal-debug.js";
61
+ import { runIdentityExempt } from "../../cache/cache-exec-scope.js";
60
62
  import {
61
63
  getRequestContext,
62
64
  _getRequestContext,
63
65
  runWithRequestContext,
64
66
  } from "../../server/request-context.js";
65
67
  import { observePhase, PHASES } from "../instrument.js";
66
- import { sortedRouteParams } from "../../cache/cache-key-utils.js";
68
+ import {
69
+ KEY_PART_PREFIX,
70
+ sortedRouteParams,
71
+ } from "../../cache/cache-key-utils.js";
67
72
  import {
68
73
  resolveTtl,
69
74
  resolveSwrWindow,
@@ -73,19 +78,25 @@ import {
73
78
  DEFAULT_ROUTE_TTL,
74
79
  } from "../../cache/cache-policy.js";
75
80
  import { readThroughItem } from "../../cache/read-through-swr.js";
81
+ import {
82
+ executionStart,
83
+ predatesInvalidation,
84
+ } from "../../cache/tag-invalidation.js";
76
85
  import {
77
86
  maskNestedContainerThenables,
78
87
  overlayLoaderContainer,
79
88
  } from "./loader-snapshot.js";
80
89
  import {
81
90
  SHELL_BAKE_TAG_OWNER,
82
- armLoaderTagSets,
83
91
  captureRecordedTags,
92
+ earliestRecordedStart,
84
93
  flattenRecordedTags,
85
94
  linkLoaderTags,
86
95
  linkLoaderTagsTo,
87
96
  linkRecordedTags,
88
97
  markIdentityRead,
98
+ markTagSetStart,
99
+ readStartedLoaderValue,
89
100
  readValueTags,
90
101
  recordedIdentityRead,
91
102
  recordLoaderTags,
@@ -128,6 +139,28 @@ function getDefaultLoaderCacheKey(
128
139
  return `loader:${loaderId}:${host}${base}`;
129
140
  }
130
141
 
142
+ /**
143
+ * A loader's own `key()` result as its entry key (#1009). The result is
144
+ * often request input, and the item family (`getItem`/`setItem`) has no
145
+ * other discriminator, so stored raw it could name another loader's entry
146
+ * or a `"use cache"` entry (`use-cache:<id>:...`, cache-runtime.ts), which
147
+ * shares that family; route records use `get`/`set`. Namespaced, it can't:
148
+ * - it starts with `loader:`, which no `"use cache"` key does;
149
+ * - an encoded result holds no `:`, so the key reads unambiguously from
150
+ * the right (result, `key:`, loader id), and two namespaced keys are
151
+ * equal only for the same loader and result;
152
+ * - a default key `loader:<id>:<host><pathname>...` holds a `/` past its
153
+ * id, and an encoded result holds none, so the two are equal only if one
154
+ * loader's id is literally `<other id>:<host>/<path>...`. Loader ids can
155
+ * hold `:` (a dev id is a root-relative path, absolute `D:/...` on
156
+ * Windows), so this is the guarantee, not "ids hold no `:`".
157
+ * A store keyGenerator result stays the store's own namespace, as on routes
158
+ * (composeCacheKeys).
159
+ */
160
+ function loaderKeyFromResult(loaderId: string, result: string): string {
161
+ return `loader:${loaderId}:${KEY_PART_PREFIX}${encodeURIComponent(result)}`;
162
+ }
163
+
131
164
  /**
132
165
  * Resolve cache key using the shared 3-tier priority. `declared`: a key() or
133
166
  * store keyGenerator produced it, so it can carry request identity (#972).
@@ -148,9 +181,14 @@ async function resolveLoaderKey(
148
181
  const host = getRequestContext()?.url?.host ?? "localhost";
149
182
  const defaultKey = getDefaultLoaderCacheKey(loaderId, host, pathname, params);
150
183
  if (options === false) return { key: defaultKey, declared: false };
184
+ const keyFn = options.key;
151
185
  return {
152
- key: await resolveCacheKey(options.key, store, defaultKey, "LoaderCache"),
153
- declared: Boolean(options.key || store.keyGenerator),
186
+ key: await resolveCacheKey(
187
+ keyFn && (async (ctx) => loaderKeyFromResult(loaderId, await keyFn(ctx))),
188
+ store,
189
+ defaultKey,
190
+ ),
191
+ declared: Boolean(keyFn || store.keyGenerator),
154
192
  };
155
193
  }
156
194
 
@@ -199,6 +237,64 @@ export function bindsLoaderCache(entries: readonly EntryData[]): boolean {
199
237
  return entries.some(entryBindsLoaderCache);
200
238
  }
201
239
 
240
+ const chainLoaderEntriesMemo = new WeakMap<EntryData, readonly LoaderEntry[]>();
241
+
242
+ // The loaders an entry registers, its orphan layouts' and its parallel
243
+ // slots' included.
244
+ function chainLoaderEntries(entry: EntryData): readonly LoaderEntry[] {
245
+ let loaders = chainLoaderEntriesMemo.get(entry);
246
+ if (loaders === undefined) {
247
+ loaders = [
248
+ ...(entry.loader ?? []),
249
+ ...(entry.layout ?? []).flatMap(chainLoaderEntries),
250
+ ...Object.values(entry.parallel ?? {}).flatMap((p) =>
251
+ p ? chainLoaderEntries(p) : [],
252
+ ),
253
+ ];
254
+ chainLoaderEntriesMemo.set(entry, loaders);
255
+ }
256
+ return loaders;
257
+ }
258
+
259
+ /**
260
+ * The ids of the loaders a request over the matched chain `entries` runs on
261
+ * the live lane (no `ssr: false`), on a chain entry, its orphan layouts or
262
+ * its parallel slots. A document HIT tail restores the record's pushes of
263
+ * every other owner as authoritative (withCacheLookup, HandleStore
264
+ * pushRestored); a live-lane loader is a hole, so its live run replaces its
265
+ * recorded pushes instead. Memoized per entry.
266
+ */
267
+ export function liveLaneLoaderIds(
268
+ entries: readonly EntryData[],
269
+ ): ReadonlySet<string> {
270
+ const ids = new Set<string>();
271
+ for (const entry of entries) {
272
+ for (const l of chainLoaderEntries(entry)) {
273
+ if (!l.bake) ids.add(l.loader.$$id);
274
+ }
275
+ }
276
+ return ids;
277
+ }
278
+
279
+ /**
280
+ * The lane of each loader the matched chain `entries` registers: "live" (no
281
+ * `ssr: false`) or "bake". A loader registered on both is "live". A PPR
282
+ * capture credits a push to the first registered loader around it when that
283
+ * one is live (shell-capture.ts deriveShellCaptureContext).
284
+ */
285
+ export function routeLoaderLanes(
286
+ entries: readonly EntryData[],
287
+ ): ReadonlyMap<string, "live" | "bake"> {
288
+ const lanes = new Map<string, "live" | "bake">();
289
+ for (const entry of entries) {
290
+ for (const l of chainLoaderEntries(entry)) {
291
+ if (!l.bake) lanes.set(l.loader.$$id, "live");
292
+ else if (!lanes.has(l.loader.$$id)) lanes.set(l.loader.$$id, "bake");
293
+ }
294
+ }
295
+ return lanes;
296
+ }
297
+
202
298
  function getLoaderStore(
203
299
  loaderEntry: LoaderEntry,
204
300
  ): import("../../cache/types.js").SegmentCacheStore | null {
@@ -228,31 +324,47 @@ function getLoaderStore(
228
324
  * replayed one.
229
325
  * A PPR shell capture records the replayed pushes under this loader (its
230
326
  * push funnel reads the pushReplayed owner, shell-capture.ts), so a shell
231
- * HIT restores them owned: the claim above then skips this replay when the
232
- * loader runs on that HIT, and its live pushes replace the restored ones.
327
+ * HIT restores them owned (HandleStore.pushRestored). This replay still
328
+ * runs on that HIT: the store drops its settled values, which the record
329
+ * already restored, and keeps the thenable ones the record could not keep
330
+ * (a deferred push, the reason the loader's record carries `runs`).
233
331
  */
234
332
  async function replayLoaderHandles(
235
- encoded: string,
333
+ encoded: string | undefined,
236
334
  handleStore: HandleStore,
237
335
  segmentId: string,
336
+ cachedLoaderId: string,
238
337
  claim: ((loaderId: string) => boolean) | undefined,
239
338
  ): Promise<void> {
240
- const recorded = await decodeHandles(encoded);
339
+ const recorded = encoded ? await decodeHandles(encoded) : {};
241
340
  if (!recorded) return;
242
341
  const delivers = new Map<string, boolean>();
243
- for (const key in recorded) {
244
- const owner = key.slice(key.indexOf(":") + 1);
342
+ const claims = (owner: string): boolean => {
245
343
  let deliver = delivers.get(owner);
246
344
  if (deliver === undefined) {
247
345
  deliver = claim ? claim(owner) : true;
248
346
  delivers.set(owner, deliver);
249
347
  }
250
- if (!deliver) continue;
251
- for (const [handleName, values] of Object.entries(recorded[key])) {
252
- for (const value of values) {
253
- handleStore.pushReplayed(handleName, segmentId, value, owner);
348
+ return deliver;
349
+ };
350
+ const replay = (): void => {
351
+ for (const key in recorded) {
352
+ const owner = key.slice(key.indexOf(":") + 1);
353
+ if (!claims(owner)) continue;
354
+ for (const [handleName, values] of Object.entries(recorded[key])) {
355
+ for (const value of values) {
356
+ handleStore.pushReplayed(handleName, segmentId, value, owner);
357
+ }
254
358
  }
255
359
  }
360
+ };
361
+ // A hole's entry, pushes or none, takes the place of the shell copies of
362
+ // its pushes (HandleStore.redeliverReplays). Not once the hole delivered
363
+ // in this request: a run a reader started first replaces them itself.
364
+ if (claims(cachedLoaderId)) {
365
+ handleStore.redeliverReplays(cachedLoaderId, replay);
366
+ } else {
367
+ replay();
256
368
  }
257
369
  }
258
370
 
@@ -449,9 +561,10 @@ function executeLoaderData<TEnv>(
449
561
 
450
562
  // Evaluate runtime condition if provided
451
563
  const options = cacheConfig.options;
452
- if (options.condition) {
564
+ const condition = options.condition;
565
+ if (condition) {
453
566
  const requestCtx = getRequestContext();
454
- if (requestCtx && !options.condition(requestCtx)) {
567
+ if (requestCtx && !runIdentityExempt(() => condition(requestCtx))) {
455
568
  return ctx.use(loaderEntry.loader);
456
569
  }
457
570
  }
@@ -479,7 +592,7 @@ function executeLoaderData<TEnv>(
479
592
  // useLoader.
480
593
  linkLoaderTags(item.$$id);
481
594
  readValueTags(cached);
482
- return cached;
595
+ return readStartedLoaderValue(cached, item.$$id);
483
596
  }
484
597
  return originalUse(item);
485
598
  }) as typeof ctx.use;
@@ -508,11 +621,8 @@ function executeLoaderData<TEnv>(
508
621
  const swr = swrWindow || undefined;
509
622
  const tags = resolveTags(loaderEntry);
510
623
  recordLoaderTags(loaderId, tags);
511
- // Production arms at match, before any loader runs (match-api.ts,
512
- // bindsLoaderCache); no production path relies on this. It only keeps a
513
- // resolveLoaders call outside a match from running unarmed after this
514
- // point (loaders it started earlier stay unrecorded).
515
- armLoaderTagSets();
624
+ // The per-execution loader tag sets were armed before any loader ran: by
625
+ // the match (match-api.ts, bindsLoaderCache), or by runLoader.
516
626
  // An execution of this loader outside the binding (a reader started it
517
627
  // first, a stale refresh) answers for the cache() tags too.
518
628
  if (tags && tags.length > 0) {
@@ -530,8 +640,10 @@ function executeLoaderData<TEnv>(
530
640
  const isOwnBodyPush = () => isInsideLoaderBody(loaderId);
531
641
  // What a reader of this value takes on (tagLoaderValue below,
532
642
  // readValueTags): the cache() tags, plus the entry's tags on a HIT or the
533
- // execution's on a MISS.
643
+ // execution's on a MISS. A HIT's value is as old as its lookup: a cached
644
+ // reader gates its own write from here at the latest (#977).
534
645
  const valueTags = new Set(tags);
646
+ markTagSetStart(valueTags, executionStart());
535
647
 
536
648
  const dataPromise = (async () => {
537
649
  const codec = await getCodec();
@@ -560,7 +672,9 @@ function executeLoaderData<TEnv>(
560
672
  const bodyTags = new Set(tags);
561
673
  let revalidating = false;
562
674
  let hitHandles: string | undefined;
675
+ let cachedRead = false;
563
676
  const onCachedRead = (label: string, cached: CacheItemResult) => {
677
+ cachedRead = true;
564
678
  hitHandles = cached.handles;
565
679
  const { read } = unmarkIdentity(cached.value);
566
680
  if (read) markIdentityRead(valueTags, read);
@@ -634,6 +748,15 @@ function executeLoaderData<TEnv>(
634
748
  // After the handle encode: it settles pending pushes, which can read.
635
749
  const read = identityRead();
636
750
  const entryTags = [...flattenRecordedTags(bodyTags)];
751
+ // One of the entry's tags was invalidated after the value's oldest
752
+ // part started (#977): this execution, or the run of this loader or
753
+ // of a dependency another reader started earlier. Written now, a
754
+ // value that may predate it would outlive it. Skip the write; the
755
+ // next read runs the body.
756
+ const start = earliestRecordedStart(bodyTags);
757
+ if (start && (await predatesInvalidation(store, entryTags, start))) {
758
+ return;
759
+ }
637
760
  await store.setItem!(k, markIdentity(v, read), {
638
761
  ...o,
639
762
  tags: entryTags.length > 0 ? entryTags : undefined,
@@ -642,6 +765,7 @@ function executeLoaderData<TEnv>(
642
765
  }),
643
766
  key,
644
767
  execute: async () => {
768
+ markTagSetStart(bodyTags, executionStart());
645
769
  const value = await captureRecordedTags(bodyTags, runBody);
646
770
  identityRead();
647
771
  return value;
@@ -675,12 +799,14 @@ function executeLoaderData<TEnv>(
675
799
  host: requestCtxForExecute,
676
800
  });
677
801
 
678
- // An entry written before handles were recorded has none: no replay.
679
- if (hitHandles && handleStore && owningSegmentId) {
802
+ // An entry without handles (none recorded, or dropped by an encode
803
+ // timeout) replays none, and still stands for a hole's shell copies.
804
+ if (cachedRead && handleStore && owningSegmentId) {
680
805
  await replayLoaderHandles(
681
806
  hitHandles,
682
807
  handleStore,
683
808
  owningSegmentId,
809
+ loaderId,
684
810
  internal._claimLoaderPushes,
685
811
  );
686
812
  }
@@ -8,10 +8,9 @@
8
8
  * fresh into it.
9
9
  *
10
10
  * Capture mode is signalled by `requestCtx._shellCaptureRun`, set to true ONLY on
11
- * the derived request context of the background capture task (shell-capture.ts) —
12
- * NOT by the foreground render, whose `_shellCapture` descriptor merely means "a
13
- * capture is wanted" and must not change behavior. This module is the single home
14
- * for the mask so every loader execution site gates the same way (loader-cache.ts
11
+ * the derived request context of the background capture task (shell-capture.ts);
12
+ * the foreground render never sets it. This module is the single home for the
13
+ * mask so every loader execution site gates the same way (loader-cache.ts
15
14
  * `resolveLoaderData`, fresh.ts `resolveLoaders`).
16
15
  */
17
16
 
@@ -10,24 +10,18 @@
10
10
  * server-component element holding a promise, which is a hole as a whole (see
11
11
  * elideLoaderContainer):
12
12
  *
13
- * - elide: deep-walk the settled container; a PENDING nested promise is a
14
- * hole, replaced by {@link LOADER_HOLE_KEY} — and since
15
- * loader-cache masks every nested thenable at capture
16
- * ({@link maskNestedContainerThenables}), nested promises are
17
- * ALWAYS pending here for new captures. The SETTLED-marker branch
18
- * below is legacy: it fires only if a settled thenable reaches
19
- * elide anyway, and the overlay keeps decoding SETTLED markers
20
- * from pre-mask snapshots. The result is promise-free and
21
- * Flight-serializable.
13
+ * - elide: deep-walk the settled container; a nested promise is a hole,
14
+ * replaced by {@link LOADER_HOLE_KEY}. loader-cache masks every
15
+ * nested thenable at capture ({@link maskNestedContainerThenables},
16
+ * a never-settling promise), so the promise shape alone decides:
17
+ * a value that settled in time does not bake (#692). The result
18
+ * is promise-free and Flight-serializable.
22
19
  * - overlay: on a HIT the loader runs fresh (only the loader body can mint
23
20
  * the live nested promises), then the recorded container is laid
24
21
  * over it: recorded paths win (they are what the prelude froze),
25
22
  * hole-marker paths take the fresh run's value (the live hole),
26
- * SETTLED-marker paths become Promise.resolve(pinned) — consumers
27
- * wrote use(data.x) against a promise-shaped container, and
28
- * handing them the raw value throws React #438 on every HIT (the
29
- * storefront PDP regression) — and fresh-only paths pass through
30
- * (they cannot contradict prelude bytes that never rendered them).
23
+ * and fresh-only paths pass through (they cannot contradict
24
+ * prelude bytes that never rendered them).
31
25
  */
32
26
 
33
27
  import { isValidElement, type ReactElement } from "react";
@@ -64,37 +58,6 @@ export function isLoaderHoleMarker(value: unknown): value is LoaderHoleMarker {
64
58
  );
65
59
  }
66
60
 
67
- /**
68
- * Marker wrapping the inlined value of a NESTED promise that settled during
69
- * capture. The value baked (physics), but the container key was a promise —
70
- * the overlay must hand consumers a Promise.resolve(value), not the raw value,
71
- * or an unconditional use(data.x) throws React #438 on every HIT. The ROOT
72
- * container is never wrapped: loader-cache overlays against the awaited fresh
73
- * container value.
74
- */
75
- export const LOADER_SETTLED_KEY = "$rangoLoaderSettled" as const;
76
-
77
- export interface LoaderSettledMarker {
78
- [LOADER_SETTLED_KEY]: 1;
79
- value: unknown;
80
- /**
81
- * Present when the pinned subtree contains hole markers. Computed once at
82
- * capture (elide already visits every node) so the per-HIT overlay never
83
- * rescans the pinned structure to pick its rehydration path.
84
- */
85
- holes?: 1;
86
- }
87
-
88
- export function isLoaderSettledMarker(
89
- value: unknown,
90
- ): value is LoaderSettledMarker {
91
- return (
92
- typeof value === "object" &&
93
- value !== null &&
94
- (value as Record<string, unknown>)[LOADER_SETTLED_KEY] === 1
95
- );
96
- }
97
-
98
61
  /**
99
62
  * Probe whether a promise is already settled without waiting for it: races it
100
63
  * against an immediately-resolved sentinel across two microtask hops (then
@@ -128,12 +91,12 @@ export type ElideResult =
128
91
  | { state: "rejected" };
129
92
 
130
93
  /**
131
- * Deep-elide a settled bake-lane container for recording. Settled nested
132
- * promises pin their value behind a settled marker (they baked, but consumers
133
- * hold a promise-shaped key); pending ones become hole markers; a REJECTED
134
- * nested promise poisons the record (error UI must never bake into a shared
135
- * shell) — the caller refuses the capture. Plain data objects, arrays and the
136
- * props of host, Suspense/Fragment and client-reference elements are
94
+ * Deep-elide a settled bake-lane container for recording. Nested promises
95
+ * become hole markers: the capture masked each of them (never settles), so
96
+ * it postponed as a hole. A REJECTED container poisons the record (error UI
97
+ * must never bake into a shared shell) — the caller refuses the capture.
98
+ * Plain data objects, arrays and the props of host, Suspense/Fragment and
99
+ * client-reference elements are
137
100
  * traversed copy-on-write (an element through cloneElementWithProps); a shared
138
101
  * subtree or a cycle resolves to the same elided result. An element Flight
139
102
  * renders by calling user code (see rendersOnServer) is a leaf, or ONE hole
@@ -165,17 +128,7 @@ async function elideNested(
165
128
  seen: Map<object, ElideResult>,
166
129
  ): Promise<ElideResult> {
167
130
  if (isThenable(value)) {
168
- const probed = await probeSettled(value);
169
- if (probed.state === "pending") {
170
- return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true };
171
- }
172
- if (probed.state === "rejected") return { state: "rejected" };
173
- const inner = await elideNested(probed.value, seen);
174
- if (inner.state === "rejected") return inner;
175
- const marker: LoaderSettledMarker = inner.hasHole
176
- ? { [LOADER_SETTLED_KEY]: 1, value: inner.value, holes: 1 }
177
- : { [LOADER_SETTLED_KEY]: 1, value: inner.value };
178
- return { state: "ok", value: marker, hasHole: inner.hasHole };
131
+ return { state: "ok", value: { [LOADER_HOLE_KEY]: 1 }, hasHole: true };
179
132
  }
180
133
  if (typeof value !== "object" || value === null) {
181
134
  return { state: "ok", value, hasHole: false };
@@ -324,22 +277,6 @@ export function overlayLoaderContainer(
324
277
  ): unknown {
325
278
  if (isLoaderHoleMarker(recorded)) return fresh;
326
279
 
327
- if (isLoaderSettledMarker(recorded)) {
328
- const pinned = recorded.value;
329
- // Deep holes inside a settled container (capture-computed `holes` bit)
330
- // need the fresh promise's resolved value to fill them; a fully-pinned
331
- // container resolves immediately (the prelude already shows it — never
332
- // gate it on fresh latency). A rejecting fresh run degrades holes to
333
- // undefined instead of poisoning the pin.
334
- if (recorded.holes === 1) {
335
- return Promise.resolve(fresh).then(
336
- (freshValue) => overlayLoaderContainer(freshValue, pinned),
337
- () => overlayLoaderContainer(undefined, pinned),
338
- );
339
- }
340
- return Promise.resolve(overlayLoaderContainer(undefined, pinned));
341
- }
342
-
343
280
  if (Array.isArray(recorded)) {
344
281
  const freshArr = Array.isArray(fresh) ? fresh : [];
345
282
  let out: unknown[] | undefined;
@@ -81,30 +81,55 @@ export function cloneElementWithProps(
81
81
  */
82
82
  export function settleNestedThenables(value: unknown): Promise<void> {
83
83
  const pending: Promise<void>[] = [];
84
- const seen = new Set<object>();
85
- const walk = (v: unknown): void => {
86
- if (isThenable(v)) {
87
- pending.push(
88
- Promise.resolve(v).then(settleNestedThenables, () => undefined),
89
- );
90
- return;
91
- }
92
- if (isValidElement<Record<string, unknown>>(v)) {
93
- walk(v.props);
94
- return;
95
- }
96
- if (Array.isArray(v) || isPlainDataObject(v)) {
97
- if (seen.has(v)) return;
98
- seen.add(v);
99
- for (const item of Array.isArray(v) ? v : Object.values(v)) walk(item);
100
- }
101
- };
102
- walk(value);
84
+ someNestedThenable(value, (thenable) => {
85
+ pending.push(
86
+ Promise.resolve(thenable).then(settleNestedThenables, () => undefined),
87
+ );
88
+ return false;
89
+ });
103
90
  return pending.length === 0
104
91
  ? Promise.resolve()
105
92
  : Promise.all(pending).then(() => undefined);
106
93
  }
107
94
 
95
+ /**
96
+ * The walk settleNestedThenables and holdsThenable share: visit each thenable
97
+ * reachable from `value` through plain data objects, arrays and React element
98
+ * props (never an element's owner or type), the shapes
99
+ * maskNestedContainerThenables masks; every other object is a leaf. Stops at
100
+ * the first visit that returns true and reports whether one did.
101
+ */
102
+ function someNestedThenable(
103
+ value: unknown,
104
+ visit: (thenable: PromiseLike<unknown>) => boolean,
105
+ ): boolean {
106
+ let seen: Set<object> | undefined;
107
+ const walk = (v: unknown): boolean => {
108
+ if (isThenable(v)) return visit(v);
109
+ if (typeof v !== "object" || v === null) return false;
110
+ if (isValidElement<Record<string, unknown>>(v)) return walk(v.props);
111
+ if (!Array.isArray(v) && !isPlainDataObject(v)) return false;
112
+ seen ??= new Set();
113
+ if (seen.has(v)) return false;
114
+ seen.add(v);
115
+ // Own enumerable values, as maskNestedContainerThenables walks
116
+ // Object.keys: an array's non-index properties are walked too.
117
+ return Object.values(v).some(walk);
118
+ };
119
+ return walk(value);
120
+ }
121
+
122
+ /**
123
+ * True when `value` is a thenable or holds one where the capture's loader
124
+ * push funnel masks it (maskNestedContainerThenables): through plain data
125
+ * objects, arrays and React element props. Such a push stays out of a shell's
126
+ * record and reaches a HIT only from a run of its loader there, so a HIT
127
+ * keeps it; a push without one is the record's (HandleStore.pushRestored).
128
+ */
129
+ export function holdsThenable(value: unknown): boolean {
130
+ return someNestedThenable(value, () => true);
131
+ }
132
+
108
133
  /**
109
134
  * Optional single-walk report for maskNestedContainerThenables: `thenable`
110
135
  * flips true when the walk masked at least one thenable. The capture push
@@ -139,9 +164,9 @@ export interface MaskReport {
139
164
  * nodes included, are leaves. Copy-on-write: a subtree without a thenable
140
165
  * comes back by identity, so an element is rebuilt only when its props hold
141
166
  * one, and then through cloneElementWithProps, which keeps its type, key,
142
- * owner, dev fields and key validation. The INPUT IS NEVER MUTATED — handler-side loader consumption (the
143
- * consumption-lane rule, semantic-matrix PPR3) shares the raw container and
144
- * must keep real values. Cycles are preserved as cycles in the copy.
167
+ * owner, dev fields and key validation. The INPUT IS NEVER MUTATED: a handler
168
+ * that reads the loader (`ctx.use`) reads the raw container and must keep
169
+ * real values. Cycles are preserved as cycles in the copy.
145
170
  */
146
171
  export function maskNestedContainerThenables(
147
172
  value: unknown,