@rangojs/router 0.16.0 → 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 (245) 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 +7 -4
  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 +33 -9
  32. package/dist/types/route-definition/resolve-handler-use.d.ts +23 -1
  33. package/dist/types/route-types.d.ts +1 -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/types/cache-types.d.ts +14 -10
  67. package/dist/types/types/handler-context.d.ts +14 -0
  68. package/dist/types/types/loader-types.d.ts +8 -6
  69. package/dist/types/urls/response-types.d.ts +3 -12
  70. package/dist/types/use-loader.d.ts +12 -3
  71. package/dist/types/vite/plugins/use-cache-transform.d.ts +33 -2
  72. package/dist/types/vite/rango.d.ts +1 -1
  73. package/dist/vite/index.js +1381 -1346
  74. package/package.json +2 -2
  75. package/skills/api-client/SKILL.md +22 -10
  76. package/skills/breadcrumbs/SKILL.md +65 -22
  77. package/skills/bundle-analysis/SKILL.md +22 -22
  78. package/skills/cache-guide/SKILL.md +207 -97
  79. package/skills/caching/SKILL.md +295 -93
  80. package/skills/catalog.json +16 -16
  81. package/skills/client-urls/SKILL.md +53 -31
  82. package/skills/cloudflare/SKILL.md +72 -20
  83. package/skills/cloudflare/references/d1-and-local-dev.md +15 -2
  84. package/skills/cloudflare/references/streaming-and-deploy.md +10 -3
  85. package/skills/cloudflare/references/webhooks-and-crypto.md +9 -2
  86. package/skills/comparison/SKILL.md +5 -0
  87. package/skills/comparison/references/framework-comparison.md +39 -33
  88. package/skills/composability/SKILL.md +38 -29
  89. package/skills/css/SKILL.md +10 -13
  90. package/skills/debug-manifest/SKILL.md +74 -31
  91. package/skills/defer-hydration/SKILL.md +2 -1
  92. package/skills/deployment-caching/SKILL.md +6 -1
  93. package/skills/document-cache/SKILL.md +92 -28
  94. package/skills/fonts/SKILL.md +16 -8
  95. package/skills/handler-use/SKILL.md +53 -17
  96. package/skills/hooks/SKILL.md +28 -20
  97. package/skills/hooks/data.md +23 -3
  98. package/skills/hooks/handle-and-actions.md +31 -16
  99. package/skills/hooks/navigation.md +39 -10
  100. package/skills/hooks/outlets.md +13 -6
  101. package/skills/hooks/state.md +37 -15
  102. package/skills/hooks/urls.md +7 -5
  103. package/skills/host-router/SKILL.md +64 -12
  104. package/skills/i18n/SKILL.md +60 -34
  105. package/skills/intercept/SKILL.md +161 -26
  106. package/skills/layout/SKILL.md +106 -50
  107. package/skills/links/SKILL.md +51 -19
  108. package/skills/loader/SKILL.md +242 -106
  109. package/skills/middleware/SKILL.md +119 -23
  110. package/skills/migrate-nextjs/SKILL.md +100 -45
  111. package/skills/migrate-nextjs/backend-host-swap.md +5 -2
  112. package/skills/migrate-react-router/SKILL.md +49 -38
  113. package/skills/migrate-react-router/component-migration.md +25 -6
  114. package/skills/migrate-react-router/data-and-actions.md +18 -5
  115. package/skills/migrate-react-router/route-mapping.md +12 -6
  116. package/skills/mime-routes/SKILL.md +12 -7
  117. package/skills/observability/SKILL.md +28 -14
  118. package/skills/parallel/SKILL.md +75 -39
  119. package/skills/ppr/SKILL.md +345 -150
  120. package/skills/prerender/SKILL.md +189 -100
  121. package/skills/rango/SKILL.md +65 -34
  122. package/skills/react-compiler/SKILL.md +6 -5
  123. package/skills/response-routes/SKILL.md +133 -25
  124. package/skills/route/SKILL.md +165 -60
  125. package/skills/router-setup/SKILL.md +418 -240
  126. package/skills/scripts/SKILL.md +31 -20
  127. package/skills/server-actions/SKILL.md +220 -59
  128. package/skills/shell-manifest/SKILL.md +12 -7
  129. package/skills/streams-and-websockets/SKILL.md +32 -14
  130. package/skills/tailwind/SKILL.md +37 -33
  131. package/skills/testing/SKILL.md +68 -32
  132. package/skills/testing/cache-prerender.md +55 -29
  133. package/skills/testing/client-components.md +59 -6
  134. package/skills/testing/e2e-parity.md +12 -2
  135. package/skills/testing/flight.md +19 -11
  136. package/skills/testing/handles.md +8 -5
  137. package/skills/testing/loader.md +8 -5
  138. package/skills/testing/middleware.md +8 -3
  139. package/skills/testing/predicates.md +120 -0
  140. package/skills/testing/render-handler.md +70 -22
  141. package/skills/testing/response-routes.md +11 -4
  142. package/skills/testing/reverse-and-types.md +20 -14
  143. package/skills/testing/server-actions.md +12 -3
  144. package/skills/testing/server-tree.md +9 -0
  145. package/skills/testing/setup.md +9 -5
  146. package/skills/theme/SKILL.md +106 -29
  147. package/skills/typesafety/SKILL.md +20 -9
  148. package/skills/typesafety/env-and-bindings.md +126 -47
  149. package/skills/typesafety/generated-files-and-cli.md +26 -14
  150. package/skills/typesafety/params-and-search.md +44 -19
  151. package/skills/typesafety/route-types.md +50 -14
  152. package/skills/use-cache/SKILL.md +200 -82
  153. package/skills/vercel/SKILL.md +21 -11
  154. package/skills/view-transitions/SKILL.md +41 -20
  155. package/src/__augment-tests__/augmented.check.ts +26 -1
  156. package/src/browser/index.ts +2 -2
  157. package/src/browser/navigation-bridge.ts +3 -4
  158. package/src/browser/navigation-transaction.ts +22 -2
  159. package/src/browser/partial-update.ts +35 -25
  160. package/src/browser/react/ScrollRestoration.tsx +6 -6
  161. package/src/browser/rsc-router.tsx +1 -1
  162. package/src/browser/scroll-restoration.ts +53 -6
  163. package/src/cache/cache-exec-scope.ts +37 -4
  164. package/src/cache/cache-key-utils.ts +1 -6
  165. package/src/cache/cache-runtime.ts +230 -61
  166. package/src/cache/cache-scope.ts +76 -12
  167. package/src/cache/document-cache.ts +15 -0
  168. package/src/cache/handle-capture.ts +90 -10
  169. package/src/cache/handle-snapshot.ts +132 -28
  170. package/src/cache/segment-codec.ts +26 -7
  171. package/src/cache/tag-invalidation.ts +4 -3
  172. package/src/cache/taint.ts +1 -1
  173. package/src/cache/types.ts +16 -3
  174. package/src/client.rsc.tsx +10 -9
  175. package/src/client.tsx +5 -4
  176. package/src/components/DefaultDocument.tsx +7 -2
  177. package/src/context-var.ts +6 -5
  178. package/src/escape-script.ts +1 -1
  179. package/src/handles/MetaTags.tsx +13 -11
  180. package/src/handles/Scripts.tsx +11 -10
  181. package/src/handles/meta.ts +2 -2
  182. package/src/handles/script.ts +17 -16
  183. package/src/href-client.ts +1 -1
  184. package/src/html.ts +12 -0
  185. package/src/index.rsc.ts +5 -0
  186. package/src/index.ts +1 -0
  187. package/src/loader-store.ts +7 -4
  188. package/src/prerender/build-shell-capture.ts +8 -11
  189. package/src/reverse.ts +44 -4
  190. package/src/route-definition/dsl-helpers.ts +117 -30
  191. package/src/route-definition/helpers-types.ts +33 -9
  192. package/src/route-definition/resolve-handler-use.ts +121 -6
  193. package/src/route-types.ts +0 -3
  194. package/src/router/debug-manifest.ts +10 -2
  195. package/src/router/error-handling.ts +51 -15
  196. package/src/router/handler-context.ts +34 -25
  197. package/src/router/intercept-resolution.ts +125 -39
  198. package/src/router/loader-resolution.ts +56 -11
  199. package/src/router/match-api.ts +12 -17
  200. package/src/router/match-middleware/background-revalidation.ts +161 -96
  201. package/src/router/match-middleware/cache-lookup.ts +93 -10
  202. package/src/router/match-middleware/cache-store.ts +17 -90
  203. package/src/router/middleware-types.ts +3 -5
  204. package/src/router/middleware.ts +52 -9
  205. package/src/router/prerender-match.ts +53 -19
  206. package/src/router/route-snapshot.ts +11 -1
  207. package/src/router/router-context.ts +2 -0
  208. package/src/router/router-interfaces.ts +25 -2
  209. package/src/router/router-options.ts +8 -5
  210. package/src/router/segment-resolution/fresh.ts +44 -12
  211. package/src/router/segment-resolution/loader-cache.ts +205 -45
  212. package/src/router/segment-resolution/loader-mask.ts +12 -22
  213. package/src/router/segment-resolution/loader-snapshot.ts +6 -6
  214. package/src/router/segment-resolution/mask-nested.ts +7 -7
  215. package/src/router/segment-resolution/revalidation.ts +34 -6
  216. package/src/router/segment-wrappers.ts +4 -0
  217. package/src/rsc/render-pipeline.ts +3 -0
  218. package/src/rsc/response-route-handler.ts +11 -3
  219. package/src/rsc/rsc-rendering.ts +8 -45
  220. package/src/rsc/shell-capture-constants.ts +13 -0
  221. package/src/rsc/shell-capture.ts +111 -90
  222. package/src/rsc/ssr-setup.ts +14 -8
  223. package/src/rsc/types.ts +11 -0
  224. package/src/server/context.ts +88 -11
  225. package/src/server/cookie-store.ts +5 -4
  226. package/src/server/handle-store.ts +232 -7
  227. package/src/server/request-context.ts +35 -30
  228. package/src/ssr/index.tsx +40 -7
  229. package/src/static-handler.ts +4 -1
  230. package/src/testing/flight-runtime.d.ts +21 -0
  231. package/src/testing/generated-routes.ts +2 -2
  232. package/src/testing/internal/context.ts +1 -1
  233. package/src/testing/internal/flight-client-globals.ts +7 -5
  234. package/src/testing/render-route.tsx +85 -24
  235. package/src/testing/run-loader.ts +5 -1
  236. package/src/testing/vitest-stubs/plugin-rsc.ts +123 -20
  237. package/src/testing/vitest.ts +62 -1
  238. package/src/theme/ThemeScript.tsx +3 -3
  239. package/src/types/cache-types.ts +14 -10
  240. package/src/types/handler-context.ts +14 -0
  241. package/src/types/loader-types.ts +8 -6
  242. package/src/urls/response-types.ts +3 -23
  243. package/src/use-loader.tsx +12 -3
  244. package/src/vite/plugins/use-cache-transform.ts +118 -74
  245. package/src/vite/rango.ts +7 -3
