@rangojs/router 0.0.0-experimental.32 → 0.0.0-experimental.3232cd17

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 (376) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +198 -44
  3. package/dist/bin/rango.js +287 -105
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +3248 -1117
  6. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  7. package/package.json +73 -21
  8. package/skills/api-client/SKILL.md +211 -0
  9. package/skills/breadcrumbs/SKILL.md +107 -1
  10. package/skills/bundle-analysis/SKILL.md +159 -0
  11. package/skills/cache-guide/SKILL.md +245 -21
  12. package/skills/caching/SKILL.md +302 -6
  13. package/skills/composability/SKILL.md +27 -2
  14. package/skills/css/SKILL.md +76 -0
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +364 -0
  17. package/skills/hooks/SKILL.md +270 -30
  18. package/skills/host-router/SKILL.md +82 -22
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +49 -5
  21. package/skills/layout/SKILL.md +35 -9
  22. package/skills/links/SKILL.md +249 -17
  23. package/skills/loader/SKILL.md +294 -30
  24. package/skills/middleware/SKILL.md +52 -13
  25. package/skills/migrate-nextjs/SKILL.md +584 -0
  26. package/skills/migrate-react-router/SKILL.md +769 -0
  27. package/skills/mime-routes/SKILL.md +27 -0
  28. package/skills/observability/SKILL.md +137 -0
  29. package/skills/parallel/SKILL.md +203 -7
  30. package/skills/prerender/SKILL.md +123 -100
  31. package/skills/rango/SKILL.md +250 -22
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +122 -47
  34. package/skills/route/SKILL.md +97 -5
  35. package/skills/router-setup/SKILL.md +90 -5
  36. package/skills/server-actions/SKILL.md +775 -0
  37. package/skills/streams-and-websockets/SKILL.md +283 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +129 -0
  40. package/skills/testing/bindings.md +89 -0
  41. package/skills/testing/cache-prerender.md +124 -0
  42. package/skills/testing/client-components.md +122 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +92 -0
  45. package/skills/testing/handles.md +129 -0
  46. package/skills/testing/loader.md +128 -0
  47. package/skills/testing/middleware.md +99 -0
  48. package/skills/testing/render-handler.md +121 -0
  49. package/skills/testing/response-routes.md +95 -0
  50. package/skills/testing/reverse-and-types.md +84 -0
  51. package/skills/testing/server-actions.md +107 -0
  52. package/skills/testing/server-tree.md +128 -0
  53. package/skills/testing/setup.md +120 -0
  54. package/skills/typesafety/SKILL.md +329 -27
  55. package/skills/use-cache/SKILL.md +36 -5
  56. package/skills/view-transitions/SKILL.md +294 -0
  57. package/src/__augment-tests__/augment.ts +81 -0
  58. package/src/__augment-tests__/augmented.check.ts +116 -0
  59. package/src/__internal.ts +67 -40
  60. package/src/browser/action-coordinator.ts +53 -36
  61. package/src/browser/action-fence.ts +47 -0
  62. package/src/browser/app-shell.ts +39 -0
  63. package/src/browser/app-version.ts +14 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +86 -147
  66. package/src/browser/history-state.ts +21 -0
  67. package/src/browser/index.ts +3 -3
  68. package/src/browser/invalidate-client-cache.ts +52 -0
  69. package/src/browser/link-interceptor.ts +4 -0
  70. package/src/browser/navigation-bridge.ts +148 -19
  71. package/src/browser/navigation-client.ts +187 -67
  72. package/src/browser/navigation-store-handle.ts +38 -0
  73. package/src/browser/navigation-store.ts +76 -67
  74. package/src/browser/navigation-transaction.ts +18 -66
  75. package/src/browser/partial-update.ts +123 -94
  76. package/src/browser/prefetch/cache.ts +214 -36
  77. package/src/browser/prefetch/fetch.ts +260 -38
  78. package/src/browser/prefetch/policy.ts +6 -0
  79. package/src/browser/prefetch/queue.ts +126 -20
  80. package/src/browser/prefetch/resource-ready.ts +77 -0
  81. package/src/browser/rango-state.ts +158 -76
  82. package/src/browser/react/Link.tsx +93 -11
  83. package/src/browser/react/NavigationProvider.tsx +115 -34
  84. package/src/browser/react/ScrollRestoration.tsx +10 -6
  85. package/src/browser/react/context.ts +7 -2
  86. package/src/browser/react/filter-segment-order.ts +49 -7
  87. package/src/browser/react/index.ts +0 -48
  88. package/src/browser/react/location-state-shared.ts +166 -8
  89. package/src/browser/react/location-state.ts +39 -14
  90. package/src/browser/react/use-action.ts +6 -15
  91. package/src/browser/react/use-handle.ts +23 -69
  92. package/src/browser/react/use-link-status.ts +0 -4
  93. package/src/browser/react/use-navigation.ts +22 -5
  94. package/src/browser/react/use-params.ts +20 -10
  95. package/src/browser/react/use-reverse.ts +106 -0
  96. package/src/browser/react/use-router.ts +46 -11
  97. package/src/browser/react/use-search-params.ts +0 -5
  98. package/src/browser/react/use-segments.ts +11 -21
  99. package/src/browser/response-adapter.ts +52 -1
  100. package/src/browser/rsc-router.tsx +215 -76
  101. package/src/browser/scroll-restoration.ts +46 -39
  102. package/src/browser/segment-reconciler.ts +36 -9
  103. package/src/browser/segment-structure-assert.ts +2 -2
  104. package/src/browser/server-action-bridge.ts +176 -50
  105. package/src/browser/types.ts +95 -11
  106. package/src/browser/validate-redirect-origin.ts +43 -16
  107. package/src/build/collect-fallback-refs.ts +107 -0
  108. package/src/build/generate-manifest.ts +65 -40
  109. package/src/build/generate-route-types.ts +5 -0
  110. package/src/build/index.ts +8 -2
  111. package/src/build/prefix-tree-utils.ts +123 -0
  112. package/src/build/route-trie.ts +137 -32
  113. package/src/build/route-types/codegen.ts +4 -4
  114. package/src/build/route-types/include-resolution.ts +9 -2
  115. package/src/build/route-types/param-extraction.ts +6 -3
  116. package/src/build/route-types/per-module-writer.ts +7 -4
  117. package/src/build/route-types/router-processing.ts +278 -96
  118. package/src/build/route-types/scan-filter.ts +9 -2
  119. package/src/build/route-types/source-scan.ts +118 -0
  120. package/src/build/runtime-discovery.ts +9 -20
  121. package/src/cache/cache-error.ts +104 -0
  122. package/src/cache/cache-policy.ts +68 -28
  123. package/src/cache/cache-runtime.ts +149 -43
  124. package/src/cache/cache-scope.ts +148 -81
  125. package/src/cache/cache-tag.ts +98 -0
  126. package/src/cache/cf/cf-cache-store.ts +2550 -93
  127. package/src/cache/cf/index.ts +11 -17
  128. package/src/cache/document-cache.ts +78 -27
  129. package/src/cache/handle-snapshot.ts +63 -0
  130. package/src/cache/index.ts +23 -20
  131. package/src/cache/memory-segment-store.ts +136 -37
  132. package/src/cache/profile-registry.ts +6 -30
  133. package/src/cache/read-through-swr.ts +41 -11
  134. package/src/cache/segment-codec.ts +0 -16
  135. package/src/cache/tag-invalidation.ts +230 -0
  136. package/src/cache/taint.ts +55 -0
  137. package/src/cache/types.ts +33 -100
  138. package/src/cache/vercel/index.ts +11 -0
  139. package/src/cache/vercel/vercel-cache-store.ts +799 -0
  140. package/src/client.rsc.tsx +6 -21
  141. package/src/client.tsx +108 -290
  142. package/src/component-utils.ts +19 -0
  143. package/src/context-var.ts +84 -2
  144. package/src/debug.ts +2 -2
  145. package/src/decode-loader-results.ts +36 -0
  146. package/src/defer.ts +196 -0
  147. package/src/deps/ssr.ts +0 -1
  148. package/src/errors.ts +30 -4
  149. package/src/handle.ts +70 -22
  150. package/src/handles/MetaTags.tsx +0 -14
  151. package/src/handles/breadcrumbs.ts +16 -5
  152. package/src/handles/meta.ts +0 -39
  153. package/src/host/cookie-handler.ts +0 -36
  154. package/src/host/errors.ts +0 -24
  155. package/src/host/index.ts +8 -2
  156. package/src/host/pattern-matcher.ts +7 -50
  157. package/src/host/router.ts +107 -99
  158. package/src/host/testing.ts +40 -27
  159. package/src/host/types.ts +37 -4
  160. package/src/host/utils.ts +1 -1
  161. package/src/href-client.ts +137 -22
  162. package/src/index.rsc.ts +52 -26
  163. package/src/index.ts +100 -38
  164. package/src/internal-debug.ts +2 -4
  165. package/src/loader-store.ts +500 -0
  166. package/src/loader.rsc.ts +20 -13
  167. package/src/loader.ts +12 -11
  168. package/src/missing-id-error.ts +68 -0
  169. package/src/network-error-thrower.tsx +1 -6
  170. package/src/outlet-context.ts +1 -1
  171. package/src/outlet-provider.tsx +1 -5
  172. package/src/prerender/param-hash.ts +10 -11
  173. package/src/prerender/store.ts +37 -41
  174. package/src/prerender.ts +198 -82
  175. package/src/redirect-origin.ts +100 -0
  176. package/src/response-utils.ts +37 -0
  177. package/src/reverse.ts +65 -15
  178. package/src/root-error-boundary.tsx +1 -19
  179. package/src/route-content-wrapper.tsx +7 -72
  180. package/src/route-definition/dsl-helpers.ts +437 -274
  181. package/src/route-definition/helper-factories.ts +29 -139
  182. package/src/route-definition/helpers-types.ts +113 -37
  183. package/src/route-definition/index.ts +3 -0
  184. package/src/route-definition/redirect.ts +52 -10
  185. package/src/route-definition/resolve-handler-use.ts +161 -0
  186. package/src/route-definition/use-item-types.ts +32 -0
  187. package/src/route-map-builder.ts +7 -17
  188. package/src/route-types.ts +37 -41
  189. package/src/router/basename.ts +14 -0
  190. package/src/router/content-negotiation.ts +108 -9
  191. package/src/router/error-handling.ts +13 -17
  192. package/src/router/find-match.ts +45 -22
  193. package/src/router/handler-context.ts +83 -41
  194. package/src/router/intercept-resolution.ts +25 -23
  195. package/src/router/lazy-includes.ts +19 -53
  196. package/src/router/loader-resolution.ts +213 -30
  197. package/src/router/logging.ts +5 -8
  198. package/src/router/manifest.ts +49 -45
  199. package/src/router/match-api.ts +120 -204
  200. package/src/router/match-context.ts +0 -22
  201. package/src/router/match-handlers.ts +58 -58
  202. package/src/router/match-middleware/background-revalidation.ts +27 -6
  203. package/src/router/match-middleware/cache-lookup.ts +205 -249
  204. package/src/router/match-middleware/cache-store.ts +45 -32
  205. package/src/router/match-middleware/intercept-resolution.ts +8 -28
  206. package/src/router/match-middleware/segment-resolution.ts +52 -18
  207. package/src/router/match-pipelines.ts +1 -42
  208. package/src/router/match-result.ts +104 -40
  209. package/src/router/metrics.ts +5 -34
  210. package/src/router/middleware-types.ts +13 -142
  211. package/src/router/middleware.ts +173 -143
  212. package/src/router/navigation-snapshot.ts +131 -0
  213. package/src/router/params-util.ts +23 -0
  214. package/src/router/pattern-matching.ts +109 -63
  215. package/src/router/prerender-match.ts +190 -54
  216. package/src/router/preview-match.ts +32 -102
  217. package/src/router/request-classification.ts +276 -0
  218. package/src/router/revalidation.ts +63 -55
  219. package/src/router/route-snapshot.ts +244 -0
  220. package/src/router/router-context.ts +6 -28
  221. package/src/router/router-interfaces.ts +100 -35
  222. package/src/router/router-options.ts +91 -11
  223. package/src/router/router-registry.ts +2 -5
  224. package/src/router/segment-resolution/fresh.ts +242 -75
  225. package/src/router/segment-resolution/helpers.ts +63 -24
  226. package/src/router/segment-resolution/loader-cache.ts +41 -37
  227. package/src/router/segment-resolution/revalidation.ts +456 -372
  228. package/src/router/segment-resolution/static-store.ts +19 -5
  229. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  230. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  231. package/src/router/segment-resolution.ts +4 -1
  232. package/src/router/segment-wrappers.ts +2 -3
  233. package/src/router/state-cookie-name.ts +33 -0
  234. package/src/router/substitute-pattern-params.ts +56 -0
  235. package/src/router/telemetry-otel.ts +0 -20
  236. package/src/router/telemetry.ts +96 -19
  237. package/src/router/timeout.ts +0 -20
  238. package/src/router/trie-matching.ts +91 -46
  239. package/src/router/types.ts +10 -63
  240. package/src/router/url-params.ts +44 -0
  241. package/src/router.ts +134 -43
  242. package/src/rsc/handler-context.ts +3 -2
  243. package/src/rsc/handler.ts +492 -383
  244. package/src/rsc/helpers.ts +162 -46
  245. package/src/rsc/index.ts +1 -1
  246. package/src/rsc/json-route-result.ts +38 -0
  247. package/src/rsc/loader-fetch.ts +23 -3
  248. package/src/rsc/manifest-init.ts +33 -42
  249. package/src/rsc/origin-guard.ts +39 -25
  250. package/src/rsc/progressive-enhancement.ts +30 -3
  251. package/src/rsc/redirect-guard.ts +99 -0
  252. package/src/rsc/response-error.ts +79 -12
  253. package/src/rsc/response-route-handler.ts +90 -63
  254. package/src/rsc/rsc-rendering.ts +56 -54
  255. package/src/rsc/runtime-warnings.ts +23 -10
  256. package/src/rsc/server-action.ts +74 -67
  257. package/src/rsc/ssr-setup.ts +18 -2
  258. package/src/rsc/types.ts +25 -6
  259. package/src/runtime-env.ts +18 -0
  260. package/src/search-params.ts +4 -20
  261. package/src/segment-content-promise.ts +67 -0
  262. package/src/segment-loader-promise.ts +134 -0
  263. package/src/segment-system.tsx +272 -129
  264. package/src/serialize.ts +243 -0
  265. package/src/server/context.ts +309 -61
  266. package/src/server/cookie-store.ts +80 -5
  267. package/src/server/handle-store.ts +26 -24
  268. package/src/server/loader-registry.ts +10 -28
  269. package/src/server/request-context.ts +338 -126
  270. package/src/ssr/index.tsx +23 -15
  271. package/src/static-handler.ts +27 -18
  272. package/src/testing/cache-status.ts +162 -0
  273. package/src/testing/collect-handle.ts +40 -0
  274. package/src/testing/dispatch.ts +618 -0
  275. package/src/testing/dom.entry.ts +22 -0
  276. package/src/testing/e2e/fixture.ts +188 -0
  277. package/src/testing/e2e/index.ts +128 -0
  278. package/src/testing/e2e/matchers.ts +35 -0
  279. package/src/testing/e2e/page-helpers.ts +272 -0
  280. package/src/testing/e2e/parity.ts +387 -0
  281. package/src/testing/e2e/server.ts +195 -0
  282. package/src/testing/flight-matchers.ts +97 -0
  283. package/src/testing/flight-normalize.ts +11 -0
  284. package/src/testing/flight-runtime.d.ts +57 -0
  285. package/src/testing/flight-tree.ts +682 -0
  286. package/src/testing/flight.entry.ts +52 -0
  287. package/src/testing/flight.ts +232 -0
  288. package/src/testing/generated-routes.ts +183 -0
  289. package/src/testing/index.ts +99 -0
  290. package/src/testing/internal/context.ts +348 -0
  291. package/src/testing/internal/flight-client-globals.ts +30 -0
  292. package/src/testing/internal/seed-vars.ts +54 -0
  293. package/src/testing/render-handler.ts +330 -0
  294. package/src/testing/render-route.tsx +566 -0
  295. package/src/testing/run-loader.ts +378 -0
  296. package/src/testing/run-middleware.ts +205 -0
  297. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  298. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  299. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  300. package/src/testing/vitest-stubs/version.ts +5 -0
  301. package/src/testing/vitest.ts +305 -0
  302. package/src/theme/ThemeProvider.tsx +0 -52
  303. package/src/theme/ThemeScript.tsx +0 -6
  304. package/src/theme/constants.ts +0 -12
  305. package/src/theme/index.ts +0 -7
  306. package/src/theme/theme-context.ts +1 -5
  307. package/src/theme/theme-script.ts +0 -14
  308. package/src/theme/use-theme.ts +0 -3
  309. package/src/types/boundaries.ts +0 -35
  310. package/src/types/cache-types.ts +17 -8
  311. package/src/types/error-types.ts +30 -90
  312. package/src/types/global-namespace.ts +54 -41
  313. package/src/types/handler-context.ts +233 -81
  314. package/src/types/index.ts +1 -10
  315. package/src/types/loader-types.ts +44 -15
  316. package/src/types/request-scope.ts +107 -0
  317. package/src/types/route-config.ts +6 -50
  318. package/src/types/route-entry.ts +19 -7
  319. package/src/types/segments.ts +37 -14
  320. package/src/urls/include-helper.ts +33 -70
  321. package/src/urls/index.ts +1 -11
  322. package/src/urls/path-helper-types.ts +58 -11
  323. package/src/urls/path-helper.ts +57 -111
  324. package/src/urls/pattern-types.ts +48 -19
  325. package/src/urls/response-types.ts +25 -22
  326. package/src/urls/type-extraction.ts +58 -139
  327. package/src/urls/urls-function.ts +1 -18
  328. package/src/use-loader.tsx +346 -89
  329. package/src/vite/debug.ts +185 -0
  330. package/src/vite/discovery/bundle-postprocess.ts +36 -38
  331. package/src/vite/discovery/discover-routers.ts +130 -85
  332. package/src/vite/discovery/discovery-errors.ts +194 -0
  333. package/src/vite/discovery/gate-state.ts +171 -0
  334. package/src/vite/discovery/prerender-collection.ts +192 -99
  335. package/src/vite/discovery/route-types-writer.ts +40 -84
  336. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  337. package/src/vite/discovery/state.ts +51 -6
  338. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  339. package/src/vite/index.ts +8 -0
  340. package/src/vite/plugin-types.ts +187 -69
  341. package/src/vite/plugins/cjs-to-esm.ts +8 -18
  342. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  343. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  344. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  345. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  346. package/src/vite/plugins/cloudflare-protocol-stub.ts +194 -0
  347. package/src/vite/plugins/expose-action-id.ts +49 -98
  348. package/src/vite/plugins/expose-id-utils.ts +11 -50
  349. package/src/vite/plugins/expose-ids/export-analysis.ts +76 -34
  350. package/src/vite/plugins/expose-ids/handler-transform.ts +10 -48
  351. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -20
  352. package/src/vite/plugins/expose-ids/router-transform.ts +20 -16
  353. package/src/vite/plugins/expose-internal-ids.ts +554 -317
  354. package/src/vite/plugins/performance-tracks.ts +89 -0
  355. package/src/vite/plugins/refresh-cmd.ts +89 -27
  356. package/src/vite/plugins/use-cache-transform.ts +73 -83
  357. package/src/vite/plugins/vercel-output.ts +258 -0
  358. package/src/vite/plugins/version-injector.ts +21 -25
  359. package/src/vite/plugins/version-plugin.ts +41 -20
  360. package/src/vite/plugins/virtual-entries.ts +2 -17
  361. package/src/vite/rango.ts +257 -289
  362. package/src/vite/router-discovery.ts +930 -140
  363. package/src/vite/utils/ast-handler-extract.ts +15 -31
  364. package/src/vite/utils/banner.ts +4 -4
  365. package/src/vite/utils/bundle-analysis.ts +10 -15
  366. package/src/vite/utils/client-chunks.ts +184 -0
  367. package/src/vite/utils/forward-user-plugins.ts +171 -0
  368. package/src/vite/utils/manifest-utils.ts +4 -59
  369. package/src/vite/utils/package-resolution.ts +20 -52
  370. package/src/vite/utils/prerender-utils.ts +27 -29
  371. package/src/vite/utils/shared-utils.ts +92 -42
  372. package/src/browser/action-response-classifier.ts +0 -99
  373. package/src/browser/react/use-client-cache.ts +0 -58
  374. package/src/browser/shallow.ts +0 -40
  375. package/src/handles/index.ts +0 -7
  376. package/src/router/middleware-cookies.ts +0 -55
