@rangojs/router 0.15.1 → 0.17.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 (249) hide show
  1. package/README.md +254 -89
  2. package/dist/testing/vitest.js +252 -0
  3. package/dist/types/browser/navigation-transaction.d.ts +6 -0
  4. package/dist/types/browser/partial-update.d.ts +11 -0
  5. package/dist/types/browser/react/ScrollRestoration.d.ts +6 -6
  6. package/dist/types/browser/rsc-router.d.ts +1 -1
  7. package/dist/types/browser/scroll-restoration.d.ts +7 -0
  8. package/dist/types/cache/cache-exec-scope.d.ts +18 -1
  9. package/dist/types/cache/cache-scope.d.ts +34 -5
  10. package/dist/types/cache/handle-capture.d.ts +41 -3
  11. package/dist/types/cache/handle-snapshot.d.ts +53 -13
  12. package/dist/types/cache/segment-codec.d.ts +10 -3
  13. package/dist/types/cache/taint.d.ts +1 -1
  14. package/dist/types/cache/types.d.ts +15 -3
  15. package/dist/types/client.d.ts +4 -4
  16. package/dist/types/client.rsc.d.ts +4 -5
  17. package/dist/types/components/DefaultDocument.d.ts +5 -2
  18. package/dist/types/context-var.d.ts +6 -5
  19. package/dist/types/escape-script.d.ts +1 -1
  20. package/dist/types/handles/MetaTags.d.ts +5 -4
  21. package/dist/types/handles/Scripts.d.ts +8 -7
  22. package/dist/types/handles/meta.d.ts +2 -2
  23. package/dist/types/handles/script.d.ts +17 -16
  24. package/dist/types/href-client.d.ts +1 -1
  25. package/dist/types/html.d.ts +12 -0
  26. package/dist/types/index.d.ts +1 -1
  27. package/dist/types/index.rsc.d.ts +3 -2
  28. package/dist/types/loader-store.d.ts +52 -0
  29. package/dist/types/reverse.d.ts +36 -4
  30. package/dist/types/route-definition/dsl-helpers.d.ts +3 -3
  31. package/dist/types/route-definition/helpers-types.d.ts +36 -10
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +3 -1
  34. package/dist/types/router/debug-manifest.d.ts +3 -2
  35. package/dist/types/router/error-handling.d.ts +20 -0
  36. package/dist/types/router/handler-context.d.ts +6 -0
  37. package/dist/types/router/match-middleware/background-revalidation.d.ts +46 -5
  38. package/dist/types/router/middleware-types.d.ts +3 -2
  39. package/dist/types/router/router-context.d.ts +3 -1
  40. package/dist/types/router/router-interfaces.d.ts +24 -2
  41. package/dist/types/router/router-options.d.ts +8 -5
  42. package/dist/types/router/segment-resolution/fresh.d.ts +10 -0
  43. package/dist/types/router/segment-resolution/loader-cache.d.ts +37 -1
  44. package/dist/types/router/segment-resolution/loader-mask.d.ts +9 -19
  45. package/dist/types/router/segment-resolution/loader-snapshot.d.ts +3 -3
  46. package/dist/types/router/segment-resolution/mask-nested.d.ts +7 -7
  47. package/dist/types/router/segment-resolution/revalidation.d.ts +6 -2
  48. package/dist/types/router/segment-wrappers.d.ts +1 -1
  49. package/dist/types/rsc/response-route-handler.d.ts +1 -1
  50. package/dist/types/rsc/rsc-rendering.d.ts +1 -2
  51. package/dist/types/rsc/shell-capture-constants.d.ts +8 -0
  52. package/dist/types/rsc/shell-capture.d.ts +5 -4
  53. package/dist/types/rsc/ssr-setup.d.ts +6 -6
  54. package/dist/types/rsc/types.d.ts +10 -0
  55. package/dist/types/server/context.d.ts +33 -3
  56. package/dist/types/server/handle-store.d.ts +26 -2
  57. package/dist/types/server/request-context.d.ts +24 -21
  58. package/dist/types/ssr/index.d.ts +15 -0
  59. package/dist/types/static-handler.d.ts +4 -1
  60. package/dist/types/testing/generated-routes.d.ts +2 -2
  61. package/dist/types/testing/internal/context.d.ts +1 -1
  62. package/dist/types/testing/render-route.d.ts +32 -6
  63. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +28 -5
  64. package/dist/types/testing/vitest.d.ts +35 -1
  65. package/dist/types/theme/ThemeScript.d.ts +3 -3
  66. package/dist/types/thenable-status.d.ts +9 -0
  67. package/dist/types/types/cache-types.d.ts +14 -10
  68. package/dist/types/types/handler-context.d.ts +14 -0
  69. package/dist/types/types/loader-types.d.ts +8 -6
  70. package/dist/types/urls/response-types.d.ts +3 -12
  71. package/dist/types/use-loader.d.ts +12 -3
  72. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  73. package/dist/types/vite/rango.d.ts +1 -1
  74. package/dist/vite/index.js +884 -768
  75. package/package.json +4 -4
  76. package/skills/api-client/SKILL.md +22 -10
  77. package/skills/breadcrumbs/SKILL.md +65 -22
  78. package/skills/bundle-analysis/SKILL.md +22 -22
  79. package/skills/cache-guide/SKILL.md +207 -97
  80. package/skills/caching/SKILL.md +295 -93
  81. package/skills/catalog.json +16 -16
  82. package/skills/client-urls/SKILL.md +53 -31
  83. package/skills/cloudflare/SKILL.md +72 -20
  84. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  85. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  86. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  87. package/skills/comparison/SKILL.md +5 -0
  88. package/skills/comparison/references/framework-comparison.md +39 -33
  89. package/skills/composability/SKILL.md +38 -29
  90. package/skills/css/SKILL.md +10 -13
  91. package/skills/debug-manifest/SKILL.md +74 -31
  92. package/skills/defer-hydration/SKILL.md +2 -1
  93. package/skills/deployment-caching/SKILL.md +6 -1
  94. package/skills/document-cache/SKILL.md +92 -28
  95. package/skills/fonts/SKILL.md +16 -8
  96. package/skills/handler-use/SKILL.md +53 -17
  97. package/skills/hooks/SKILL.md +28 -20
  98. package/skills/hooks/data.md +43 -1
  99. package/skills/hooks/handle-and-actions.md +31 -16
  100. package/skills/hooks/navigation.md +39 -10
  101. package/skills/hooks/outlets.md +13 -6
  102. package/skills/hooks/state.md +37 -15
  103. package/skills/hooks/urls.md +7 -5
  104. package/skills/host-router/SKILL.md +64 -12
  105. package/skills/i18n/SKILL.md +60 -34
  106. package/skills/intercept/SKILL.md +161 -26
  107. package/skills/layout/SKILL.md +106 -50
  108. package/skills/links/SKILL.md +51 -19
  109. package/skills/loader/SKILL.md +242 -106
  110. package/skills/middleware/SKILL.md +119 -23
  111. package/skills/migrate-nextjs/SKILL.md +100 -45
  112. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  113. package/skills/migrate-react-router/SKILL.md +49 -38
  114. package/skills/migrate-react-router/component-migration.md +25 -6
  115. package/skills/migrate-react-router/data-and-actions.md +18 -5
  116. package/skills/migrate-react-router/route-mapping.md +12 -6
  117. package/skills/mime-routes/SKILL.md +12 -7
  118. package/skills/observability/SKILL.md +28 -14
  119. package/skills/parallel/SKILL.md +75 -39
  120. package/skills/ppr/SKILL.md +345 -150
  121. package/skills/prerender/SKILL.md +189 -100
  122. package/skills/rango/SKILL.md +65 -34
  123. package/skills/react-compiler/SKILL.md +6 -5
  124. package/skills/response-routes/SKILL.md +133 -25
  125. package/skills/route/SKILL.md +165 -60
  126. package/skills/router-setup/SKILL.md +418 -240
  127. package/skills/scripts/SKILL.md +31 -20
  128. package/skills/server-actions/SKILL.md +220 -59
  129. package/skills/shell-manifest/SKILL.md +12 -7
  130. package/skills/streams-and-websockets/SKILL.md +32 -14
  131. package/skills/tailwind/SKILL.md +37 -33
  132. package/skills/testing/SKILL.md +68 -32
  133. package/skills/testing/cache-prerender.md +55 -29
  134. package/skills/testing/client-components.md +59 -6
  135. package/skills/testing/e2e-parity.md +12 -2
  136. package/skills/testing/flight.md +19 -11
  137. package/skills/testing/handles.md +8 -5
  138. package/skills/testing/loader.md +8 -5
  139. package/skills/testing/middleware.md +8 -3
  140. package/skills/testing/predicates.md +120 -0
  141. package/skills/testing/render-handler.md +70 -22
  142. package/skills/testing/response-routes.md +11 -4
  143. package/skills/testing/reverse-and-types.md +20 -14
  144. package/skills/testing/server-actions.md +12 -3
  145. package/skills/testing/server-tree.md +9 -0
  146. package/skills/testing/setup.md +9 -5
  147. package/skills/theme/SKILL.md +106 -29
  148. package/skills/typesafety/SKILL.md +20 -9
  149. package/skills/typesafety/env-and-bindings.md +126 -47
  150. package/skills/typesafety/generated-files-and-cli.md +26 -14
  151. package/skills/typesafety/params-and-search.md +44 -19
  152. package/skills/typesafety/route-types.md +50 -14
  153. package/skills/use-cache/SKILL.md +200 -82
  154. package/skills/vercel/SKILL.md +21 -11
  155. package/skills/view-transitions/SKILL.md +41 -20
  156. package/src/__augment-tests__/augmented.check.ts +26 -1
  157. package/src/browser/index.ts +2 -2
  158. package/src/browser/navigation-bridge.ts +3 -4
  159. package/src/browser/navigation-transaction.ts +22 -2
  160. package/src/browser/partial-update.ts +75 -80
  161. package/src/browser/react/ScrollRestoration.tsx +6 -6
  162. package/src/browser/rsc-router.tsx +1 -1
  163. package/src/browser/scroll-restoration.ts +53 -6
  164. package/src/cache/cache-exec-scope.ts +37 -4
  165. package/src/cache/cache-key-utils.ts +1 -6
  166. package/src/cache/cache-runtime.ts +230 -61
  167. package/src/cache/cache-scope.ts +76 -12
  168. package/src/cache/document-cache.ts +15 -0
  169. package/src/cache/handle-capture.ts +90 -10
  170. package/src/cache/handle-snapshot.ts +132 -28
  171. package/src/cache/segment-codec.ts +26 -7
  172. package/src/cache/tag-invalidation.ts +4 -3
  173. package/src/cache/taint.ts +1 -1
  174. package/src/cache/types.ts +16 -3
  175. package/src/client.rsc.tsx +10 -9
  176. package/src/client.tsx +5 -4
  177. package/src/components/DefaultDocument.tsx +7 -2
  178. package/src/context-var.ts +6 -5
  179. package/src/escape-script.ts +1 -1
  180. package/src/handles/MetaTags.tsx +13 -11
  181. package/src/handles/Scripts.tsx +11 -10
  182. package/src/handles/meta.ts +2 -2
  183. package/src/handles/script.ts +17 -16
  184. package/src/href-client.ts +1 -1
  185. package/src/html.ts +12 -0
  186. package/src/index.rsc.ts +5 -0
  187. package/src/index.ts +1 -0
  188. package/src/loader-store.ts +106 -0
  189. package/src/prerender/build-shell-capture.ts +8 -11
  190. package/src/reverse.ts +44 -4
  191. package/src/route-definition/dsl-helpers.ts +134 -42
  192. package/src/route-definition/helpers-types.ts +36 -10
  193. package/src/route-definition/resolve-handler-use.ts +121 -6
  194. package/src/route-types.ts +2 -3
  195. package/src/router/debug-manifest.ts +10 -2
  196. package/src/router/error-handling.ts +51 -15
  197. package/src/router/handler-context.ts +34 -25
  198. package/src/router/intercept-resolution.ts +125 -39
  199. package/src/router/loader-resolution.ts +56 -11
  200. package/src/router/match-api.ts +12 -17
  201. package/src/router/match-middleware/background-revalidation.ts +161 -96
  202. package/src/router/match-middleware/cache-lookup.ts +93 -10
  203. package/src/router/match-middleware/cache-store.ts +17 -90
  204. package/src/router/middleware-types.ts +3 -5
  205. package/src/router/middleware.ts +52 -9
  206. package/src/router/prerender-match.ts +53 -19
  207. package/src/router/route-snapshot.ts +11 -1
  208. package/src/router/router-context.ts +2 -0
  209. package/src/router/router-interfaces.ts +25 -2
  210. package/src/router/router-options.ts +8 -5
  211. package/src/router/segment-resolution/fresh.ts +44 -12
  212. package/src/router/segment-resolution/loader-cache.ts +205 -45
  213. package/src/router/segment-resolution/loader-mask.ts +12 -22
  214. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  215. package/src/router/segment-resolution/mask-nested.ts +7 -7
  216. package/src/router/segment-resolution/revalidation.ts +34 -6
  217. package/src/router/segment-wrappers.ts +4 -0
  218. package/src/rsc/render-pipeline.ts +3 -0
  219. package/src/rsc/response-route-handler.ts +11 -3
  220. package/src/rsc/rsc-rendering.ts +8 -45
  221. package/src/rsc/shell-capture-constants.ts +13 -0
  222. package/src/rsc/shell-capture.ts +111 -90
  223. package/src/rsc/ssr-setup.ts +14 -8
  224. package/src/rsc/types.ts +11 -0
  225. package/src/server/context.ts +88 -11
  226. package/src/server/cookie-store.ts +5 -4
  227. package/src/server/handle-store.ts +232 -7
  228. package/src/server/request-context.ts +35 -30
  229. package/src/ssr/index.tsx +40 -7
  230. package/src/ssr-suspension-warning.ts +2 -13
  231. package/src/static-handler.ts +4 -1
  232. package/src/testing/flight-runtime.d.ts +21 -0
  233. package/src/testing/generated-routes.ts +2 -2
  234. package/src/testing/internal/context.ts +1 -1
  235. package/src/testing/internal/flight-client-globals.ts +7 -5
  236. package/src/testing/render-route.tsx +85 -24
  237. package/src/testing/run-loader.ts +5 -1
  238. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  239. package/src/testing/vitest.ts +62 -1
  240. package/src/theme/ThemeProvider.tsx +14 -12
  241. package/src/theme/ThemeScript.tsx +3 -3
  242. package/src/thenable-status.ts +16 -0
  243. package/src/types/cache-types.ts +14 -10
  244. package/src/types/handler-context.ts +14 -0
  245. package/src/types/loader-types.ts +8 -6
  246. package/src/urls/response-types.ts +3 -23
  247. package/src/use-loader.tsx +24 -4
  248. package/src/vite/plugins/use-cache-transform.ts +118 -74
  249. package/src/vite/rango.ts +7 -3
