@rangojs/router 0.0.0-experimental.eb0645d3 → 0.0.0-experimental.f1468e3c

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 (392) hide show
  1. package/AGENTS.md +8 -0
  2. package/README.md +126 -16
  3. package/dist/bin/rango.js +319 -95
  4. package/dist/testing/vitest.js +82 -0
  5. package/dist/vite/index.js +2724 -1053
  6. package/package.json +68 -14
  7. package/skills/api-client/SKILL.md +211 -0
  8. package/skills/breadcrumbs/SKILL.md +64 -2
  9. package/skills/bundle-analysis/SKILL.md +159 -0
  10. package/skills/cache-guide/SKILL.md +224 -32
  11. package/skills/caching/SKILL.md +279 -17
  12. package/skills/composability/SKILL.md +27 -3
  13. package/skills/css/SKILL.md +76 -0
  14. package/skills/debug-manifest/SKILL.md +4 -2
  15. package/skills/document-cache/SKILL.md +78 -55
  16. package/skills/handler-use/SKILL.md +11 -9
  17. package/skills/hooks/SKILL.md +243 -29
  18. package/skills/host-router/SKILL.md +83 -23
  19. package/skills/i18n/SKILL.md +276 -0
  20. package/skills/intercept/SKILL.md +68 -19
  21. package/skills/layout/SKILL.md +13 -9
  22. package/skills/links/SKILL.md +190 -23
  23. package/skills/loader/SKILL.md +235 -9
  24. package/skills/middleware/SKILL.md +18 -10
  25. package/skills/migrate-nextjs/SKILL.md +43 -19
  26. package/skills/migrate-react-router/SKILL.md +8 -2
  27. package/skills/mime-routes/SKILL.md +28 -1
  28. package/skills/observability/SKILL.md +172 -0
  29. package/skills/parallel/SKILL.md +18 -7
  30. package/skills/prerender/SKILL.md +65 -60
  31. package/skills/rango/SKILL.md +251 -24
  32. package/skills/react-compiler/SKILL.md +168 -0
  33. package/skills/response-routes/SKILL.md +115 -48
  34. package/skills/route/SKILL.md +46 -5
  35. package/skills/router-setup/SKILL.md +30 -8
  36. package/skills/scripts/SKILL.md +179 -0
  37. package/skills/server-actions/SKILL.md +775 -0
  38. package/skills/tailwind/SKILL.md +27 -3
  39. package/skills/testing/SKILL.md +130 -0
  40. package/skills/testing/bindings.md +103 -0
  41. package/skills/testing/cache-prerender.md +127 -0
  42. package/skills/testing/client-components.md +124 -0
  43. package/skills/testing/e2e-parity.md +125 -0
  44. package/skills/testing/flight.md +91 -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 +122 -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 +123 -0
  54. package/skills/typesafety/SKILL.md +322 -29
  55. package/skills/use-cache/SKILL.md +57 -14
  56. package/skills/view-transitions/SKILL.md +337 -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 +0 -65
  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/connection-warmup.ts +134 -0
  64. package/src/browser/cookie-name.ts +140 -0
  65. package/src/browser/event-controller.ts +192 -150
  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/navigation-bridge.ts +94 -25
  70. package/src/browser/navigation-client.ts +121 -84
  71. package/src/browser/navigation-store-handle.ts +38 -0
  72. package/src/browser/navigation-store.ts +115 -67
  73. package/src/browser/navigation-transaction.ts +9 -59
  74. package/src/browser/network-error-handler.ts +34 -7
  75. package/src/browser/partial-update.ts +147 -128
  76. package/src/browser/prefetch/cache.ts +107 -56
  77. package/src/browser/prefetch/fetch.ts +204 -34
  78. package/src/browser/prefetch/queue.ts +6 -3
  79. package/src/browser/rango-state.ts +158 -76
  80. package/src/browser/react/Link.tsx +30 -7
  81. package/src/browser/react/NavigationProvider.tsx +283 -118
  82. package/src/browser/react/ScrollRestoration.tsx +10 -6
  83. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  84. package/src/browser/react/filter-segment-order.ts +66 -7
  85. package/src/browser/react/index.ts +0 -48
  86. package/src/browser/react/location-state-shared.ts +178 -8
  87. package/src/browser/react/location-state.ts +39 -14
  88. package/src/browser/react/use-action.ts +6 -15
  89. package/src/browser/react/use-handle.ts +17 -14
  90. package/src/browser/react/use-href.tsx +8 -1
  91. package/src/browser/react/use-link-status.ts +33 -8
  92. package/src/browser/react/use-navigation.ts +10 -5
  93. package/src/browser/react/use-params.ts +11 -11
  94. package/src/browser/react/use-reverse.ts +106 -0
  95. package/src/browser/react/use-router.ts +25 -3
  96. package/src/browser/react/use-search-params.ts +0 -5
  97. package/src/browser/react/use-segments.ts +11 -21
  98. package/src/browser/response-adapter.ts +99 -8
  99. package/src/browser/rsc-router.tsx +91 -24
  100. package/src/browser/scroll-restoration.ts +30 -17
  101. package/src/browser/segment-structure-assert.ts +2 -2
  102. package/src/browser/server-action-bridge.ts +214 -55
  103. package/src/browser/types.ts +80 -9
  104. package/src/browser/validate-redirect-origin.ts +43 -16
  105. package/src/build/collect-fallback-refs.ts +107 -0
  106. package/src/build/generate-manifest.ts +60 -35
  107. package/src/build/generate-route-types.ts +2 -1
  108. package/src/build/index.ts +8 -2
  109. package/src/build/prefix-tree-utils.ts +123 -0
  110. package/src/build/route-trie.ts +117 -14
  111. package/src/build/route-types/ast-route-extraction.ts +15 -8
  112. package/src/build/route-types/codegen.ts +16 -5
  113. package/src/build/route-types/include-resolution.ts +117 -23
  114. package/src/build/route-types/param-extraction.ts +6 -3
  115. package/src/build/route-types/per-module-writer.ts +22 -6
  116. package/src/build/route-types/router-processing.ts +55 -28
  117. package/src/build/route-types/scan-filter.ts +1 -1
  118. package/src/build/route-types/source-scan.ts +216 -0
  119. package/src/build/runtime-discovery.ts +9 -20
  120. package/src/cache/cache-error.ts +104 -0
  121. package/src/cache/cache-key-utils.ts +29 -13
  122. package/src/cache/cache-policy.ts +108 -34
  123. package/src/cache/cache-runtime.ts +224 -41
  124. package/src/cache/cache-scope.ts +188 -82
  125. package/src/cache/cache-tag.ts +103 -0
  126. package/src/cache/cf/cf-base64.ts +33 -0
  127. package/src/cache/cf/cf-cache-constants.ts +127 -0
  128. package/src/cache/cf/cf-cache-store.ts +1989 -378
  129. package/src/cache/cf/cf-cache-types.ts +349 -0
  130. package/src/cache/cf/cf-kv-utils.ts +46 -0
  131. package/src/cache/cf/cf-tag-marker-memo.ts +105 -0
  132. package/src/cache/cf/index.ts +6 -16
  133. package/src/cache/document-cache.ts +89 -21
  134. package/src/cache/handle-snapshot.ts +70 -0
  135. package/src/cache/index.ts +10 -20
  136. package/src/cache/memory-segment-store.ts +136 -37
  137. package/src/cache/profile-registry.ts +46 -31
  138. package/src/cache/read-through-swr.ts +56 -12
  139. package/src/cache/segment-codec.ts +9 -17
  140. package/src/cache/tag-invalidation.ts +230 -0
  141. package/src/cache/types.ts +37 -100
  142. package/src/client.rsc.tsx +44 -21
  143. package/src/client.tsx +36 -61
  144. package/src/cloudflare/index.ts +11 -0
  145. package/src/cloudflare/tracing.ts +109 -0
  146. package/src/component-utils.ts +19 -0
  147. package/src/components/DefaultDocument.tsx +8 -2
  148. package/src/context-var.ts +18 -6
  149. package/src/decode-loader-results.ts +52 -0
  150. package/src/defer.ts +196 -0
  151. package/src/deps/ssr.ts +0 -1
  152. package/src/encode-kv.ts +49 -0
  153. package/src/errors.ts +30 -4
  154. package/src/escape-script.ts +52 -0
  155. package/src/handle.ts +31 -23
  156. package/src/handles/MetaTags.tsx +62 -19
  157. package/src/handles/Scripts.tsx +183 -0
  158. package/src/handles/breadcrumbs.ts +37 -8
  159. package/src/handles/is-thenable.ts +19 -0
  160. package/src/handles/meta.ts +51 -40
  161. package/src/handles/script.ts +244 -0
  162. package/src/host/cookie-handler.ts +9 -60
  163. package/src/host/errors.ts +0 -24
  164. package/src/host/index.ts +8 -2
  165. package/src/host/pattern-matcher.ts +23 -52
  166. package/src/host/router.ts +107 -99
  167. package/src/host/testing.ts +40 -27
  168. package/src/host/types.ts +37 -4
  169. package/src/host/utils.ts +1 -1
  170. package/src/href-client.ts +137 -22
  171. package/src/index.rsc.ts +96 -12
  172. package/src/index.ts +94 -14
  173. package/src/internal-debug.ts +11 -10
  174. package/src/loader-store.ts +500 -0
  175. package/src/loader.rsc.ts +20 -13
  176. package/src/loader.ts +12 -11
  177. package/src/missing-id-error.ts +68 -0
  178. package/src/outlet-context.ts +1 -1
  179. package/src/outlet-provider.tsx +1 -5
  180. package/src/prerender/param-hash.ts +16 -16
  181. package/src/prerender/store.ts +32 -37
  182. package/src/prerender.ts +61 -6
  183. package/src/redirect-origin.ts +100 -0
  184. package/src/regex-escape.ts +8 -0
  185. package/src/render-error-thrower.tsx +20 -0
  186. package/src/response-utils.ts +34 -0
  187. package/src/reverse.ts +65 -40
  188. package/src/root-error-boundary.tsx +1 -19
  189. package/src/route-content-wrapper.tsx +19 -77
  190. package/src/route-definition/dsl-helpers.ts +304 -309
  191. package/src/route-definition/helper-factories.ts +28 -140
  192. package/src/route-definition/helpers-types.ts +82 -55
  193. package/src/route-definition/index.ts +1 -2
  194. package/src/route-definition/redirect.ts +44 -11
  195. package/src/route-definition/resolve-handler-use.ts +12 -1
  196. package/src/route-definition/use-item-types.ts +29 -0
  197. package/src/route-map-builder.ts +0 -16
  198. package/src/route-types.ts +19 -46
  199. package/src/router/basename.ts +14 -0
  200. package/src/router/content-negotiation.ts +73 -25
  201. package/src/router/error-handling.ts +45 -18
  202. package/src/router/find-match.ts +44 -23
  203. package/src/router/handler-context.ts +27 -43
  204. package/src/router/instrument.ts +350 -0
  205. package/src/router/intercept-resolution.ts +39 -20
  206. package/src/router/lazy-includes.ts +10 -47
  207. package/src/router/loader-resolution.ts +155 -72
  208. package/src/router/logging.ts +0 -6
  209. package/src/router/manifest.ts +18 -29
  210. package/src/router/match-api.ts +9 -24
  211. package/src/router/match-context.ts +0 -22
  212. package/src/router/match-handlers.ts +58 -58
  213. package/src/router/match-middleware/background-revalidation.ts +40 -24
  214. package/src/router/match-middleware/cache-lookup.ts +159 -285
  215. package/src/router/match-middleware/cache-store.ts +64 -52
  216. package/src/router/match-middleware/intercept-resolution.ts +0 -22
  217. package/src/router/match-middleware/segment-resolution.ts +0 -22
  218. package/src/router/match-pipelines.ts +1 -42
  219. package/src/router/match-result.ts +44 -74
  220. package/src/router/metrics.ts +0 -34
  221. package/src/router/middleware-types.ts +7 -134
  222. package/src/router/middleware.ts +247 -166
  223. package/src/router/navigation-snapshot.ts +0 -51
  224. package/src/router/params-util.ts +23 -0
  225. package/src/router/pattern-matching.ts +85 -94
  226. package/src/router/prefetch-cache-ttl.ts +51 -0
  227. package/src/router/prerender-match.ts +104 -65
  228. package/src/router/preview-match.ts +3 -1
  229. package/src/router/request-classification.ts +28 -62
  230. package/src/router/revalidation.ts +123 -73
  231. package/src/router/route-snapshot.ts +0 -1
  232. package/src/router/router-context.ts +3 -28
  233. package/src/router/router-interfaces.ts +83 -35
  234. package/src/router/router-options.ts +136 -5
  235. package/src/router/router-registry.ts +2 -5
  236. package/src/router/segment-resolution/fresh.ts +97 -84
  237. package/src/router/segment-resolution/helpers.ts +86 -6
  238. package/src/router/segment-resolution/loader-cache.ts +76 -39
  239. package/src/router/segment-resolution/revalidation.ts +272 -320
  240. package/src/router/segment-resolution/static-store.ts +19 -5
  241. package/src/router/segment-resolution/streamed-handler-telemetry.ts +52 -0
  242. package/src/router/segment-resolution/view-transition-default.ts +56 -0
  243. package/src/router/segment-resolution.ts +5 -1
  244. package/src/router/segment-wrappers.ts +6 -5
  245. package/src/router/state-cookie-name.ts +33 -0
  246. package/src/router/substitute-pattern-params.ts +56 -0
  247. package/src/router/telemetry-otel.ts +161 -199
  248. package/src/router/telemetry.ts +96 -19
  249. package/src/router/timeout.ts +0 -20
  250. package/src/router/tracing.ts +206 -0
  251. package/src/router/trie-matching.ts +162 -64
  252. package/src/router/types.ts +9 -63
  253. package/src/router/url-params.ts +0 -5
  254. package/src/router.ts +110 -55
  255. package/src/rsc/handler-context.ts +3 -2
  256. package/src/rsc/handler.ts +264 -220
  257. package/src/rsc/helpers.ts +100 -6
  258. package/src/rsc/index.ts +2 -5
  259. package/src/rsc/json-route-result.ts +38 -0
  260. package/src/rsc/loader-fetch.ts +114 -38
  261. package/src/rsc/manifest-init.ts +28 -41
  262. package/src/rsc/origin-guard.ts +39 -25
  263. package/src/rsc/progressive-enhancement.ts +117 -11
  264. package/src/rsc/redirect-guard.ts +99 -0
  265. package/src/rsc/response-cache-serve.ts +238 -0
  266. package/src/rsc/response-error.ts +79 -12
  267. package/src/rsc/response-route-handler.ts +88 -188
  268. package/src/rsc/rsc-rendering.ts +98 -76
  269. package/src/rsc/runtime-warnings.ts +23 -10
  270. package/src/rsc/server-action.ts +281 -117
  271. package/src/rsc/ssr-setup.ts +16 -0
  272. package/src/rsc/transition-gate.ts +89 -0
  273. package/src/rsc/types.ts +23 -5
  274. package/src/runtime-env.ts +18 -0
  275. package/src/search-params.ts +35 -30
  276. package/src/segment-loader-promise.ts +31 -4
  277. package/src/segment-system.tsx +254 -143
  278. package/src/serialize.ts +243 -0
  279. package/src/server/context.ts +163 -51
  280. package/src/server/cookie-parse.ts +32 -0
  281. package/src/server/cookie-store.ts +80 -5
  282. package/src/server/handle-store.ts +21 -38
  283. package/src/server/loader-registry.ts +33 -42
  284. package/src/server/request-context.ts +287 -178
  285. package/src/ssr/index.tsx +21 -16
  286. package/src/static-handler.ts +10 -13
  287. package/src/testing/cache-status.ts +162 -0
  288. package/src/testing/collect-handle.ts +40 -0
  289. package/src/testing/dispatch.ts +701 -0
  290. package/src/testing/dom.entry.ts +22 -0
  291. package/src/testing/e2e/fixture.ts +188 -0
  292. package/src/testing/e2e/index.ts +128 -0
  293. package/src/testing/e2e/matchers.ts +35 -0
  294. package/src/testing/e2e/page-helpers.ts +272 -0
  295. package/src/testing/e2e/parity.ts +387 -0
  296. package/src/testing/e2e/server.ts +195 -0
  297. package/src/testing/flight-matchers.ts +97 -0
  298. package/src/testing/flight-normalize.ts +11 -0
  299. package/src/testing/flight-runtime.d.ts +57 -0
  300. package/src/testing/flight-tree.ts +682 -0
  301. package/src/testing/flight.entry.ts +52 -0
  302. package/src/testing/flight.ts +257 -0
  303. package/src/testing/generated-routes.ts +183 -0
  304. package/src/testing/index.ts +105 -0
  305. package/src/testing/internal/context.ts +371 -0
  306. package/src/testing/internal/flight-client-globals.ts +30 -0
  307. package/src/testing/internal/seed-vars.ts +54 -0
  308. package/src/testing/render-handler.ts +357 -0
  309. package/src/testing/render-route.tsx +581 -0
  310. package/src/testing/run-loader.ts +385 -0
  311. package/src/testing/run-middleware.ts +205 -0
  312. package/src/testing/run-transition-when.ts +164 -0
  313. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  314. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  315. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  316. package/src/testing/vitest-stubs/version.ts +5 -0
  317. package/src/testing/vitest.ts +305 -0
  318. package/src/theme/ThemeProvider.tsx +20 -58
  319. package/src/theme/ThemeScript.tsx +7 -9
  320. package/src/theme/constants.ts +52 -13
  321. package/src/theme/index.ts +0 -7
  322. package/src/theme/theme-context.ts +1 -5
  323. package/src/theme/theme-script.ts +22 -21
  324. package/src/theme/use-theme.ts +0 -3
  325. package/src/types/boundaries.ts +0 -35
  326. package/src/types/cache-types.ts +13 -4
  327. package/src/types/error-types.ts +30 -90
  328. package/src/types/global-namespace.ts +54 -41
  329. package/src/types/handler-context.ts +110 -62
  330. package/src/types/index.ts +3 -10
  331. package/src/types/loader-types.ts +11 -9
  332. package/src/types/request-scope.ts +112 -0
  333. package/src/types/route-config.ts +6 -50
  334. package/src/types/route-entry.ts +0 -6
  335. package/src/types/segments.ts +135 -14
  336. package/src/urls/include-helper.ts +9 -56
  337. package/src/urls/index.ts +1 -11
  338. package/src/urls/path-helper-types.ts +29 -12
  339. package/src/urls/path-helper.ts +17 -106
  340. package/src/urls/pattern-types.ts +36 -19
  341. package/src/urls/response-types.ts +22 -29
  342. package/src/urls/type-extraction.ts +58 -139
  343. package/src/urls/urls-function.ts +1 -19
  344. package/src/use-loader.tsx +292 -107
  345. package/src/vite/debug.ts +185 -0
  346. package/src/vite/discovery/bundle-postprocess.ts +8 -7
  347. package/src/vite/discovery/discover-routers.ts +126 -85
  348. package/src/vite/discovery/discovery-errors.ts +194 -0
  349. package/src/vite/discovery/gate-state.ts +171 -0
  350. package/src/vite/discovery/prerender-collection.ts +96 -68
  351. package/src/vite/discovery/route-types-writer.ts +40 -84
  352. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  353. package/src/vite/discovery/state.ts +44 -0
  354. package/src/vite/discovery/virtual-module-codegen.ts +14 -34
  355. package/src/vite/index.ts +2 -0
  356. package/src/vite/inject-client-debug.ts +36 -0
  357. package/src/vite/plugin-types.ts +126 -8
  358. package/src/vite/plugins/cjs-to-esm.ts +16 -19
  359. package/src/vite/plugins/client-ref-dedup.ts +16 -11
  360. package/src/vite/plugins/client-ref-hashing.ts +28 -15
  361. package/src/vite/plugins/cloudflare-protocol-stub.ts +1 -21
  362. package/src/vite/plugins/expose-action-id.ts +48 -95
  363. package/src/vite/plugins/expose-id-utils.ts +88 -55
  364. package/src/vite/plugins/expose-ids/export-analysis.ts +101 -34
  365. package/src/vite/plugins/expose-ids/handler-transform.ts +11 -90
  366. package/src/vite/plugins/expose-ids/loader-transform.ts +14 -24
  367. package/src/vite/plugins/expose-ids/router-transform.ts +118 -29
  368. package/src/vite/plugins/expose-internal-ids.ts +505 -486
  369. package/src/vite/plugins/performance-tracks.ts +26 -25
  370. package/src/vite/plugins/refresh-cmd.ts +1 -1
  371. package/src/vite/plugins/use-cache-transform.ts +73 -83
  372. package/src/vite/plugins/version-injector.ts +40 -29
  373. package/src/vite/plugins/version-plugin.ts +37 -40
  374. package/src/vite/plugins/virtual-entries.ts +39 -25
  375. package/src/vite/rango.ts +109 -118
  376. package/src/vite/router-discovery.ts +718 -119
  377. package/src/vite/utils/ast-handler-extract.ts +26 -35
  378. package/src/vite/utils/banner.ts +1 -1
  379. package/src/vite/utils/bundle-analysis.ts +10 -15
  380. package/src/vite/utils/client-chunks.ts +184 -0
  381. package/src/vite/utils/directive-prologue.ts +40 -0
  382. package/src/vite/utils/forward-user-plugins.ts +171 -0
  383. package/src/vite/utils/manifest-utils.ts +4 -59
  384. package/src/vite/utils/package-resolution.ts +20 -52
  385. package/src/vite/utils/prerender-utils.ts +54 -39
  386. package/src/vite/utils/shared-utils.ts +90 -41
  387. package/src/browser/action-response-classifier.ts +0 -99
  388. package/src/browser/react/use-client-cache.ts +0 -58
  389. package/src/browser/shallow.ts +0 -40
  390. package/src/handles/index.ts +0 -7
  391. package/src/network-error-thrower.tsx +0 -23
  392. package/src/router/middleware-cookies.ts +0 -55
