@timber-js/app 0.2.0-alpha.196 → 0.2.0-alpha.198

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 (289) hide show
  1. package/dist/_chunks/{actions-CWYtq6ii.js → actions-BS-m5SLv.js} +3 -3
  2. package/dist/_chunks/{actions-CWYtq6ii.js.map → actions-BS-m5SLv.js.map} +1 -1
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/{build-manifest-DWppEdLB.js → build-manifest-DTmSGLRz.js} +51 -2
  5. package/dist/_chunks/build-manifest-DTmSGLRz.js.map +1 -0
  6. package/dist/_chunks/{cache-api-CQeYzA5g.js → cache-api-DqzgTEqk.js} +4 -49
  7. package/dist/_chunks/cache-api-DqzgTEqk.js.map +1 -0
  8. package/dist/_chunks/{chains-h7EO-u3n.js → chains-CZG7E5zg.js} +2 -2
  9. package/dist/_chunks/{chains-h7EO-u3n.js.map → chains-CZG7E5zg.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-BVthpfLS.js → cli-check-dVDi1GQz.js} +3 -3
  11. package/dist/_chunks/{cli-check-BVthpfLS.js.map → cli-check-dVDi1GQz.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js → cli-schema-sync-DTy_-Msq.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js.map → cli-schema-sync-DTy_-Msq.js.map} +1 -1
  14. package/dist/_chunks/{cloudflare-BKJC3SC_.js → cloudflare-BFb__LYG.js} +2 -2
  15. package/dist/_chunks/{cloudflare-BKJC3SC_.js.map → cloudflare-BFb__LYG.js.map} +1 -1
  16. package/dist/_chunks/{convention-lint-DO10_pVl.js → convention-lint-Ph6luW4c.js} +4 -2
  17. package/dist/_chunks/convention-lint-Ph6luW4c.js.map +1 -0
  18. package/dist/_chunks/{error-boundary-D-lkwyaD.js → error-boundary-BvRCCmbN.js} +3 -3
  19. package/dist/_chunks/{error-boundary-D-lkwyaD.js.map → error-boundary-BvRCCmbN.js.map} +1 -1
  20. package/dist/_chunks/{href-validation-CMc5JRls.js → href-validation-BIrxavIy.js} +74 -2
  21. package/dist/_chunks/href-validation-BIrxavIy.js.map +1 -0
  22. package/dist/_chunks/{live-graph-Bx4HodF1.js → live-graph-BXDsdzBv.js} +3 -3
  23. package/dist/_chunks/{live-graph-Bx4HodF1.js.map → live-graph-BXDsdzBv.js.map} +1 -1
  24. package/dist/_chunks/{logger-pumCm3Il.js → logger-DDirEsn7.js} +3 -4
  25. package/dist/_chunks/{logger-pumCm3Il.js.map → logger-DDirEsn7.js.map} +1 -1
  26. package/dist/_chunks/navigation-root-B00jjGd5.js +233 -0
  27. package/dist/_chunks/navigation-root-B00jjGd5.js.map +1 -0
  28. package/dist/_chunks/{segment-context-CjOlyB8Y.js → param-value-C8TNYchQ.js} +2 -33
  29. package/dist/_chunks/param-value-C8TNYchQ.js.map +1 -0
  30. package/dist/_chunks/{poison-scan-BAxfTT5L.js → poison-scan-BoDLgbix.js} +2 -2
  31. package/dist/_chunks/{poison-scan-BAxfTT5L.js.map → poison-scan-BoDLgbix.js.map} +1 -1
  32. package/dist/_chunks/{router-ref-BzqbPwYC.js → router-ref-8gr8qsxN.js} +2 -2
  33. package/dist/_chunks/{router-ref-BzqbPwYC.js.map → router-ref-8gr8qsxN.js.map} +1 -1
  34. package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js → rsc-cache-key-ClUiXQnK.js} +2 -2
  35. package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js.map → rsc-cache-key-ClUiXQnK.js.map} +1 -1
  36. package/dist/_chunks/{scanner-BRIOmHE2.js → scanner-tdFPvDYi.js} +174 -7
  37. package/dist/_chunks/scanner-tdFPvDYi.js.map +1 -0
  38. package/dist/_chunks/segment-context-D9_89u34.js +34 -0
  39. package/dist/_chunks/segment-context-D9_89u34.js.map +1 -0
  40. package/dist/_chunks/singleflight-2lUWfcAk.js +54 -0
  41. package/dist/_chunks/singleflight-2lUWfcAk.js.map +1 -0
  42. package/dist/_chunks/{ssr-data-Ya2HJPFp.js → ssr-data-BQGhTPAK.js} +2 -17
  43. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +1 -0
  44. package/dist/_chunks/{walkers-BU6z9xRV.js → walkers-DNX05dC0.js} +2 -2
  45. package/dist/_chunks/{walkers-BU6z9xRV.js.map → walkers-DNX05dC0.js.map} +1 -1
  46. package/dist/adapters/cloudflare-dev.js +1 -1
  47. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  48. package/dist/adapters/cloudflare.js +1 -1
  49. package/dist/adapters/nitro.d.ts +1 -1
  50. package/dist/adapters/nitro.d.ts.map +1 -1
  51. package/dist/adapters/nitro.js.map +1 -1
  52. package/dist/analyze/crawl-entry.js +2 -2
  53. package/dist/analyze/graph-command.js +2 -2
  54. package/dist/cache/index.js +1 -1
  55. package/dist/cache/singleflight.d.ts +2 -0
  56. package/dist/cache/singleflight.d.ts.map +1 -1
  57. package/dist/cli.js +2 -2
  58. package/dist/client/browser-entry/hydrate.d.ts +21 -15
  59. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  60. package/dist/client/browser-entry/index.d.ts +4 -3
  61. package/dist/client/browser-entry/index.d.ts.map +1 -1
  62. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  63. package/dist/client/browser-entry/router-init.d.ts +17 -1
  64. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  65. package/dist/client/error-boundary.js +1 -1
  66. package/dist/client/global-context.d.ts +15 -0
  67. package/dist/client/global-context.d.ts.map +1 -0
  68. package/dist/client/index.js +138 -35
  69. package/dist/client/index.js.map +1 -1
  70. package/dist/client/internal.d.ts +0 -1
  71. package/dist/client/internal.d.ts.map +1 -1
  72. package/dist/client/internal.js +206 -55
  73. package/dist/client/internal.js.map +1 -1
  74. package/dist/client/link.d.ts.map +1 -1
  75. package/dist/client/location-search.d.ts +12 -0
  76. package/dist/client/location-search.d.ts.map +1 -0
  77. package/dist/client/navigation-api.d.ts.map +1 -1
  78. package/dist/client/navigation-commit.d.ts +18 -0
  79. package/dist/client/navigation-commit.d.ts.map +1 -1
  80. package/dist/client/navigation-context.d.ts +13 -11
  81. package/dist/client/navigation-context.d.ts.map +1 -1
  82. package/dist/client/navigation-root.d.ts +47 -108
  83. package/dist/client/navigation-root.d.ts.map +1 -1
  84. package/dist/client/navigation-transition.d.ts +136 -0
  85. package/dist/client/navigation-transition.d.ts.map +1 -0
  86. package/dist/client/nuqs-adapter.d.ts.map +1 -1
  87. package/dist/client/params-context.d.ts +4 -5
  88. package/dist/client/params-context.d.ts.map +1 -1
  89. package/dist/client/react-root.d.ts +44 -0
  90. package/dist/client/react-root.d.ts.map +1 -0
  91. package/dist/client/router-pipeline.d.ts +2 -2
  92. package/dist/client/router-pipeline.d.ts.map +1 -1
  93. package/dist/client/router-types.d.ts +12 -2
  94. package/dist/client/router-types.d.ts.map +1 -1
  95. package/dist/client/router.d.ts.map +1 -1
  96. package/dist/client/segment-cache.d.ts +39 -0
  97. package/dist/client/segment-cache.d.ts.map +1 -1
  98. package/dist/client/segment-context.d.ts.map +1 -1
  99. package/dist/client/segment-outlet.d.ts +25 -14
  100. package/dist/client/segment-outlet.d.ts.map +1 -1
  101. package/dist/client/segment-update-context.d.ts +3 -9
  102. package/dist/client/segment-update-context.d.ts.map +1 -1
  103. package/dist/client/slot-content-cache-context.d.ts +35 -0
  104. package/dist/client/slot-content-cache-context.d.ts.map +1 -0
  105. package/dist/client/ssr-data.d.ts +8 -2
  106. package/dist/client/ssr-data.d.ts.map +1 -1
  107. package/dist/client/state.d.ts +0 -15
  108. package/dist/client/state.d.ts.map +1 -1
  109. package/dist/client/use-pathname.d.ts +13 -11
  110. package/dist/client/use-pathname.d.ts.map +1 -1
  111. package/dist/client/use-search-params.d.ts +13 -13
  112. package/dist/client/use-search-params.d.ts.map +1 -1
  113. package/dist/client/use-segment-params.d.ts +18 -68
  114. package/dist/client/use-segment-params.d.ts.map +1 -1
  115. package/dist/config-types.d.ts +17 -0
  116. package/dist/config-types.d.ts.map +1 -1
  117. package/dist/config-validation.d.ts.map +1 -1
  118. package/dist/cookies/index.js +1 -1
  119. package/dist/dev-tools/holding-server.d.ts +15 -10
  120. package/dist/dev-tools/holding-server.d.ts.map +1 -1
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js +44 -43
  123. package/dist/index.js.map +1 -1
  124. package/dist/plugins/dev-server.d.ts.map +1 -1
  125. package/dist/plugins/entries.d.ts.map +1 -1
  126. package/dist/plugins/shims.d.ts.map +1 -1
  127. package/dist/plugins/static-build.d.ts +2 -2
  128. package/dist/plugins/static-build.d.ts.map +1 -1
  129. package/dist/routing/codegen-write.d.ts.map +1 -1
  130. package/dist/routing/index.js +2 -2
  131. package/dist/routing/interception-overlap.d.ts +35 -0
  132. package/dist/routing/interception-overlap.d.ts.map +1 -0
  133. package/dist/routing/interception.d.ts.map +1 -1
  134. package/dist/rsc-runtime/ssr.d.ts +3 -1
  135. package/dist/rsc-runtime/ssr.d.ts.map +1 -1
  136. package/dist/server/als-registry.d.ts +6 -0
  137. package/dist/server/als-registry.d.ts.map +1 -1
  138. package/dist/server/csp-nonce.d.ts +45 -0
  139. package/dist/server/csp-nonce.d.ts.map +1 -0
  140. package/dist/server/default-status-page.d.ts.map +1 -1
  141. package/dist/server/deny-renderer.d.ts.map +1 -1
  142. package/dist/server/flight-scripts.d.ts +5 -2
  143. package/dist/server/flight-scripts.d.ts.map +1 -1
  144. package/dist/server/html-injector-core.d.ts +17 -2
  145. package/dist/server/html-injector-core.d.ts.map +1 -1
  146. package/dist/server/html-injectors.d.ts +3 -2
  147. package/dist/server/html-injectors.d.ts.map +1 -1
  148. package/dist/server/index.js +2 -2
  149. package/dist/server/internal.js +86 -37
  150. package/dist/server/internal.js.map +1 -1
  151. package/dist/server/metadata-render.d.ts.map +1 -1
  152. package/dist/server/node-stream-transforms.d.ts +3 -17
  153. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  154. package/dist/server/nuqs-ssr-provider.d.ts +7 -3
  155. package/dist/server/nuqs-ssr-provider.d.ts.map +1 -1
  156. package/dist/server/pipeline-phases.d.ts.map +1 -1
  157. package/dist/server/prebuilt/key-discipline.d.ts +32 -3
  158. package/dist/server/prebuilt/key-discipline.d.ts.map +1 -1
  159. package/dist/server/primitives.d.ts.map +1 -1
  160. package/dist/server/render-utils.d.ts +4 -3
  161. package/dist/server/render-utils.d.ts.map +1 -1
  162. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  163. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  164. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  165. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  166. package/dist/server/ssr-bridge-types.d.ts +22 -2
  167. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  168. package/dist/server/ssr-entry.d.ts.map +1 -1
  169. package/dist/server/ssr-render.d.ts +5 -1
  170. package/dist/server/ssr-render.d.ts.map +1 -1
  171. package/dist/server/ssr-wrappers.d.ts +59 -27
  172. package/dist/server/ssr-wrappers.d.ts.map +1 -1
  173. package/dist/server/types.d.ts +10 -0
  174. package/dist/server/types.d.ts.map +1 -1
  175. package/dist/shims/navigation-rsc.d.ts +21 -0
  176. package/dist/shims/navigation-rsc.d.ts.map +1 -0
  177. package/docs/api/30-api-server.mdx +1 -0
  178. package/docs/api/35-api-typescript.mdx +4 -83
  179. package/docs/learn/03-fetching-data.mdx +1 -1
  180. package/docs/learn/{03b-access-control.mdx → 04-access-control.mdx} +2 -17
  181. package/docs/learn/05-the-flush-point.mdx +175 -0
  182. package/docs/learn/{05-typed-params.mdx → 06-typed-params.mdx} +1 -1
  183. package/docs/learn/07-typed-routes.mdx +25 -49
  184. package/docs/learn/{08-streaming.mdx → 09-streaming.mdx} +1 -7
  185. package/docs/learn/{10-middleware.mdx → 11-middleware.mdx} +1 -0
  186. package/package.json +3 -3
  187. package/src/adapters/nitro.ts +7 -7
  188. package/src/cache/singleflight.ts +5 -0
  189. package/src/client/browser-entry/hydrate.ts +54 -104
  190. package/src/client/browser-entry/index.ts +16 -6
  191. package/src/client/browser-entry/post-hydration.ts +3 -2
  192. package/src/client/browser-entry/router-init.ts +84 -33
  193. package/src/client/global-context.ts +31 -0
  194. package/src/client/internal.ts +1 -2
  195. package/src/client/link.tsx +18 -18
  196. package/src/client/location-search.ts +15 -0
  197. package/src/client/navigation-api.ts +4 -2
  198. package/src/client/navigation-commit.ts +48 -2
  199. package/src/client/navigation-context.ts +25 -37
  200. package/src/client/navigation-root.tsx +55 -411
  201. package/src/client/navigation-transition.ts +278 -0
  202. package/src/client/nuqs-adapter.tsx +4 -5
  203. package/src/client/params-context.ts +13 -18
  204. package/src/client/react-root.ts +72 -0
  205. package/src/client/router-lifecycle.ts +1 -1
  206. package/src/client/router-pipeline.ts +96 -22
  207. package/src/client/router-types.ts +12 -2
  208. package/src/client/router.ts +48 -36
  209. package/src/client/segment-cache.ts +70 -2
  210. package/src/client/segment-context.ts +7 -4
  211. package/src/client/segment-outlet.tsx +41 -86
  212. package/src/client/segment-update-context.ts +7 -26
  213. package/src/client/slot-content-cache-context.ts +43 -0
  214. package/src/client/ssr-data.ts +8 -2
  215. package/src/client/state.ts +0 -26
  216. package/src/client/use-pathname.ts +21 -31
  217. package/src/client/use-search-params.ts +31 -29
  218. package/src/client/use-segment-params.ts +27 -126
  219. package/src/config-types.ts +17 -0
  220. package/src/config-validation.ts +17 -0
  221. package/src/dev-tools/holding-server.ts +23 -12
  222. package/src/index.ts +26 -11
  223. package/src/plugins/dev-server.ts +9 -12
  224. package/src/plugins/entries.ts +3 -0
  225. package/src/plugins/shims.ts +8 -7
  226. package/src/plugins/static-build.ts +9 -5
  227. package/src/react-canary.d.ts +2 -0
  228. package/src/routing/codegen-write.ts +2 -0
  229. package/src/routing/interception-overlap.ts +141 -0
  230. package/src/routing/interception.ts +118 -5
  231. package/src/rsc-runtime/ssr.ts +3 -2
  232. package/src/server/als-registry.ts +6 -0
  233. package/src/server/csp-nonce.ts +70 -0
  234. package/src/server/default-status-page.ts +1 -0
  235. package/src/server/deny-renderer.ts +7 -3
  236. package/src/server/flight-scripts.ts +9 -4
  237. package/src/server/html-injector-core.ts +26 -9
  238. package/src/server/html-injectors.ts +8 -8
  239. package/src/server/metadata-render.ts +26 -4
  240. package/src/server/node-stream-transforms.ts +7 -20
  241. package/src/server/nuqs-ssr-provider.tsx +8 -7
  242. package/src/server/pipeline-phases.ts +5 -0
  243. package/src/server/prebuilt/key-discipline.ts +82 -13
  244. package/src/server/prebuilt-runtime.ts +2 -2
  245. package/src/server/primitives.ts +4 -4
  246. package/src/server/render-utils.ts +8 -4
  247. package/src/server/rsc-entry/action-middleware-runner.ts +7 -0
  248. package/src/server/rsc-entry/error-renderer.ts +5 -2
  249. package/src/server/rsc-entry/index.ts +8 -0
  250. package/src/server/rsc-entry/ssr-renderer.ts +11 -4
  251. package/src/server/ssr-bridge-types.ts +22 -2
  252. package/src/server/ssr-entry.ts +35 -28
  253. package/src/server/ssr-render.ts +13 -4
  254. package/src/server/ssr-wrappers.tsx +81 -61
  255. package/src/server/types.ts +10 -0
  256. package/src/shared/slot-params.ts +3 -4
  257. package/src/shims/navigation-rsc.ts +47 -0
  258. package/dist/_chunks/build-manifest-DWppEdLB.js.map +0 -1
  259. package/dist/_chunks/cache-api-CQeYzA5g.js.map +0 -1
  260. package/dist/_chunks/convention-lint-DO10_pVl.js.map +0 -1
  261. package/dist/_chunks/href-validation-CMc5JRls.js.map +0 -1
  262. package/dist/_chunks/scanner-BRIOmHE2.js.map +0 -1
  263. package/dist/_chunks/segment-context-CjOlyB8Y.js.map +0 -1
  264. package/dist/_chunks/slot-params-BCTmZkQB.js +0 -76
  265. package/dist/_chunks/slot-params-BCTmZkQB.js.map +0 -1
  266. package/dist/_chunks/ssr-data-Ya2HJPFp.js.map +0 -1
  267. package/dist/_chunks/use-segment-params-DzTBpkvj.js +0 -398
  268. package/dist/_chunks/use-segment-params-DzTBpkvj.js.map +0 -1
  269. package/docs/learn/04-loading-states.mdx +0 -67
  270. package/docs/learn/04b-the-flush-point.mdx +0 -115
  271. package/docs/learn/12-client-navigation.mdx +0 -176
  272. package/docs/learn/13-configuration.mdx +0 -166
  273. package/docs/more/01-advanced-routing.mdx +0 -344
  274. package/docs/more/02-advanced-forms.mdx +0 -137
  275. package/docs/more/03-coming-from-nextjs.mdx +0 -186
  276. package/docs/more/04-metadata-and-fonts.mdx +0 -193
  277. package/docs/more/04b-mdx.mdx +0 -229
  278. package/docs/more/05-content-collections.mdx +0 -90
  279. package/docs/more/06-instrumentation.mdx +0 -214
  280. package/docs/more/07-security.mdx +0 -129
  281. package/docs/more/08-developer-experience.mdx +0 -134
  282. package/docs/more/40-why-timber.mdx +0 -50
  283. package/docs/more/41-timber-vs-nextjs.mdx +0 -81
  284. package/docs/more/42-timber-vs-others.mdx +0 -68
  285. package/docs/more/50-ai-agent-instructions.mdx +0 -171
  286. /package/docs/learn/{06-forms-and-actions.mdx → 08-forms-and-actions.mdx} +0 -0
  287. /package/docs/learn/{09-caching.mdx → 10-caching.mdx} +0 -0
  288. /package/docs/learn/{11-error-handling.mdx → 12-error-handling.mdx} +0 -0
  289. /package/docs/learn/{14-deploying.mdx → 13-deploying.mdx} +0 -0
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Navigation transition — the router-level hand-off of a navigation's tree to
3
+ * React, and the supersession that governs it.
4
+ *
5
+ * `navigateTransition` runs a navigation's fetch OUTSIDE any transition scope
6
+ * and hands the resulting tree to React through a `NavigationRender`, which
7
+ * is a synchronous `startTransition` around `root.render` (client/react-root.ts).
8
+ * Two React behaviours force that shape; both were measured on React 19.2.7
9
+ * (tests/navigation-transition-suspense.test.ts pins the observable half).
10
+ *
11
+ * 1. A transition scope does not survive an `await`. `startTransition`
12
+ * restores the previous scope in its `finally`, which for an async
13
+ * callback runs when that callback RETURNS — i.e. at its first `await`.
14
+ * So an update scheduled past an await inside `startTransition(async …)`
15
+ * is an ordinary urgent update:
16
+ *
17
+ * startTransition(() => render(x)) -> 'HOME' held
18
+ * startTransition(async () => { await p; render(x) }) -> fallback shown
19
+ *
20
+ * Left uncorrected, every navigation whose new tree suspends replaces the
21
+ * visible page with a Suspense fallback — the exact thing NavigationRoot
22
+ * exists to prevent (TIM-1306). React documents the caveat under
23
+ * `startTransition`: updates after an await need their own transition.
24
+ *
25
+ * 2. Re-wrapping *inside* the async callback is not enough. Returning a
26
+ * thenable from `startTransition` hands it to `ReactSharedInternals.S`,
27
+ * which calls react-dom's `entangleAsyncAction`. That opens an action
28
+ * scope: `currentEntangledLane` collects EVERY transition update
29
+ * scheduled while the scope is open — including one from a nested,
30
+ * fully synchronous `startTransition` — and rendering that lane throws
31
+ * `currentEntangledActionThenable`, suspending until the action settles.
32
+ * Which is after the promise `navigateTransition` hands back, so a caller
33
+ * that awaits a navigation and then reads the DOM sees the departing
34
+ * page, and the TIM-1301 publish (a layout effect on the commit) is just
35
+ * as late. (Not `ReactSharedInternals.asyncTransitions` — that counter is
36
+ * write-only in 19.2.7.)
37
+ *
38
+ * No async action is created here, so every navigation commits as soon as
39
+ * React can render its destination — where TIM-1301 put it. That holds for
40
+ * ALL of them. It used not to hold for `<Link>`, which wrapped
41
+ * `router.navigate()` in its own `useTransition`: that action scope entangled
42
+ * the root's updates just the same, so a Link navigation committed only once
43
+ * the payload had finished decoding, and with it `pushState`, the segment
44
+ * cache and `timber:navigation-end`. Link now calls `router.navigate()`
45
+ * outside any transition and tracks its own `isPending` with a plain
46
+ * `useState` (TIM-1307).
47
+ *
48
+ * Nothing here may reopen an action scope: no `startTransition` callback in
49
+ * this path may return a thenable, and no caller may invoke this from inside
50
+ * a React action scope. That is a rule about `startTransition`, not about
51
+ * `perform` — `perform` is async by contract and is awaited OUTSIDE any
52
+ * transition scope, which is exactly why it is safe.
53
+ *
54
+ * Standalone `startTransition` rather than `useTransition`'s is deliberate:
55
+ * the hook's is tied to a single fiber and tracks one async callback at a
56
+ * time, and two overlapping navigations through it corrupted React's hook
57
+ * tracking ("Rendered more hooks than during the previous render"). Each
58
+ * navigation needs an independent lane. This matches the Next.js pattern
59
+ * (TIM-625): "No useTransition in the router at all — only standalone
60
+ * startTransition."
61
+ *
62
+ * See design/19-client-navigation.md §"How Pending State Works"
63
+ */
64
+
65
+ import type { ReactNode } from 'react';
66
+ import type { NavigationRender } from './navigation-root.tsx';
67
+
68
+ // ─── Transition Result ──────────────────────────────────────────
69
+
70
+ /**
71
+ * What a navigation's `perform()` hands back to the transition.
72
+ *
73
+ * Declared once and shared by every layer that passes it along — the router's
74
+ * `RouterDeps.navigateTransition` imports it too — so a field cannot be added
75
+ * to the producer and dropped by the adapter in between. `E` is the element
76
+ * type: `ReactNode` here, `unknown` in the router, which never touches it.
77
+ */
78
+ export interface TransitionResult<E = ReactNode> {
79
+ /** The wrapped tree to render. */
80
+ element: E;
81
+ /** Resolves when the Flight stream finishes decoding, or null. */
82
+ decodePromise: Promise<void> | null;
83
+ /**
84
+ * Publishes the navigation's state — segment cache, pathname, address bar,
85
+ * history stack, and the client's record of the mounted tree — and
86
+ * announces it to listeners outside React.
87
+ *
88
+ * Run when React commits this tree, not when the tree is handed over: a
89
+ * tree React is still waiting on, or one a later navigation replaces first,
90
+ * has been given to React without being on screen. A superseded navigation
91
+ * never runs it and leaves every one of those consumers describing the
92
+ * route still on screen (TIM-1301).
93
+ */
94
+ commit: () => void;
95
+ }
96
+
97
+ // ─── Navigation Transition Counter ──────────────────────────────
98
+ // Monotonically increasing counter that increments each time
99
+ // navigateTransition() is called. Used to detect stale transitions:
100
+ // if a newer transition started while the current one's perform()
101
+ // was in flight, the current transition is stale and should reject.
102
+ //
103
+ // Separate from the link-pending navId (which only increments on
104
+ // link clicks). This counter covers all navigation types: link clicks,
105
+ // programmatic navigate(), refresh(), and handlePopState().
106
+ //
107
+ // Uses globalThis for singleton guarantee across chunks — same pattern
108
+ // as NavigationContext and the link pending store.
109
+
110
+ const NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');
111
+
112
+ /**
113
+ * `waiters` are woken on every bump so an in-flight navigation can stop
114
+ * waiting on its own payload the moment it is superseded — see
115
+ * `settleOnDecodeOrSupersession`.
116
+ */
117
+ interface TransitionCounter {
118
+ id: number;
119
+ waiters: Set<() => void>;
120
+ }
121
+
122
+ function getTransitionCounter(): TransitionCounter {
123
+ const g = globalThis as Record<symbol, unknown>;
124
+ const existing = g[NAV_TRANSITION_KEY] as Partial<TransitionCounter> | undefined;
125
+ if (!existing) {
126
+ const created: TransitionCounter = { id: 0, waiters: new Set() };
127
+ g[NAV_TRANSITION_KEY] = created;
128
+ return created;
129
+ }
130
+ // A duplicated copy of this module may have created the singleton before
131
+ // `waiters` existed. The object is shared across chunks, so fill it in
132
+ // rather than replacing it — replacing would strand the other copy's id.
133
+ existing.waiters ??= new Set();
134
+ return existing as TransitionCounter;
135
+ }
136
+
137
+ /** Bump the counter and wake everything waiting on an older transition. */
138
+ function bumpTransitionCounter(): number {
139
+ const counter = getTransitionCounter();
140
+ counter.id += 1;
141
+ for (const wake of [...counter.waiters]) wake();
142
+ return counter.id;
143
+ }
144
+
145
+ /**
146
+ * Invalidate all in-flight navigation transitions. Any navigateTransition()
147
+ * call whose perform() has not yet committed will reject with AbortError
148
+ * instead of committing its element.
149
+ *
150
+ * Called by the router when a render supersedes in-flight navigations
151
+ * WITHOUT going through navigateTransition() — the cached popstate replay
152
+ * renders directly, which doesn't bump the counter, so a stale forward
153
+ * navigation's render would otherwise pass the `counter.id !== transId`
154
+ * guard and commit the forward page over the replayed back page (TIM-1022).
155
+ */
156
+ export function supersedeNavigationTransitions(): void {
157
+ bumpTransitionCounter();
158
+ }
159
+
160
+ /**
161
+ * Wait for the payload to finish decoding, OR for this transition to be
162
+ * superseded — whichever happens first.
163
+ *
164
+ * A superseded navigation must stop waiting on its own stream. The stream is
165
+ * deliberately NOT aborted once its tree has been handed to React (the tree
166
+ * may be on screen with boundaries still feeding from it — see
167
+ * `handedOffNavAbort` in `client/router-lifecycle.ts`), so there is nothing
168
+ * left to make `decodePromise` settle promptly. Awaiting it bare would keep
169
+ * the loser's `router.navigate()` promise pending for the rest of the stream
170
+ * — and forever if it stalls — which is what `<Link>`'s `isPending` is timed
171
+ * against, so the losing link would sit spinning while the winner loaded
172
+ * (codex on #1004).
173
+ *
174
+ * A decode *failure* still propagates: it is a real error for this
175
+ * navigation, and the caller's recovery is timed against it.
176
+ */
177
+ function settleOnDecodeOrSupersession(
178
+ decodePromise: Promise<void>,
179
+ counter: TransitionCounter,
180
+ transId: number
181
+ ): Promise<void> {
182
+ if (counter.id !== transId) return Promise.resolve();
183
+ return new Promise<void>((resolve, reject) => {
184
+ const stopWaiting = (): void => {
185
+ counter.waiters.delete(wake);
186
+ };
187
+ const wake = (): void => {
188
+ if (counter.id !== transId) {
189
+ stopWaiting();
190
+ resolve();
191
+ }
192
+ };
193
+ counter.waiters.add(wake);
194
+ decodePromise.then(
195
+ () => {
196
+ stopWaiting();
197
+ resolve();
198
+ },
199
+ (error: unknown) => {
200
+ stopWaiting();
201
+ reject(error instanceof Error ? error : new Error(String(error)));
202
+ }
203
+ );
204
+ });
205
+ }
206
+
207
+ // ─── navigateTransition ─────────────────────────────────────────
208
+
209
+ /**
210
+ * Run a full navigation, handing its tree to React in a transition.
211
+ *
212
+ * The `perform` callback fetches the RSC payload, updates router state, and
213
+ * returns the wrapped React element. `perform` is async by contract and runs
214
+ * OUTSIDE any transition scope — only the `render` that hands its result over
215
+ * is wrapped, in a synchronous `startTransition` (see the module comment).
216
+ *
217
+ * Do not call this from inside a React action scope, and never let a
218
+ * `startTransition` callback in this path return a thenable: either opens an
219
+ * action scope that entangles the update and defers the commit until the
220
+ * action settles (TIM-1306, TIM-1307).
221
+ *
222
+ * Returns a Promise that resolves when the async work completes — the payload
223
+ * is fetched, decoded, and handed to React. It does **not** wait for React to
224
+ * commit the tree, so the state that publishes on that commit (address bar,
225
+ * segment cache, history entry, pathname) may still be a beat behind when it
226
+ * resolves. Anything that needs the destination to be current should listen
227
+ * for `timber:navigation-end`, which is dispatched by the publish itself.
228
+ *
229
+ * Awaiting the publish here was tried and rejected: it makes the promise
230
+ * depend on a React commit, which never arrives if the root unmounts
231
+ * mid-navigation, and deadlocks any caller that awaits a navigation inside
232
+ * `act()`.
233
+ *
234
+ * Rejects with an `AbortError` when a newer transition started while this
235
+ * one's `perform()` was in flight — the stale tree is never handed to React
236
+ * and its `commit` never runs (TIM-629, TIM-1301).
237
+ *
238
+ * Used for: navigate(), refresh(), popstate with fetch.
239
+ */
240
+ export function navigateTransition(
241
+ perform: () => Promise<TransitionResult>,
242
+ render: NavigationRender
243
+ ): Promise<void> {
244
+ // Increment the transition counter SYNCHRONOUSLY (before any await). Each
245
+ // call gets a unique transId; the counter is the same globalThis
246
+ // singleton, so a newer call always has a higher id.
247
+ const counter = getTransitionCounter();
248
+ const transId = bumpTransitionCounter();
249
+
250
+ const superseded = () => new DOMException('Navigation superseded', 'AbortError');
251
+
252
+ return (async () => {
253
+ const { element, decodePromise, commit } = await perform();
254
+ if (counter.id !== transId) {
255
+ decodePromise?.catch(() => {});
256
+ throw superseded();
257
+ }
258
+ // Hand the tree over with its commit attached. NavigationRoot runs it if
259
+ // and when React commits this tree, so a navigation that is superseded
260
+ // while its payload streams publishes nothing (TIM-1301).
261
+ //
262
+ // This is THE update that must not hide the departing page (TIM-1306);
263
+ // `render` is the synchronous transition around `root.render`.
264
+ render(element, commit);
265
+ // React may commit the tree before this settles — that is the point:
266
+ // the destination reveals as React is able to render it
267
+ // rather than waiting for the whole Flight stream. The await is here so
268
+ // the promise this function hands back still means "the payload is
269
+ // decoded", which is what the router's scroll restoration, the
270
+ // Navigation API deferred and `<Link>`'s `isPending` are timed against.
271
+ //
272
+ // ...unless this navigation loses first, in which case it stops waiting
273
+ // on a stream that is no longer its business. See
274
+ // `settleOnDecodeOrSupersession`.
275
+ if (decodePromise) await settleOnDecodeOrSupersession(decodePromise, counter, transId);
276
+ if (counter.id !== transId) throw superseded();
277
+ })();
278
+ }
@@ -21,6 +21,7 @@ import {
21
21
  } from 'nuqs/adapters/custom';