@@ -106,6 +106,8 @@ export type CacheItem = {
106
106
  export type TransitionItem = {
107
107
  name: string;
108
108
  type: "transition";
109
+ /** Wrapper form only: the children it wraps (see hasRoutesInItem). */
110
+ uses?: AllUseItems[];
109
111
  };
110
112
 
111
113
  /**
@@ -220,11 +222,8 @@ export type ParallelUseItem =
220
222
  | TransitionItem;
221
223
  export type InterceptUseItem =
222
224
  | MiddlewareItem
223
- | RevalidateItem
224
225
  | LoaderItem
225
226
  | LoadingItem
226
- | ErrorBoundaryItem
227
- | NotFoundBoundaryItem
228
227
  | LayoutItem
229
228
  | RouteItem
230
229
  | TransitionItem;
@@ -5,8 +5,9 @@ import MapRootLayout from "../server/root-layout.js";
5
5
  import type { RouteEntry, TrailingSlashMode } from "../types";
6
6
 
7
7
  /**
8
- * Build a serialized manifest from all route entries for debug inspection.
9
- * Used by the `debugManifest()` method on the router instance.
8
+ * Build a serialized manifest from the eager route entries for debug
9
+ * inspection. Used by the `debugManifest()` method on the router instance.
10
+ * include() children are not expanded (lazy placeholders are skipped).
10
11
  */
11
12
  export async function buildDebugManifest<TEnv = any>(
12
13
  routesEntries: RouteEntry<TEnv>[],
@@ -14,6 +15,13 @@ export async function buildDebugManifest<TEnv = any>(
14
15
  const manifest = new Map<string, EntryData>();
15
16
 
16
17
  for (const entry of routesEntries) {
18
+ // Lazy include placeholders are not mounts: a top-level one carries the
19
+ // parent urls() handler (router.ts routes()), so re-running it re-registers
20
+ // the parent's routes into the shared manifest (path() duplicate-name
21
+ // invariant). A nested one (lazy-includes.ts runExpansion()) is only valid
22
+ // under its captured include context.
23
+ if (entry.lazy) continue;
24
+
17
25
  const Store = {
18
26
  manifest,
19
27
  namespace: `debug.M${entry.mountIndex}`,
@@ -105,31 +105,67 @@ export function invokeOnError<TEnv = any>(
105
105
  }
106
106
  }
107
107
 
108
+ /**
109
+ * First non-undefined `pick(orphan)` over the routeless entries in
110
+ * `entry.layout` and, recursively, in theirs: depth-first in render order
111
+ * (resolveOrphanLayout in segment-resolution/fresh.ts). Since #922 a routeless
112
+ * entry nested in another renders (a layout() after a bare cache() marker, a
113
+ * layout() in a routeless wrapper); a one-level scan missed the boundaries and
114
+ * intercepts it declares (#926).
115
+ *
116
+ * `skip` is the entry an upward walk came from. Only a bare cache() marker is
117
+ * both in its layout's layout[] and in the chain, and its subtree was already
118
+ * scanned at its chain position.
119
+ */
120
+ export function findInOrphans<T>(
121
+ entry: EntryData,
122
+ pick: (orphan: EntryData) => T | undefined,
123
+ skip?: EntryData | null,
124
+ ): T | undefined {
125
+ for (const orphan of entry.layout ?? []) {
126
+ if (orphan === skip) continue;
127
+ const found = pick(orphan) ?? findInOrphans(orphan, pick);
128
+ if (found !== undefined) return found;
129
+ }
130
+ return undefined;
131
+ }
132
+
133
+ /** `findInOrphans` pick: the orphan when it declares an errorBoundary(). */
134
+ export const withErrorBoundary = (entry: EntryData): EntryData | undefined =>
135
+ entry.errorBoundary && entry.errorBoundary.length > 0 ? entry : undefined;
136
+
137
+ const withNotFoundBoundary = (entry: EntryData): EntryData | undefined =>
138
+ entry.notFoundBoundary && entry.notFoundBoundary.length > 0
139
+ ? entry
140
+ : undefined;
141
+
108
142
  /**
109
143
  * Find the nearest error boundary by walking up the entry chain
110
144
  * Also checks sibling layouts (orphan layouts) for error boundaries
111
145
  * Returns the first fallback found, or the default error boundary if configured
146
+ *
147
+ * An orphan's parent is null, so from an orphan the walk continues at its
148
+ * orphanOwner (EntryData). Without that step, errors from an intercept declared
149
+ * in a routeless layout, or from that layout's loaders, stopped at the orphan
150
+ * and skipped every ancestor boundary (#898).
112
151
  */
113
152
  export function findNearestErrorBoundary(
114
153
  entry: EntryData | null,
115
154
  defaultErrorBoundary?: ReactNode | ErrorBoundaryHandler,
116
155
  ): ReactNode | ErrorBoundaryHandler | null {
117
156
  let current: EntryData | null = entry;
157
+ let prev: EntryData | null = null;
118
158
 
119
159
  while (current) {
120
160
  if (current.errorBoundary && current.errorBoundary.length > 0) {
121
161
  return current.errorBoundary[current.errorBoundary.length - 1];
122
162
  }
123
163
 
124
- if (current.layout && current.layout.length > 0) {
125
- for (const orphan of current.layout) {
126
- if (orphan.errorBoundary && orphan.errorBoundary.length > 0) {
127
- return orphan.errorBoundary[orphan.errorBoundary.length - 1];
128
- }
129
- }
130
- }
164
+ const orphan = findInOrphans(current, withErrorBoundary, prev);
165
+ if (orphan) return orphan.errorBoundary[orphan.errorBoundary.length - 1];
131
166
 
132
- current = current.parent;
167
+ prev = current;
168
+ current = current.parent ?? current.orphanOwner ?? null;
133
169
  }
134
170
 
135
171
  // Return default error boundary if configured
@@ -145,6 +181,7 @@ export function findNearestNotFoundBoundary(
145
181
  defaultNotFoundBoundary?: ReactNode | NotFoundBoundaryHandler,
146
182
  ): ReactNode | NotFoundBoundaryHandler | null {
147
183
  let current: EntryData | null = entry;
184
+ let prev: EntryData | null = null;
148
185
 
149
186
  while (current) {
150
187
  if (current.notFoundBoundary && current.notFoundBoundary.length > 0) {
@@ -154,15 +191,14 @@ export function findNearestNotFoundBoundary(
154
191
  // Check orphan layouts mirroring findNearestErrorBoundary: notFoundBoundary
155
192
  // attaches identically (onto parent.notFoundBoundary), and an orphan layout
156
193
  // (parent=null) is reachable only via this scan. First sibling is "outer".
157
- if (current.layout && current.layout.length > 0) {
158
- for (const orphan of current.layout) {
159
- if (orphan.notFoundBoundary && orphan.notFoundBoundary.length > 0) {
160
- return orphan.notFoundBoundary[orphan.notFoundBoundary.length - 1];
161
- }
162
- }
194
+ const orphan = findInOrphans(current, withNotFoundBoundary, prev);
195
+ if (orphan) {
196
+ return orphan.notFoundBoundary[orphan.notFoundBoundary.length - 1];
163
197
  }
164
198
 
165
- current = current.parent;
199
+ // Orphan: continue at its owner, as findNearestErrorBoundary does.
200
+ prev = current;
201
+ current = current.parent ?? current.orphanOwner ?? null;
166
202
  }
167
203
 
168
204
  // Return default notFound boundary if configured
@@ -16,7 +16,7 @@ import {
16
16
  } from "../context-var.js";
17
17
  import {
18
18
  assertCachedHeaderWriteAllowed,
19
- isInsideCacheScope,
19
+ assertNonCacheableReadAllowed,
20
20
  } from "../server/context.js";
21
21
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
22
22
  import { isAutoGeneratedRouteName } from "../route-name.js";
@@ -29,6 +29,32 @@ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
29
29
  // once at module load instead of per createHandlerContext (per-request) call.
30
30
  const MUTATING_HEADERS_METHODS = new Set(["set", "append", "delete"]);
31
31
 
32
+ /**
33
+ * `headers` whose mutating methods run `assertWrite(method)` first. Shared by
34
+ * the handler ctx (createHandlerContext) and the middleware ctx
35
+ * (createMiddlewareContext).
36
+ */
37
+ export function guardHeaderWrites(
38
+ headers: Headers,
39
+ assertWrite: (method: string | symbol) => void,
40
+ ): Headers {
41
+ return new Proxy(headers, {
42
+ get(target, prop, receiver) {
43
+ const value = Reflect.get(target, prop, receiver);
44
+ if (typeof value === "function") {
45
+ if (MUTATING_HEADERS_METHODS.has(prop as string)) {
46
+ return (...args: any[]) => {
47
+ assertWrite(prop);
48
+ return value.apply(target, args);
49
+ };
50
+ }
51
+ return value.bind(target);
52
+ }
53
+ return value;
54
+ },
55
+ });
56
+ }
57
+
32
58
  /**
33
59
  * Strip internal _rsc* query params from a URL.
34
60
  * Returns a new URL with only user-facing params.
@@ -224,23 +250,10 @@ export function createHandlerContext<TEnv>(
224
250
  // assertCachedHeaderWriteAllowed, the unified #713 guard).
225
251
  // Uses lazy `ctx` reference (assigned below) — only the specific handler ctx
226
252
  // is stamped by cache-runtime, not the shared request context.
227
- // MUTATING_HEADERS_METHODS is hoisted to module scope (constant, read-only).
228
253
  let ctx: InternalHandlerContext<any, TEnv>;
229
- const guardedHeaders = new Proxy(stubResponse.headers, {
230
- get(target, prop, receiver) {
231
- const value = Reflect.get(target, prop, receiver);
232
- if (typeof value === "function") {
233
- if (MUTATING_HEADERS_METHODS.has(prop as string)) {
234
- return (...args: any[]) => {
235
- assertNotInsideCacheExec(ctx, "headers");
236
- assertCachedHeaderWriteAllowed("ctx.headers", prop);
237
- return value.apply(target, args);
238
- };
239
- }
240
- return value.bind(target);
241
- }
242
- return value;
243
- },
254
+ const guardedHeaders = guardHeaderWrites(stubResponse.headers, (prop) => {
255
+ assertNotInsideCacheExec(ctx, "headers");
256
+ assertCachedHeaderWriteAllowed("ctx.headers", prop);
244
257
  });
245
258
 
246
259
  ctx = {
@@ -263,14 +276,10 @@ export function createHandlerContext<TEnv>(
263
276
  executionContext: requestContext?.executionContext,
264
277
  _variables: variables,
265
278
  get: ((keyOrVar: any) => {
266
- // Read-time guard: non-cacheable var inside cache() → throw.
267
- // Works for both ContextVar tokens and string keys.
268
- if (isNonCacheable(variables, keyOrVar) && isInsideCacheScope()) {
269
- throw new Error(
270
- `ctx.get() for a non-cacheable variable cannot be called inside a cache() boundary. ` +
271
- `The variable was created with { cache: false } or set with { cache: false }, ` +
272
- `and its value would be stale on cache hit. Move the read outside the cached scope.`,
273
- );
279
+ // Read-time guard: non-cacheable var inside "use cache" or cache() →
280
+ // throw. Works for both ContextVar tokens and string keys.
281
+ if (isNonCacheable(variables, keyOrVar)) {
282
+ assertNonCacheableReadAllowed(keyOrVar);
274
283
  }
275
284
  return contextGet(variables, keyOrVar);
276
285
  }) as HandlerContext<any, TEnv>["get"],
@@ -26,8 +26,12 @@ import {
26
26
  warnOnStreamedResponse,
27
27
  buildLoaderErrorContext,
28
28
  } from "./segment-resolution.js";
29
+ import { catchSegmentError } from "./segment-resolution/helpers.js";
30
+ import { findInOrphans } from "./error-handling.js";
31
+ import { resolveLoaderData } from "./segment-resolution/loader-cache.js";
29
32
  import type { SegmentResolutionDeps } from "./types.js";
30
33
  import { debugLog } from "./logging.js";
34
+ import { getRouterContext } from "./router-context.js";
31
35
  import {
32
36
  RangoContext,
33
37
  latchPprHeaderScopeForEntries,
@@ -101,15 +105,20 @@ export function collectInterceptTargetNames(
101
105
  fromEntry: EntryData | null,
102
106
  ): string[] {
103
107
  const names = new Set<string>();
108
+ // Tolerate partial entries (unit-test mocks omit the arrays); real
109
+ // registration always populates layout/intercept.
110
+ const add = (source: EntryData): undefined => {
111
+ for (const intercept of source.intercept ?? []) {
112
+ names.add(intercept.routeName);
113
+ }
114
+ return undefined;
115
+ };
104
116
  let current: EntryData | null = fromEntry;
117
+ let prev: EntryData | null = null;
105
118
  while (current) {
106
- // Tolerate partial entries (unit-test mocks omit the arrays); real
107
- // registration always populates layout/intercept.
108
- for (const source of [current, ...(current.layout ?? [])]) {
109
- for (const intercept of source.intercept ?? []) {
110
- names.add(intercept.routeName);
111
- }
112
- }
119
+ add(current);
120
+ findInOrphans(current, add, prev);
121
+ prev = current;
113
122
  current = current.parent;
114
123
  }
115
124
  return [...names];
@@ -121,27 +130,62 @@ export function findInterceptForRoute(
121
130
  selectorContext: InterceptSelectorContext | null = null,
122
131
  isAction: boolean = false,
123
132
  ): { intercept: InterceptEntry; entry: EntryData } | null {
133
+ const match = (source: EntryData) => {
134
+ for (const intercept of source.intercept) {
135
+ if (
136
+ intercept.routeName === targetRouteKey &&
137
+ evaluateInterceptWhen(intercept, selectorContext, isAction)
138
+ ) {
139
+ return { intercept, entry: source };
140
+ }
141
+ }
142
+ return undefined;
143
+ };
124
144
  let current: EntryData | null = fromEntry;
145
+ let prev: EntryData | null = null;
125
146
 
126
147
  while (current) {
127
- // current first, then its sibling layouts — same order as before.
128
- for (const source of [current, ...current.layout]) {
129
- for (const intercept of source.intercept) {
130
- if (
131
- intercept.routeName === targetRouteKey &&
132
- evaluateInterceptWhen(intercept, selectorContext, isAction)
133
- ) {
134
- return { intercept, entry: source };
135
- }
136
- }
137
- }
148
+ // current first, then its orphans (nested ones included) in render order.
149
+ const found = match(current) ?? findInOrphans(current, match, prev);
150
+ if (found) return found;
138
151
 
152
+ prev = current;
139
153
  current = current.parent;
140
154
  }
141
155
 
142
156
  return null;
143
157
  }
144
158
 
159
+ /**
160
+ * An intercept handler's throw or notFound() renders the declaring entry's
161
+ * boundary as the slot component (catchSegmentError, as for route handlers on
162
+ * partial navigations: 500/404 status, onError "handler"). Responses and every
163
+ * error on the background re-render (skipMiddleware) rethrow, so a failed
164
+ * re-render aborts its cache write instead of storing the fallback.
165
+ */
166
+ function renderInterceptHandlerError<TEnv>(
167
+ error: unknown,
168
+ parentEntry: EntryData,
169
+ params: Record<string, string>,
170
+ context: HandlerContext<any, TEnv>,
171
+ deps: SegmentResolutionDeps<TEnv>,
172
+ skipMiddleware: boolean | undefined,
173
+ ): ReactNode {
174
+ if (error instanceof Response || skipMiddleware) throw error;
175
+ return catchSegmentError(
176
+ error,
177
+ parentEntry,
178
+ params,
179
+ deps,
180
+ {
181
+ ...buildLoaderErrorContext(context),
182
+ isPartial: true,
183
+ telemetry: getRouterContext()?.telemetry,
184
+ },
185
+ context.pathname,
186
+ ).component;
187
+ }
188
+
145
189
  /**
146
190
  * Resolve an intercept entry and emit segment with the slot name.
147
191
  */
@@ -210,8 +254,8 @@ export async function resolveInterceptEntry<TEnv>(
210
254
  const loaderIds: string[] = [];
211
255
 
212
256
  for (let i = 0; i < interceptEntry.loader.length; i++) {
213
- const { loader, revalidate: loaderRevalidateFns } =
214
- interceptEntry.loader[i];
257
+ const loaderEntry = interceptEntry.loader[i];
258
+ const { loader, revalidate: loaderRevalidateFns } = loaderEntry;
215
259
  const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
216
260
 
217
261
  if (revalidationContext) {
@@ -273,7 +317,11 @@ export async function resolveInterceptEntry<TEnv>(
273
317
  loaderIds.push(loader.$$id);
274
318
  loaderPromises.push(
275
319
  deps.wrapLoaderPromise(
276
- runInsideLoaderScope(() => context.use(loader)),
320
+ // The DSL binding's funnel, as for route loaders: a loader's own
321
+ // cache() applies here too.
322
+ runInsideLoaderScope(() =>
323
+ resolveLoaderData(loaderEntry, context, context.pathname),
324
+ ),
277
325
  parentEntry,
278
326
  segmentId,
279
327
  context.pathname,
@@ -284,10 +332,26 @@ export async function resolveInterceptEntry<TEnv>(
284
332
  );
285
333
  }
286
334
 
287
- const handlerResult =
288
- typeof interceptEntry.handler === "function"
289
- ? handleHandlerResult(interceptEntry.handler(context))
290
- : interceptEntry.handler;
335
+ let handlerResult: ReactNode;
336
+ try {
337
+ handlerResult =
338
+ typeof interceptEntry.handler === "function"
339
+ ? handleHandlerResult(interceptEntry.handler(context))
340
+ : interceptEntry.handler;
341
+ } catch (error) {
342
+ handlerResult = renderInterceptHandlerError(
343
+ error,
344
+ parentEntry,
345
+ params,
346
+ context,
347
+ deps,
348
+ options?.skipMiddleware,
349
+ );
350
+ }
351
+ // Consumed only after the layout and loader awaits below; a rejection in that
352
+ // window would be an unhandledRejection (process crash on Node's default
353
+ // mode). The later await / trackHandler still observes it.
354
+ if (handlerResult instanceof Promise) handlerResult.catch(() => {});
291
355
 
292
356
  let layoutElement: ReactNode | undefined;
293
357
  if (interceptEntry.layout) {
@@ -304,6 +368,14 @@ export async function resolveInterceptEntry<TEnv>(
304
368
 
305
369
  let component: ReactNode;
306
370
  let loaderDataPromise: Promise<any[]> | any[] | undefined;
371
+ // A streamed (loading()) handler resolves after the 200 has started, so its
372
+ // rejection stays with the client; track it like route handlers do
373
+ // (onError report, handle-store completion).
374
+ const trackStreamedHandler = (promise: Promise<unknown>): ReactNode =>
375
+ deps.trackHandler(promise, {
376
+ segmentId: `${parentEntry.shortCode}.${interceptEntry.slotName}`,
377
+ segmentType: "parallel",
378
+ }) as ReactNode;
307
379
 
308
380
  if (interceptEntry.loading && loaderPromises.length > 0) {
309
381
  if (handlerResult instanceof Promise) {
@@ -314,20 +386,30 @@ export async function resolveInterceptEntry<TEnv>(
314
386
  }
315
387
  component =
316
388
  handlerResult instanceof Promise
317
- ? handlerResult
389
+ ? trackStreamedHandler(handlerResult)
318
390
  : (Promise.resolve(handlerResult) as ReactNode);
319
391
  loaderDataPromise = Promise.all(loaderPromises);
320
- } else if (loaderPromises.length > 0) {
321
- loaderDataPromise = await Promise.all(loaderPromises);
322
- component =
323
- handlerResult instanceof Promise ? await handlerResult : handlerResult;
392
+ } else if (interceptEntry.loading && handlerResult instanceof Promise) {
393
+ component = trackStreamedHandler(handlerResult);
324
394
  } else {
325
- component =
326
- interceptEntry.loading && handlerResult instanceof Promise
327
- ? handlerResult
328
- : handlerResult instanceof Promise
329
- ? await handlerResult
330
- : handlerResult;
395
+ if (loaderPromises.length > 0) {
396
+ loaderDataPromise = await Promise.all(loaderPromises);
397
+ }
398
+ component = handlerResult;
399
+ if (handlerResult instanceof Promise) {
400
+ try {
401
+ component = await handlerResult;
402
+ } catch (error) {
403
+ component = renderInterceptHandlerError(
404
+ error,
405
+ parentEntry,
406
+ params,
407
+ context,
408
+ deps,
409
+ options?.skipMiddleware,
410
+ );
411
+ }
412
+ }
331
413
  }
332
414
 
333
415
  const interceptSegment = {
@@ -401,8 +483,8 @@ export async function resolveInterceptLoadersOnly<TEnv>(
401
483
  } = revalidationContext;
402
484
 
403
485
  for (let i = 0; i < interceptEntry.loader.length; i++) {
404
- const { loader, revalidate: loaderRevalidateFns } =
405
- interceptEntry.loader[i];
486
+ const loaderEntry = interceptEntry.loader[i];
487
+ const { loader, revalidate: loaderRevalidateFns } = loaderEntry;
406
488
  const segmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}D${i}.${loader.$$id}`;
407
489
 
408
490
  const interceptSegmentId = `${parentEntry.shortCode}.${interceptEntry.slotName}`;
@@ -451,7 +533,11 @@ export async function resolveInterceptLoadersOnly<TEnv>(
451
533
  loaderIds.push(loader.$$id);
452
534
  loaderPromises.push(
453
535
  deps.wrapLoaderPromise(
454
- runInsideLoaderScope(() => context.use(loader)),
536
+ // The DSL binding's funnel, as for route loaders: a loader's own
537
+ // cache() applies here too.
538
+ runInsideLoaderScope(() =>
539
+ resolveLoaderData(loaderEntry, context, context.pathname),
540
+ ),
455
541
  parentEntry,
456
542
  segmentId,
457
543
  context.pathname,
@@ -7,7 +7,8 @@
7
7
  import type { ReactNode } from "react";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { observePhase, PHASES } from "./instrument.js";
10
- import { contextGet } from "../context-var.js";
10
+ import { contextGet, isNonCacheable } from "../context-var.js";
11
+ import { NOCACHE_SYMBOL } from "../cache/taint.js";
11
12
  import type {
12
13
  ResolvedSegment,
13
14
  HandlerContext,
@@ -43,6 +44,7 @@ import {
43
44
  import { getFetchableLoader } from "../server/fetchable-loader-store.js";
44
45
  import { _getRequestContext } from "../server/request-context.js";
45
46
  import {
47
+ assertNonCacheableReadAllowed,
46
48
  isInsideLoaderScope,
47
49
  runInsideLoaderBodyScope,
48
50
  isInsidePushCallbackScope,
@@ -394,6 +396,7 @@ function createLoaderExecutor<TEnv>(
394
396
  // Capture RequestContext eagerly for cookie access (ALS protection on Cloudflare)
395
397
  const reqCtxRef = _getRequestContext();
396
398
  const silentHandles = executorOptions?.silentHandles === true;
399
+ const internal = ctx as InternalHandlerContext<any, TEnv>;
397
400
 
398
401
  // Dependency graph: loaderId -> set of loader IDs it directly depends on.
399
402
  const dependsOn = new Map<string, Set<string>>();
@@ -429,7 +432,12 @@ function createLoaderExecutor<TEnv>(
429
432
  deps.add(loader.$$id);
430
433
  }
431
434
 
432
- // Return cached promise if already started
435
+ // Every loader execution funnels here (handler and loader-to-loader
436
+ // reads, uncached DSL bindings, cache misses): memoized per request, so a
437
+ // loader runs at most once however many reads, with no store cache. A
438
+ // loader's cache() read-through lives in loader-cache.ts
439
+ // (executeLoaderData), whose ctx.use override serves a later handler read
440
+ // the binding's result.
433
441
  if (loaderPromises.has(loader.$$id)) {
434
442
  return loaderPromises.get(loader.$$id)!;
435
443
  }
@@ -452,7 +460,7 @@ function createLoaderExecutor<TEnv>(
452
460
  pendingLoaders.add(loader.$$id);
453
461
 
454
462
  const currentLoaderId = loader.$$id;
455
- const variables = (ctx as InternalHandlerContext<any, TEnv>)._variables;
463
+ const variables = internal._variables;
456
464
 
457
465
  // Capture whether this loader is being started from a DSL loader scope
458
466
  // (runInsideLoaderScope in fresh.ts). Handler-invoked loaders are NOT
@@ -466,18 +474,26 @@ function createLoaderExecutor<TEnv>(
466
474
  // stale. Attributing to the owning route/layout segment gives handle
467
475
  // pushes the same granularity handler pushes have (segmentOrder-correct
468
476
  // for breadcrumb accumulation).
469
- const owningSegmentId = (ctx as InternalHandlerContext<any, TEnv>)
470
- ._currentSegmentId;
477
+ const owningSegmentId = internal._currentSegmentId;
471
478
 
472
479
  let renderedResolved = false;
473
480
  let renderedPromise: Promise<void> | null = null;
474
481
 
475
- // Loader functions are always fresh (never cached), so they get an
476
- // unguarded get that bypasses non-cacheable read guards. This applies
477
- // to ALL loaders — DSL and handler-called — because the loader
478
- // function itself always re-executes. Also handles nested deps
479
- // (loaderA → use(loaderB)) since all share this unguarded get.
480
- const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> = {
482
+ // Request-scoped like the handler ctx it derives from (issue #940): the
483
+ // NOCACHE_SYMBOL brand below makes a "use cache" function taking it key by
484
+ // the route fields (_routeName, _responseType) and replay its handle
485
+ // pushes into the owning segment, claimed per loader like a handler ctx
486
+ // (_claimLoaderPushes, _runLoaderIsolated). Non-cacheable ctx.get reads
487
+ // run the shared guard; the body itself stays exempt (loader body scope).
488
+ const loaderCtx: LoaderContext<Record<string, string | undefined>, TEnv> &
489
+ Pick<
490
+ InternalHandlerContext<any, TEnv>,
491
+ | "_routeName"
492
+ | "_responseType"
493
+ | "_currentSegmentId"
494
+ | "_claimLoaderPushes"
495
+ | "_runLoaderIsolated"
496
+ > = {
481
497
  params: ctx.params,
482
498
  routeParams: (ctx.params ?? {}) as Record<string, string>,
483
499
  request: ctx.request,
@@ -517,6 +533,9 @@ function createLoaderExecutor<TEnv>(
517
533
  buildHandleSnapshot(reqCtx._handleStore, segmentOrder);
518
534
  return collectHandleData(keyOrVar, snapshot, segmentOrder);
519
535
  }
536
+ if (isNonCacheable(variables, keyOrVar)) {
537
+ assertNonCacheableReadAllowed(keyOrVar);
538
+ }
520
539
  return contextGet(variables, keyOrVar);
521
540
  }) as typeof ctx.get,
522
541
  use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
@@ -552,6 +571,11 @@ function createLoaderExecutor<TEnv>(
552
571
  method: "GET",
553
572
  body: undefined,
554
573
  reverse: ctx.reverse as LoaderContext["reverse"],
574
+ _routeName: internal._routeName,
575
+ _responseType: internal._responseType,
576
+ _currentSegmentId: owningSegmentId,
577
+ _claimLoaderPushes: internal._claimLoaderPushes,
578
+ _runLoaderIsolated: internal._runLoaderIsolated,
555
579
  rendered: (): Promise<void> => {
556
580
  // Guard: only DSL loaders may use rendered()
557
581
  if (!isDslLoader) {
@@ -639,6 +663,7 @@ function createLoaderExecutor<TEnv>(
639
663
  return renderedPromise;
640
664
  },
641
665
  };
666
+ (loaderCtx as any)[NOCACHE_SYMBOL] = true;
642
667
 
643
668
  // Meter this loader once via observePhase (loader:<id> perf metric +
644
669
  // rango.loader span); loaderFn runs inside the span callback so its KV/D1/
@@ -736,6 +761,26 @@ export function setupLoaderAccess<TEnv>(
736
761
 
737
762
  const useLoader = createLoaderExecutor(ctx, loaderPromises);
738
763
 
764
+ // Loader-cache HIT replay (loader-cache.ts replayLoaderHandles): a cached
765
+ // entry records its ctx.use dependencies' pushes too, and a dependency is
766
+ // memoized here and shared with live readers. A loader that already ran
767
+ // here keeps its live pushes; otherwise one replay delivers its recorded
768
+ // pushes, and a later run here replaces them (HandleStore.pushReplayed).
769
+ const claimed = new Set<string>();
770
+ const internal = ctx as InternalHandlerContext<any, TEnv>;
771
+ internal._claimLoaderPushes = (loaderId) => {
772
+ if (loaderPromises.has(loaderId) || claimed.has(loaderId)) return false;
773
+ claimed.add(loaderId);
774
+ return true;
775
+ };
776
+ // A refresh's pushes are diverted, so its loader ctxs never claim
777
+ // (cache-runtime.ts refreshView).
778
+ internal._runLoaderIsolated = (loader) =>
779
+ createLoaderExecutor(
780
+ Object.create(ctx, { _claimLoaderPushes: { value: undefined } }),
781
+ new Map(),
782
+ )(loader, null);
783
+
739
784
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
740
785
  if (isHandle(item)) {
741
786
  const handle = item;