@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
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Hydration — pre-hydration sequence and React root creation.
2
+ * Hydration — pre-hydration sequence and hydrating the React root.
3
3
  *
4
4
  * Handles two paths:
5
- * 1. RSC payload available: hydrateRoot with NavigationProvider wrapping
6
- * 2. No RSC payload: deferred root creation via installDeferredNavigation
5
+ * 1. RSC payload available: hydrate the router's React root with the payload
6
+ * 2. No RSC payload: nothing — the root is created by the first render
7
+ * (client/react-root.ts)
7
8
  *
8
9
  * Pre-hydration ordering contract (MUST execute in this order):
9
10
  * 1. initRouter() — creates the global router so useRouter()
@@ -21,22 +22,14 @@
21
22
  * See design/19-client-navigation.md §"NavigationContext"
22
23
  */
23
24
 
24
- import { createElement } from 'react';
25
- import { hydrateRoot, createRoot } from 'react-dom/client';
26
25
  import { getRouterOrNull } from '#client-internal';
27
- import { PayloadRoot } from '../params-context.ts';
28
- import {
29
- NavigationProvider,
30
- getNavigationState,
31
- setNavigationState,
32
- } from '../navigation-context.ts';
33
- import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.ts';
34
- import { TimberNuqsAdapter } from '../nuqs-adapter.tsx';
26
+ import { getNavigationState, setNavigationState } from '../navigation-context.ts';
35
27
  import { isPageUnloading } from '../unload-guard.ts';
36
- import { NavigationRoot, installDeferredNavigation } from '../navigation-root.tsx';
37
- import type { TopLoaderConfig } from '../top-loader.tsx';
28
+ import { locationSearch } from '../location-search.ts';
29
+ import type { ReactRootHost } from '../react-root.ts';
38
30
 
39
31
  import type { RscStreamResult } from './rsc-stream.ts';
32
+ import type { RouterInitResult } from './router-init.ts';
40
33
 
41
34
  let showHydrationError: ((error: unknown) => void) | null = null;
42
35
  let isHydrationError: ((error: unknown) => boolean) | null = null;