@@ -1,9 +1,9 @@
1
1
  import * as React from "react";
2
2
  import { createElement, type ReactNode, type ComponentType } from "react";
3
- import { OutletProvider } from "./client.js";
3
+ import { OutletProvider } from "./outlet-provider.js";
4
4
  import { MountContextProvider } from "./browser/react/mount-context.js";
5
5
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
6
- import { isLoaderDataResult } from "./types.js";
6
+ import { decodeLoaderResults } from "./decode-loader-results.js";
7
7
  import { invariant } from "./errors.js";
8
8
  import {
9
9
  RouteContentWrapper,
@@ -11,16 +11,34 @@ import {
11
11
  } from "./route-content-wrapper.js";
12
12
  import { RootErrorBoundary } from "./root-error-boundary.js";
13
13
  import { getMemoizedContentPromise } from "./segment-content-promise.js";
14
- import { getMemoizedLoaderPromise } from "./segment-loader-promise.js";
14
+ import {
15
+ buildLoaderPromise,
16
+ getMemoizedLoaderPromise,
17
+ } from "./segment-loader-promise.js";
15
18
 
16
19
  // ViewTransition is only available in React experimental.
17
20
  // Access via namespace import to avoid compile-time errors on stable React.
18
21
  const ReactViewTransition: any =
19
22
  "ViewTransition" in React ? (React as any).ViewTransition : null;
20
23
 
21
- function restoreParallelLoaderMarkers(
24
+ // A loading skeleton is renderable only when it is a real ReactNode value.
25
+ // `false` is treated as "not renderable" here. This is the three-term gate;
26
+ // the distinct two-term gate at the LoaderBoundary site deliberately treats
27
+ // `false` as "create a boundary without a RouteContentWrapper"
28
+ // (tree-structure.md), so it must NOT use this helper.
29
+ function isRenderableLoading(loading: ReactNode): boolean {
30
+ return loading !== undefined && loading !== null && loading !== false;
31
+ }
32
+
33
+ // Exported for unit testing the no-parallel fast path (D6); internal otherwise.
34
+ export function restoreParallelLoaderMarkers(
22
35
  segments: ResolvedSegment[],
23
36
  ): ResolvedSegment[] {
37
+ // Parallel-loading markers only exist when a parallel segment is present, so
38
+ // a list with no parallel slot has nothing to restore. Skip the Map alloc and
39
+ // full scan in that (common) case — this runs on every render.
40
+ if (!segments.some((s) => s.type === "parallel")) return segments;
41
+
24
42
  const parallelLoadingByNamespace = new Map<string, ReactNode>();
25
43
  let nextSegments: ResolvedSegment[] | null = null;
26
44
 
@@ -28,12 +46,7 @@ function restoreParallelLoaderMarkers(
28
46
  const segment = segments[i];
29
47
 
30
48
  if (segment.type === "parallel") {
31
- if (
32
- segment.namespace &&
33
- segment.loading !== undefined &&
34
- segment.loading !== null &&
35
- segment.loading !== false
36
- ) {
49
+ if (segment.namespace && isRenderableLoading(segment.loading)) {
37
50
  parallelLoadingByNamespace.set(segment.namespace, segment.loading);
38
51
  }
39
52
  continue;
@@ -59,42 +72,6 @@ function restoreParallelLoaderMarkers(
59
72
  return nextSegments ?? segments;
60
73
  }
61
74
 
62
- /**
63
- * Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
64
- */
65
- function resolveLoaderData(
66
- resolvedData: any[],
67
- loaderIds: string[],
68
- ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
69
- const loaderData: Record<string, any> = {};
70
- let errorFallback: ReactNode = null;
71
-
72
- for (let i = 0; i < loaderIds.length; i++) {
73
- const id = loaderIds[i];
74
- const result = resolvedData[i];
75
-
76
- if (!isLoaderDataResult(result)) {
77
- // Legacy format - direct data
78
- loaderData[id] = result;
79
- continue;
80
- }
81
-
82
- if (result.ok) {
83
- loaderData[id] = result.data;
84
- continue;
85
- }
86
-
87
- // Error case
88
- if (result.fallback) {
89
- errorFallback = result.fallback;
90
- } else {
91
- throw new Error(result.error.message);
92
- }
93
- }
94
-
95
- return { loaderData, errorFallback };
96
- }
97
-
98
75
  /**
99
76
  * Options for renderSegments
100
77
  */
@@ -131,11 +108,61 @@ export interface RenderSegmentsOptions {
131
108
  rootLayout?: ComponentType<RootLayoutProps>;
132
109
  }
133
110
 
111
+ function createViewTransitionBoundary(
112
+ transition: NonNullable<ResolvedSegment["transition"]>,
113
+ children: ReactNode,
114
+ ): ReactNode {
115
+ // `viewTransition` is a router-specific flag (boundary opt-out), not a React
116
+ // <ViewTransition> prop — strip it so it never reaches React.
117
+ const { viewTransition: _viewTransition, ...vtProps } = transition;
118
+ return createElement(ReactViewTransition, {
119
+ ...vtProps,
120
+ children,
121
+ });
122
+ }
123
+
124
+ function wrapDefaultOutletContent(
125
+ content: ReactNode,
126
+ transition: NonNullable<ResolvedSegment["transition"]>,
127
+ ): ReactNode {
128
+ if (!React.isValidElement(content)) {
129
+ return createViewTransitionBoundary(transition, content);
130
+ }
131
+
132
+ const props = content.props as any;
133
+
134
+ if (content.type === MountContextProvider) {
135
+ return React.cloneElement(content, {
136
+ children: wrapDefaultOutletContent(props.children, transition),
137
+ } as any);
138
+ }
139
+
140
+ if (content.type === OutletProvider && props.segment?.type === "layout") {
141
+ return React.cloneElement(content, {
142
+ content: wrapDefaultOutletContent(props.content, transition),
143
+ } as any);
144
+ }
145
+
146
+ if (content.type === LoaderBoundary && props.segment?.type === "layout") {
147
+ return React.cloneElement(content, {
148
+ outletContent: wrapDefaultOutletContent(props.outletContent, transition),
149
+ } as any);
150
+ }
151
+
152
+ return createViewTransitionBoundary(transition, content);
153
+ }
154
+
134
155
  /**
135
156
  * Render segments into a React tree with proper layout nesting
136
157
  *
137
- * Layouts nest using OutletProvider, while route + parallel + error + notFound segments
138
- * render as siblings in a Fragment.
158
+ * Layouts nest using OutletProvider; a layout receives the inner content via
159
+ * its `<Outlet />`. Parallel segments do NOT render as inline Fragment siblings
160
+ * — they flow through OutletContext.parallel and are resolved where a layout
161
+ * places `<ParallelOutlet name="@sidebar" />` (or `<Outlet name="@sidebar" />`).
162
+ *
163
+ * The result is always wrapped in RootErrorBoundary so unhandled errors never
164
+ * blank the screen. When `options.rootLayout` is provided it wraps the error
165
+ * boundary at the OUTERMOST level (so the app shell survives errors).
139
166
  *
140
167
  * Error segments are treated like route segments - they render their fallback
141
168
  * component in place of the failed segment. When an error occurs in a handler,
@@ -147,27 +174,30 @@ export interface RenderSegmentsOptions {
147
174
  * notFoundBoundary's fallback component.
148
175
  *
149
176
  * @param segments - Array of resolved segments to render
150
- * @returns ReactNode representing the component tree
177
+ * @returns Promise resolving to the ReactNode tree (the function is async)
151
178
  *
152
179
  * @example
153
180
  * ```typescript
154
181
  * const segments = [
155
- * { id: 'L0.0', type: 'layout', component: <RootLayout /> },
156
- * { id: 'L1.0', type: 'layout', component: <BlogLayout /> },
157
- * { id: 'R2.0', type: 'route', component: <BlogPost /> },
158
- * { id: 'P3.0', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
182
+ * { id: 'L0.0', type: 'layout', component: <BlogLayout /> },
183
+ * { id: 'L0R1', type: 'route', component: <BlogPost /> },
184
+ * { id: 'L0R1.@sidebar', type: 'parallel', component: <Sidebar />, slot: '@sidebar' }
159
185
  * ];
160
186
  *
161
- * const tree = renderSegments(segments);
162
- * // Results in:
163
- * // <OutletProvider><RootLayout>
164
- * // <OutletProvider><BlogLayout>
165
- * // <><BlogPost /><Sidebar /></>
166
- * // </BlogLayout></OutletProvider>
167
- * // </RootLayout></OutletProvider>
187
+ * // BlogLayout renders <Outlet /> for the route and
188
+ * // <ParallelOutlet name="@sidebar" /> for the parallel slot.
189
+ * const tree = await renderSegments(segments, { rootLayout: RootLayout });
190
+ * // Results in (outermost first):
191
+ * // <RootLayout>
192
+ * // <RootErrorBoundary>
193
+ * // <OutletProvider segment={BlogLayout} parallel={[Sidebar]}>
194
+ * // <BlogPost />
195
+ * // </OutletProvider>
196
+ * // </RootErrorBoundary>
197
+ * // </RootLayout>
168
198
  *
169
199
  * // For server actions, pass isAction to await components:
170
- * const tree = renderSegments(segments, { isAction: true });
200
+ * const tree = await renderSegments(segments, { isAction: true });
171
201
  * ```
172
202
  */
173
203
  export async function renderSegments(
@@ -211,6 +241,25 @@ export async function renderSegments(
211
241
  }
212
242
  // Separate segments by type, passing intercept segments for explicit injection
213
243
  const tree = segmentTreeWalk(normalizedSegments, normalizedInterceptSegments);
244
+
245
+ // A route is "in a transition scope" when its own segment OR any layout in
246
+ // its matched chain declares transition(). Both transition() forms land here:
247
+ // the per-route item form sets transition on the route entry, and the block
248
+ // wrapper form sets it on a transparent ancestor layout (dsl-helpers.ts). When
249
+ // in scope, the route and its route-owned layouts use param-agnostic keys so a
250
+ // same-route navigation reconciles (holds content) instead of remounting. The
251
+ // value is a static property of the route's position in the tree, so it is the
252
+ // same on every render of that route (SSR, navigation, action) — the keys
253
+ // never drift. Cross-route navigation still remounts: different routes have
254
+ // different segment ids regardless of transition scope.
255
+ const inTransitionScope = normalizedSegments.some(
256
+ (s) =>
257
+ s.transition != null &&
258
+ (s.type === "layout" ||
259
+ s.type === "route" ||
260
+ s.type === "error" ||
261
+ s.type === "notFound"),
262
+ );
214
263
  // Render content segments as siblings
215
264
  let content: ReactNode = null;
216
265
  for (const node of tree) {
@@ -223,17 +272,31 @@ export async function renderSegments(
223
272
  );
224
273
  const { component, id, params, loading } = node.segment;
225
274
 
226
- // Only include params in key for segments that belong to the route
227
- // - Routes: always include params (they render param-specific content)
228
- // - Error/notFound segments: always include params (they replace failed route content)
229
- // - Route's layouts (orphans): include params (children of parameterized route)
230
- // - Parent chain layouts: exclude params (shared across routes, param-agnostic)
231
- // This prevents unnecessary unmounting when params change
275
+ // Param-agnostic keys are opt-in via the transition() DSL (see
276
+ // inTransitionScope above). A route (and its route-owned layouts) inside a
277
+ // transition scope drops the param from its key, so navigating between two
278
+ // param values of the SAME route (e.g. /product/1 -> /product/2) reconciles
279
+ // the route subtree instead of remounting it. Combined with the
280
+ // startTransition wrap that shouldStartViewTransition already applies to
281
+ // transition routes (browser/partial-update.ts), the previous content stays
282
+ // on screen while the new loaders resolve (stale-while-revalidate) instead
283
+ // of flashing the loading skeleton. This works on stable React; experimental
284
+ // React adds the animated <ViewTransition> cross-fade on top.
285
+ //
286
+ // Outside a transition scope the key stays param-bearing and the route
287
+ // remounts on param change (the default: a fresh skeleton and fresh
288
+ // component state).
289
+ //
290
+ // error/notFound always keep param-bearing keys: createErrorSegment reuses
291
+ // the boundary layout's shortCode as the error segment id (router/
292
+ // error-handling.ts), so a param-agnostic error key could collide with that
293
+ // layout's key within the same render.
232
294
  const includeParams =
233
- node.segment.type === "route" ||
234
295
  node.segment.type === "error" ||
235
296
  node.segment.type === "notFound" ||
236
- (node.segment.type === "layout" && node.segment.belongsToRoute);
297
+ ((node.segment.type === "route" ||
298
+ (node.segment.type === "layout" && node.segment.belongsToRoute)) &&
299
+ !inTransitionScope);
237
300
 
238
301
  const paramStr =
239
302
  includeParams && params && Object.keys(params).length > 0
@@ -242,57 +305,100 @@ export async function renderSegments(
242
305
  .map(([k, v]) => `${k}=${v}`)
243
306
  .join(",")
244
307
  : "";
245
- const key = `${paramStr ? `${id}-${paramStr}` : id}`;
308
+ const key = paramStr ? `${id}-${paramStr}` : id;
246
309
 
247
- // Get loader entries for this node
248
310
  const loaderEntries = node.loaders.filter(
249
311
  (loader) => loader.loaderId && loader.loaderData !== undefined,
250
312
  );
251
313
 
252
- // Determine the component content (with or without Suspense wrapper)
253
- // Wrap when loading skeleton defined OR component is Promise (needs Suspense)
254
- // During actions, await component Promise to prevent Suspense from triggering
255
- // This keeps existing content visible instead of showing loading skeleton
256
314
  let resolvedComponent = component;
257
315
  if (isAction && component instanceof Promise) {
258
316
  resolvedComponent = await component;
259
317
  }
260
318
 
261
- let nodeContent: ReactNode =
262
- loading !== null && loading !== undefined && loading !== false
263
- ? createElement(RouteContentWrapper, {
264
- key: `suspense-loading-${id}`,
265
- content: getMemoizedContentPromise(resolvedComponent),
266
- fallback: loading,
267
- segmentId: id,
268
- })
269
- : registerLazyRef(resolvedComponent);
319
+ let nodeContent: ReactNode = null;
320
+ if (isRenderableLoading(loading)) {
321
+ // forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
322
+ // loading() route with the route content ALREADY resolved, so its
323
+ // RouteContentWrapper Suspender does not suspend for a microtask and flash
324
+ // the loading() fallback on a NORMAL (non-transition) commit. The router
325
+ // data is known-ready on these paths, so awaiting the content here is free.
326
+ // The wrapper tree is unchanged (RouteContentWrapper is still created with
327
+ // the same key/fallback) — only the `content` prop is a resolved node
328
+ // instead of a pending promise, which Suspender renders synchronously. This
329
+ // mirrors the forceAwait loaderData unwrap above; a CLIENT component that
330
+ // suspends on mount inside the content still reveals a fallback (it is not
331
+ // pre-resolved).
332
+ const contentPromise = getMemoizedContentPromise(resolvedComponent);
333
+ const loadingContent: Promise<ReactNode> | ReactNode = forceAwait
334
+ ? await contentPromise
335
+ : contentPromise;
336
+ nodeContent = createElement(RouteContentWrapper, {
337
+ key: `suspense-loading-${id}`,
338
+ content: loadingContent,
339
+ fallback: loading,
340
+ segmentId: id,
341
+ });
342
+ } else {
343
+ // TEMP DIAGNOSTIC (streaming-await investigation) - remove before merge.
344
+ if (typeof window === "object") {
345
+ const c = resolvedComponent as any;
346
+ console.log("[VT-DIAG renderSegments] no-loading-branch segment", {
347
+ id,
348
+ type: node.segment.type,
349
+ componentIsPromise: c instanceof Promise,
350
+ componentIsLazy:
351
+ c != null && typeof c === "object" && "_payload" in c,
352
+ componentTypeof: typeof c,
353
+ });
354
+ }
355
+ nodeContent = registerLazyRef(resolvedComponent);
356
+ }
270
357
 
271
358
  // Wrap with <ViewTransition> if transition config exists (React experimental only).
272
359
  // An empty config ({}) creates a bare <ViewTransition> boundary that participates
273
360
  // in transitions without adding custom animation classes. Named element-level
274
361
  // <ViewTransition> components inside (with name/share props) morph independently
275
362
  // from the parent's default cross-fade.
276
- if (ReactViewTransition && node.segment.transition) {
277
- nodeContent = createElement(ReactViewTransition, {
278
- ...node.segment.transition,
279
- children: nodeContent,
280
- });
281
- }
282
-
283
- // Common props for OutletProvider
284
- const outletContent: ReactNode =
363
+ //
364
+ // For layouts, wrap the outlet content (what `<Outlet />` renders) rather
365
+ // than the layout component itself. Parallel slots like `<ParallelOutlet
366
+ // name="@modal" />` read from a separate context channel and end up as
367
+ // siblings of the VT in the rendered tree, so modal mounts don't trigger a
368
+ // subtree update on the layout-level VT — which would otherwise make
369
+ // React's commit walker fire `document.startViewTransition` and apply
370
+ // view-transition-names to the underlying main subtree (cover/title/etc.).
371
+ //
372
+ // `transition.viewTransition === false` opts out of the router-owned
373
+ // boundary only. Driving (the startTransition wrap in browser/partial-update.ts
374
+ // and the param-agnostic key/hold below) keys off transition *presence*, not
375
+ // this flag, so a boundary-less transition still holds content and lets
376
+ // consumer-placed <ViewTransition> elements animate. The global
377
+ // createRouter({ viewTransition }) default is resolved into this field
378
+ // during segment resolution (only `false` is stamped; unset/"auto" is left
379
+ // as-is and means "wrap"), so this gate needs no router-option threading.
380
+ let outletContent: ReactNode =
285
381
  node.segment.type === "layout" ? content : null;
286
382
 
383
+ const transition = node.segment.transition;
384
+
385
+ if (
386
+ ReactViewTransition &&
387
+ transition &&
388
+ transition.viewTransition !== false
389
+ ) {
390
+ if (node.segment.type === "layout") {
391
+ outletContent = wrapDefaultOutletContent(outletContent, transition);
392
+ } else {
393
+ nodeContent = createViewTransitionBoundary(transition, nodeContent);
394
+ }
395
+ }
396
+
287
397
  // Prepare loader data if there are loaders
288
398
  const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
289
- const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
290
399
 
291
- // Use LoaderBoundary when loading is defined to maintain consistent tree structure
292
- // This ensures cached segments (which may not have loader segments) have the same
293
- // tree structure as fresh segments, preventing React remounts
294
- // If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
295
400
  if (loading !== undefined && loading !== null) {
401
+ const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
296
402
  content = createElement(LoaderBoundary, {
297
403
  key: `loader-boundary-${key}`,
298
404
  loaderDataPromise:
@@ -306,7 +412,6 @@ export async function renderSegments(
306
412
  children: nodeContent,
307
413
  });
308
414
  } else if (loaderEntries.length === 0) {
309
- // No loaders, no loading - simple OutletProvider
310
415
  content = createElement(OutletProvider, {
311
416
  key,
312
417
  content: outletContent,
@@ -315,34 +420,18 @@ export async function renderSegments(
315
420
  children: nodeContent,
316
421
  });
317
422
  } else {
318
- // Has loaders but no loading skeleton.
319
- // Split: parallel-owned loaders stream (their parallel has loading()),
320
- // layout-owned loaders are awaited (they gate the layout content).
321
423
  const layoutLoaders = loaderEntries.filter((l) => !l.parallelLoading);
322
424
  const parallelOwnedLoaders = loaderEntries.filter(
323
425
  (l) => !!l.parallelLoading,
324
426
  );
325
427
 
326
- // Await only layout-owned loaders
327
428
  const layoutLoaderIds = layoutLoaders.map((l) => l.loaderId!);
328
- const layoutLoaderDataPromise =
329
- layoutLoaders.length > 0
330
- ? Promise.all(
331
- layoutLoaders.map((l) =>
332
- l.loaderData instanceof Promise
333
- ? l.loaderData
334
- : Promise.resolve(l.loaderData),
335
- ),
336
- )
337
- : Promise.resolve([]);
338
- const resolvedData = await layoutLoaderDataPromise;
339
- const { loaderData, errorFallback } = resolveLoaderData(
429
+ const resolvedData = await buildLoaderPromise(layoutLoaders);
430
+ const { loaderData, errorFallback } = decodeLoaderResults(
340
431
  resolvedData,
341
432
  layoutLoaderIds,
342
433
  );
343
434
 
344
- // Parallel-owned loaders: attach to their owning parallel segment
345
- // as loaderDataPromise so ParallelOutlet wraps in LoaderBoundary
346
435
  if (parallelOwnedLoaders.length > 0) {
347
436
  const loadersByParallelNamespace = new Map<string, ResolvedSegment[]>();
348
437
 
@@ -399,20 +488,30 @@ export async function renderSegments(
399
488
  }
400
489
  }
401
490
 
402
- // Always wrap with root error boundary to prevent white screens
403
- // This catches any unhandled errors that bubble up from the segment tree
404
491
  const errorBoundaryWrapped = createElement(RootErrorBoundary, {
405
492
  children: content,
406
493
  });
407
494
  if (typeof window === "object") {
495
+ // TEMP DIAGNOSTIC (streaming-await investigation) - remove before merge.
496
+ // If this await dominates the navigation time, a deferred/lazy segment
497
+ // component is being fully resolved before commit, defeating streaming.
498
+ const __vtT0 = performance.now();
499
+ if (temporalLazyRefs.length > 0) {
500
+ console.log("[VT-DIAG renderSegments] awaiting temporalLazyRefs", {
501
+ count: temporalLazyRefs.length,
502
+ });
503
+ }
408
504
  await Promise.allSettled(temporalLazyRefs);
505
+ if (temporalLazyRefs.length > 0) {
506
+ console.log("[VT-DIAG renderSegments] temporalLazyRefs settled", {
507
+ count: temporalLazyRefs.length,
508
+ ms: Math.round(performance.now() - __vtT0),
509
+ });
510
+ }
409
511
  }
410
512
 
411
- // Build the final result, optionally wrapped with root layout
412
513
  let result: ReactNode = errorBoundaryWrapped;
413
514
 
414
- // If rootLayout is provided, wrap the error boundary with it
415
- // This ensures the app shell stays mounted even during errors (prevents FOUC)
416
515
  if (RootLayout) {
417
516
  result = createElement(RootLayout, {
418
517
  children: errorBoundaryWrapped,
@@ -450,6 +549,31 @@ export async function renderSegments(
450
549
  * @param segments - Main segments from the route tree
451
550
  * @param interceptSegments - Optional intercept segments to inject
452
551
  */
552
+ // Loader segment ids have the grammar `${parentId}D${index}.${loaderId}`.
553
+ // parentId is the parent shortCode (M/L/P/R/C + digits, never "D") for normal
554
+ // loaders, or `${shortCode}.${slotName}` for intercept-slot loaders, where the
555
+ // slot name is user-controlled (`@${string}`) and may contain an uppercase "D"
556
+ // (e.g. "@Detail"). Strip from the first `D<index>.` separator so the slot name
557
+ // is preserved; splitting on a bare "D" mis-cut "@Detail" to "@" and silently
558
+ // dropped the loader's data. The first-`D<index>.` strip is only correct because
559
+ // slot names cannot contain "." -- assertValidSlotName (route-definition/
560
+ // dsl-helpers.ts) rejects a "." at definition time, so a name like "@D3.foo"
561
+ // (which WOULD mis-cut here) can never reach this function.
562
+ function loaderParentId(loaderSegmentId: string): string {
563
+ return loaderSegmentId.replace(/D\d+\..*$/, "");
564
+ }
565
+
566
+ // Append a value to the array stored under `key`, creating the array on first
567
+ // use. Single Map lookup (vs the has/get!().push double-lookup idiom).
568
+ function pushToGroup<K, V>(map: Map<K, V[]>, key: K, value: V): void {
569
+ const arr = map.get(key);
570
+ if (arr) {
571
+ arr.push(value);
572
+ } else {
573
+ map.set(key, [value]);
574
+ }
575
+ }
576
+
453
577
  function* segmentTreeWalk(
454
578
  segments: ResolvedSegment[],
455
579
  interceptSegments?: ResolvedSegment[],
@@ -470,19 +594,12 @@ function* segmentTreeWalk(
470
594
  // Extract parent ID from parallel ID
471
595
  // Example: "L0R1L0.@sidebar" → "L0R1L0"
472
596
  const parentId = segment.id.split(".")[0];
473
- if (!parallelsByParent.has(parentId)) {
474
- parallelsByParent.set(parentId, []);
475
- }
476
- parallelsByParent.get(parentId)!.push(segment);
597
+ pushToGroup(parallelsByParent, parentId, segment);
477
598
  } else if (segment.type === "loader") {
478
599
  // Extract parent ID from loader ID
479
- // Example: "L0D0.cart" → "L0"
480
- // Loader ID format: {parentShortCode}D{index}.{loaderId}
481
- const parentId = segment.id.split("D")[0];
482
- if (!loadersByParent.has(parentId)) {
483
- loadersByParent.set(parentId, []);
484
- }
485
- loadersByParent.get(parentId)!.push(segment);
600
+ // Example: "L0D0.cart" → "L0"; "L0.@DetailD0.x" → "L0.@Detail"
601
+ const parentId = loaderParentId(segment.id);
602
+ pushToGroup(loadersByParent, parentId, segment);
486
603
  } else {
487
604
  // Layout, route, error, and notFound segments are all rendered in the tree
488
605
  // Error/notFound segments replace the failed segment with fallback UI
@@ -497,17 +614,11 @@ function* segmentTreeWalk(
497
614
  if (intercept.type === "parallel" && intercept.slot) {
498
615
  // Extract parent ID from intercept ID (e.g., "M4L0L0L2.@modal" → "M4L0L0L2")
499
616
  const parentId = intercept.id.split(".")[0];
500
- if (!parallelsByParent.has(parentId)) {
501
- parallelsByParent.set(parentId, []);
502
- }
503
- parallelsByParent.get(parentId)!.push(intercept);
617
+ pushToGroup(parallelsByParent, parentId, intercept);
504
618
  } else if (intercept.type === "loader") {
505
- // Intercept loaders - extract parent from loader ID
506
- const parentId = intercept.id.split("D")[0];
507
- if (!loadersByParent.has(parentId)) {
508
- loadersByParent.set(parentId, []);
509
- }
510
- loadersByParent.get(parentId)!.push(intercept);
619
+ // Intercept loaders - extract parent from loader ID (slot name preserved)
620
+ const parentId = loaderParentId(intercept.id);
621
+ pushToGroup(loadersByParent, parentId, intercept);
511
622
  }
512
623
  }
513
624
  }