@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
@@ -44,7 +44,7 @@ import { isRouteNotFoundError } from "../errors.js";
44
44
  import { createReverseFunction } from "../router/handler-context.js";
45
45
  import { executeMiddleware, matchMiddleware } from "../router/middleware.js";
46
46
  import type { MiddlewareEntry } from "../router/middleware.js";
47
- import { getGlobalRouteMap, isRouteRootScoped } from "../route-map-builder.js";
47
+ import { getGlobalRouteMap } from "../route-map-builder.js";
48
48
  import {
49
49
  resolvePprConfig,
50
50
  type ResolvedPprConfig,
@@ -234,14 +234,11 @@ async function attemptBuildCapture(
234
234
  setRequestContextParams(preview.params ?? {}, preview.routeKey);
235
235
  }
236
236
 
237
- const routeReverse = createReverseFunction(
238
- getGlobalRouteMap(),
239
- preview?.routeKey,
240
- preview?.params ?? {},
241
- preview?.routeKey
242
- ? isRouteRootScoped(preview.routeKey, router.id)
243
- : undefined,
244
- );
237
+ // Global-only, like the live request's middleware reverse
238
+ // (rsc/handler.ts: createReverseFunction(getRequiredRouteMap())): no
239
+ // include() scope, no param auto-fill. A scoped reverse here resolved
240
+ // `.name` at build while the live request threw `Unknown route`.
241
+ const middlewareReverse = createReverseFunction(getGlobalRouteMap());
245
242
 
246
243
  const runCapture = () =>
247
244
  runBuildCaptureFinal({
@@ -268,7 +265,7 @@ async function attemptBuildCapture(
268
265
  env,
269
266
  variables,
270
267
  runCapture,
271
- routeReverse,
268
+ middlewareReverse,
272
269
  baseCtx,
273
270
  );
274
271
 
@@ -281,7 +278,7 @@ async function attemptBuildCapture(
281
278
  env,
282
279
  variables,
283
280
  runRouteMiddleware,
284
- routeReverse,
281
+ middlewareReverse,
285
282
  baseCtx,
286
283
  );
287
284
  });
package/src/reverse.ts CHANGED
@@ -207,6 +207,43 @@ export type ScopedReverseFunction<
207
207
  ): string;
208
208
  };
209
209
 