@@ -11,12 +11,16 @@ import {
11
11
  getContext,
12
12
  getNamePrefix,
13
13
  getUrlPrefix,
14
+ requireDslContext,
14
15
  type EntryData,
16
+ type EntryPropDatas,
17
+ type EntryPropSegments,
18
+ type HelperContext,
15
19
  type InterceptEntry,
16
20
  } from "../server/context";
17
21
  import { invariant } from "../errors";
18
22
  import { isCachedFunction } from "../cache/taint.js";
19
- import { RSCRouterContext } from "../server/context";
23
+ import { RangoContext } from "../server/context";
20
24
  import { isStaticHandler } from "../static-handler.js";
21
25
  import RootLayout from "../server/root-layout";
22
26
  import type {
@@ -37,6 +41,8 @@ import type {
37
41
  UseItems,
38
42
  } from "../route-types.js";
39
43
  import type { RouteHelpers } from "./helpers-types.js";
44
+ import { resolveHandlerUse, mergeHandlerUse } from "./resolve-handler-use.js";
45
+ import { ALL_USE_ITEM_TYPES } from "./use-item-types.js";
40
46
 
41
47
  /**
42
48
  * Check if an item contains routes (directly or inside nested structures like cache).
@@ -54,19 +60,111 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
54
60
  if (item.type === "layout" && item.uses) {
55
61
  return item.uses.some((child) => hasRoutesInItem(child));
56
62
  }
63
+ if (item.type === "middleware" && item.uses) {
64
+ return item.uses.some((child) => hasRoutesInItem(child));
65
+ }
57
66
  return false;
58
67
  };
59
68
 
69
+ /**
70
+ * Fresh empty collections shared by every from-scratch segment entry. Returns
71
+ * new arrays/objects per call so no two entries share mutable references.
72
+ * mountPath is intentionally NOT included here — each call site adds it from
73
+ * getUrlPrefix() where applicable: the route() and transition() helpers add
74
+ * none, while path() (which also builds a `type: "route"` entry) and the
75
+ * structural helpers (layout/cache/middleware/parallel) do.
76
+ */
77
+ const emptySegmentBase = (): EntryPropDatas &
78
+ EntryPropSegments & { loading: undefined } => ({
79
+ loading: undefined,
80
+ middleware: [],
81
+ revalidate: [],
82
+ errorBoundary: [],
83
+ notFoundBoundary: [],
84
+ layout: [],
85
+ parallel: {},
86
+ intercept: [],
87
+ loader: [],
88
+ });
89
+
90
+ /**
91
+ * Run a children/use callback as a nested scope, flatten the result, and assert
92
+ * every item is a valid use item. `kind` preserves the existing error wording
93
+ * ("use()" vs "children" callback).
94
+ */
95
+ function runAndValidateUseItems(
96
+ store: ReturnType<typeof getContext>,
97
+ namespace: string,
98
+ entry: EntryData,
99
+ cb: () => any,
100
+ label: string,
101
+ kind: "use" | "children",
102
+ ): AllUseItems[] {
103
+ const result = store.run(namespace, entry, cb)?.flat(3);
104
+ return validateUseItems(result, namespace, label, kind);
105
+ }
106
+
107
+ /** Assert an already-invoked, flattened callback result is a use-item array. */
108
+ function validateUseItems(
109
+ result: any,
110
+ namespace: string,
111
+ label: string,
112
+ kind: "use" | "children",
113
+ ): AllUseItems[] {
114
+ invariant(
115
+ Array.isArray(result) && result.every((item) => isValidUseItem(item)),
116
+ `${label}() ${kind === "use" ? "use()" : "children"} callback must return an array of use items [${namespace}]`,
117
+ );
118
+ return result as AllUseItems[];
119
+ }
120
+
121
+ /** True when a children/use result contains no routes (directly or nested). */
122
+ const isOrphan = (result: AllUseItems[]): boolean =>
123
+ !result.some((item) => item != null && hasRoutesInItem(item));
124
+
125
+ /**
126
+ * Register a routeless structural entry as an orphan sibling: clear its parent
127
+ * pointer so it leaves the middleware/parent-pointer chain (LOAD-BEARING — see
128
+ * docs/tree-structure.md) and push it onto the parent's layout[] so it renders
129
+ * as a wrapper. Used by cache()/middleware()/transition(); layout() runs extra
130
+ * validation and registers inline.
131
+ */
132
+ const attachOrphanSibling = (
133
+ parent: EntryData | null,
134
+ entry: EntryData,
135
+ ): void => {
136
+ entry.parent = null;
137
+ if (parent && "layout" in parent) parent.layout.push(entry);
138
+ };
139
+
140
+ /**
141
+ * Run `fn` with `ctx.parent` temporarily redirected to `temp` — a satellite
142
+ * entry that captures the attachments declared by a use() callback — restoring
143
+ * the original parent afterward, including on throw. loader()/intercept() each
144
+ * build their own tempParent shape (intercept keeps a loading get/set accessor
145
+ * and a captured-layouts array); this only centralizes the save/restore.
146
+ */
147
+ function withParent<T>(ctx: HelperContext, temp: EntryData, fn: () => T): T {
148
+ const original = ctx.parent;
149
+ ctx.parent = temp;
150
+ try {
151
+ return fn();
152
+ } finally {
153
+ ctx.parent = original;
154
+ }
155
+ }
156
+
60
157
  const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
61
- const ctx = getContext().getStore();
62
- if (!ctx) throw new Error("revalidate() must be called inside map()");
158
+ const { store, ctx } = requireDslContext(
159
+ "revalidate() must be called inside urls()",
160
+ );
63
161
 
64
162
  // Attach to last entry in stack
65
163
  const parent = ctx.parent;
66
164
  if (!parent || !("revalidate" in parent)) {
67
165
  invariant(false, "No parent entry available for revalidate()");
68
166
  }
69
- const name = `$${getContext().getNextIndex("revalidate")}`;
167
+ const name = `$${store.getNextIndex("revalidate")}`;
70
168
  parent.revalidate.push(fn);
71
169
  return { name, type: "revalidate" } as RevalidateItem;
72
170
  };
