@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
@@ -28,6 +28,60 @@ import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import { createAppShellRef, type AppShellRef } from "../app-shell.js";
32
+ import { startConnectionWarmup } from "../connection-warmup.js";
33
+ import { debugLog } from "../logging.js";
34
+ import { cloneHandleData } from "../navigation-store.js";
35
+ import { collectHandleData } from "../../handle.js";
36
+ import { Meta } from "../../handles/meta.js";
37
+ import type { MetaDescriptor } from "../../router/types.js";
38
+ import {
39
+ HEAD_RESOLVE_HANDLE_NAMES,
40
+ hasDeferredHandleValue,
41
+ resolveDeferredHandleValues,
42
+ } from "./deferred-handle-resolution.js";
43
+
44
+ /** Meta handle-name key. Meta is the only head-placed handle whose consumer
45
+ * use()s a deferred value above the route <Suspense>, so it must be resolved in
46
+ * the store before apply; every other handle keeps the promise contract. */
47
+ const META = "__rsc_router_meta__";
48
+
49
+ /**
50
+ * Carry the previous page's COLLECTED Meta forward so the title is kept (no
51
+ * blank) while a deferred Meta resolves on a soft navigation.
52
+ *
53
+ * Why a carry-forward and not just preserving the previous Meta data: handle
54
+ * collection (useHandle/MetaTags) is driven by the event controller's
55
+ * `segmentOrder`, which becomes the NEW route's order so the synchronous
56
+ * breadcrumbs render immediately. The previous route's title lives under a
57
+ * segment that is NOT in the new order, so it would stop being collected — the
58
+ * title would fall back to the layout default. Re-keying the previous COLLECTED
59
+ * descriptors under a segment that IS in the new order keeps them visible.
60
+ *
61
+ * Title descriptors are wrapped as `{ title: { absolute } }` so re-collection
62
+ * under a (possibly template-bearing) new layout does not re-apply a title
63
+ * template to an already-final title. Promise and default (charSet/viewport)
64
+ * descriptors are dropped: Promise ones would suspend MetaTags, and the defaults
65
+ * are re-added by collectMeta.
66
+ */
67
+ function carriedPreviousMeta(prev: MetaDescriptor[]): MetaDescriptor[] {
68
+ const out: MetaDescriptor[] = [];
69
+ for (const d of prev) {
70
+ if (d && typeof (d as { then?: unknown }).then === "function") continue;
71
+ const base = d as Exclude<MetaDescriptor, Promise<unknown>>;
72
+ if ("charSet" in base) continue;
73
+ if ("name" in base && (base as { name?: unknown }).name === "viewport") {
74
+ continue;
75
+ }
76
+ if ("title" in base) {
77
+ const t = (base as { title: unknown }).title;
78
+ out.push({ title: { absolute: typeof t === "string" ? t : String(t) } });
79
+ continue;
80
+ }
81
+ out.push(base);
82
+ }
83
+ return out;
84
+ }
31
85
 
32
86
  /**
33
87
  * Process handles from an async generator, updating the event controller
@@ -46,10 +100,35 @@ async function processHandles(
46
100
  store: NavigationStore;
47
101
  matched?: string[];
48
102
  isPartial?: boolean;
103
+ /** Server's `resolvedIds`: every segment re-resolved this request,
104
+ * including null-component ones excluded from `diff`/`segments`.
105
+ * Drives cleanup of stale handle buckets when a re-resolved segment
106
+ * pushed nothing. */
107
+ resolvedIds?: string[];
49
108
  historyKey: string;
50
109
  },