22
22
  import { getRouter } from './router-ref.ts';
23
23
  import { getNavigationApi } from './navigation-api.ts';
24
+ import { locationSearch } from './location-search.ts';
24
25
 
25
26
  // ─── Adapter Hook ─────────────────────────────────────────────────
26
27
 
@@ -32,15 +33,13 @@ import { getNavigationApi } from './navigation-api.ts';
32
33
  * (used by nuqs for selective re-rendering)
33
34
  */
34
35
  function useTimberAdapter(_watchKeys: string[]): AdapterInterface {
35
- const [searchParams, setSearchParams] = useState(
36
- () => new URLSearchParams(window.location.search)
37
- );
36
+ const [searchParams, setSearchParams] = useState(() => new URLSearchParams(locationSearch()));
38
37
 
39
38
  // Sync search params on popstate (back/forward) and after
40
39
  // timber navigations that change the URL.
41
40
  useEffect(() => {
42
41
  function sync() {
43
- setSearchParams(new URLSearchParams(window.location.search));
42
+ setSearchParams(new URLSearchParams(locationSearch()));
44
43
  }
45
44
 
46
45
  window.addEventListener('popstate', sync);
@@ -97,7 +96,7 @@ function useTimberAdapter(_watchKeys: string[]): AdapterInterface {
97
96
  return {
98
97
  searchParams,
99
98
  updateUrl,
100
- getSearchParamsSnapshot: () => new URLSearchParams(window.location.search),
99
+ getSearchParamsSnapshot: () => new URLSearchParams(locationSearch()),
101
100
  };
102
101
  }
103
102
 
@@ -31,7 +31,6 @@
31
31
  'use client';
32
32
 
33
33
  import React, { createElement, useMemo, use } from 'react';
34
- import { _setCurrentParams, _setCurrentSlotParams } from './state.ts';
35
34
  import { toNullProtoRecord, type CoercedParams } from '../shared/param-value.ts';
36
35
  import { readPublishedParams, type PublishedParams } from '../shared/payload-root.ts';
37
36
  import type { SlotParamsRecord } from '../shared/slot-params.ts';
@@ -53,7 +52,9 @@ export type ParamsContextValue = PublishedParams;
53
52
  * `useParamsContext()` arrives through the client-reference graph with the
54
53
  * app's own components. A duplicate would put the provider on instance A and
55
54
  * every reader on instance B, so `useContext` returns `null` and every
56
- * `useSegmentParams()` call silently falls back to the module snapshot.
55
+ * `useSegmentParams()` call throws the outside-the-timber-app-tree error
56
+ * even though the provider is mounted (the module-snapshot fallback it once
57
+ * silently landed on was deleted in TIM-1425).
57
58
  *
58
59
  * This module was the one client context without the guard — harmless while
59
60
  * the provider travelled inside the payload, in the same graph as its readers,
@@ -90,11 +91,10 @@ function getOrCreateContext(): React.Context<ParamsContextValue | null> {
90
91
  const ParamsContext = getOrCreateContext();
91
92
 
92
93
  /**
93
- * Read the params provided by the tree. Returns null when no provider is
94
- * above the caller — a component rendered outside a timber route, a
95
- * `useSegmentParams()` call from outside React entirely, or any component
96
- * during SSR (where the params reach the hook through the ALS-backed SSR data
97
- * context instead, and there is no client-owned tree to hold a provider).
94
+ * Read the params provided by the tree. Returns null only when no provider
95
+ * is above the caller — a component rendered outside a timber route. During
96
+ * SSR the wrapper chain mounts `PayloadRoot` too (TIM-1424), so both sides
97
+ * resolve through this context.
98
98
  */
99
99
  export function useParamsContext(): ParamsContextValue | null {
100
100
  return React.useContext(ParamsContext);
@@ -115,13 +115,12 @@ interface ParamsProviderProps {
115
115
  * the tree would shadow this one for the region below it, which is precisely
116
116
  * the defect TIM-1297 fixed.
117
117
  *
118
- * The module-level snapshot in `state.ts` is written during render rather
119
- * than in an effect. It is the fallback path for `useSegmentParams()` called
120
- * outside a component (tests, module scope), and an effect would leave that
121
- * path reading the *previous* route's params for the whole commit — the
122
- * window in which a navigation's components actually run. Writing during
123
- * render is safe here because the value is derived entirely from props: a
124
- * double-invoked render in StrictMode writes the same record twice.
118
+ * This used to also write a module-level snapshot during render, as the
119
+ * fallback for `useSegmentParams()` called outside a component. That tier is
120
+ * gone (TIM-1425) — the provider is unconditional on every render path,
121
+ * browser and SSR alike, so the hook reads context or throws. Removing the
122
+ * write also removes render-phase shared mutation from the SSR environment,
123
+ * where concurrent requests rendered through this component.
125
124
  */
126
125
  function ParamsProvider({ params, slotParams, children }: ParamsProviderProps) {
127
126
  // Restore the null prototype the wire could not carry. Flight rejects a
@@ -139,10 +138,6 @@ function ParamsProvider({ params, slotParams, children }: ParamsProviderProps) {
139
138
  [params, slotParams]
140
139
  );
141
140
 
142
- // Keep the out-of-component fallback in step with the tree being rendered.
143
- _setCurrentParams(value.params);
144
- _setCurrentSlotParams(value.slotParams);
145
-
146
141
  return createElement(ParamsContext.Provider, { value }, children);
147
142
  }
148
143
 
@@ -0,0 +1,72 @@
1
+ /**
2
+ * React root host — the one React root the router renders the page through.
3
+ *
4
+ * The router owns the displayed tree and drives React: every render is
5
+ * `startTransition(() => root.render(<NavigationRoot rendered={…} />))`,
6
+ * synchronous, with a fresh `rendered` object whose `publish` NavigationRoot
7
+ * runs when React commits that tree (TIM-1301, TIM-1431).
8
+ *
9
+ * The root itself is created lazily. On the RSC-payload path `hydrate()`
10
+ * creates it by hydrating the server HTML. On the JS-only path (no inlined
11
+ * payload, TIM-600) nothing may create it at bootstrap — `createRoot(document)`
12
+ * followed by a render would take React ownership of the document and blank
13
+ * the SSR HTML — so the first `render()` (the first navigation, or a
14
+ * revalidation after a server action) creates it with the tree it renders.
15
+ * That first render carries no placeholder, and a fresh root with nothing
16
+ * committed leaves the container alone until it can commit, so the SSR HTML
17
+ * stays until the destination is ready (TIM-580).
18
+ *
19
+ * `container` is `document` in production; tests pass an element.
20
+ */
21
+
22
+ import { createElement, startTransition, type ReactNode } from 'react';
23
+ import { createRoot, hydrateRoot, type HydrationOptions, type Root } from 'react-dom/client';
24
+ import { NavigationRoot, type NavigationRender, type RenderedTree } from './navigation-root.tsx';
25
+ import type { TopLoaderConfig } from './top-loader.tsx';
26
+
27
+ export interface ReactRootHost {
28
+ /**
29
+ * Hydrate the server HTML with `element` as the initial tree. Nothing is
30
+ * published on that commit — the address bar, pathname and history already
31
+ * describe the page the server sent.
32
+ */
33
+ hydrate(element: ReactNode, options?: HydrationOptions): void;
34
+ /**
35
+ * Hand a tree to React in a synchronous transition; `publish` runs when
36
+ * React commits it. Creates the root on first use.
37
+ */
38
+ render: NavigationRender;
39
+ /** Tear the root down (tests). */
40
+ unmount(): void;
41
+ }
42
+
43
+ export function createReactRoot(options: {
44
+ container: Document | Element;
45
+ topLoaderConfig?: TopLoaderConfig;
46
+ }): ReactRootHost {
47
+ const { container, topLoaderConfig } = options;
48
+ let root: Root | null = null;
49
+
50
+ const rootElement = (rendered: RenderedTree) =>
51
+ createElement(NavigationRoot, { rendered, topLoaderConfig });
52
+
53
+ return {
54
+ hydrate(element, hydrationOptions) {
55
+ root = hydrateRoot(container, rootElement({ element, publish: null }), hydrationOptions);
56
+ },
57
+ render(element, publish) {
58
+ const target = (root ??= createRoot(container));
59
+ // Synchronous, and returns nothing: an async callback would drop the
60
+ // transition scope at its first await, and a returned thenable would
61
+ // open an action scope that holds the commit until it settles
62
+ // (TIM-1306, TIM-1307 — see client/navigation-transition.ts).
63
+ startTransition(() => {
64
+ target.render(rootElement({ element, publish }));
65
+ });
66
+ },
67
+ unmount() {
68
+ root?.unmount();
69
+ root = null;
70
+ },
71
+ };
72
+ }
@@ -16,7 +16,7 @@
16
16
  * See design/19-client-navigation.md §"How Pending State Works".
17
17
  */
18
18
 
19
- import { supersedeNavigationTransitions } from './navigation-root.tsx';
19
+ import { supersedeNavigationTransitions } from './navigation-transition.ts';
20
20
  import type { RouterPhase } from './router-types.ts';
21
21
 
22
22
  /** The subset of `RouterDeps` the navigation lifecycle needs. */
@@ -16,13 +16,31 @@
16
16
  import { fetchRscPayload, NonRscResponse } from './rsc-fetch.ts';
17
17
  import type { FetchResult } from './rsc-fetch.ts';
18
18
  import { readPublishedParams, type ParamsSource } from '../shared/payload-root.ts';
19
- import type { PrefetchCache, PrefetchKey } from './segment-cache.ts';
19
+ import type { PrefetchCache, PrefetchKey, FlightOutcome } from './segment-cache.ts';
20
20
  import { prefetchScopeOf } from './segment-cache.ts';
21
21
  import type { StateTree } from '../shared/segment-info.ts';
22
22
  import type { NavigationState } from './navigation-context.ts';
23
- import type { NavigationCommitter } from './navigation-commit.ts';
23
+ import { isPartialNavigation, type NavigationCommitter } from './navigation-commit.ts';
24
24
  import type { RouterDeps } from './router-types.ts';
25
25
  import { recordSkew } from './router-skew.ts';
26
+ import { SingleflightTimeoutError } from '../cache/singleflight.ts';
27
+
28
+ /**
29
+ * Race a promise against an abort signal. The promise continues regardless —
30
+ * only the *await* gives up. Used so a click superseded by a newer navigation
31
+ * stops waiting for a shared prefetch flight without aborting the flight
32
+ * itself (TIM-1438).
33
+ */
34
+ function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
35
+ if (!signal) return promise;
36
+ if (signal.aborted) return Promise.reject(signal.reason);
37
+ return Promise.race([
38
+ promise,
39
+ new Promise<never>((_, reject) => {
40
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true });
41
+ }),
42
+ ]);
43
+ }
26
44
 
27
45
  /** A fetched payload plus the state update that makes it the current page. */
28
46
  export type NavigationPayload = FetchResult & {
@@ -64,7 +82,12 @@ export interface NavigationPipeline {
64
82
  perform: () => Promise<NavigationPayload>
65
83
  ) => Promise<void>;
66
84
  /** Render a decoded payload into the DOM if a renderer is available. */
67
- renderPayload: (payload: unknown, navState: NavigationState, params: ParamsSource) => void;
85
+ renderPayload: (
86
+ payload: unknown,
87
+ navState: NavigationState,
88
+ params: ParamsSource,
89
+ commit: () => void
90
+ ) => void;
68
91
  /** Resolve thenable payloads on the fallback (test) path. */
69
92
  resolveForFallback: (payload: unknown) => Promise<unknown>;
70
93
  }
@@ -109,10 +132,6 @@ export function prefetchKeyFor(
109
132
  return { url, from: departingPathname(departingUrl), scope: prefetchScopeOf(stateTree) };
110
133
  }
111
134
 
112
- function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
113
- return skippedSegments != null && skippedSegments.length > 0;
114
- }
115
-
116
135
  /**
117
136
  * Build a segment updates map for partial navigation. Identifies the
118
137
  * first non-skipped segment and maps it to the payload content.
@@ -155,10 +174,21 @@ export function createNavigationPipeline({
155
174
  markHandedOff,
156
175
  forgetOlderHandoffs,
157
176
  }: NavigationPipelineDeps): NavigationPipeline {
158
- /** Render a decoded RSC payload into the DOM if a renderer is available. */
159
- function renderPayload(payload: unknown, navState: NavigationState, params: ParamsSource): void {
177
+ /**
178
+ * Render a decoded RSC payload into the DOM, publishing `commit` when the
179
+ * tree is current. The renderer owns that moment (see `RootRenderer`);
180
+ * with no renderer there is no tree to wait for, so it publishes now.
181
+ */
182
+ function renderPayload(
183
+ payload: unknown,
184
+ navState: NavigationState,
185
+ params: ParamsSource,
186
+ commit: () => void
187
+ ): void {
160
188
  if (deps.renderRoot) {
161
- deps.renderRoot(payload, navState, params);
189
+ deps.renderRoot(payload, navState, params, commit);
190
+ } else {
191
+ commit();
162
192
  }
163
193
  }
164
194
 
@@ -260,9 +290,11 @@ export function createNavigationPipeline({
260
290
  // Fallback: no transition (tests, no React tree)
261
291
  const result = await perform();
262
292
  handOff();
263
- commitAndForget(result.commit)();
264
- if (!isPartialNavigation(result.skippedSegments)) {
265
- renderPayload(result.payload, result.navState, await result.params);
293
+ const commit = commitAndForget(result.commit);
294
+ if (isPartialNavigation(result.skippedSegments)) {
295
+ commit();
296
+ } else {
297
+ renderPayload(result.payload, result.navState, await result.params, commit);
266
298
  }
267
299
  }
268
300
 
@@ -320,9 +352,52 @@ export function createNavigationPipeline({
320
352
  }
321
353
  : undefined;
322
354
 
355
+ // If a hover prefetch is in-flight, join it instead of issuing a
356
+ // duplicate (TIM-1438). The click races its await against its own signal
357
+ // so a superseded navigation gives up immediately — the shared flight
358
+ // continues for other consumers. On flight failure (network error,
359
+ // singleflight timeout), fall through to a fresh fetch — the failed
360
+ // hover should not block a click that might succeed.
361
+ if (result === undefined) {
362
+ const inflight = prefetchCache.joinInflight(cacheKey);
363
+ if (inflight) {
364
+ try {
365
+ const outcome = await raceAbort(inflight, options.signal);
366
+ if (outcome.kind === 'non-route') {
367
+ throw new NonRscResponse(url);
368
+ }
369
+ // Consume the ready entry so a second click re-fetches
370
+ prefetchCache.consume(cacheKey);
371
+ result = {
372
+ payload: outcome.result.payload,
373
+ params: outcome.result.params ?? readPublishedParams(undefined),
374
+ decodePromise: outcome.result.decodePromise ?? null,
375
+ segmentInfo: outcome.result.segmentInfo ?? null,
376
+ skippedSegments: outcome.result.skippedSegments ?? null,
377
+ status: outcome.result.status ?? 200,
378
+ };
379
+ } catch (error) {
380
+ // Supersession aborts propagate — don't retry a cancelled navigation
381
+ if (error instanceof DOMException && error.name === 'AbortError') throw error;
382
+ if (options.signal?.aborted) throw options.signal.reason;
383
+ // Retriable failures fall through to a fresh fetch: singleflight
384
+ // timeout (hover hung) and transport TypeError (transient network
385
+ // error). Framework control-flow errors — NonRscResponse,
386
+ // VersionSkewError, RedirectError, ServerErrorResponse — are
387
+ // definitive server answers and must reach navigate's recovery.
388
+ if (error instanceof SingleflightTimeoutError || error instanceof TypeError) {
389
+ // fall through to fresh fetch below
390
+ } else {
391
+ throw error;
392
+ }
393
+ }
394
+ }
395
+ }
396
+
323
397
  if (result === undefined) {
324
- // Fetch RSC payload with state tree for partial rendering.
325
- // Send departing URL (pre-navigation) for slot skip comparison.
398
+ // No in-flight hover prefetch, or the joined flight failed — fetch
399
+ // directly with the navigation's signal so superseded navigations
400
+ // abort immediately.
326
401
  result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
327
402
  }
328
403
 
@@ -357,17 +432,16 @@ export function createNavigationPipeline({
357
432
  const params = await result.params;
358
433
 
359
434
  // Prepare the atomic navigation-state update. It is published by the
360
- // caller once this navigation is known to have won (TIM-1301).
361
- // Partial navigations and slot-skip navigations store null payload —
362
- // the RSC tree contains skip holes that can't be replayed standalone;
363
- // popstate will fetch fresh.
364
- const isPartial = isPartialNavigation(result.skippedSegments);
365
- const hasSkippedSlots = result.segmentInfo?.some((s) => s.slot && s.skipped) ?? false;
435
+ // caller once this navigation is known to have won (TIM-1301). Whether
436
+ // the payload is stored for replay — not when it has skip holes — is
437
+ // `prepareNavigation`'s decision, made from `skippedSegments` and
438
+ // `segmentInfo` (TIM-1432).
366
439
  const { navState, commit } = prepareNavigation(url, {
367
- payload: isPartial || hasSkippedSlots ? null : payload,
440
+ payload,
368
441
  params,
369
442
  segmentInfo: result.segmentInfo,
370
443
  status: result.status,
444
+ skippedSegments: result.skippedSegments,
371
445
  });
372
446
 
373
447
  return {
@@ -14,7 +14,7 @@ import type { ParamsSource } from '../shared/payload-root.ts';
14
14
  import type { SegmentInfo } from '../shared/segment-info.ts';
15
15
  import type { SegmentCache, PrefetchCache } from './segment-cache.ts';
16
16
  import type { HistoryStack } from './history.ts';
17
- import type { TransitionResult } from './navigation-root.tsx';
17
+ import type { TransitionResult } from './navigation-transition.ts';
18
18
 
19
19
  export interface NavigationOptions {
20
20
  /** Set to false to prevent scroll-to-top on forward navigation */
@@ -61,7 +61,17 @@ export type RscDecoder = (fetchPromise: Promise<Response>) => unknown;
61
61
  export type RootRenderer = (
62
62
  element: unknown,
63
63
  navState: NavigationState,
64
- params: ParamsSource
64
+ params: ParamsSource,
65
+ /**
66
+ * Publishes the state that makes this tree the current page (segment
67
+ * cache, pathname, history stack, address bar). The renderer runs it when
68
+ * the tree is on screen — in production from React's commit of the tree
69
+ * (`NavigationRoot`'s layout effect), a test double synchronously. It is not run before
70
+ * handing the tree over: a replay or revalidation whose tree suspends is
71
+ * given to React without being on screen, and publishing then advertises
72
+ * slot content keys the slot content cache has not recorded (TIM-1423).
73
+ */
74
+ commit: () => void
65
75
  ) => void;
66
76
 
67
77
  /**