@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
@@ -44,7 +44,11 @@ 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
  /**
@@ -131,16 +135,37 @@ const isOrphan = (result: AllUseItems[]): boolean =>
131
135
  * pointer so it leaves the middleware/parent-pointer chain (LOAD-BEARING — see
132
136
  * docs/tree-structure.md) and push it onto the parent's layout[] so it renders
133
137
  * as a wrapper. Used by cache()/middleware()/transition(); layout() runs extra
134
- * validation and registers inline.
138
+ * validation and registers inline. `orphanOwner` keeps the owner reachable for
139
+ * boundary lookup only (router/error-handling.ts).
135
140
  */
136
141
  const attachOrphanSibling = (
137
142
  parent: EntryData | null,
138
143
  entry: EntryData,
139
144
  ): void => {
140
145
  entry.parent = null;
141
- 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
+ }
142
150
  };
143
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
+
144
169
  /**
145
170
  * Run `fn` with `ctx.parent` temporarily redirected to `temp` — a satellite
146
171
  * entry that captures the attachments declared by a use() callback — restoring
@@ -179,7 +204,7 @@ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
179
204
  * When an error occurs during rendering of this segment or its children,
180
205
  * the fallback will be rendered instead. The fallback can be:
181
206
  * - A static ReactNode (e.g., <ErrorPage />)
182
- * - A handler function that receives error info and reset function
207
+ * - A handler function that receives `{ error }` (server fallbacks get no reset)
183
208
  *
184
209
  * Error boundaries catch errors from:
185
210
  * - Middleware execution
@@ -195,11 +220,11 @@ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
195
220
  *
196
221
  * // Or with handler for dynamic error UI:
197
222
  * route("products.detail", ProductDetail, () => [
198
- * errorBoundary(({ error, reset }) => (
223
+ * errorBoundary(({ error }) => (
199
224
  * <div>
200
225
  * <h2>Product failed to load</h2>
201
226
  * <p>{error.message}</p>
202
- * <button onClick={reset}>Retry</button>
227
+ * <a href="/products">Back to products</a>
203
228
  * </div>
204
229
  * )),
205
230
  * ])
@@ -317,6 +342,30 @@ const cache: RouteHelpers<any, any>["cache"] = (
317
342
  const name = `$${cacheIndex}`;
318
343
  const cacheConfig = { options };
319
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
+
320
369
  // If no children, create an orphan cache entry (like orphan layouts)
321
370
  // This allows cache() to wrap subsequent siblings
322
371
  if (!children) {
@@ -491,7 +540,19 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
491
540
  "children",
492
541
  );
493
542
 
494
- 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
+ }
495
556
 
496
557
  return {
497
558
  name: namespace,
@@ -714,15 +775,12 @@ const intercept = (
714
775
  const prefixedRouteName =
715
776
  isLocal && namePrefix ? `${namePrefix}.${bareRouteName}` : bareRouteName;
716
777
 
717
- // Create intercept entry with its own loaders/revalidate/middleware/when
778
+ // Create intercept entry with its own loaders/middleware/when
718
779
  const entry: InterceptEntry = {
719
780
  slotName: slotName as `@${string}`,
720
781
  routeName: prefixedRouteName,
721
782
  handler,
722
783
  middleware: [],
723
- revalidate: [],
724
- errorBoundary: [],
725
- notFoundBoundary: [],
726
784
  loader: [],
727
785
  when: [], // Selector conditions for conditional interception
728
786
  };
@@ -735,25 +793,44 @@ const intercept = (
735
793
  entry.when.push(...selectors);
736
794
  }
737
795
 
738
- // 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.
739
798
  const handlerUseFn = resolveHandlerUse(handler);
740
- const mergedUse = mergeHandlerUse(handlerUseFn, useFn, "intercept");
799
+ const mergedUse = mergeHandlerUse(handlerUseFn, useFn, null);
741
800
 
742
- // Run merged use callback to collect loaders, revalidate, middleware, etc.
801
+ // Run merged use callback to collect loaders, middleware, etc.
743
802
  if (mergedUse) {
744
803
  // Capture layout() calls into a temporary array
745
804
  const capturedLayouts: EntryData[] = [];
746
805
 
747
- // Temporary parent so middleware/loader/revalidate/when attach to the
748
- // 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).
749
823
  const tempParent = {
750
824
  ...ctx.parent,
751
825
  middleware: entry.middleware,
752
- revalidate: entry.revalidate,
753
- errorBoundary: entry.errorBoundary,
754
- notFoundBoundary: entry.notFoundBoundary,
755
826
  loader: entry.loader,
756
- layout: capturedLayouts, // Capture layout() calls
827
+ layout: capturedLayouts,
828
+ revalidate: [],
829
+ errorBoundary: [],
830
+ notFoundBoundary: [],
831
+ parallel: {},
832
+ intercept: [],
833
+ cache: undefined,
757
834
  get loading() {
758
835
  return entry.loading;
759
836
  },
@@ -762,19 +839,29 @@ const intercept = (
762
839
  },
763
840
  };
764
841
 
765
- const result = withParent(ctx, tempParent as EntryData, () =>
766
- 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,
767
854
  );
768
855
 
769
- // Extract layout from captured layouts (use first one if multiple)
770
- // Layout inside intercept should always be ReactNode or Handler, not Record slots
771
- if (capturedLayouts.length > 0 && capturedLayouts[0].type === "layout") {
772
- entry.layout = capturedLayouts[0].handler as
773
- | ReactNode
774
- | 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>;
775
864
  }
776
-
777
- validateUseItems(result, namespace, "intercept", "use");
778
865
  }
779
866
 
780
867
  ctx.parent.intercept.push(entry);
@@ -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)
@@ -459,7 +481,9 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
459
481
  * content while the new loader resolves instead of flashing the route's
460
482
  * loading() skeleton (see segment-system.tsx inTransitionScope). Held
461
483
  * useLoader readers of the re-run loaders report isLoading: true until
462
- * the new data commits (use-loader.tsx). This is also the precondition
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
463
487
  * for any view-transition animation.
464
488
  * 2. <ViewTransition> (experimental React only): the segment content is also
465
489
  * wrapped in React's <ViewTransition>, so the held swap cross-fades/morphs.
@@ -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
  }
@@ -222,11 +222,8 @@ export type ParallelUseItem =
222
222
  | TransitionItem;
223
223
  export type InterceptUseItem =
224
224
  | MiddlewareItem
225
- | RevalidateItem
226
225
  | LoaderItem
227
226
  | LoadingItem
228
- | ErrorBoundaryItem
229
- | NotFoundBoundaryItem
230
227
  | LayoutItem
231
228
  | RouteItem
232
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