51
110
  ): Promise<void> {
52
- const { eventController, store, matched, isPartial, historyKey } = opts;
111
+ const {
112
+ eventController,
113
+ store,
114
+ matched,
115
+ isPartial,
116
+ resolvedIds,
117
+ historyKey,
118
+ } = opts;
119
+
120
+ // This nav's instance token, captured before any await — processHandles runs
121
+ // right after its own commit, so this is that commit's token. generateHistoryKey
122
+ // is URL-only, so an A->B->A revisit reuses the key; the token lets a late
123
+ // resolution tell its own visit apart from a newer same-URL visit, so a stale
124
+ // nav can never clobber a fresher one's live state or cache (P1).
125
+ const myInstance = store.getNavInstance();
126
+
127
+ // True while this nav still owns the live page: same history key AND the most
128
+ // recent commit is still ours (no newer nav has committed since).
129
+ const stillLive = (): boolean =>
130
+ historyKey === store.getHistoryKey() &&
131
+ myInstance === store.getNavInstance();
53
132
 
54
133
  let yieldCount = 0;
55
134
  for await (const handleData of handlesGenerator) {
@@ -57,14 +136,139 @@ async function processHandles(
57
136
  // This prevents handle data from cancelled navigations polluting
58
137
  // the current route's breadcrumbs (e.g., quick popstate after clicking a link).
59
138
  if (historyKey !== store.getHistoryKey()) {
60
- console.log(
139
+ debugLog(
61
140
  "[NavigationProvider] Stopping handle processing - user navigated away",
62
141
  );
63
142
  return;
64
143
  }
65
144
 
66
145
  yieldCount++;
67
- eventController.setHandleData(handleData, matched, isPartial);
146
+
147
+ // Resolve ONLY Meta in the store before applying. Meta is the sole
148
+ // head-placed handle whose consumer use()s a deferred value above the route
149
+ // <Suspense>; an uncontained suspension there would revert the just-committed
150
+ // route and hide its loading fallback. Every other handle (Breadcrumbs,
151
+ // custom handles) keeps the DeferredHandleEntry contract: its deferred values
152
+ // reach the consumer AS A PROMISE and are narrowed via isThenable(). So sync
153
+ // handles AND non-Meta deferred promises apply/stream through immediately —
154
+ // only Meta is held back and swapped in once resolved.
155
+ const metaDeferred = hasDeferredHandleValue(
156
+ handleData,
157
+ HEAD_RESOLVE_HANDLE_NAMES,
158
+ );
159
+
160
+ // Apply now. The non-deferred-Meta case applies the whole snapshot in one
161
+ // call (Meta included). When Meta IS deferred, replace the deferred Meta with
162
+ // the previous page's COLLECTED Meta (stale-while-revalidate — never a blank
163
+ // title) keyed under one of the NEW route's Meta segments, so it stays
164
+ // collected under the new segment order while the synchronous and non-Meta
165
+ // deferred handles update with normal cleanup. The resolved Meta is swapped
166
+ // in by the partial merge below.
167
+ if (metaDeferred) {
168
+ const immediate: HandleData = { ...handleData };
169
+ const metaSegments = handleData[META] ?? {};
170
+ // Anchor: the last new Meta segment in matched order (collected after the
171
+ // shared layout, so its carried title wins). Falls back to any new Meta
172
+ // segment if matched ordering does not surface one.
173
+ const metaSegmentIds = Object.keys(metaSegments);
174
+ const ordered = (matched ?? []).filter((id) =>
175
+ metaSegmentIds.includes(id),
176
+ );
177
+ const anchor = ordered.at(-1) ?? metaSegmentIds.at(-1);
178
+
179
+ const prevState = eventController.getHandleState();
180
+ const prevCollected = collectHandleData(
181
+ Meta,
182
+ prevState.data,
183
+ prevState.segmentOrder,
184
+ ) as MetaDescriptor[];
185
+ const carried = carriedPreviousMeta(prevCollected);
186
+
187
+ if (anchor && carried.length > 0) {
188
+ immediate[META] = { [anchor]: carried };
189
+ } else {
190
+ // No previous Meta to carry and/or no anchor: leave Meta unset until it
191
+ // resolves (the documented no-previous-Meta behavior).
192
+ delete immediate[META];
193
+ }
194
+ eventController.setHandleData(immediate, matched, isPartial, resolvedIds);
195
+ } else {
196
+ eventController.setHandleData(
197
+ handleData,
198
+ matched,
199
+ isPartial,
200
+ resolvedIds,
201
+ );
202
+ }
203
+
204
+ // Snapshot of the nav's full applied handle state (sync handles, non-Meta
205
+ // deferred promises, and — when Meta is deferred — the carried previous Meta).
206
+ // Captured AFTER applying so it reflects what is actually on screen now.
207
+ const baseSnapshot = cloneHandleData(eventController.getHandleState().data);
208
+
209
+ if (!metaDeferred) {
210
+ // Non-deferred: the applied snapshot is final. Keep the cache in sync and
211
+ // fresh. The token guard stops a stale same-URL nav writing a newer entry.
212
+ if (store.getCacheEntryInstance(historyKey) === myInstance) {
213
+ store.updateCacheHandleData(historyKey, baseSnapshot, false);
214
+ }
215
+ continue;
216
+ }
217
+
218
+ // Meta is deferred-pending. The applied snapshot carries the PREVIOUS page's
219
+ // Meta (or none), not this route's final title, so the cache entry must NOT
220
+ // be served as fresh on a popstate return. Mark it STALE and handlesPending
221
+ // (token-guarded). This is the P1 fix: the deferred Meta is a SERVER-side
222
+ // promise streamed via Flight, so a navigate-away ABORTS the stream and the
223
+ // client's deferred-Meta promise never resolves — the .then below never
224
+ // fires. stale makes a popstate return revalidate; handlesPending makes that
225
+ // revalidation a FULL re-render (no client segment IDs) so the server
226
+ // re-streams the handles. A diff-only revalidation would omit the unchanged
227
+ // segments' handles and the deferred Meta would never land — see the
228
+ // segmentIds branch in navigation-bridge.ts.
229
+ if (store.getCacheEntryInstance(historyKey) === myInstance) {
230
+ store.updateCacheHandleData(historyKey, baseSnapshot, true, true);
231
+ }
232
+
233
+ // Resolve Meta late, then swap it in. The swap is a PARTIAL merge with
234
+ // resolvedIds=undefined so the stale-clear loop (which scans all handle
235
+ // names under resolvedIds) cannot wipe the non-Meta buckets we already
236
+ // applied. When the deferred Meta DOES resolve while this nav still owns the
237
+ // entry (no navigate-away abort), write the resolved handle data and clear
238
+ // stale + handlesPending — the entry is now complete, so a popstate return
239
+ // serves it without revalidating.
240
+ //
241
+ // Order-safety: each stream yield is a full cumulative snapshot and a
242
+ // segment's handle array is atomic, so concurrent Meta resolutions of
243
+ // different yields write identical per-segment arrays or touch disjoint
244
+ // segments — neither can clobber the other.
245
+ void resolveDeferredHandleValues(
246
+ handleData,
247
+ HEAD_RESOLVE_HANDLE_NAMES,
248
+ ).then((resolved) => {
249
+ const cacheValue = { ...baseSnapshot, [META]: resolved[META] };
250
+ if (stillLive()) {
251
+ // Still on the live page: swap Meta in and refresh the cache as fresh.
252
+ eventController.setHandleData(
253
+ { [META]: resolved[META] },
254
+ matched,
255
+ true,
256
+ undefined,
257
+ );
258
+ store.updateCacheHandleData(
259
+ historyKey,
260
+ eventController.getHandleState().data,
261
+ false,
262
+ false,
263
+ );
264
+ } else if (store.getCacheEntryInstance(historyKey) === myInstance) {
265
+ // Navigated away, but THIS nav still owns the target cache entry: write
266
+ // the resolved data and clear stale + handlesPending so a popstate return
267
+ // is fresh.
268
+ store.updateCacheHandleData(historyKey, cacheValue, false, false);
269
+ }
270
+ // else: a newer nav to the same URL superseded us — do nothing.
271
+ });
68
272
  }
69
273
 
70
274
  // Check again before final updates
@@ -72,19 +276,19 @@ async function processHandles(
72
276
  return;
73
277
  }
74
278
 
75
- // For partial updates where the generator yielded nothing (cached handlers),
76
- // we still need to update the segment order to clean up stale handle data.
77
- // This happens when navigating away from a route - the handlers for the new
78
- // route might not push any breadcrumbs, but we still need to remove the old ones.
279
+ // For partial updates where the generator yielded nothing (every
280
+ // re-resolved handler pushed nothing), still call setHandleData so the
281
+ // cleanup pass can clear out stale buckets for those segments.
79
282
  if (yieldCount === 0 && matched) {
80
- eventController.setHandleData({}, matched, true);
283
+ eventController.setHandleData({}, matched, true, resolvedIds);
81
284
  }
82
285
 
83
286
  // After handles processing completes, update the cache's handleData.
84
287
  // This fixes a race condition where commit() caches stale handleData before
85
288
  // the async handles processing completes.
86
- // Only update if we're still on the same page (historyKey matches).
87
- if (historyKey === store.getHistoryKey()) {
289
+ // Only update if we're still on the same page AND this is still the live nav
290
+ // (the token guard stops a stale same-URL nav writing a newer nav's state).
291
+ if (stillLive()) {
88
292
  const finalHandleData = eventController.getHandleState().data;
89
293
  store.updateCacheHandleData(historyKey, finalHandleData);
90
294
  }
@@ -133,15 +337,33 @@ export interface NavigationProviderProps {
133
337
  warmupEnabled?: boolean;
134
338
 
135
339
  /**
136
- * App version from server payload (stable, immutable).
137
- * Forwarded to context for cache key building.
340
+ * App version from server payload.
341
+ * Used only as a fallback when `appShellRef` is not supplied.
138
342
  */
139
343
  version?: string;
140
344
 
141
345
  /**
142
346
  * URL prefix for all routes (from createRouter({ basename })).
347
+ * Used only as a fallback when `appShellRef` is not supplied.
143
348
  */
144
349
  basename?: string;
350
+
351
+ /**
352
+ * App-shell ref. When provided, the context's `basename` and `version` are
353
+ * read through it (live getters) so they don't close over a stale snapshot or
354
+ * invalidate the memoized context value. The shell is set once at init and is
355
+ * not swapped within a session — a cross-app navigation is a full document
356
+ * load (X-RSC-Reload), so the target app establishes its own shell on load.
357
+ */
358
+ appShellRef?: AppShellRef;
359
+
360
+ /**
361
+ * CSP nonce to expose via NonceContext. Production leaves this undefined — the
362
+ * browser has no nonce (it is a server-side HTML concern), and SSR provides the
363
+ * nonce through its own NonceContext.Provider. Test harnesses (renderRoute) set
364
+ * it to seed a nonce so components calling useNonce() can be exercised.
365
+ */
366
+ nonce?: string;
145
367
  }
146
368
 
147
369
  /**
@@ -175,6 +397,8 @@ export function NavigationProvider({
175
397
  warmupEnabled,
176
398
  version,
177
399
  basename,
400
+ appShellRef,
401
+ nonce,
178
402
  }: NavigationProviderProps): ReactNode {
179
403
  // Track current payload for rendering (this triggers re-renders)
180
404
  const [payload, setPayload] = useState(initialPayload);
@@ -196,104 +420,43 @@ export function NavigationProvider({
196
420
  await bridge.refresh();
197
421
  }, []);
198
422
 
199
- // Context value is stable (store, eventController, navigate, refresh never change)
200
- const contextValue = useMemo<NavigationStoreContextValue>(
201
- () => ({
423
+ // basename/version are always read through a shell ref so the context value
424
+ // has a single shape. Both are set once: a supplied appShellRef is seeded
425
+ // from the init payload (a cross-app navigation reloads, so it is not swapped
426
+ // in-session), and the standalone fallback wraps the mount-time props.
427
+ const fallbackShellRef = useRef<AppShellRef | null>(null);
428
+ if (!fallbackShellRef.current) {
429
+ fallbackShellRef.current = createAppShellRef({ basename, version });
430
+ }
431
+ const shellRef = appShellRef ?? fallbackShellRef.current;
432
+
433
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
434
+ const value = {
202
435
  store,
203
436
  eventController,
204
437
  navigate,
205
438
  refresh,
206
- version,
207
- basename,
208
- }),
209
- [],
210
- );
439
+ } as NavigationStoreContextValue;
440
+ Object.defineProperty(value, "basename", {
441
+ configurable: true,
442
+ enumerable: true,
443
+ get: () => shellRef.get().basename,
444
+ });
445
+ Object.defineProperty(value, "version", {
446
+ configurable: true,
447
+ enumerable: true,
448
+ get: () => shellRef.get().version,
449
+ });
450
+ return value;
451
+ }, []);
211
452
 
212
- // Connection warmup: keep TLS alive after idle periods.
213
- // After 60s of no user interaction, marks connection as "cold".
214
- // On next interaction or visibility change, sends a HEAD request to warm TLS
215
- // before the user actually clicks a link.
453
+ // Connection warmup: keep TLS alive after idle periods. After 60s of no
454
+ // interaction the connection is marked cold; the next pointer/touch
455
+ // interaction or visibility change warms TLS via a HEAD request before the
456
+ // user clicks a link. State machine lives in connection-warmup.ts.
216
457
  useEffect(() => {
217
458
  if (!warmupEnabled) return;
218
-
219
- const IDLE_TIMEOUT = 60_000;
220
- const DEBOUNCE_DELAY = 150;
221
-
222
- let idleTimer: ReturnType<typeof setTimeout> | undefined;
223
- let debounceTimer: ReturnType<typeof setTimeout> | undefined;
224
- let isCold = false;
225
- let warmupListenersAttached = false;
226
-
227
- function sendWarmup() {
228
- isCold = false;
229
- fetch("/?_rsc_warmup", { method: "HEAD" }).catch(() => {});
230
- }
231
-
232
- function triggerWarmup() {
233
- if (!isCold) return;
234
- clearTimeout(debounceTimer);
235
- debounceTimer = setTimeout(() => {
236
- sendWarmup();
237
- detachWarmupListeners();
238
- resetIdleTimer();
239
- }, DEBOUNCE_DELAY);
240
- }
241
-
242
- function onVisibilityChange() {
243
- if (document.visibilityState === "visible" && isCold) {
244
- triggerWarmup();
245
- }
246
- }
247
-
248
- function attachWarmupListeners() {
249
- if (warmupListenersAttached) return;
250
- warmupListenersAttached = true;
251
- document.addEventListener("visibilitychange", onVisibilityChange);
252
- document.addEventListener("mousemove", triggerWarmup, { once: true });
253
- document.addEventListener("touchstart", triggerWarmup, { once: true });
254
- }
255
-
256
- function detachWarmupListeners() {
257
- warmupListenersAttached = false;
258
- document.removeEventListener("visibilitychange", onVisibilityChange);
259
- document.removeEventListener("mousemove", triggerWarmup);
260
- document.removeEventListener("touchstart", triggerWarmup);
261
- }
262
-
263
- function markCold() {
264
- isCold = true;
265
- attachWarmupListeners();
266
- }
267
-
268
- function resetIdleTimer() {
269
- clearTimeout(idleTimer);
270
- isCold = false;
271
- idleTimer = setTimeout(markCold, IDLE_TIMEOUT);
272
- }
273
-
274
- // Activity events that reset the idle timer
275
- const activityEvents = [
276
- "mousemove",
277
- "keydown",
278
- "touchstart",
279
- "scroll",
280
- ] as const;
281
- const activityOptions: AddEventListenerOptions = { passive: true };
282
-
283
- for (const event of activityEvents) {
284
- document.addEventListener(event, resetIdleTimer, activityOptions);
285
- }
286
-
287
- resetIdleTimer();
288
-
289
- return () => {
290
- clearTimeout(idleTimer);
291
- clearTimeout(debounceTimer);
292
- detachWarmupListeners();
293
- for (const event of activityEvents) {
294
- document.removeEventListener(event, resetIdleTimer);
295
- }
296
- };
459
+ return startConnectionWarmup();
297
460
  }, [warmupEnabled]);
298
461
 
299
462
  // Cancel non-matching prefetches when navigation starts.
@@ -363,24 +526,20 @@ export function NavigationProvider({
363
526
  store,
364
527
  matched: update.metadata.matched,
365
528
  isPartial: update.metadata.isPartial,
529
+ resolvedIds: update.metadata.resolvedIds,
366
530
  historyKey,
367
531
  }).catch((err) =>
368
532
  console.error("[NavigationProvider] Error consuming handles:", err),
369
533
  );
370
- } else if (update.metadata.cachedHandleData) {
371
- // For back/forward navigation from cache, restore the cached handleData
372
- // This restores breadcrumbs to the exact state they were when the page was cached
373
- eventController.setHandleData(
374
- update.metadata.cachedHandleData,
375
- update.metadata.matched,
376
- false, // full replace - restore entire cached state
377
- );
378
534
  } else if (update.metadata.matched) {
379
- // For cached navigations without handleData, update segmentOrder to clean up stale data
535
+ // cachedHandleData present -> full restore (back/forward); absent ->
536
+ // partial cleanup of segments no longer matched.
537
+ const cached = update.metadata.cachedHandleData;
380
538
  eventController.setHandleData(
381
- {}, // Empty data - all existing data not in matched will be cleaned up
539
+ cached ?? {},
382
540
  update.metadata.matched,
383
- true, // partial update - will clean up segments not in matched
541
+ cached === undefined,
542
+ cached === undefined ? update.metadata.resolvedIds : undefined,
384
543
  );
385
544
  }
386
545
  });
@@ -393,7 +552,8 @@ export function NavigationProvider({
393
552
  payload.root instanceof Promise ? use(payload.root) : payload.root;
394
553
 
395
554
  // Wrap content in RootErrorBoundary to catch:
396
- // 1. Errors from NetworkErrorThrower (rendered during network failures)
555
+ // 1. Errors from RenderErrorThrower (network failures and unprocessable
556
+ // navigation responses, routed here by the navigation bridge)
397
557
  // 2. Client component errors that occur before/outside the segment tree's error boundary
398
558
  // 3. Errors during promise resolution or navigation state updates
399
559
  // This acts as a safety net - the segment tree has its own RootErrorBoundary that
@@ -402,7 +562,11 @@ export function NavigationProvider({
402
562
  // Build the content tree
403
563
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
404
564
 
405
- // Wrap with ThemeProvider when theme is enabled
565
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
566
+ // document-lifetime: its config comes from the initial load and persists for
567
+ // the session. It sits above the segment tree and is not remounted in-session;
568
+ // a cross-app navigation is a full document load (X-RSC-Reload), so the target
569
+ // app's theme config takes effect on its own load.
406
570
  if (themeConfig) {
407
571
  content = (
408
572
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -413,9 +577,10 @@ export function NavigationProvider({
413
577
 
414
578
  // Match SSR tree shape: NonceContext.Provider is always present so
415
579
  // hydration sees the same component tree. Value is undefined on the
416
- // client — CSP nonces are a server-side HTML concern.
580
+ // client — CSP nonces are a server-side HTML concern — unless a test
581
+ // harness seeded one via the `nonce` prop.
417
582
  content = (
418
- <NonceContext.Provider value={undefined}>{content}</NonceContext.Provider>
583
+ <NonceContext.Provider value={nonce}>{content}</NonceContext.Provider>
419
584
  );
420
585
 
421
586
  return (
@@ -14,17 +14,21 @@ export interface ScrollRestorationProps {
14
14
  * Return location.pathname to restore scroll based on path
15
15
  * (useful for keeping scroll position on the same page).
16
16
  *
17
+ * Provide a stable reference: a module-level function or one wrapped in
18
+ * useCallback. The init effect re-runs when getKey's identity changes, and
19
+ * teardown clears in-memory scroll positions — a fresh inline arrow on every
20
+ * parent render would discard unpersisted positions mid-session.
21
+ *
17
22
  * @example
18
23
  * ```tsx
24
+ * // Stable module-level getKey (recommended)
25
+ * const byPathname = (location) => location.pathname;
26
+ *
19
27
  * // Restore based on pathname (same URL = same scroll)
20
- * <ScrollRestoration
21
- * getKey={(location) => location.pathname}
22
- * />
28
+ * <ScrollRestoration getKey={byPathname} />
23
29
  *
24
30
  * // Restore based on unique history entry (default)
25
- * <ScrollRestoration
26
- * getKey={(location) => location.key}
27
- * />
31
+ * // <ScrollRestoration /> — omit getKey to use location.key
28
32
  * ```
29
33
  */
30
34
  getKey?: (location: {
@@ -0,0 +1,75 @@
1
+ import type { HandleData } from "../types.js";
2
+ import { isThenable } from "../../handles/is-thenable.js";
3
+
4
+ /**
5
+ * The set of handle names whose deferred (Promise) values MUST be resolved in
6
+ * the store BEFORE the snapshot is applied during client navigation.
7
+ *
8
+ * The boundary: a handle belongs here only if its consumer `use()`s a promise in
9
+ * <head>, above the route's <Suspense>. Suspending there would revert the
10
+ * just-committed route and hide its loading fallback. Today that is Meta alone
11
+ * (MetaTags lives in <head> and use()s deferred descriptors). Every OTHER handle
12
+ * keeps the public DeferredHandleEntry contract: its deferred value reaches the
13
+ * consumer AS A PROMISE during soft navigation, narrowed via isThenable().
14
+ *
15
+ * If a future head-placed handle starts use()-ing promises, add its name here.
16
+ */
17
+ export const HEAD_RESOLVE_HANDLE_NAMES: readonly string[] = [
18
+ "__rsc_router_meta__",
19
+ ];
20
+
21
+ /**
22
+ * True when a handle value in this snapshot is a deferred (Promise) value.
23
+ *
24
+ * When `onlyHandleNames` is given, only those handle buckets are considered;
25
+ * deferred values under any other handle are ignored (they pass through to the
26
+ * consumer as promises, by contract).
27
+ */
28
+ export function hasDeferredHandleValue(
29
+ data: HandleData,
30
+ onlyHandleNames?: readonly string[],
31
+ ): boolean {
32
+ const scope = onlyHandleNames ? new Set(onlyHandleNames) : null;
33
+ for (const [handleName, segments] of Object.entries(data)) {
34
+ if (scope && !scope.has(handleName)) continue;
35
+ for (const values of Object.values(segments)) {
36
+ if (values.some(isThenable)) return true;
37
+ }
38
+ }
39
+ return false;
40
+ }
41
+
42
+ /**
43
+ * Snapshot with deferred (Promise) values awaited; a rejected deferred is
44
+ * dropped (it contributes nothing), mirroring the render-side REJECTED_META.
45
+ * Promise.allSettled treats non-promise values as already-fulfilled, so plain
46
+ * values pass through unchanged.
47
+ *
48
+ * When `onlyHandleNames` is given, ONLY those handle buckets are resolved; every
49
+ * other bucket is copied through by reference (its deferred values keep their
50
+ * promise identity so the consumer can narrow them).
51
+ */
52
+ export async function resolveDeferredHandleValues(
53
+ data: HandleData,
54
+ onlyHandleNames?: readonly string[],
55
+ ): Promise<HandleData> {
56
+ const scope = onlyHandleNames ? new Set(onlyHandleNames) : null;
57
+ const out: HandleData = {};
58
+ await Promise.all(
59
+ Object.entries(data).flatMap(([handleName, segments]) => {
60
+ // Out-of-scope buckets pass through untouched (promise identity kept).
61
+ if (scope && !scope.has(handleName)) {
62
+ out[handleName] = segments;
63
+ return [];
64
+ }
65
+ out[handleName] = {};
66
+ return Object.entries(segments).map(async ([segmentId, values]) => {
67
+ const settled = await Promise.allSettled(values);
68
+ out[handleName][segmentId] = settled
69
+ .filter((r) => r.status === "fulfilled")
70
+ .map((r) => (r as PromiseFulfilledResult<unknown>).value);
71
+ });
72
+ }),
73
+ );
74
+ return out;
75
+ }