210
+ /**
211
+ * Reverse function that resolves global route names only: no include() scope,
212
+ * no param auto-fill.
213
+ *
214
+ * This is the `ctx.reverse` of middleware (`MiddlewareContext`) and
215
+ * response-route handlers (`ResponseHandlerContext`). On a live request both
216
+ * get `createReverseFunction(routeMap)` with no route name or params (the
217
+ * middleware callers in rsc/handler.ts and rsc/loader-fetch.ts,
218
+ * handleResponseRoute, router/intercept-resolution.ts; mirrored by
219
+ * testing/run-middleware.ts and testing/dispatch.ts), so a dot-local `.name`
220
+ * has no scope to resolve against and throws `Unknown route`. Build-time PPR
221
+ * shell capture (prerender/build-shell-capture.ts) gives global and route
222
+ * middleware the same global-only reverse.
223
+ *
224
+ * - Route map with a string index signature (no generated map, the permissive
225
+ * `Record<string, string>` fallback): any name except a dot-prefixed literal.
226
+ * `Exclude` is required because `keyof` collapses to `string` there, so the
227
+ * global overloads of `ScopedReverseFunction` would accept `.name` too.
228
+ * - Concrete route map: the global overloads of `ScopedReverseFunction`, with
229
+ * name and param validation. The empty local map (`{}`) leaves no dot-prefixed
230
+ * overload that can match.
231
+ *
232
+ * @example
233
+ * ```typescript
234
+ * reverse("blog.post", { slug: "hello" }) // ok: global route + params
235
+ * reverse(".post", { slug: "hello" }) // compile error: no include() scope
236
+ * ```
237
+ */
238
+ export type GlobalReverseFunction<TGlobalRoutes> =
239
+ string extends keyof TGlobalRoutes
240
+ ? <TName extends string>(
241
+ name: Exclude<TName, `.${string}`>,
242
+ params?: Record<string, string>,
243
+ search?: Record<string, unknown>,
244
+ ) => string
245
+ : ScopedReverseFunction<{}, TGlobalRoutes>;
246
+
210
247
  /**
211
248
  * Extract local routes type from UrlPatterns
212
249
  * Used with scopedReverse() to get the routes type from patterns
@@ -290,11 +327,14 @@ export type { RouteResponse } from "./urls.js";
290
327
  * Get a locally-typed reverse function from ctx.reverse for composable modules.
291
328
  *
292
329
  * This is a type-only cast - ctx.reverse already resolves names at runtime.
293
- * Provides type safety: `.name` validates against local routes,
294
- * `name` validates against global named-routes.
330
+ * Only the module's LOCAL routes are typed: `.name` and bare `name` both
331
+ * validate against the local map (the global map parameter defaults to it).
332
+ * Always dot-prefix local names — a bare name type-checks against the local
333
+ * map but resolves as a global name at runtime. For global names, call
334
+ * ctx.reverse directly.
295
335
  *
296
336
  * @param reverse - The ctx.reverse function from HandlerContext
297
- * @returns The same reverse function, typed with local + global routes
337
+ * @returns The same reverse function, typed with the module's local routes
298
338
  *
299
339
  * @example
300
340
  * ```typescript
@@ -305,7 +345,7 @@ export type { RouteResponse } from "./urls.js";
305
345
  *
306
346
  * reverse(".index"); // ✓ Local route
307
347
  * reverse(".post", { slug: "x" }); // ✓ Local with params
308
- * reverse("shop.cart"); // ✓ Global route
348
+ * ctx.reverse("shop.cart"); // Global route: use ctx.reverse
309
349
  *
310
350
  * return <BlogIndex />;
311
351
  * }, { name: "index" }),
@@ -44,12 +44,25 @@ import type {
44
44
  UseItems,
45
45
  } from "../route-types.js";
46
46
  import type { RouteHelpers } from "./helpers-types.js";
47
- import { resolveHandlerUse, mergeHandlerUse } from "./resolve-handler-use.js";
47
+ import {
48
+ resolveHandlerUse,
49
+ mergeHandlerUse,
50
+ validateInterceptUseItems,
51
+ } from "./resolve-handler-use.js";
48
52
  import { ALL_USE_ITEM_TYPES } from "./use-item-types.js";
49
53
 
50
54
  /**
51
- * Check if an item contains routes (directly or inside nested structures like cache).
55
+ * Check if an item contains routes (directly or inside nested wrappers).
52
56
  * Used to determine if a layout or cache should be treated as an orphan.
57
+ *
58
+ * Recurses through ANY item carrying `uses` (cache, layout, middleware,
59
+ * wrapper-form transition) rather than an enumerated list: a wrapper helper
60
+ * that returns `uses` is covered the moment it exists. Scar tissue: the list
61
+ * form missed `transition`, so `layout(Shell, () => [transition(cfg, () =>
62
+ * [routes])])` was classified orphan and pushed onto its PARENT's layout[] —
63
+ * rendering around every sibling route (test-app TxBlockShell wrapped "/").
64
+ * parallel()/intercept() register their children by side effect and return
65
+ * no `uses`, so a layout whose only children are those still reads as orphan.
53
66
  */
54
67
  const hasRoutesInItem = (item: AllUseItems): boolean => {
55
68
  if (item.type === "route") return true;
@@ -57,16 +70,8 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
57
70
  // to prevent the parent layout from being misclassified as orphan,
58
71
  // which would clear its parent pointer and break the middleware chain.
59
72
  if (item.type === "include") return true;
60
- if (item.type === "cache" && item.uses) {
61
- return item.uses.some((child) => hasRoutesInItem(child));
62
- }
63
- if (item.type === "layout" && item.uses) {
64
- return item.uses.some((child) => hasRoutesInItem(child));
65
- }
66
- if (item.type === "middleware" && item.uses) {
67
- return item.uses.some((child) => hasRoutesInItem(child));
68
- }
69
- return false;
73
+ const uses = (item as { uses?: AllUseItems[] }).uses;
74
+ return uses ? uses.some(hasRoutesInItem) : false;
70
75
  };