@@ -104,15 +202,16 @@ const revalidate: RouteHelpers<any, any>["revalidate"] = (fn) => {
104
202
  * ```
105
203
  */
106
204
  const errorBoundary: RouteHelpers<any, any>["errorBoundary"] = (fallback) => {
107
- const ctx = getContext().getStore();
108
- if (!ctx) throw new Error("errorBoundary() must be called inside map()");
205
+ const { store, ctx } = requireDslContext(
206
+ "errorBoundary() must be called inside urls()",
207
+ );
109
208
 
110
209
  // Attach to parent entry in stack
111
210
  const parent = ctx.parent;
112
211
  if (!parent || !("errorBoundary" in parent)) {
113
212
  invariant(false, "No parent entry available for errorBoundary()");
114
213
  }
115
- const name = `$${getContext().getNextIndex("errorBoundary")}`;
214
+ const name = `$${store.getNextIndex("errorBoundary")}`;
116
215
  parent.errorBoundary.push(fallback);
117
216
  return { name, type: "errorBoundary" } as ErrorBoundaryItem;
118
217
  };
@@ -151,15 +250,16 @@ const errorBoundary: RouteHelpers<any, any>["errorBoundary"] = (fallback) => {
151
250
  const notFoundBoundary: RouteHelpers<any, any>["notFoundBoundary"] = (
152
251
  fallback,
153
252
  ) => {
154
- const ctx = getContext().getStore();
155
- if (!ctx) throw new Error("notFoundBoundary() must be called inside map()");
253
+ const { store, ctx } = requireDslContext(
254
+ "notFoundBoundary() must be called inside urls()",
255
+ );
156
256
 
157
257
  // Attach to parent entry in stack
158
258
  const parent = ctx.parent;
159
259
  if (!parent || !("notFoundBoundary" in parent)) {
160
260
  invariant(false, "No parent entry available for notFoundBoundary()");
161
261
  }
162
- const name = `$${getContext().getNextIndex("notFoundBoundary")}`;
262
+ const name = `$${store.getNextIndex("notFoundBoundary")}`;
163
263
  parent.notFoundBoundary.push(fallback);
164
264
  return { name, type: "notFoundBoundary" } as NotFoundBoundaryItem;
165
265
  };
@@ -173,8 +273,9 @@ const notFoundBoundary: RouteHelpers<any, any>["notFoundBoundary"] = (
173
273
  * for the intercept to activate.
174
274
  */
175
275
  const when: RouteHelpers<any, any>["when"] = (fn) => {
176
- const ctx = getContext().getStore();
177
- if (!ctx) throw new Error("when() must be called inside intercept()");
276
+ const { store, ctx } = requireDslContext(
277
+ "when() must be called inside intercept()",
278
+ );
178
279
 
179
280
  // The when() function needs to be captured by the intercept's tempParent
180
281
  // which should have a `when` array. If not present, we're not inside intercept()
@@ -186,7 +287,7 @@ const when: RouteHelpers<any, any>["when"] = (fn) => {
186
287
  );
187
288
  }
188
289
 
189
- const name = `$${getContext().getNextIndex("when")}`;
290
+ const name = `$${store.getNextIndex("when")}`;
190
291
  parent.when.push(fn);
191
292
  return { name, type: "when" } as WhenItem;
192
293
  };
@@ -201,21 +302,21 @@ const when: RouteHelpers<any, any>["when"] = (fn) => {
201
302
  * Supports these call signatures:
202
303
  * - cache() - no args, uses app-level defaults (for loader caching)
203
304
  * - cache(() => [...]) - wraps children with app-level defaults
204
- * - cache('profileName') - uses a named cache profile
205
- * - cache('profileName', () => [...]) - named profile with children
206
305
  * - cache({ ttl: 60 }, () => [...]) - with explicit options
306
+ *
307
+ * Named cache profiles are applied via the `"use cache: <profile>"` directive,
308
+ * not a `cache("profileName")` form in the route tree.
207
309
  */
208
310
  const cache: RouteHelpers<any, any>["cache"] = (
209
311
  optionsOrChildren?:
210
312
  | PartialCacheOptions
211
313
  | false
212
- | string
213
314
  | (() => UseItems<AllUseItems>),
214
315
  maybeChildren?: () => UseItems<AllUseItems>,
215
316
  ) => {
216
- const store = getContext();
217
- const ctx = store.getStore();
218
- if (!ctx) throw new Error("cache() must be called inside map()");
317
+ const { store, ctx } = requireDslContext(
318
+ "cache() must be called inside urls()",
319
+ );
219
320
 
220
321
  // Handle overloaded signature
221
322
  let options: PartialCacheOptions | false;
@@ -225,18 +326,6 @@ const cache: RouteHelpers<any, any>["cache"] = (
225
326
  // cache() - no args, use defaults
226
327
  options = {};
227
328
  children = undefined;
228
- } else if (typeof optionsOrChildren === "string") {
229
- // cache('profileName') or cache('profileName', () => [...])
230
- // Resolve from context-scoped profiles (set per-router via HelperContext).
231
- const ctxStore = RSCRouterContext.getStore();
232
- const profile = ctxStore?.cacheProfiles?.[optionsOrChildren];
233
- invariant(
234
- profile,
235
- `cache("${optionsOrChildren}"): unknown cache profile. ` +
236
- `Define it in createRouter({ cacheProfiles: { "${optionsOrChildren}": { ttl: ... } } }).`,
237
- );
238
- options = { ttl: profile.ttl, swr: profile.swr, tags: profile.tags };
239
- children = maybeChildren;
240
329
  } else if (typeof optionsOrChildren === "function") {
241
330
  // cache(() => [...]) - use empty options (will use defaults)
242
331
  options = {};
@@ -267,26 +356,18 @@ const cache: RouteHelpers<any, any>["cache"] = (
267
356
  // Create orphan cache entry (like orphan layout)
268
357
  // Subsequent siblings in the same array will attach to this entry
269
358
  const namespace = `${ctx.namespace}.${cacheIndex}`;
270
- const cacheUrlPrefix = getUrlPrefix();
359
+ const urlPrefix = getUrlPrefix();
271
360
 
272
361
  const entry = {
362
+ ...emptySegmentBase(),
273
363
  id: namespace,
274
364
  shortCode: store.getShortCode("cache"),
275
365
  type: "cache",
276
366
  parent: parent, // link to current parent for hierarchy
277
367
  cache: cacheConfig,
278
368
  handler: RootLayout,
279
- loading: undefined, // Allow loading() to attach loading state
280
- middleware: [],
281
- revalidate: [],
282
- errorBoundary: [],
283
- notFoundBoundary: [],
284
- layout: [],
285
- parallel: [],
286
- intercept: [],
287
- loader: [],
288
- ...(cacheUrlPrefix ? { mountPath: cacheUrlPrefix } : {}),
289
- } as EntryData;
369
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
370
+ } satisfies EntryData;
290
371
 
291
372
  // Attach to parent's layout array (cache entries are structural like layouts)
292
373
  if (parent && "layout" in parent) {
@@ -300,13 +381,23 @@ const cache: RouteHelpers<any, any>["cache"] = (
300
381
  return { name: namespace, type: "cache" } as CacheItem;
301
382
  }
302
383
 
384
+ // Inside a loader() use() callback, only the direct form — cache()/cache(opts)
385
+ // — writes cache config to the loader entry. The wrapper form creates a
386
+ // structural cache boundary with its own children scope, which has no effect
387
+ // on the loader and would silently no-op.
388
+ invariant(
389
+ !(ctx.parent && (ctx.parent as any).type === "loader"),
390
+ "cache() wrapper form is not valid inside loader() use(). Use cache({...}) without children to configure the loader's cache.",
391
+ );
392
+
303
393
  // With children: create a cache entry (like layout with caching semantics)
304
394
  const namespace = `${ctx.namespace}.${cacheIndex}`;
305
395
  const cacheShortCode = store.getShortCode("cache");
306
396
 
307
- const cacheUrlPrefix2 = getUrlPrefix();
397
+ const urlPrefix = getUrlPrefix();
308
398
 
309
399
  const entry = {
400
+ ...emptySegmentBase(),
310
401
  id: namespace,
311
402
  shortCode: cacheShortCode,
312
403
  type: "cache",
@@ -314,48 +405,57 @@ const cache: RouteHelpers<any, any>["cache"] = (
314
405
  cache: cacheConfig,
315
406
  // Cache entries render like layouts (with Outlet as default handler)
316
407
  handler: RootLayout, // RootLayout just renders <Outlet />
317
- loading: undefined, // Allow loading() to attach loading state
318
- middleware: [],
319
- revalidate: [],
320
- errorBoundary: [],
321
- notFoundBoundary: [],
322
- layout: [],
323
- parallel: [],
324
- intercept: [],
325
- loader: [],
326
- ...(cacheUrlPrefix2 ? { mountPath: cacheUrlPrefix2 } : {}),
327
- } as EntryData;
408
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
409
+ } satisfies EntryData;
328
410
 
329
411
  // Run children with cache entry as parent
330
- const result = store.run(namespace, entry, children)?.flat(3);
331
-
332
- invariant(
333
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
334
- `cache() children callback must return an array of use items [${namespace}]`,
412
+ const result = runAndValidateUseItems(
413
+ store,
414
+ namespace,
415
+ entry,
416
+ children,
417
+ "cache",
418
+ "children",
335
419
  );
336
420
 
337
- // Check if this cache has routes (including nested caches/layouts)
338
- const hasRoutes =
339
- result &&
340
- Array.isArray(result) &&
341
- result.some((item) => hasRoutesInItem(item));
342
-
343
- if (!hasRoutes) {
344
- const parent = ctx.parent;
345
- if (parent && "layout" in parent) {
346
- // Attach to parent's layout array (cache entries are structural like layouts)
347
- entry.parent = null;
348
- parent.layout.push(entry);
349
- }
350
- }
421
+ // Cache entries are structural like layouts: with no routes inside, register
422
+ // as an orphan sibling.
423
+ if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
351
424
 
352
425
  return { name: namespace, type: "cache", uses: result } as CacheItem;
353
426
  };
354
427
 
355
- const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
428
+ const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
429
+ // Four call forms:
430
+ // middleware(fn) — single fn, sibling
431
+ // middleware(fn, () => [...]) — single fn, wrapping
432
+ // middleware([fn1, fn2]) — array, sibling
433
+ // middleware([fn1, fn2], () => [...]) — array, wrapping
434
+ const isArray = Array.isArray(args[0]);
435
+
436
+ // Reject the removed variadic form before executing anything.
437
+ // middleware(fn1, fn2, fn3) — 3+ args, always wrong.
438
+ // middleware(fn1, fn2) where fn2 is a middleware fn (length >= 1), not a
439
+ // children callback (length === 0) — legacy two-fn form, reject early.
440
+ if (
441
+ args.length > 2 ||
442
+ (!isArray &&
443
+ args.length === 2 &&
444
+ typeof args[1] === "function" &&
445
+ args[1].length > 0)
446
+ ) {
447
+ throw new Error(
448
+ "middleware() no longer accepts variadic arguments. " +
449
+ "Use middleware([fn1, fn2, ...]) instead of middleware(fn1, fn2, ...).",
450
+ );
451
+ }
452
+
453
+ const fns: MiddlewareFn<any>[] = isArray ? args[0] : [args[0]];
454
+ const children: (() => any[]) | undefined =
455
+ typeof args[1] === "function" ? args[1] : undefined;
456
+
356
457
  // Prevent "use cache" functions from being used as middleware.
357
- // Checked before context validation — this is a static invariant.
358
- for (const f of fn) {
458
+ for (const f of fns) {
359
459
  if (isCachedFunction(f)) {
360
460
  throw new Error(
361
461
  `A "use cache" function cannot be used as middleware. ` +
@@ -366,23 +466,68 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
366
466
  }
367
467
  }
368
468
 
369
- const ctx = getContext().getStore();
370
- if (!ctx) throw new Error("middleware() must be called inside map()");
469
+ const { store, ctx } = requireDslContext(
470
+ "middleware() must be called inside urls()",
471
+ );
371
472
 
372
- // Attach to last entry in stack
373
- const parent = ctx.parent;
374
- if (!parent || !("middleware" in parent)) {
375
- invariant(false, "No parent entry available for middleware()");
473
+ if (!children) {
474
+ // Sibling mode: attach to parent entry
475
+ const parent = ctx.parent;
476
+ if (!parent || !("middleware" in parent)) {
477
+ invariant(false, "No parent entry available for middleware()");
478
+ }
479
+ const name = `$${store.getNextIndex("middleware")}`;
480
+ parent.middleware.push(...fns);
481
+ return { name, type: "middleware" } as MiddlewareItem;
376
482
  }
377
- const name = `$${getContext().getNextIndex("middleware")}`;
378
- parent.middleware.push(...fn);
379
- return { name, type: "middleware" } as MiddlewareItem;
483
+
484
+ // Wrapping mode: create a transparent layout that carries the middleware
485
+ const mwIndex = store.getNextIndex("middleware");
486
+ const namespace = `${ctx.namespace}.${mwIndex}`;
487
+
488
+ const urlPrefix = getUrlPrefix();
489
+ const entry = {
490
+ ...emptySegmentBase(),
491
+ id: namespace,
492
+ shortCode: store.getShortCode("layout"),
493
+ type: "layout",
494
+ parent: ctx.parent,
495
+ handler: RootLayout,
496
+ middleware: [...fns],
497
+ ...(urlPrefix ? { mountPath: urlPrefix } : {}),
498
+ } satisfies EntryData;
499
+
500
+ // Run children callback. If the second arg was actually a middleware fn
501
+ // (old variadic form: middleware(mw1, mw2)), this will return a non-array
502
+ // and the invariant below gives a clear migration error.
503
+ const rawResult = store.run(namespace, entry, children);
504
+
505
+ invariant(
506
+ Array.isArray(rawResult),
507
+ "middleware(fn, children) expects the second argument to return an array of use items. " +
508
+ "To pass multiple middleware, use middleware([fn1, fn2]).",
509
+ );
510
+
511
+ const result = validateUseItems(
512
+ rawResult.flat(3),
513
+ namespace,
514
+ "middleware",
515
+ "children",
516
+ );
517
+
518
+ if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
519
+
520
+ return {
521
+ name: namespace,
522
+ type: "middleware",
523
+ uses: result,
524
+ } as MiddlewareItem;
380
525
  };
381
526
 
382
527
  const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
383
- const store = getContext();
384
- const ctx = store.getStore();
385
- if (!ctx) throw new Error("parallel() must be called inside map()");
528
+ const { store, ctx } = requireDslContext(
529
+ "parallel() must be called inside urls()",
530
+ );
386
531
 
387
532
  if (!ctx.parent || !ctx.parent?.parallel) {
388
533
  invariant(false, "No parent entry available for parallel()");
@@ -393,15 +538,29 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
393
538
  "parallel() cannot be nested inside another parallel()",
394
539
  );
395
540
 
541
+ const slotNames = Object.keys(slots as Record<string, any>) as `@${string}`[];
542
+
396
543
  const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
397
544
 
398
- // Unwrap any static handler definitions in parallel slots
545
+ // Unwrap slot values. A slot value can be:
546
+ // - a Handler / ReactNode (legacy form)
547
+ // - a Static() definition (build-time only)
548
+ // - a slot descriptor `{ handler, use? }` for slot-local overrides
549
+ // The descriptor's `use` runs after the broadcast `use` for that slot,
550
+ // so single-assignment items like `loading()` placed there win without
551
+ // affecting siblings.
399
552
  const unwrappedSlots: Record<string, any> = {};
553
+ const slotLocalUses: Record<string, (() => any[]) | undefined> = {};
400
554
  let hasStaticSlot = false;
401
555
  const staticSlotIds: Record<string, string> = {};
402
- for (const [slotName, slotHandler] of Object.entries(
556
+ for (const [slotName, rawSlot] of Object.entries(
403
557
  slots as Record<string, any>,
404
558
  )) {
559
+ let slotHandler: any = rawSlot;
560
+ if (isSlotDescriptor(rawSlot)) {
561
+ slotHandler = rawSlot.handler;
562
+ slotLocalUses[slotName] = rawSlot.use;
563
+ }
405
564
  if (isStaticHandler(slotHandler)) {
406
565
  hasStaticSlot = true;
407
566
  unwrappedSlots[slotName] = slotHandler.handler;
@@ -420,20 +579,12 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
420
579
  // Create full EntryData for parallel with its own loaders/revalidate/loading
421
580
  const parallelUrlPrefix = getUrlPrefix();
422
581
  const entry = {
582
+ ...emptySegmentBase(),
423
583
  id: namespace,
424
584
  shortCode: store.getShortCode("parallel"),
425
585
  type: "parallel",
426
586
  parent: null, // Parallels don't participate in parent chain traversal
427
587
  handler: unwrappedSlots,
428
- loading: undefined, // Allow loading() to attach loading state
429
- middleware: [],
430
- revalidate: [],
431
- errorBoundary: [],
432
- notFoundBoundary: [],
433
- layout: [],
434
- parallel: [],
435
- intercept: [],
436
- loader: [],
437
588
  ...(parallelUrlPrefix ? { mountPath: parallelUrlPrefix } : {}),
438
589
  ...(hasStaticSlot
439
590
  ? {
@@ -445,19 +596,86 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
445
596
  : {}),
446
597
  } satisfies EntryData;
447
598
 
448
- // Run use callback if provided to collect loaders, revalidate, loading
449
- if (use && typeof use === "function") {
450
- const result = store.run(namespace, entry, use)?.flat(3);
451
- invariant(
452
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
453
- `parallel() use() callback must return an array of use items [${namespace}]`,
599
+ for (const slotName of slotNames) {
600
+ const slotEntry = {
601
+ ...entry,
602
+ handler: { [slotName]: unwrappedSlots[slotName]! },
603
+ middleware: [...entry.middleware],
604
+ revalidate: [...entry.revalidate],
605
+ errorBoundary: [...entry.errorBoundary],
606
+ notFoundBoundary: [...entry.notFoundBoundary],
607
+ layout: [...entry.layout],
608
+ parallel: { ...entry.parallel },
609
+ intercept: [...entry.intercept],
610
+ loader: [...entry.loader],
611
+ ...(entry.staticHandlerIds?.[slotName]
612
+ ? {
613
+ isStaticPrerender: true as const,
614
+ staticHandlerIds: { [slotName]: entry.staticHandlerIds[slotName]! },
615
+ }
616
+ : {
617
+ isStaticPrerender: undefined,
618
+ staticHandlerIds: undefined,
619
+ }),
620
+ } satisfies EntryData;
621
+
622
+ // Per-slot merge order (narrowest-scope-wins for single-assignment items
623
+ // like loading()):
624
+ // 1. handler.use — defaults baked into the handler
625
+ // 2. shared `use` — broadcast at the parallel() call site
626
+ // 3. slot-local `use` — per-slot override via `{ handler, use }` descriptor
627
+ // Items that accumulate (loader, middleware, revalidate, …) compose
628
+ // across all three layers regardless of order.
629
+ const rawSlot = (slots as Record<string, any>)[slotName];
630
+ const slotHandlerForUse = isSlotDescriptor(rawSlot)
631
+ ? rawSlot.handler
632
+ : rawSlot;
633
+ const slotHandlerUse = resolveHandlerUse(slotHandlerForUse);
634
+ const slotLocalUse = slotLocalUses[slotName];
635
+ const explicitUse = combineExplicitUses(use, slotLocalUse);
636
+ const slotMergedUse = mergeHandlerUse(
637
+ slotHandlerUse,
638
+ explicitUse,
639
+ "parallel",
454
640
  );
455
- }
641
+ if (slotMergedUse) {
642
+ runAndValidateUseItems(
643
+ store,
644
+ namespace,
645
+ slotEntry,
646
+ slotMergedUse,
647
+ "parallel",
648
+ "use",
649
+ );
650
+ }
456
651
 
457
- ctx.parent.parallel.push(entry);
652
+ ctx.parent.parallel[slotName] = slotEntry;
653
+ }
458
654
  return { name: namespace, type: "parallel" } as ParallelItem;
459
655
  };
460
656
 
657
+ function isSlotDescriptor(
658
+ value: unknown,
659
+ ): value is { handler: unknown; use?: () => any[] } {
660
+ return (
661
+ typeof value === "object" &&
662
+ value !== null &&
663
+ !("__brand" in value) &&
664
+ "handler" in value &&
665
+ typeof (value as any).handler !== "undefined"
666
+ );
667
+ }
668
+
669
+ function combineExplicitUses(
670
+ sharedUse: (() => any[]) | undefined,
671
+ slotLocalUse: (() => any[]) | undefined,
672
+ ): (() => any[]) | undefined {
673
+ if (!sharedUse && !slotLocalUse) return undefined;
674
+ if (!slotLocalUse) return sharedUse;
675
+ if (!sharedUse) return slotLocalUse;
676
+ return () => [...sharedUse(), ...slotLocalUse()];
677
+ }
678
+
461
679
  /**
462
680
  * Intercept helper - defines an intercepting route for soft navigation
463
681
  */
@@ -467,9 +685,9 @@ const intercept = (
467
685
  handler: any,
468
686
  use?: () => any[],
469
687
  ) => {
470
- const store = getContext();
471
- const ctx = store.getStore();
472
- if (!ctx) throw new Error("intercept() must be called inside map()");
688
+ const { store, ctx } = requireDslContext(
689
+ "intercept() must be called inside urls()",
690
+ );
473
691
 
474
692
  if (!ctx.parent || !ctx.parent?.intercept) {
475
693
  invariant(false, "No parent entry available for intercept()");
@@ -502,17 +720,19 @@ const intercept = (
502
720
  when: [], // Selector conditions for conditional interception
503
721
  };
504
722
 
505
- // Run use callback if provided to collect loaders, revalidate, middleware, etc.
506
- if (use && typeof use === "function") {
507
- // Create a temporary parent context for the use() callback
508
- // so that middleware, loader, revalidate attach to the intercept entry
509
- const originalParent = ctx.parent;
723
+ // Merge handler.use defaults with explicit use
724
+ const handlerUseFn = resolveHandlerUse(handler);
725
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "intercept");
510
726
 
511
- // Capture layouts in a temporary array
727
+ // Run merged use callback to collect loaders, revalidate, middleware, etc.
728
+ if (mergedUse) {
729
+ // Capture layout() calls into a temporary array
512
730
  const capturedLayouts: EntryData[] = [];
513
731
 
732
+ // Temporary parent so middleware/loader/revalidate/when attach to the
733
+ // intercept entry; the loading get/set accessor mirrors writes onto `entry`.
514
734
  const tempParent = {
515
- ...originalParent,
735
+ ...ctx.parent,
516
736
  middleware: entry.middleware,
517
737
  revalidate: entry.revalidate,
518
738
  errorBoundary: entry.errorBoundary,
@@ -520,7 +740,6 @@ const intercept = (
520
740
  loader: entry.loader,
521
741
  layout: capturedLayouts, // Capture layout() calls
522
742
  when: entry.when, // Capture when() conditions
523
- // Use getter/setter to capture loading on the entry
524
743
  get loading() {
525
744
  return entry.loading;
526
745
  },
@@ -528,12 +747,10 @@ const intercept = (
528
747
  entry.loading = value;
529
748
  },
530
749
  };
531
- ctx.parent = tempParent as EntryData;
532
-
533
- const result = use()?.flat(3);
534
750
 
535
- // Restore original parent
536
- ctx.parent = originalParent;
751
+ const result = withParent(ctx, tempParent as EntryData, () =>
752
+ mergedUse()?.flat(3),
753
+ );
537
754
 
538
755
  // Extract layout from captured layouts (use first one if multiple)
539
756
  // Layout inside intercept should always be ReactNode or Handler, not Record slots
@@ -543,10 +760,7 @@ const intercept = (
543
760
  | Handler<any, any, any>;
544
761
  }
545
762
 
546
- invariant(
547
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
548
- `intercept() use() callback must return an array of use items [${namespace}]`,
549
- );
763
+ validateUseItems(result, namespace, "intercept", "use");
550
764
  }
551
765
 
552
766
  ctx.parent.intercept.push(entry);
@@ -556,10 +770,10 @@ const intercept = (
556
770
  /**
557
771
  * Loader helper - attaches a loader to the current entry
558
772
  */
559
- const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
560
- const store = getContext();
561
- const ctx = store.getStore();
562
- if (!ctx) throw new Error("loader() must be called inside map()");
773
+ const loader: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
774
+ const { store, ctx } = requireDslContext(
775
+ "loader() must be called inside urls()",
776
+ );
563
777
 
564
778
  // Attach to last entry in stack
565
779
  if (!ctx.parent || !ctx.parent?.loader) {
@@ -574,25 +788,28 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
574
788
  revalidate: [] as ShouldRevalidateFn<any, any>[],
575
789
  };
576
790
 
577
- // If use() callback provided, run it to collect revalidation rules and cache config
578
- if (use && typeof use === "function") {
579
- // Temporarily set context for revalidate()/cache() calls to target this loader
580
- const originalParent = ctx.parent;
791
+ // Merge handler.use defaults (attached to the loader definition) with explicit use
792
+ const handlerUseFn = resolveHandlerUse(loaderDef);
793
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "loader");
794
+
795
+ // If any use callback is in effect, run it to collect revalidation rules and cache config
796
+ if (mergedUse) {
581
797
  // Create a temporary "parent" with type "loader" so cache() can detect it.
582
798
  // Save existing .cache to distinguish inherited config from newly set config.
583
- const parentCache = (originalParent as any).cache;
799
+ const parentCache = (ctx.parent as any).cache;
584
800
  const tempParent = {
585
- ...originalParent,
801
+ ...ctx.parent,
586
802
  type: "loader",
587
803
  revalidate: loaderEntry.revalidate,
588
804
  };
589
- ctx.parent = tempParent as EntryData;
590
805
 
591
- const result = use()?.flat(3);
806
+ const result = withParent(ctx, tempParent as EntryData, () =>
807
+ mergedUse()?.flat(3),
808
+ );
592
809
 
593
810
  // Copy cache config only if cache() was called during the use() callback.
594
- // The spread from originalParent may carry an inherited .cache from
595
- // a parent cache() boundary — only copy if it was newly set.
811
+ // The spread may carry an inherited .cache from a parent cache() boundary —
812
+ // only copy if it was newly set.
596
813
  if (
597
814
  (tempParent as any).cache &&
598
815
  (tempParent as any).cache !== parentCache
@@ -600,13 +817,7 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
600
817
  (loaderEntry as any).cache = (tempParent as any).cache;
601
818
  }
602
819
 
603
- // Restore original parent
604
- ctx.parent = originalParent;
605
-
606
- invariant(
607
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
608
- `loader() use() callback must return an array of use items [${name}]`,
609
- );
820
+ validateUseItems(result, name, "loader", "use");
610
821
  }
611
822
 
612
823
  ctx.parent.loader.push(loaderEntry);
@@ -617,21 +828,25 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
617
828
  * Loading helper - attaches a loading component to the current entry
618
829
  * Loading components are static (no context) and shown during navigation
619
830
  */
620
- const loadingFn: RouteHelpers<any, any>["loading"] = (component, options) => {
621
- const store = getContext();
622
- const ctx = store.getStore();
623
- if (!ctx) throw new Error("loading() must be called inside map()");
831
+ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
832
+ const { store, ctx } = requireDslContext(
833
+ "loading() must be called inside urls()",
834
+ );
624
835
 
625
836
  const parent = ctx.parent;
626
837
  if (!parent || !("loading" in parent)) {
627
838
  invariant(false, "No parent entry available for loading()");
628
839
  }
629
840
 
841
+ // Unwrap function form: loading(() => <Skeleton />) → loading(<Skeleton />)
842
+ const resolved =
843
+ typeof component === "function" ? (component as () => any)() : component;
844
+
630
845
  // If ssr: false and we're in SSR, set loading to false
631
846
  if (options?.ssr === false && ctx.isSSR) {
632
847
  parent.loading = false;
633
848
  } else {
634
- parent.loading = component;
849
+ parent.loading = resolved;
635
850
  }
636
851
 
637
852
  const name = `$${store.getNextIndex("loading")}`;
@@ -639,10 +854,13 @@ const loadingFn: RouteHelpers<any, any>["loading"] = (component, options) => {
639
854
  };
640
855
 
641
856
  /**
642
- * Transition helper - attaches a ViewTransition config to the current entry
643
- * or wraps a group of routes in a transparent layout with ViewTransition
857
+ * Transition helper - opts the entry (or a wrapped group of routes) into
858
+ * transition-driven navigation by attaching a TransitionConfig. This drives the
859
+ * commit through startTransition (content hold on all React versions) and, on
860
+ * experimental React, places a `<ViewTransition>` boundary unless
861
+ * `viewTransition: false`. See skills/view-transitions for the matrix.
644
862
  */
645
- const transitionFn = (
863
+ const transition = (
646
864
  configOrChildren?: TransitionConfig | (() => UseItems<AllUseItems>),
647
865
  maybeChildren?: () => UseItems<AllUseItems>,
648
866
  ): TransitionItem => {
@@ -656,9 +874,9 @@ const transitionFn = (
656
874
  const children: (() => UseItems<AllUseItems>) | undefined =
657
875
  typeof configOrChildren === "function" ? configOrChildren : maybeChildren;
658
876
 
659
- const store = getContext();
660
- const ctx = store.getStore();
661
- if (!ctx) throw new Error("transition() must be called inside map()");
877
+ const { store, ctx } = requireDslContext(
878
+ "transition() must be called inside urls()",
879
+ );
662
880
 
663
881
  const name = `$${store.getNextIndex("transition")}`;
664
882
 
@@ -675,68 +893,43 @@ const transitionFn = (
675
893
  // Position 2: wrapper — create a transparent layout with transition config
676
894
  const namespace = `${ctx.namespace}.${store.getNextIndex("transition")}`;
677
895
  const entry = {
896
+ ...emptySegmentBase(),
678
897
  id: namespace,
679
898
  shortCode: store.getShortCode("layout"),
680
899
  type: "layout",
681
900
  parent: ctx.parent,
682
901
  handler: RootLayout,
683
- loading: undefined,
684
902
  transition: config,
685
- middleware: [],
686
- revalidate: [],
687
- errorBoundary: [],
688
- notFoundBoundary: [],
689
- layout: [],
690
- parallel: [],
691
- intercept: [],
692
- loader: [],
693
- } as EntryData;
694
-
695
- const result = store.run(namespace, entry, children)?.flat(3);
903
+ } satisfies EntryData;
696
904
 
697
- invariant(
698
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
699
- `transition() children callback must return an array of use items [${namespace}]`,
905
+ const result = runAndValidateUseItems(
906
+ store,
907
+ namespace,
908
+ entry,
909
+ children,
910
+ "transition",
911
+ "children",
700
912
  );
701
913
 
702
- const hasRoutes =
703
- result &&
704
- Array.isArray(result) &&
705
- result.some((item) => hasRoutesInItem(item));
706
-
707
- if (!hasRoutes) {
708
- const parent = ctx.parent;
709
- if (parent && "layout" in parent) {
710
- entry.parent = null;
711
- parent.layout.push(entry);
712
- }
713
- }
914
+ if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
714
915
 
715
916
  return { name: namespace, type: "transition" } as TransitionItem;
716
917
  };
717
918
 
718
- const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
719
- const store = getContext();
720
- const ctx = store.getStore();
721
- if (!ctx) throw new Error("route() must be called inside map()");
919
+ const route: RouteHelpers<any, any>["route"] = (name, handler, use) => {
920
+ const { store, ctx } = requireDslContext(
921
+ "route() must be called inside urls()",
922
+ );
722
923
 
723
924
  const namespace = `${ctx.namespace}.${store.getNextIndex("route")}.${name}`;
724
925
 
725
926
  const entry = {
927
+ ...emptySegmentBase(),
726
928
  id: namespace,
727
929
  shortCode: store.getShortCode("route"),
728
930
  type: "route",
729
931
  parent: ctx.parent,
730
- handler,
731
- loading: undefined, // Allow loading() to attach loading state
732
- middleware: [],
733
- revalidate: [],
734
- errorBoundary: [],
735
- notFoundBoundary: [],
736
- layout: [],
737
- parallel: [],
738
- intercept: [],
739
- loader: [],
932
+ handler: handler as unknown as Handler<any, any, any>,
740
933
  } satisfies EntryData;
741
934
 
742
935
  /* We will throw if user is registring same route name twice */
@@ -746,12 +939,18 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
746
939
  );
747
940
  /* Register route entry */
748
941
  ctx.manifest.set(name, entry);
942
+ /* Merge handler.use defaults with explicit use */
943
+ const handlerUseFn = resolveHandlerUse(handler);
944
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "route");
749
945
  /* Run use and attach handlers */
750
- if (use && typeof use === "function") {
751
- const result = store.run(namespace, entry, use)?.flat(3);
752
- invariant(
753
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
754
- `route() use() callback must return an array of use items [${namespace}]`,
946
+ if (mergedUse) {
947
+ const result = runAndValidateUseItems(
948
+ store,
949
+ namespace,
950
+ entry,
951
+ mergedUse,
952
+ "route",
953
+ "use",
755
954
  );
756
955
  return { name: namespace, type: "route", uses: result } as RouteItem;
757
956
  }
@@ -761,9 +960,9 @@ const routeFn: RouteHelpers<any, any>["route"] = (name, handler, use) => {
761
960
  };
762
961
 
763
962
  const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
764
- const store = getContext();
765
- const ctx = store.getStore();
766
- if (!ctx) throw new Error("layout() must be called inside map()");
963
+ const { store, ctx } = requireDslContext(
964
+ "layout() must be called inside urls()",
965
+ );
767
966
 
768
967
  invariant(
769
968
  !ctx.parent || ctx.parent.type !== "parallel",
@@ -781,20 +980,12 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
781
980
 
782
981
  const urlPrefix = getUrlPrefix();
783
982
  const entry = {
983
+ ...emptySegmentBase(),
784
984
  id: namespace,
785
985
  shortCode,
786
986
  type: "layout",
787
987
  parent: ctx.parent,
788
988
  handler: unwrappedHandler,
789
- loading: undefined, // Allow loading() to attach loading state
790
- middleware: [],
791
- revalidate: [],
792
- errorBoundary: [],
793
- notFoundBoundary: [],
794
- parallel: [],
795
- intercept: [],
796
- layout: [],
797
- loader: [],
798
989
  ...(urlPrefix ? { mountPath: urlPrefix } : {}),
799
990
  ...(isStatic
800
991
  ? {
@@ -809,14 +1000,20 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
809
1000
  (handler as any).$$routePrefix = ctx.namePrefix;
810
1001
  }
811
1002
 
812
- // Run use callback if provided
813
- let result: AllUseItems[] | undefined;
814
- if (use && typeof use === "function") {
815
- result = store.run(namespace, entry, use)?.flat(3);
1003
+ // Merge handler.use defaults with explicit use
1004
+ const handlerUseFn = resolveHandlerUse(handler);
1005
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "layout");
816
1006
 
817
- invariant(
818
- Array.isArray(result) && result.every((item) => isValidUseItem(item)),
819
- `layout() use() callback must return an array of use items [${namespace}]`,
1007
+ // Run merged use callback if present
1008
+ let result: AllUseItems[] | undefined;
1009
+ if (mergedUse) {
1010
+ result = runAndValidateUseItems(
1011
+ store,
1012
+ namespace,
1013
+ entry,
1014
+ mergedUse,
1015
+ "layout",
1016
+ "use",
820
1017
  );
821
1018
  }
822
1019
 
@@ -858,9 +1055,7 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
858
1055
  `Orphan layouts can only be defined inside route or layout > check [${namespace}]`,
859
1056
  );
860
1057
 
861
- // Clear parent pointer for orphan layouts to prevent duplicate processing
862
- entry.parent = null;
863
- parent.layout.push(entry);
1058
+ attachOrphanSibling(parent, entry);
864
1059
  }
865
1060
  }
866
1061
 
@@ -873,33 +1068,15 @@ const layout: RouteHelpers<any, any>["layout"] = (handler, use) => {
873
1068
  } as LayoutItem;
874
1069
  };
875
1070
 
876
- const isValidUseItem = (item: any): item is AllUseItems | undefined | null => {
877
- return (
878
- typeof item === "undefined" ||
879
- item === null ||
880
- (item &&
881
- typeof item === "object" &&
882
- "type" in item &&
883
- [
884
- "layout",
885
- "route",
886
- "middleware",
887
- "revalidate",
888
- "parallel",
889
- "intercept",
890
- "loader",
891
- "loading",
892
- "errorBoundary",
893
- "notFoundBoundary",
894
- "when",
895
- "cache",
896
- "transition",
897
- "include", // For urls() include() helper
898
- ].includes(item.type))
899
- );
900
- };
1071
+ const isValidUseItem = (item: any): item is AllUseItems | undefined | null =>
1072
+ item == null ||
1073
+ (typeof item === "object" &&
1074
+ "type" in item &&
1075
+ ALL_USE_ITEM_TYPES.has(item.type));
901
1076
 
902
- // Global helper exports for direct import from @rangojs/router
1077
+ // DSL helpers exported for direct import from @rangojs/router and for
1078
+ // assembly into the RouteHelpers object in helper-factories.ts. The route-item
1079
+ // types are discriminated by their `type` literal, so the helpers carry no brand.
903
1080
  export {
904
1081
  layout,
905
1082
  cache,
@@ -910,25 +1087,11 @@ export {
910
1087
  when,
911
1088
  errorBoundary,
912
1089
  notFoundBoundary,
913
- loaderFn as loader,
914
- loadingFn as loading,
915
- transitionFn as transition,
916
- };
917
-
918
- const isOrphanLayout = (item: AllUseItems): boolean => {
919
- return (
920
- item.type === "layout" &&
921
- !item.uses?.some((child) => hasRoutesInItem(child))
922
- );
923
- };
924
-
925
- // Internal exports used by helper-factories.ts
926
- export {
927
- routeFn,
928
- loaderFn,
929
- loadingFn,
930
- transitionFn,
931
- hasRoutesInItem,
1090
+ route,
1091
+ loader,
1092
+ loading,
1093
+ transition,
932
1094
  isValidUseItem,
933
- isOrphanLayout,
1095
+ emptySegmentBase,
1096
+ runAndValidateUseItems,
934
1097
  };