@@ -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;
@@ -7,7 +7,9 @@ import { RouteNotFoundError } from "../errors";
7
7
  import {
8
8
  createErrorInfo,
9
9
  createErrorSegment,
10
+ findInOrphans,
10
11
  findNearestErrorBoundary as findErrorBoundary,
12
+ withErrorBoundary,
11
13
  } from "./error-handling.js";
12
14
  import {
13
15
  createHandlerContext,
@@ -481,24 +483,21 @@ export async function matchError<TEnv>(
481
483
  segmentType,
482
484
  );
483
485
 
486
+ // Same search order as findNearestErrorBoundary, so the fallback above and
487
+ // the entry that positions it agree.
484
488
  let entryWithBoundary: EntryData | null = null;
485
489
  let current: EntryData | null = manifestEntry;
490
+ let prev: EntryData | null = null;
486
491
  while (current) {
487
492
  if (current.errorBoundary && current.errorBoundary.length > 0) {
488
493
  entryWithBoundary = current;
489
494
  break;
490
495
  }
491
496
 
492
- if (current.layout && current.layout.length > 0) {
493
- for (const orphan of current.layout) {
494
- if (orphan.errorBoundary && orphan.errorBoundary.length > 0) {
495
- entryWithBoundary = orphan;
496
- break;
497
- }
498
- }
499
- if (entryWithBoundary) break;
500
- }
497
+ entryWithBoundary = findInOrphans(current, withErrorBoundary, prev) ?? null;
498
+ if (entryWithBoundary) break;
501
499
 
500
+ prev = current;
502
501
  current = current.parent;
503
502
  }
504
503
 
@@ -507,23 +506,19 @@ export async function matchError<TEnv>(
507
506
 
508
507
  if (entryWithBoundary) {
509
508
  boundaryEntry = entryWithBoundary;
509
+ // The chain entry holding the boundary, itself or in its orphan tree: the
510
+ // error segment takes the place of its child in the chain.
511
+ const boundaryOwner = current;
510
512
 
511
513
  outletEntry = manifestEntry;
512
514
  current = manifestEntry;
513
515
 
514
516
  while (current) {
515
- if (current.parent === boundaryEntry) {
517
+ if (current.parent === boundaryOwner) {
516
518
  outletEntry = current;
517
519
  break;
518
520
  }
519
521
 
520
- if (current.parent && current.parent.layout) {
521
- if (current.parent.layout.includes(boundaryEntry)) {
522
- outletEntry = current;
523
- break;
524
- }
525
- }
526
-
527
522
  current = current.parent;
528
523
  }
529
524
  } else {