71
76
 
72
77
  /**
@@ -130,16 +135,37 @@ const isOrphan = (result: AllUseItems[]): boolean =>
130
135
  * pointer so it leaves the middleware/parent-pointer chain (LOAD-BEARING — see
131
136
  * docs/tree-structure.md) and push it onto the parent's layout[] so it renders
132
137
  * as a wrapper. Used by cache()/middleware()/transition(); layout() runs extra
133
- * validation and registers inline.
138
+ * validation and registers inline. `orphanOwner` keeps the owner reachable for
139
+ * boundary lookup only (router/error-handling.ts).
134
140
  */
135
141
  const attachOrphanSibling = (
136
142
  parent: EntryData | null,
137
143
  entry: EntryData,
138
144
  ): void => {
139
145
  entry.parent = null;
140
- if (parent && "layout" in parent) parent.layout.push(entry);
146
+ if (parent && "layout" in parent) {
147
+ parent.layout.push(entry);
148
+ entry.orphanOwner = parent;
149
+ }
141
150
  };
142
151
 
152
+ /**
153
+ * The route whose use() list `parent` sits in: `parent` itself, or the route
154
+ * above a chain of layout-typed wrappers (layout(), middleware()/transition()
155
+ * wrapper form). Inside a path those wrappers never hold routes (path-in-path
156
+ * is rejected), so the route is the only cacheable unit there. An intercept()
157
+ * scope (its temporary parent: a spread of the declaring entry with an own
158
+ * `cache` sink) stops the walk, so its cache() stays rejected by
159
+ * validateInterceptUseItems instead of configuring the path around it.
160
+ */
161
+ function enclosingRoute(parent: EntryData | null): EntryData | undefined {
162
+ for (let entry = parent; entry; entry = entry.parent) {
163
+ if (entry.type === "route") return entry;
164
+ if (entry.type !== "layout" || "cache" in entry) return undefined;
165
+ }
166
+ return undefined;
167
+ }
168
+
143
169
  /**
144
170
  * Run `fn` with `ctx.parent` temporarily redirected to `temp` — a satellite
145
171
  * entry that captures the attachments declared by a use() callback — restoring
@@ -178,7 +204,7 @@ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
178
204
  * When an error occurs during rendering of this segment or its children,
179
205
  * the fallback will be rendered instead. The fallback can be:
180
206
  * - A static ReactNode (e.g., <ErrorPage />)
181
- * - A handler function that receives error info and reset function
207
+ * - A handler function that receives `{ error }` (server fallbacks get no reset)
182
208
  *
183
209
  * Error boundaries catch errors from:
184
210
  * - Middleware execution
@@ -194,11 +220,11 @@ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
194
220
  *
195
221
  * // Or with handler for dynamic error UI:
196
222
  * route("products.detail", ProductDetail, () => [
197
- * errorBoundary(({ error, reset }) => (
223
+ * errorBoundary(({ error }) => (
198
224
  * <div>
199
225
  * <h2>Product failed to load</h2>
200
226
  * <p>{error.message}</p>
201
- * <button onClick={reset}>Retry</button>
227
+ * <a href="/products">Back to products</a>
202
228
  * </div>
203
229
  * )),
204
230
  * ])
@@ -316,6 +342,30 @@ const cache: RouteHelpers<any, any>["cache"] = (
316
342
  const name = `$${cacheIndex}`;
317
343
  const cacheConfig = { options };
318
344
 
345
+ // Among a path's children, cache() configures that path: the route entry
346
+ // carries the config, so buildEntriesAndCacheScope (router/route-snapshot.ts)
347
+ // makes the route the boundary and its own segments (handler, layouts,
348
+ // parallels) the cached unit. Both forms leave ctx.parent unchanged, so
349
+ // every sibling and wrapped item still attaches where it was. Issue #912: a
350
+ // cache entry here was never an ancestor of the route (no scope, no store
351
+ // write). The same holds inside a routeless layout()/middleware()/
352
+ // transition() wrapper in a path (issue #918): a route's record is
353
+ // all-or-nothing, so the path is the only thing such a cache() can cache.
354
+ const route = enclosingRoute(ctx.parent);
355
+ if (route && ctx.parent) {
356
+ route.cache = cacheConfig;
357
+ if (!children) return { name, type: "cache" } as CacheItem;
358
+ const uses = runAndValidateUseItems(
359
+ store,
360
+ `${ctx.namespace}.${cacheIndex}`,
361
+ ctx.parent,
362
+ children,
363
+ "cache",
364
+ "children",
365
+ );
366
+ return { name, type: "cache", uses } as CacheItem;
367
+ }
368
+
319
369
  // If no children, create an orphan cache entry (like orphan layouts)
320
370
  // This allows cache() to wrap subsequent siblings
321
371
  if (!children) {
@@ -490,7 +540,19 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
490
540
  "children",
491
541
  );
492
542
 
493
- if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
543
+ if (isOrphan(result)) {
544
+ // Its middleware runs for every route of the enclosing entry
545
+ // (collectRouteMiddleware walks orphans), so the wrapper scopes nothing;
546
+ // the flat form says what runs. Issue #918: the nested layout() was
547
+ // dropped at render.
548
+ invariant(
549
+ !result.some((item) => item?.type === "layout"),
550
+ `middleware(fn, () => [...]) with no routes inside cannot contain layout() [${namespace}]. ` +
551
+ "The middleware runs for every route of the enclosing path or layout either way; " +
552
+ "list them as siblings instead: middleware(fn), layout(...)",
553
+ );
554
+ attachOrphanSibling(ctx.parent, entry);
555
+ }
494
556
 
495
557
  return {
496
558
  name: namespace,
@@ -713,15 +775,12 @@ const intercept = (
713
775
  const prefixedRouteName =
714
776
  isLocal && namePrefix ? `${namePrefix}.${bareRouteName}` : bareRouteName;
715
777
 
716
- // Create intercept entry with its own loaders/revalidate/middleware/when
778
+ // Create intercept entry with its own loaders/middleware/when
717
779
  const entry: InterceptEntry = {
718
780
  slotName: slotName as `@${string}`,
719
781
  routeName: prefixedRouteName,
720
782
  handler,
721
783
  middleware: [],
722
- revalidate: [],
723
- errorBoundary: [],
724
- notFoundBoundary: [],
725
784
  loader: [],
726
785
  when: [], // Selector conditions for conditional interception
727
786
  };
@@ -734,25 +793,44 @@ const intercept = (
734
793
  entry.when.push(...selectors);
735
794
  }
736
795
 
737
- // Merge handler.use defaults with explicit use
796
+ // Merge handler.use defaults with explicit use. mountSite null: the merged
797
+ // items are validated once below, so both sources get the same message.
738
798
  const handlerUseFn = resolveHandlerUse(handler);
739
- const mergedUse = mergeHandlerUse(handlerUseFn, useFn, "intercept");
799
+ const mergedUse = mergeHandlerUse(handlerUseFn, useFn, null);
740
800
 
741
- // Run merged use callback to collect loaders, revalidate, middleware, etc.
801
+ // Run merged use callback to collect loaders, middleware, etc.
742
802
  if (mergedUse) {
743
803
  // Capture layout() calls into a temporary array
744
804
  const capturedLayouts: EntryData[] = [];
745
805
 
746
- // Temporary parent so middleware/loader/revalidate/when attach to the
747
- // intercept entry; the loading get/set accessor mirrors writes onto `entry`.
806
+ // Temporary parent so middleware/loader attach to the intercept entry;
807
+ // the loading get/set accessor mirrors writes onto `entry`. It is a
808
+ // shallow spread of the enclosing entry, so every field a rejected helper
809
+ // writes to points at a throwaway here: a helper called but not returned
810
+ // from use() never lands on the enclosing layout. cache() writes into
811
+ // `layout` (capturedLayouts), or into `cache` when the enclosing entry
812
+ // is a route.
813
+ //
814
+ // Why an intercept rejects these (validateInterceptUseItems):
815
+ // - revalidate(): an intercept only evaluates its loaders' revalidate()
816
+ // (router/intercept-resolution.ts).
817
+ // - errorBoundary()/notFoundBoundary(): error lookup walks EntryData
818
+ // parent chains (router/error-handling.ts), which never include an
819
+ // InterceptEntry; intercept loader errors resolve against the declaring
820
+ // entry's boundaries instead.
821
+ // - cache(): an intercept navigation is cached under the target route's
822
+ // cache() scope, with its own "intercept:" key (cache/cache-scope.ts).
748
823
  const tempParent = {
749
824
  ...ctx.parent,
750
825
  middleware: entry.middleware,
751
- revalidate: entry.revalidate,
752
- errorBoundary: entry.errorBoundary,
753
- notFoundBoundary: entry.notFoundBoundary,
754
826
  loader: entry.loader,
755
- layout: capturedLayouts, // Capture layout() calls
827
+ layout: capturedLayouts,
828
+ revalidate: [],
829
+ errorBoundary: [],
830
+ notFoundBoundary: [],
831
+ parallel: {},
832
+ intercept: [],
833
+ cache: undefined,
756
834
  get loading() {
757
835
  return entry.loading;
758
836
  },
@@ -761,19 +839,29 @@ const intercept = (
761
839
  },
762
840
  };
763
841
 
764
- const result = withParent(ctx, tempParent as EntryData, () =>
765
- mergedUse()?.flat(3),
842
+ const result = validateUseItems(
843
+ withParent(ctx, tempParent as EntryData, () => mergedUse()?.flat(3)),
844
+ namespace,
845
+ "intercept",
846
+ "use",
847
+ );
848
+ validateInterceptUseItems(
849
+ result,
850
+ capturedLayouts,
851
+ slotName,
852
+ routeName,
853
+ tempParent,
766
854
  );
767
855
 
768
- // Extract layout from captured layouts (use first one if multiple)
769
- // Layout inside intercept should always be ReactNode or Handler, not Record slots
770
- if (capturedLayouts.length > 0 && capturedLayouts[0].type === "layout") {
771
- entry.layout = capturedLayouts[0].handler as
772
- | ReactNode
773
- | Handler<any, any, any>;
856
+ // The modal chrome is the first returned layout() item. Match it by id so
857
+ // an entry pushed into capturedLayouts without being returned is never
858
+ // taken as the chrome. It carries no use() items (validated above).
859
+ const chromeItem = result.find((item) => item?.type === "layout");
860
+ const chrome =
861
+ chromeItem && capturedLayouts.find((l) => l.id === chromeItem.name);
862
+ if (chrome) {
863
+ entry.layout = chrome.handler as ReactNode | Handler<any, any, any>;
774
864
  }
775
-
776
- validateUseItems(result, namespace, "intercept", "use");
777
865
  }
778
866
 
779
867
  ctx.parent.intercept.push(entry);
@@ -958,7 +1046,11 @@ const transition = (
958
1046
 
959
1047
  if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
960
1048
 
961
- return { name: namespace, type: "transition" } as TransitionItem;
1049
+ return {
1050
+ name: namespace,
1051
+ type: "transition",
1052
+ uses: result,
1053
+ } as TransitionItem;
962
1054
  };
963
1055
 
964
1056
  const route: RouteHelpers<any, any>["route"] = (name, handler, use) => {
@@ -180,10 +180,10 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
180
180
  * intercept("@modal", "card", () => <CardModal />),
181
181
  * ])
182
182
  *
183
- * // With loaders and revalidation
183
+ * // With loaders and revalidation (revalidate() goes on the loader;
184
+ * // an intercept-level revalidate() throws)
184
185
  * intercept("@modal", "card", () => <CardModal />, () => [
185
- * loader(CardModalLoader),
186
- * revalidate(() => false),
186
+ * loader(CardModalLoader, () => [revalidate(() => false)]),
187
187
  * ])
188
188
  *
189
189
  * // Conditional activation via the config object's `when` selector
@@ -205,7 +205,11 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
205
205
  * @param handler - Component or handler for intercepted render
206
206
  * @param config - Optional InterceptConfig (e.g. `{ when }`), or the use
207
207
  * callback directly when there is no config
208
- * @param use - Optional callback for loaders, middleware, revalidate, etc.
208
+ * @param use - Optional callback for loader(), middleware(), loading(),
209
+ * transition() and a layout() modal chrome without use() items.
210
+ * revalidate() (goes on the loader), errorBoundary()/notFoundBoundary()
211
+ * (go on the enclosing layout) and cache() (goes on the target route)
212
+ * throw here.
209
213
  */