@@ -79,8 +72,10 @@ function queueOrShowHydrationError(error: unknown): void {
79
72
  interface HydrateOptions {
80
73
  /** RSC stream result (null if no inlined payload) */
81
74
  rscResult: RscStreamResult | null;
82
- /** Runtime config from virtual:timber-config */
83
- config: { topLoader?: TopLoaderConfig };
75
+ /** The router's React root — the one every navigation renders through. */
76
+ reactRoot: ReactRootHost;
77
+ /** The router's wrapper chain — see `RouterInitResult.renderTree`. */
78
+ renderTree: RouterInitResult['renderTree'];
84
79
  }
85
80
 
86
81
  /**
@@ -97,102 +92,57 @@ interface HydrateOptions {
97
92
  export function runPreHydration(): void {
98
93
  setNavigationState({
99
94
  pathname: window.location.pathname,
100
- search: window.location.search,
95
+ search: locationSearch(),
101
96
  });
102
97
  }
103
98
 
104
99
  /**
105
- * Hydrate the React tree or set up deferred root creation.
100
+ * Hydrate the React tree when an RSC payload is available.
106
101
  *
107
- * When an RSC payload is available, wraps it with NavigationProvider +
108
- * TimberNuqsAdapter + NavigationRoot and calls hydrateRoot on the
109
- * document.
102
+ * Wraps the payload with the router's chain (NavigationProvider,
103
+ * TimberNuqsAdapter, the slot content cache, ...) and hydrates the router's
104
+ * React root with it, on the document.
110
105
  *
111
- * When no RSC payload is available (JS-only client), sets up deferred
112
- * navigation so the first client navigation creates the React root.
106
+ * When no RSC payload is available (JS-only client) there is nothing to
107
+ * hydrate and nothing to do: the root must not be created at bootstrap —
108
+ * `createRoot(document).render()` would take React ownership of the entire
109
+ * document and blank the SSR HTML — so the router's first render, i.e. the
110
+ * first navigation or revalidation, creates it with the tree it renders
111
+ * (`createReactRoot`, TIM-600 / TIM-580).
113
112
  */
114
- export function hydrateApp({ rscResult, config }: HydrateOptions): void {
115
- if (rscResult) {
116
- const element = rscResult.element;
113
+ export function hydrateApp({ rscResult, reactRoot, renderTree }: HydrateOptions): void {
114
+ if (!rscResult) return;
117
115
 
118
- // Wrap with NavigationProvider (for atomic useParams/usePathname),
119
- // TimberNuqsAdapter (for nuqs context), and NavigationRoot (for
120
- // transition-based rendering during client navigation).
121
- //
122
- // NavigationRoot holds the element in React state and updates via
123
- // startTransition, so React keeps old UI visible while new Suspense
124
- // boundaries resolve during navigation. See design/05-streaming.md.
125
- const navState = getNavigationState();
126
- // The same chain `renderTree()` in router-init.ts builds on every
127
- // navigation, in the same order. It has to be: an element type that is
128
- // present here and absent on the first navigation (or the reverse)
129
- // changes the type at that position, and React remounts everything below
130
- // it — losing the layout state partial navigation exists to preserve.
131
- //
132
- // `source` is the payload's params promise, not a resolved record: the
133
- // inlined Flight stream is still arriving and hydration cannot wait for
134
- // it. PayloadRoot suspends on it exactly where React was going to suspend
135
- // on `element` (TIM-1297).
136
- const withParams = createElement(
137
- PayloadRoot,
138
- { source: rscResult.params },
139
- element as React.ReactNode
140
- );
141
- const withNav = createElement(NavigationProvider, { value: navState }, withParams);
142
- const withUpdates = createElement(
143
- SegmentUpdateContext.Provider,
144
- { value: EMPTY_SEGMENT_UPDATES },
145
- withNav
146
- );
147
- const wrapped = createElement(TimberNuqsAdapter, null, withUpdates);
148
- const rootElement = createElement(NavigationRoot, {
149
- initial: wrapped,
150
- topLoaderConfig: config.topLoader,
151
- });
116
+ // The chain is the router's own `renderTree`, not a copy: an element type
117
+ // that is present here and absent on the first navigation (or the
118
+ // reverse) changes the type at that position, and React remounts
119
+ // everything below it — losing the layout state partial navigation exists
120
+ // to preserve. Sharing the function makes that impossible by construction.
121
+ //
122
+ // `rscResult.params` is the payload's params promise, not a resolved
123
+ // record: the inlined Flight stream is still arriving and hydration cannot
124
+ // wait for it. PayloadRoot suspends on it exactly where React was going to
125
+ // suspend on `element` (TIM-1297).
126
+ const wrapped = renderTree(rscResult.element, getNavigationState(), rscResult.params);
152
127
 
153
- if (process.env.NODE_ENV !== 'production') {
154
- if (!getRouterOrNull()) {
155
- throw new Error(
156
- '[timber] hydrateRoot called before initRouter() — bootstrap order violated'
157
- );
158
- }
128
+ if (process.env.NODE_ENV !== 'production') {
129
+ if (!getRouterOrNull()) {
130
+ throw new Error('[timber] hydrateRoot called before initRouter() — bootstrap order violated');
159
131
  }
160
-
161
- hydrateRoot(document, rootElement, {
162
- // Suppress recoverable hydration errors from deny/error signals
163
- // inside Suspense boundaries. The server already handled these
164
- // (wrapStreamWithErrorHandling closes the stream cleanly after
165
- // the shell is flushed). React replays the error during hydration
166
- // but the server HTML is already correct — no recovery needed.
167
- onRecoverableError(error: unknown) {
168
- if (isPageUnloading()) return;
169
- if (process.env.NODE_ENV === 'development') {
170
- queueOrShowHydrationError(error);
171
- console.debug('[timber] Hydration recoverable error:', error);
172
- }
173
- },
174
- });
175
- } else {
176
- // No RSC payload available — defer React root creation until the
177
- // first client navigation (TIM-600).
178
- //
179
- // We must NOT call createRoot(document).render() here — that would
180
- // take React ownership of the entire document and blank the SSR HTML.
181
- // Instead, installDeferredNavigation sets up one-shot callbacks so
182
- // the first navigateTransition/transitionRender call creates the root
183
- // on `document` with the navigated content. After that initial render,
184
- // NavigationRoot's real startTransition-based callbacks take over.
185
- //
186
- // This also fixes TIM-580 (navigation from SSR-only pages) because
187
- // the deferred callbacks ensure NavigationRoot is mounted before the
188
- // first navigation completes.
189
- installDeferredNavigation((initial) => {
190
- const rootElement = createElement(NavigationRoot, {
191
- initial,
192
- topLoaderConfig: config.topLoader,
193
- });
194
- const root = createRoot(document);
195
- root.render(rootElement);
196
- });
197
132
  }
133
+
134
+ reactRoot.hydrate(wrapped, {
135
+ // Suppress recoverable hydration errors from deny/error signals
136
+ // inside Suspense boundaries. The server already handled these
137
+ // (wrapStreamWithErrorHandling closes the stream cleanly after
138
+ // the shell is flushed). React replays the error during hydration
139
+ // but the server HTML is already correct — no recovery needed.
140
+ onRecoverableError(error: unknown) {
141
+ if (isPageUnloading()) return;
142
+ if (process.env.NODE_ENV === 'development') {
143
+ queueOrShowHydrationError(error);
144
+ console.debug('[timber] Hydration recoverable error:', error);
145
+ }
146
+ },
147
+ });
198
148
  }
@@ -7,7 +7,7 @@
7
7
  * action-dispatch.ts — server action callServer callback
8
8
  * rsc-stream.ts — __timber_f chunk handling + ReadableStream
9
9
  * router-init.ts — createRouter + Navigation API setup
10
- * hydrate.ts — pre-hydration sequence + hydrateRoot/createRoot
10
+ * hydrate.ts — pre-hydration sequence + hydrating the root
11
11
  * post-hydration.ts — history stack, segment cache, popstate, scroll
12
12
  * hmr.ts — dev-only HMR + error forwarding
13
13
  * scroll.ts — getScrollY helper
@@ -16,9 +16,10 @@
16
16
  *
17
17
  * 1. setupServerActions() — register callServer (independent)
18
18
  * 2. createRscPayloadStream() — decode inlined RSC payload
19
- * 3. createTimberRouter() — create router + Navigation API
19
+ * 3. createReactRoot() + — the root host the router renders through,
20
+ * createTimberRouter() then the router + Navigation API
20
21
  * 4. runPreHydration() — set params + navigation state
21
- * 5. hydrateApp() — hydrateRoot or deferred createRoot
22
+ * 5. hydrateApp() — hydrate the root (no-op without a payload)
22
23
  * 6. setupPostHydration() — history stack, popstate, scroll
23
24
  * 7. setupHmr() — dev-only HMR wiring
24
25
  * 8. timber-ready signal — E2E test readiness indicator
@@ -41,6 +42,8 @@ import { initStaleClient } from '../stale-client.ts';
41
42
  import { setupServerActions } from './action-dispatch.ts';
42
43
  import { createRscPayloadStream } from './rsc-stream.ts';
43
44
  import { createTimberRouter } from './router-init.ts';
45
+ import { createReactRoot } from '../react-root.ts';
46
+ import type { TopLoaderConfig } from '../top-loader.tsx';
44
47
  import { readPublishedParams } from '../../shared/payload-root.ts';
45
48
  import { runPreHydration, hydrateApp } from './hydrate.ts';
46
49
  import { setupPostHydration } from './post-hydration.ts';
@@ -79,8 +82,15 @@ function bootstrap(runtimeConfig: typeof config): void {
79
82
  // Step 2: Decode inlined RSC payload (may be null for JS-only clients)
80
83
  const rscResult = createRscPayloadStream();
81
84
 
82
- // Step 3: Create router + Navigation API integration
83
- const { router, navApiController } = createTimberRouter({
85
+ // Step 3: Create the React root host and the router that renders through
86
+ // it. The root itself is created lazily — by hydration below, or by the
87
+ // first navigation when there is no payload to hydrate (TIM-600).
88
+ const reactRoot = createReactRoot({
89
+ container: document,
90
+ topLoaderConfig: (runtimeConfig as { topLoader?: TopLoaderConfig }).topLoader,
91
+ });
92
+ const { router, navApiController, renderTree } = createTimberRouter({
93
+ render: reactRoot.render,
84
94
  initialElement: rscResult?.element ?? undefined,
85
95
  initialParams: rscResult?.params,
86
96
  clientSegmentCache:
@@ -91,7 +101,7 @@ function bootstrap(runtimeConfig: typeof config): void {
91
101
  runPreHydration();
92
102
 
93
103
  // Step 5: Hydrate or set up deferred root creation
94
- hydrateApp({ rscResult, config: runtimeConfig });
104
+ hydrateApp({ rscResult, reactRoot, renderTree });
95
105
 
96
106
  // Step 6: Post-hydration wiring
97
107
  setupPostHydration({
@@ -16,6 +16,7 @@ import { getNavigationState } from '../navigation-context.ts';
16
16
  import type { NavigationApiController } from '../navigation-api.ts';
17
17
  import { getScrollY } from './scroll.ts';
18
18
  import type { ParamsSource } from '../../shared/payload-root.ts';
19
+ import { locationSearch } from '../location-search.ts';
19
20
 
20
21
  interface PostHydrationOptions {
21
22
  router: RouterInstance;
@@ -54,7 +55,7 @@ export function setupPostHydration({
54
55
  // `initialParams`. Both came out of one split of one decoded payload root
55
56
  // (rsc-stream.ts), so the entry cannot replay with another route's params —
56
57
  // which is what a separately-seeded copy used to do (TIM-1037, TIM-1285).
57
- router.historyStack.push(window.location.pathname + window.location.search, {
58
+ router.historyStack.push(window.location.pathname + locationSearch(), {
58
59
  payload: initialElement,
59
60
  params: initialParams,
60
61
  segmentInfo: initialSegmentInfo,
@@ -94,7 +95,7 @@ export function setupPostHydration({
94
95
 
95
96
  const state = window.history.state;
96
97
  const scrollY = state && typeof state.scrollY === 'number' ? state.scrollY : 0;
97
- void router.handlePopState(window.location.pathname + window.location.search, scrollY);
98
+ void router.handlePopState(window.location.pathname + locationSearch(), scrollY);
98
99
  });
99
100
 
100
101
  // Keep scroll position up to date as the user scrolls.
@@ -20,16 +20,26 @@ import {
20
20
  getNavigationState,
21
21
  setNavigationState,
22
22
  } from '../navigation-context.ts';
23
- import { transitionRender, navigateTransition } from '../navigation-root.tsx';
23
+ import { navigateTransition } from '../navigation-transition.ts';
24
+ import type { NavigationRender } from '../navigation-root.tsx';
24
25
  import { SegmentUpdateContext, EMPTY_SEGMENT_UPDATES } from '../segment-update-context.ts';
26
+ import { SlotContentCacheContext, type SlotContentCache } from '../slot-content-cache-context.ts';
25
27
  import {
26
28
  hasNavigationApi,
27
29
  setupNavigationApi,
28
30
  type NavigationApiController,
29
31
  } from '../navigation-api.ts';
30
32
  import { getScrollY, scrollToHashTarget } from './scroll.ts';
33
+ import { locationSearch } from '../location-search.ts';
34
+ import { stripRscCacheKey } from '../../shared/rsc-cache-key.ts';
31
35
 
32
36
  export interface RouterInitOptions {
37
+ /**
38
+ * The router's React root (`createReactRoot().render`). Every path that puts
39
+ * a page on screen renders through it — it is the synchronous transition
40
+ * around `root.render`, and the `publish` it takes runs on React's commit.
41
+ */
42
+ render: NavigationRender;
33
43
  initialElement?: unknown;
34
44
  /** The params the hydration payload published, beside `initialElement`. */
35
45
  initialParams?: ParamsSource;
@@ -39,6 +49,18 @@ export interface RouterInitOptions {
39
49
  export interface RouterInitResult {
40
50
  router: RouterInstance;
41
51
  navApiController: NavigationApiController | null;
52
+ /**
53
+ * The client's wrapper chain around a tree — the one every navigation
54
+ * renders through. Hydration builds its root with this too, so the
55
+ * hydrated tree and the first navigated tree cannot differ in shape
56
+ * (a type present on one and absent on the other remounts everything
57
+ * below it) and share the router's slot content cache.
58
+ */
59
+ renderTree: (
60
+ tree: unknown,
61
+ navState: NavigationState,
62
+ params: ParamsSource
63
+ ) => React.ReactElement;
42
64
  }
43
65
 
44
66
  /**
@@ -48,7 +70,9 @@ export interface RouterInitResult {
48
70
  * the initial render (methods lazily resolve the router at invocation,
49
71
  * not render time, but initRouter must still run first).
50
72
  */
51
- export function createTimberRouter(options?: RouterInitOptions): RouterInitResult {
73
+ export function createTimberRouter(options: RouterInitOptions): RouterInitResult {
74
+ const { render } = options;
75
+
52
76
  // Feature-detect Navigation API. When available, the navigate event
53
77
  // replaces popstate for back/forward and catches external navigations.
54
78
  // See design/19-client-navigation.md §"Navigation API Integration"
@@ -60,27 +84,35 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
60
84
  // SegmentUpdateContext, not from new children props).
61
85
  // Initialized from the hydration element so partial nav works on the
62
86
  // very first client navigation (before any wrapPayload call).
63
- let currentPayload: unknown = options?.initialElement ?? null;
87
+ let currentPayload: unknown = options.initialElement ?? null;
64
88
 
65
89
  // The params `currentPayload` is displayed with. Tracked beside it because
66
90
  // the two render paths that do NOT receive a payload — a shallow search
67
91
  // update and any re-wrap of the current tree — must republish the same
68
92
  // record rather than dropping it. They are written together, in one place
69
93
  // (`renderTree`), so they cannot come apart.
70
- let currentParams: ParamsSource = options?.initialParams ?? readPublishedParams(undefined);
94
+ let currentParams: ParamsSource = options.initialParams ?? readPublishedParams(undefined);
95
+
96
+ // The slot content the client has committed, by segmentPath. Owned here so
97
+ // there is exactly one per router (never a module or globalThis singleton —
98
+ // SegmentOutlet also executes during SSR, where a process-wide Map would be
99
+ // shared across requests). Read by SegmentOutlet in render, written by it
100
+ // at commit only. See client/slot-content-cache-context.ts (TIM-1423).
101
+ const slotContentCache: SlotContentCache = new Map();
71
102
 
72
103
  /**
73
104
  * Build the client's wrapper chain around a tree. **The only place it is
74
105
  * built.**
75
106
  *
76
- * Every render path goes through here — hydration builds the same chain in
77
- * `hydrate.ts` against this shape, and a navigation, a shallow search
78
- * update, a popstate replay and a revalidation all call this. That matters
79
- * beyond tidiness: an element type present on one render and absent on the
80
- * next changes the type at that position, so React unmounts and remounts
81
- * everything below it, destroying the layout state partial navigation
82
- * exists to preserve (caught by tests/e2e/segment-merge-navigation.test.ts
83
- * when the params wrapper was conditional).
107
+ * Every render path goes through here — hydration (`hydrate.ts`) receives
108
+ * this function and builds its root with it, and a navigation, a shallow
109
+ * search update, a popstate replay and a revalidation all call this. That
110
+ * matters beyond tidiness: an element type present on one render and absent
111
+ * on the next changes the type at that position, so React unmounts and
112
+ * remounts everything below it, destroying the layout state partial
113
+ * navigation exists to preserve (caught by
114
+ * tests/e2e/segment-merge-navigation.test.ts when the params wrapper was
115
+ * conditional).
84
116
  *
85
117
  * `PayloadRoot` is *above* `currentPayload`, which on a partial navigation
86
118
  * is the retained tree. That position is the whole design: it is the only
@@ -105,7 +137,12 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
105
137
  { value: segmentUpdates ?? EMPTY_SEGMENT_UPDATES },
106
138
  withNav
107
139
  );
108
- return createElement(TimberNuqsAdapter, null, withUpdates);
140
+ const withSlotCache = createElement(
141
+ SlotContentCacheContext.Provider,
142
+ { value: slotContentCache },
143
+ withUpdates
144
+ );
145
+ return createElement(TimberNuqsAdapter, null, withSlotCache);
109
146
  }
110
147
 
111
148
  /**
@@ -145,11 +182,11 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
145
182
  * the patched pushState/replaceState returns).
146
183
  *
147
184
  * Bails when search is unchanged (scroll saves, no-ops, dual-fire).
148
- * Re-wraps currentPayload with updated NavigationProvider and commits
149
- * via transitionRender (startTransition — non-blocking).
185
+ * Re-wraps currentPayload with updated NavigationProvider and hands it to
186
+ * React through the root's `render` (startTransition — non-blocking).
150
187
  */
151
188
  function syncShallowSearch(search?: string): void {
152
- const currentSearch = search ?? window.location.search;
189
+ const currentSearch = search != null ? stripRscCacheKey(search) : locationSearch();
153
190
  const navState = getNavigationState();
154
191
  if (currentSearch === navState.search) return;
155
192
  if (!currentPayload) return;
@@ -167,7 +204,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
167
204
  setNavigationState(newNavState);
168
205
 
169
206
  publishTree(currentPayload, currentParams);
170
- transitionRender(renderTree(currentPayload, newNavState, currentParams));
207
+ render(renderTree(currentPayload, newNavState, currentParams), null);
171
208
  }
172
209
 
173
210
  window.history.pushState = function (
@@ -222,7 +259,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
222
259
  (el as HTMLElement).scrollTop = y;
223
260
  }
224
261
  },
225
- getCurrentUrl: () => window.location.pathname + window.location.search,
262
+ getCurrentUrl: () => window.location.pathname + locationSearch(),
226
263
  getScrollY,
227
264
  scrollToHash: scrollToHashTarget,
228
265
 
@@ -230,8 +267,8 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
230
267
  return createFromFetch(fetchPromise);
231
268
  },
232
269
 
233
- // Render decoded RSC tree via NavigationRoot's state-based mechanism.
234
- // Used for non-navigation renders (popstate cached replay, applyRevalidation).
270
+ // Render a decoded RSC tree through the router's React root. Used for
271
+ // non-navigation renders (popstate cached replay, applyRevalidation).
235
272
  // Wraps with NavigationProvider + TimberNuqsAdapter.
236
273
  //
237
274
  // For navigation renders (navigate, refresh, popstate-with-fetch),
@@ -240,24 +277,38 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
240
277
  //
241
278
  // navState is passed explicitly by the router — no temporal coupling
242
279
  // with getNavigationState().
243
- renderRoot: (element: unknown, navState: NavigationState, params: ParamsSource) => {
280
+ renderRoot: (
281
+ element: unknown,
282
+ navState: NavigationState,
283
+ params: ParamsSource,
284
+ commit: () => void
285
+ ) => {
244
286
  // These paths render what is already decided (a replay, a
245
- // revalidation), so publishing here is the commit.
246
- publishTree(element, params);
247
- transitionRender(renderTree(element, navState, params));
287
+ // revalidation), but "decided" is not "on screen": the tree goes out in
288
+ // a transition React may hold. The publish — the router's and this
289
+ // module's record of the displayed tree — runs when React commits it,
290
+ // the same way a navigation's does (TIM-1301, TIM-1423).
291
+ render(renderTree(element, navState, params), () => {
292
+ publishTree(element, params);
293
+ commit();
294
+ });
248
295
  },
249
296
 
250
- // Hand a navigation's tree to React in a transition (navigation-root.tsx).
251
- // The fetch runs outside any transition scope; only the state update that
252
- // hands the tree over is wrapped, and in a synchronous `startTransition`.
253
- // Do NOT make this callback's caller an async React action — see the
254
- // entanglement note in `navigateTransition` (TIM-1306, TIM-1307).
297
+ // Hand a navigation's tree to React in a transition
298
+ // (navigation-transition.ts). The fetch runs outside any transition
299
+ // scope; only the render that hands the tree over is wrapped, and in a
300
+ // synchronous `startTransition`. Do NOT make this callback's caller an
301
+ // async React action — see the entanglement note in
302
+ // `navigateTransition` (TIM-1306, TIM-1307).
255
303
  //
256
304
  // The perform callback receives a wrapPayload function that wraps the
257
305
  // decoded RSC payload with NavigationProvider + NuqsAdapter. navState
258
306
  // is passed explicitly by the router — no getNavigationState() needed.
259
- navigateTransition: (url: string, perform) => {
260
- return navigateTransition(url, async () => {
307
+ //
308
+ // `_url` names the pending URL the router already published to its own
309
+ // pending store before calling here; the transition has no use for it.
310
+ navigateTransition: (_url: string, perform) => {
311
+ return navigateTransition(async () => {
261
312
  // What wrapPayload built, held until the transition wins. The client's
262
313
  // record of the displayed tree is navigation state like any other, so
263
314
  // it rides the same commit as the segment cache rather than being
@@ -287,7 +338,7 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
287
338
  commit();
288
339
  },
289
340
  };
290
- });
341
+ }, render);
291
342
  },
292
343
 
293
344
  _getCurrentPayload: () => currentPayload,
@@ -338,5 +389,5 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
338
389
  deps.navigationNavigate = (url, replace) => navApiController!.navigate(url, replace);
339
390
  }
340
391
 
341
- return { router, navApiController };
392
+ return { router, navApiController, renderTree };
342
393
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * createGlobalContext — a React context with a process-wide singleton identity.
3
+ *
4
+ * SINGLETON GUARANTEE: Uses globalThis + Symbol.for — same pattern as
5
+ * NavigationContext. The RSC client bundler can duplicate a module across
6
+ * chunks (browser-entry graph + client-reference graph). With ESM output,
7
+ * each chunk gets its own module scope — a bare createContext at module
8
+ * level would create separate instances per chunk. globalThis guarantees a
9
+ * single instance regardless of duplication.
10
+ *
11
+ * See design/19-client-navigation.md §"Singleton Guarantee via globalThis"
12
+ */
13
+
14
+ 'use client';
15
+
16
+ import React from 'react';
17
+
18
+ export function createGlobalContext<T>(key: string, defaultValue: T): React.Context<T> {
19
+ const g = globalThis as Record<symbol, unknown>;
20
+ const sym = Symbol.for(key);
21
+ const existing = g[sym] as React.Context<T> | undefined;
22
+ if (existing !== undefined) return existing;
23
+ if (typeof React.createContext === 'function') {
24
+ const ctx = React.createContext<T>(defaultValue);
25
+ g[sym] = ctx;
26
+ return ctx;
27
+ }
28
+ // RSC environment — createContext not available. Return a dummy that
29
+ // won't be used (the consumers only render on the client / during SSR).
30
+ return undefined as unknown as React.Context<T>;
31
+ }
@@ -39,8 +39,7 @@ export type { SegmentInfo, StateTree } from '../shared/segment-info.ts';
39
39
  export { HistoryStack } from './history.ts';
40
40
  export type { HistoryEntry } from './history.ts';
41
41
 
42
- // ── Params (internal setter + raw hooks) ─────────────────────────────────
43
- export { setCurrentParams, setCurrentSlotParams } from './use-segment-params.ts';
42
+ // ── Params (raw hooks) ───────────────────────────────────────────────────
44
43
  export { useSearchParams } from './use-search-params.ts';
45
44
 
46
45
  // ── Navigation context ───────────────────────────────────────────────────
@@ -38,6 +38,8 @@ import { getRouterOrNull } from './router-ref.ts';
38
38
  import { getSsrData } from './ssr-data.ts';
39
39
  import { mergePreservedSearchParams } from '../shared/merge-search-params.ts';
40
40
  import { getLinkCodec } from '../params/codec-registry.ts';
41
+ import { locationSearch } from './location-search.ts';
42
+ import { stripRscCacheKey } from '../shared/rsc-cache-key.ts';
41
43
  import type { LinkStatus } from './use-link-status.ts';
42
44
 
43
45
  const LINK_PENDING: LinkStatus = { isPending: true };
@@ -47,16 +49,15 @@ const LINK_IDLE: LinkStatus = { isPending: false };
47
49
 
48
50
  /**
49
51
  * Read the current URL's search string without requiring a React hook.
50
- * On the client, reads window.location.search. During SSR, reads from
51
- * the request context (getSsrData). Returns empty string if unavailable.
52
+ * On the client, reads window.location.search. During SSR, reads the raw
53
+ * query string from the request context (getSsrData) — the same `''` or
54
+ * `?…` shape, preserving repeated keys (`?tag=a&tag=b`) so the
55
+ * server-rendered href matches what the hydrated client rebuilds from the
56
+ * address bar (TIM-1428). Returns empty string if unavailable.
52
57
  */
53
58
  function getCurrentSearch(): string {
54
- if (typeof window !== 'undefined') return window.location.search;
55
- const data = getSsrData();
56
- if (!data) return '';
57
- const sp = new URLSearchParams(data.searchParams);
58
- const str = sp.toString();
59
- return str ? `?${str}` : '';
59
+ if (typeof window !== 'undefined') return locationSearch();
60
+ return getSsrData()?.search ?? '';
60
61
  }
61
62
 
62
63
  // ─── Types ───────────────────────────────────────────────────────
@@ -483,7 +484,7 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
483
484
  // a thenable from `startTransition` hands it to `ReactSharedInternals.S`,
484
485
  // which calls react-dom's `entangleAsyncAction`. That opens an action scope
485
486
  // whose `currentEntangledLane` collects every transition update scheduled
486
- // while it is open — including `NavigationRoot`'s `setRendered`, which is a
487
+ // while it is open — including the router's `root.render` of `NavigationRoot`, which is a
487
488
  // fully synchronous `startTransition` in a different component — and
488
489
  // rendering that lane suspends on `currentEntangledActionThenable` until the
489
490
  // action settles. `navigateTransition` awaits `decodePromise`, so a Link
@@ -498,7 +499,7 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
498
499
  // `isPending` runs from the click until `router.navigate()` settles, which
499
500
  // is after `decodePromise` — the same lifecycle the router's pending store
500
501
  // and the TopLoader use. The clear wraps in `startTransition` so it lands
501
- // on the same React lane as `NavigationRoot`'s `setRendered` — without it,
502
+ // on the same React lane as the router's `root.render` — without it,
502
503
  // the clear is an urgent update that paints before the transition commits,
503
504
  // and destinations with pending Suspense boundaries flash idle for one frame
504
505
  // while the old page is still showing (TIM-1418). The callback is
@@ -515,11 +516,10 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
515
516
  const linkStatus = isPending ? LINK_PENDING : LINK_IDLE;
516
517
 
517
518
  // Preserve search params from the current URL when requested.
518
- // useSearchParams() works during both SSR (reads from request context)
519
- // and on the client (reads from window.location, reactive to URL changes).
520
- // We read current search params directly to avoid unconditional hook calls.
521
- // On the client, window.location.search is always current; during SSR,
522
- // getSsrData() provides the request's search params.
519
+ // Read via getCurrentSearch() rather than a hook, to avoid an
520
+ // unconditional hook call for a prop most links don't pass. On the
521
+ // client, window.location.search is always current; during SSR,
522
+ // getSsrData() provides the request's raw query string.
523
523
  const internal = isInternalHref(baseHref);
524
524
 
525
525
  // Only preserve search params for internal links — leaking current
@@ -565,7 +565,7 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
565
565
 
566
566
  if (
567
567
  resolved.pathname === window.location.pathname &&
568
- resolved.search === window.location.search &&
568
+ stripRscCacheKey(resolved.search) === locationSearch() &&
569
569
  resolved.hash
570
570
  ) {
571
571
  return;
@@ -577,7 +577,7 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
577
577
  // Keep the #fragment — the router commits it to the address bar and
578
578
  // scrolls to the matching element after render. The hash is stripped
579
579
  // from the RSC fetch URL inside the router (TIM-1035).
580
- const absoluteHref = resolved.pathname + resolved.search + resolved.hash;
580
+ const absoluteHref = resolved.pathname + stripRscCacheKey(resolved.search) + resolved.hash;
581
581
 
582
582
  const seq = ++clickSeq.current;
583
583
  const settle = () => {
@@ -615,7 +615,7 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
615
615
  ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
616
616
  : resolvedHref;
617
617
  const resolved = new URL(prefetchHref, window.location.href);
618
- router.prefetch(resolved.pathname + resolved.search);
618
+ router.prefetch(resolved.pathname + stripRscCacheKey(resolved.search));
619
619
  }
620
620
  }
621
621
  : userOnMouseEnter;