210
214
  intercept: {
211
215
  // Local: dot-prefixed, params inferred from local route definition
@@ -285,6 +289,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
285
289
  * ({ defaultShouldRevalidate: true })
286
290
  * )
287
291
  * ```
292
+ * Not valid directly inside intercept() use() (throws at definition time):
293
+ * intercepts only revalidate their loaders, so attach it there with
294
+ * `loader(Def, () => [revalidate(...)])`.
288
295
  * @param fn - Function returning either:
289
296
  * - `boolean` (hard decision — short-circuits the chain),
290
297
  * - `{ defaultShouldRevalidate: boolean }` (soft — updates the suggestion
@@ -352,16 +359,19 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
352
359
  * ```typescript
353
360
  * errorBoundary(<ErrorFallback />)
354
361
  *
355
- * // With dynamic error handler
356
- * errorBoundary(({ error, reset }) => (
362
+ * // With dynamic error handler (server fallback: receives only { error })
363
+ * errorBoundary(({ error }) => (
357
364
  * <div>
358
365
  * <h2>Something went wrong</h2>
359
366
  * <p>{error.message}</p>
360
- * <button onClick={reset}>Try again</button>
367
+ * <a href="/">Go home</a>
361
368
  * </div>
362
369
  * ))
363
370
  * ```
364
- * @param fallback - Static JSX or handler receiving error info and reset function
371
+ * Not valid inside intercept() use() (throws at definition time): an
372
+ * intercept has no boundary of its own, and its loader errors resolve
373
+ * against the enclosing layout/path boundary.
374
+ * @param fallback - Static JSX or handler receiving `{ error }`
365
375
  */
366
376
  errorBoundary: (
367
377
  fallback: ReactNode | ErrorBoundaryHandler,
@@ -379,6 +389,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
379
389
  * </div>
380
390
  * ))
381
391
  * ```
392
+ * Not valid inside intercept() use() (throws at definition time): an
393
+ * intercept has no boundary of its own, and notFound() from its loaders
394
+ * resolves against the enclosing layout/path boundary.
382
395
  * @param fallback - Static JSX or handler receiving not-found info
383
396
  */
384
397
  notFoundBoundary: (
@@ -396,6 +409,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
396
409
  * Note: Loaders are NOT cached by default. Use cache() inside loader()
397
410
  * to explicitly opt-in to loader caching.
398
411
  *
412
+ * Not valid inside intercept() use() (throws at definition time): an
413
+ * intercept navigation is cached under the target route's cache() scope.
414
+ *
399
415
  * ```typescript
400
416
  * // Using app-level defaults (ttl inherited from store.defaults)
401
417
  * cache(() => [
@@ -430,6 +446,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
430
446
  * route("archive/:year"), // uses KV store
431
447
  * ])
432
448
  *
449
+ * // Among a path's children: caches that path (handler, layouts, parallels)
450
+ * path("/product/:id", ProductPage, { name: "product" }, () => [
451
+ * cache({ ttl: 60 }),
452
+ * layout(<ProductChrome />), // cached with the path
453
+ * ])
454
+ *
433
455
  * // Opt-in loader caching
434
456
  * route("product/:id", ProductHandler, () => [
435
457
  * loader(ProductLoader), // NOT cached (default)
@@ -457,8 +479,12 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
457
479
  * through React's startTransition, so a same-route nav (same route,
458
480
  * different params, e.g. /product/1 -> /product/2) holds the previous
459
481
  * content while the new loader resolves instead of flashing the route's
460
- * loading() skeleton (see segment-system.tsx inTransitionScope). This is
461
- * also the precondition for any view-transition animation.
482
+ * loading() skeleton (see segment-system.tsx inTransitionScope). Held
483
+ * useLoader readers of the re-run loaders report isLoading: true until
484
+ * the new data commits (use-loader.tsx). The pin is per loader family
485
+ * (`$$id`), not per segment — a layout reader of the same createLoader
486
+ * the child is re-running is flagged too. This is also the precondition
487
+ * for any view-transition animation.
462
488
  * 2. <ViewTransition> (experimental React only): the segment content is also
463
489
  * wrapped in React's <ViewTransition>, so the held swap cross-fades/morphs.
464
490
  * Layered on by default; pass { viewTransition: false } to keep #1 without
@@ -91,13 +91,12 @@ const MOUNT_SITE_ALLOWED_TYPES: Record<string, Set<string>> = {
91
91
  "notFoundBoundary",
92
92
  "transition",
93
93
  ]),
94
+ // intercept() checks its whole merged use() (explicit and handler.use)
95
+ // against this set via validateInterceptUseItems below.
94
96
  intercept: new Set([
95
97
  "middleware",
96
- "revalidate",
97
98
  "loader",
98
99
  "loading",
99
- "errorBoundary",
100
- "notFoundBoundary",
101
100
  "layout",
102
101
  "route",
103
102
  "transition",
@@ -106,6 +105,120 @@ const MOUNT_SITE_ALLOWED_TYPES: Record<string, Set<string>> = {
106
105
  loader: new Set(["revalidate", "cache"]),
107
106
  };
108
107
 
108
+ const INTERCEPT_BOUNDARY_HINT =
109
+ "put it on the enclosing layout or path; it handles the intercept's handler and loader errors";
110
+
111
+ /**
112
+ * Where each item intercept() rejects goes instead. Why an intercept cannot
113
+ * hold them is documented at intercept() in dsl-helpers.ts.
114
+ */
115
+ const INTERCEPT_REJECTION_HINTS: Record<string, string> = {
116
+ revalidate:
117
+ "attach it to the intercept's loader: loader(YourLoader, () => [revalidate(...)])",
118
+ errorBoundary: INTERCEPT_BOUNDARY_HINT,
119
+ notFoundBoundary: INTERCEPT_BOUNDARY_HINT,
120
+ cache:
121
+ 'put cache() on the target route (intercept navigations get their own "intercept:" cache key) or use "use cache" in the handler',
122
+ };
123
+
124
+ function rejectInterceptItem(
125
+ what: string,
126
+ hint: string,
127
+ slotName: string,
128
+ routeName: string,
129
+ ): never {
130
+ throw new Error(
131
+ `${what} is not valid inside intercept("${slotName}", "${routeName}") use() (including the handler's .use): ${hint}.`,
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Validate every item an intercept's merged use() returned (explicit use()
137
+ * and handler.use) against MOUNT_SITE_ALLOWED_TYPES.intercept, plus two
138
+ * structural rules:
139
+ * - a nested layout() is only the modal chrome (intercept() keeps its
140
+ * handler), so it may not carry use() items of its own;
141
+ * - a cache entry in `capturedLayouts` means cache() ran in the scope even if
142
+ * it was not returned; its orphan form re-parents the following siblings
143
+ * onto that entry, so it is rejected the same way. Inside a path, cache()
144
+ * writes `sinks.cache` instead (dsl-helpers.ts cache()).
145
+ */
146
+ export function validateInterceptUseItems(
147
+ items: AllUseItems[],
148
+ capturedLayouts: readonly { type: string }[],
149
+ slotName: string,
150
+ routeName: string,
151
+ sinks: {
152
+ readonly revalidate: readonly unknown[];
153
+ readonly errorBoundary: readonly unknown[];
154
+ readonly notFoundBoundary: readonly unknown[];
155
+ readonly intercept: readonly unknown[];
156
+ readonly parallel: Readonly<Record<string, unknown>>;
157
+ /** Set by cache() when the intercept is declared inside a path. */
158
+ readonly cache?: unknown;
159
+ },
160
+ ): void {
161
+ const allowed = MOUNT_SITE_ALLOWED_TYPES.intercept!;
162
+ for (const item of items) {
163
+ if (item == null) continue;
164
+ const type = (item as { type: string }).type;
165
+ if (!allowed.has(type)) {
166
+ rejectInterceptItem(
167
+ `${type}()`,
168
+ INTERCEPT_REJECTION_HINTS[type] ??
169
+ `allowed items are ${[...allowed].join(", ")}`,
170
+ slotName,
171
+ routeName,
172
+ );
173
+ }
174
+ if (type === "layout" && (item as { uses?: unknown[] }).uses?.length) {
175
+ rejectInterceptItem(
176
+ "layout() with its own use() items",
177
+ "put the modal chrome in the layout component and attach loaders to the intercept itself",
178
+ slotName,
179
+ routeName,
180
+ );
181
+ }
182
+ }
183
+ // A rejected helper called without being returned still wrote into its
184
+ // throwaway field on the temporary parent; reject it the same way.
185
+ for (const type of [
186
+ "revalidate",
187
+ "errorBoundary",
188
+ "notFoundBoundary",
189
+ "intercept",
190
+ ] as const) {
191
+ if (sinks[type].length > 0) {
192
+ rejectInterceptItem(
193
+ `${type}()`,
194
+ INTERCEPT_REJECTION_HINTS[type] ??
195
+ `allowed items are ${[...allowed].join(", ")}`,
196
+ slotName,
197
+ routeName,
198
+ );
199
+ }
200
+ }
201
+ if (Object.keys(sinks.parallel).length > 0) {
202
+ rejectInterceptItem(
203
+ "parallel()",
204
+ `allowed items are ${[...allowed].join(", ")}`,
205
+ slotName,
206
+ routeName,
207
+ );
208
+ }
209
+ if (
210
+ capturedLayouts.some((l) => l.type === "cache") ||
211
+ sinks.cache !== undefined
212
+ ) {
213
+ rejectInterceptItem(
214
+ "cache()",
215
+ INTERCEPT_REJECTION_HINTS.cache!,
216
+ slotName,
217
+ routeName,
218
+ );
219
+ }
220
+ }
221
+
109
222
  /**
110
223
  * Validate that items from handler.use() are valid for the given mount site.
111
224
  * Throws a descriptive error if any item is not allowed.
@@ -135,7 +248,7 @@ export function validateHandlerUseItems(
135
248
  export function mergeHandlerUse(
136
249
  handlerUse: (() => any[]) | undefined,
137
250
  explicitUse: (() => any[]) | undefined,
138
- mountSite: string,
251
+ mountSite: string | null,
139
252
  ): (() => any[]) | undefined {
140
253
  if (!handlerUse && !explicitUse) return undefined;
141
254
  // Validation asymmetry (intentional, pre-1.0): only handler.use() items are
@@ -144,17 +257,19 @@ export function mergeHandlerUse(
144
257
  // branch here and the merged branch, so a structurally-valid-but-prohibited
145
258
  // item (e.g. middleware() inside a parallel slot) is not rejected at this seam.
146
259
  // Documented rather than enforced for now; revisit before 1.0 (#569).
260
+ // intercept() is the exception: it passes mountSite null and validates the
261
+ // merged items itself (validateInterceptUseItems).
147
262
  if (!handlerUse) return explicitUse;
148
263
  if (!explicitUse) {
149
264
  return () => {
150
265
  const items = handlerUse().flat(3);
151
- validateHandlerUseItems(items, mountSite);
266
+ if (mountSite) validateHandlerUseItems(items, mountSite);
152
267
  return items;
153
268
  };
154
269
  }
155
270
  return () => {
156
271
  const hItems = handlerUse().flat(3);
157
- validateHandlerUseItems(hItems, mountSite);
272
+ if (mountSite) validateHandlerUseItems(hItems, mountSite);
158
273
  return [...hItems, ...explicitUse()];
159
274
  };
160
275
  }