@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
@@ -219,6 +219,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
219
219
  params,
220
220
  segmentInfo: result.segmentInfo,
221
221
  status: result.status,
222
+ skippedSegments: result.skippedSegments,
222
223
  });
223
224
  return { ...result, payload, params, navState, commit };
224
225
  });
@@ -294,18 +295,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
294
295
  // metadata (e.g., initial SSR page) clears the cache so the next
295
296
  // forward navigation gets a full render.
296
297
  //
297
- // Committed immediately: the replay is synchronous and its render
298
- // goes out through `transitionRender`, so there is no in-flight
299
- // window for a successor to supersede. `runNavigation` has already
300
- // superseded whatever was in flight before it (TIM-1022).
298
+ // No fetch to be superseded — `runNavigation` has already superseded
299
+ // whatever was in flight before it (TIM-1022) — but the render still
300
+ // goes out through a transition React may hold (a replayed tree can
301
+ // suspend), so the publish rides that commit rather than running
302
+ // here. Publishing first would advertise the replayed route's slot
303
+ // keys while the slot content cache still describes the departing
304
+ // one (TIM-1423, codex on #1108).
301
305
  const { navState, commit } = prepareNavigation(url, {
302
306
  payload: entry.payload,
303
307
  params: entry.params,
304
308
  segmentInfo: entry.segmentInfo,
305
309
  clearSegmentCacheOnEmpty: true,
306
310
  });
307
- commit();
308
- renderPayload(entry.payload, navState, entry.params);
311
+ renderPayload(entry.payload, navState, entry.params, commit);
309
312
  restoreScrollAfterPaint(scrollY);
310
313
  },
311
314
  externalSignal
@@ -326,6 +329,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
326
329
  /**
327
330
  * Prefetch an RSC payload for a URL and store it in the prefetch cache.
328
331
  * Called on hover of <Link prefetch> elements.
332
+ *
333
+ * Concurrent hovers and a click during the round-trip coalesce via the
334
+ * singleflight inside PrefetchCache (TIM-1438).
329
335
  */
330
336
  function prefetch(url: string): void {
331
337
  // A stale client hard-navigates on every navigation, so a prefetched RSC
@@ -348,35 +354,41 @@ export function createRouter(deps: RouterDeps): RouterInstance {
348
354
  const cacheKey = prefetchKeyFor(fetchUrl, deps.getCurrentUrl(), stateTree);
349
355
  const from = cacheKey.from;
350
356
 
351
- // Don't prefetch if already cached
352
- if (prefetchCache.get(cacheKey) !== undefined) return;
357
+ // Don't prefetch if already cached (ready or negative)
358
+ if (prefetchCache.has(cacheKey)) return;
353
359
  if (historyStack.has(fetchUrl)) return;
354
360
 
355
- // Fire-and-forget fetch
356
- void fetchRscPayload(fetchUrl, deps, stateTree, from).then(
357
- (result) => {
358
- // The decode rejects separately from the fetch, so the stale shapes
359
- // reach us here rather than in the rejection handler below — swallowing
360
- // it wholesale threw away the same evidence (TIM-1275).
361
- result.decodePromise?.catch((error: unknown) => void recordSkew(error));
362
- prefetchCache.set(cacheKey, result);
363
- },
364
- (error) => {
365
- if (error instanceof NonRscResponse) {
366
- prefetchCache.setNegative(cacheKey);
367
- return;
361
+ // Fire-and-forget. Concurrent hovers coalesce in the singleflight;
362
+ // a click during the round-trip joins the same flight via
363
+ // fetchOrCoalesce in performNavigationFetch (TIM-1438).
364
+ void prefetchCache
365
+ .fetchOrCoalesce(
366
+ cacheKey,
367
+ (signal) => fetchRscPayload(fetchUrl, deps, stateTree, from, signal),
368
+ (err) => err instanceof NonRscResponse
369
+ )
370
+ .then(
371
+ (outcome) => {
372
+ if (outcome.kind === 'ready') {
373
+ // The decode rejects separately from the fetch, so the stale
374
+ // shapes reach us here rather than in the rejection handler
375
+ // below — swallowing it wholesale threw away the same evidence
376
+ // (TIM-1275).
377
+ outcome.result.decodePromise?.catch((error: unknown) => void recordSkew(error));
378
+ }
379
+ },
380
+ (error) => {
381
+ // A prefetch can be the first thing to learn we are stale —
382
+ // during a rollout it may reach the new deployment while the
383
+ // click that follows reaches the old one and completes as an
384
+ // ordinary SPA navigation, throwing the evidence away. Record
385
+ // it, but never recover from here: nobody navigated, and a page
386
+ // load triggered by a hover is exactly the unasked-for reload
387
+ // this design removed. The next click takes the document-load
388
+ // path (TIM-1275).
389
+ recordSkew(error);
368
390
  }
369
- // A prefetch can be the first thing to learn we are stale — during a
370
- // rollout it may reach the new deployment while the click that
371
- // follows reaches the old one and completes as an ordinary SPA
372
- // navigation, throwing the evidence away. Record it, but never
373
- // recover from here: nobody navigated, and a page load triggered by
374
- // a hover is exactly the unasked-for reload this design removed. The
375
- // next click takes the document-load path (TIM-1275).
376
- recordSkew(error);
377
- // Any other prefetch failure is non-fatal — navigation fetches fresh.
378
- }
379
- );
391
+ );
380
392
  }
381
393
 
382
394
  return {
@@ -404,15 +416,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
404
416
  // Preserve existing segmentInfo so away-and-back navigation replays
405
417
  // with a correct segment cache (TIM-1037).
406
418
  const existingEntry = historyStack.get(currentUrl);
407
- // Synchronous, like the popstate replay: there is no fetch to be
408
- // superseded, and the payload is already decoded.
419
+ // Like the popstate replay: there is no fetch to be superseded and the
420
+ // payload is already decoded, but the publish still rides React's
421
+ // commit of the tree, not this call (TIM-1423).
409
422
  const { navState, commit } = prepareNavigation(currentUrl, {
410
423
  payload: tree,
411
424
  params,
412
425
  segmentInfo: existingEntry?.segmentInfo,
413
426
  });
414
- commit();
415
- renderPayload(tree, navState, params);
427
+ renderPayload(tree, navState, params, commit);
416
428
  },
417
429
  initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
418
430
  segmentCache,
@@ -3,6 +3,7 @@
3
3
 
4
4
  import type { PublishedParams } from '../shared/payload-root.ts';
5
5
  import type { SegmentInfo, StateTree } from '../shared/segment-info.ts';
6
+ import { createSingleflight } from '../cache/singleflight.ts';
6
7
 
7
8
  // ─── Types ───────────────────────────────────────────────────────
8
9
 
@@ -273,7 +274,7 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
273
274
 
274
275
  // ─── Prefetch Cache ──────────────────────────────────────────────
275
276
 
276
- interface PrefetchEntry {
277
+ interface ReadyEntry {
277
278
  result: PrefetchResult;
278
279
  expiresAt: number;
279
280
  }
@@ -281,6 +282,13 @@ interface PrefetchEntry {
281
282
  /** Sentinel value for negative cache entries (URL is not a route). */
282
283
  const NEGATIVE_ENTRY: PrefetchResult = Object.freeze({ payload: null });
283
284
 
285
+ /**
286
+ * Timeout for the in-flight singleflight (TIM-1438). Per CLAUDE.md's
287
+ * singleflight rule: "Never write a coalescing Map without a timeout."
288
+ * A hung prefetch cannot block clicks or suppress hovers forever.
289
+ */
290
+ const PREFETCH_SINGLEFLIGHT_TIMEOUT_MS = 5_000;
291
+
284
292
  /**
285
293
  * Identifies a prefetched payload. A payload is addressed by *every* input that
286
294
  * changes the server's answer, not by the destination alone.
@@ -333,17 +341,28 @@ export function prefetchScopeOf(stateTree: StateTree | undefined): string {
333
341
  return stateTree?.treePaths?.join('\u0000') ?? '';
334
342
  }
335
343
 
344
+ /** Outcome of a prefetch flight — discriminated so NonRscResponse is a
345
+ * success (stores a negative entry) rather than a rejection. */
346
+ export type FlightOutcome = { kind: 'ready'; result: PrefetchResult } | { kind: 'non-route' };
347
+
336
348
  /**
337
349
  * Short-lived cache for hover-triggered prefetches. Entries expire after
338
350
  * 30 seconds. When a link is clicked, the prefetched payload is consumed
339
351
  * (moved to the history stack) and removed from this cache.
340
352
  *
353
+ * In-flight dedup (TIM-1438): concurrent fetches for the same key are
354
+ * coalesced by a `createSingleflight` instance. A hover starts a flight;
355
+ * a click for the same key joins it instead of issuing a duplicate. The
356
+ * singleflight enforces a 5-second timeout and cleans up automatically
357
+ * on settlement.
358
+ *
341
359
  * timber.js does NOT prefetch on viewport intersection — only explicit
342
360
  * hover on <Link prefetch> triggers a prefetch.
343
361
  */
344
362
  export class PrefetchCache {
345
363
  private static readonly TTL_MS = 30_000;
346
- private entries = new Map<string, PrefetchEntry>();
364
+ private entries = new Map<string, ReadyEntry>();
365
+ private flights = createSingleflight({ timeoutMs: PREFETCH_SINGLEFLIGHT_TIMEOUT_MS });
347
366
 
348
367
  set(key: PrefetchKey, result: PrefetchResult): void {
349
368
  this.entries.set(prefetchMapKey(key), {
@@ -363,6 +382,11 @@ export class PrefetchCache {
363
382
  return entry.result;
364
383
  }
365
384
 
385
+ /** True if a ready or negative entry exists for this key. */
386
+ has(key: PrefetchKey): boolean {
387
+ return this.get(key) !== undefined;
388
+ }
389
+
366
390
  /** Get and remove the entry (used when navigation consumes a prefetch) */
367
391
  consume(key: PrefetchKey): PrefetchResult | undefined {
368
392
  const result = this.get(key);
@@ -372,6 +396,50 @@ export class PrefetchCache {
372
396
  return result;
373
397
  }
374
398
 
399
+ /**
400
+ * Fetch or coalesce with an in-flight fetch for this key (TIM-1438).
401
+ *
402
+ * Concurrent callers (hover + click, repeated hovers) get the same
403
+ * promise. On success the result is stored as a ready entry. On
404
+ * NonRscResponse a negative entry is stored and the outcome is
405
+ * `{ kind: 'non-route' }`. Other errors (network, version skew)
406
+ * reject — the singleflight cleans up the key and subsequent callers
407
+ * retry.
408
+ *
409
+ * The singleflight signal is passed to `doFetch` so a timed-out flight
410
+ * aborts the underlying fetch rather than leaving it running.
411
+ */
412
+ fetchOrCoalesce(
413
+ key: PrefetchKey,
414
+ doFetch: (signal: AbortSignal) => Promise<PrefetchResult>,
415
+ isNonRoute: (error: unknown) => boolean
416
+ ): Promise<FlightOutcome> {
417
+ return this.flights.do(prefetchMapKey(key), async (signal) => {
418
+ try {
419
+ const result = await doFetch(signal);
420
+ if (!signal.aborted) this.set(key, result);
421
+ return { kind: 'ready' as const, result };
422
+ } catch (err) {
423
+ if (isNonRoute(err)) {
424
+ if (!signal.aborted) this.setNegative(key);
425
+ return { kind: 'non-route' as const };
426
+ }
427
+ throw err;
428
+ }
429
+ });
430
+ }
431
+
432
+ /**
433
+ * Join an in-flight singleflight fetch if one exists for this key.
434
+ * Returns the in-flight promise or undefined. Used by the click path to
435
+ * coalesce with a hover prefetch without starting a new flight — when no
436
+ * flight exists, the click issues its own fetch with the navigation's
437
+ * abort signal so superseded navigations abort immediately (TIM-1438).
438
+ */
439
+ joinInflight(key: PrefetchKey): Promise<FlightOutcome> | undefined {
440
+ return this.flights.get(prefetchMapKey(key)) as Promise<FlightOutcome> | undefined;
441
+ }
442
+
375
443
  /**
376
444
  * Store a negative entry — the URL is not a route (non-RSC Content-Type).
377
445
  *
@@ -60,10 +60,13 @@ export function SegmentProvider({
60
60
  }: SegmentProviderProps) {
61
61
  const value = useMemo(
62
62
  () => ({ segments, parallelRouteKeys }),
63
- // segments and parallelRouteKeys are static per layout — they don't change
64
- // across navigations. The layout's position in the tree is fixed.
65
- // Intentionally using derived keys — segments/parallelRouteKeys are static per layout
66
- [segments.join('/'), parallelRouteKeys.join(',')]
63
+ // segments and parallelRouteKeys are static per layout — the layout's
64
+ // position in the tree is fixed. The arrays are used directly as deps
65
+ // (React Compiler requires simple expressions here). Referential stability
66
+ // of the context value doesn't matter: every consumer
67
+ // (useSelectedLayoutSegment/useSelectedLayoutSegments) also calls
68
+ // usePathname(), so it re-renders on every navigation regardless.
69
+ [segments, parallelRouteKeys]
67
70
  );
68
71
  return createElement(SegmentContext.Provider, { value }, children);
69
72
  }
@@ -4,19 +4,29 @@
4
4
  * Each layout in the segment tree is wrapped with a SegmentOutlet that:
5
5
  * 1. Knows its segment path (prop from the server)
6
6
  * 2. Reads from SegmentUpdateContext for partial navigation updates
7
- * 3. Caches its rendered content in a ref across navigations
7
+ * 3. Reads from — and, at commit, writes to — the router's slot content
8
+ * cache, so a skipped parallel slot keeps showing what it last committed
8
9
  *
9
- * On full navigation: receives new children via props, caches and renders them.
10
+ * On full navigation: receives new children via props and renders them.
10
11
  * On partial navigation (this segment skipped): the context map has no entry
11
- * for this path, so the outlet returns cached content — preserving layout state.
12
+ * for this path, so the outlet renders its children — which, because the
13
+ * router re-wraps the *retained* tree, are the same elements as before,
14
+ * preserving layout state.
12
15
  * On partial navigation (this segment updated): the context map has content
13
16
  * for this path, so the outlet renders the update.
17
+ * On a skipped parallel slot (`skip=true`, children null): the outlet renders
18
+ * the content it last committed for this path.
14
19
  *
15
20
  * Uses React context instead of useSyncExternalStore to stay compatible
16
21
  * with concurrent rendering (transitions). All outlets re-render when the
17
22
  * context value changes, but each bails out quickly if its segment has
18
23
  * no update — same approach as Next.js LayoutRouter.
19
24
  *
25
+ * Render is a pure read. The only write — recording committed slot content —
26
+ * happens in a layout effect, so a render React throws away (a superseded
27
+ * transition, a Suspense retry) cannot poison what a later `skip=true`
28
+ * resolves to (TIM-1423). See client/slot-content-cache-context.ts.
29
+ *
20
30
  * Security: performance optimization only. The server always runs all
21
31
  * access.ts files regardless of segment skipping.
22
32
  * See design/13-security.md §"State tree manipulation".
@@ -24,8 +34,9 @@
24
34
 
25
35
  'use client';
26
36
 
27
- import { useContext, useRef, type ReactNode } from 'react';
37
+ import { useContext, useLayoutEffect, type ReactNode } from 'react';
28
38
  import { SegmentUpdateContext } from './segment-update-context.ts';
39
+ import { SlotContentCacheContext } from './slot-content-cache-context.ts';
29
40
 
30
41
  export interface SegmentOutletProps {
31
42
  /**
@@ -41,101 +52,45 @@ export interface SegmentOutletProps {
41
52
  children: ReactNode;
42
53
 
43
54
  /**
44
- * When true, the outlet returns cached content instead of rendering
45
- * new children. Used for skipped parallel route slots — the server
46
- * sends an empty SegmentOutlet with skip=true, and the client keeps
47
- * the previously cached slot content.
55
+ * When true, the outlet renders the content it last committed for this
56
+ * path instead of `children`. Used for skipped parallel route slots — the
57
+ * server sends an empty SegmentOutlet with skip=true, and the client keeps
58
+ * the previously committed slot content.
48
59
  */
49
60
  skip?: boolean;
50
61
  }
51
62
 
52
- // ─── Slot Content Cache ──────────────────────────────────────────
53
- // Persists slot content across SegmentOutlet fiber lifecycles.
54
- // When React creates new fibers during full navigation (e.g., the
55
- // server sends a complete new tree and React remounts the subtree),
56
- // useRef starts fresh with null. Skipped slots (skip=true, children=null)
57
- // would lose their cached content because contentRef.current is null.
58
- //
59
- // This globalThis-backed Map provides a fallback: SegmentOutlet stores
60
- // rendered content here whenever it renders non-null slot content, and
61
- // reads from it when skip=true but contentRef.current is null.
62
- //
63
- // Uses globalThis + Symbol.for for singleton guarantee across chunks —
64
- // same pattern as NavigationContext and SegmentUpdateContext.
65
- //
66
- // SSR safety: SegmentOutlet is 'use client' — during RSC rendering it's
67
- // serialized as a client reference and never executed. During SSR
68
- // (renderToReadableStream), the component IS executed but skip=true is
69
- // never sent (the server always renders full content). The cache may
70
- // accumulate entries during SSR but they're harmless — the skip=true
71
- // fallback path only activates on the client during SPA navigation.
72
-
73
- const SLOT_CACHE_KEY = Symbol.for('__timber_slot_content_cache');
74
-
75
- function getSlotContentCache(): Map<string, ReactNode> {
76
- const g = globalThis as Record<symbol, unknown>;
77
- if (!g[SLOT_CACHE_KEY]) {
78
- g[SLOT_CACHE_KEY] = new Map<string, ReactNode>();
79
- }
80
- return g[SLOT_CACHE_KEY] as Map<string, ReactNode>;
81
- }
82
-
83
63
  /**
84
64
  * Client component boundary at each layout segment in the element tree.
85
65
  *
86
- * On full navigation (context map is empty): renders children, caches in ref.
87
- * On partial navigation: checks the context map for an update at segmentPath.
88
- * - Update found → renders update, caches in ref.
89
- * - No update → returns cached content (layout state preserved).
66
+ * Resolution order, all reads:
67
+ * 1. An update in SegmentUpdateContext for this path → render it.
68
+ * 2. `skip` → render the committed content for this path (null if none —
69
+ * a fresh outlet with nothing committed yet renders empty and is fully
70
+ * rendered on the next navigation).
71
+ * 3. Otherwise → render `children`.
90
72
  *
91
- * React preserves component instances across reactRoot.render() calls
92
- * when the same type appears at the same tree position. The ref persists
93
- * across navigations because SegmentOutlet is reconciled, not remounted.
73
+ * Whatever a non-skip render resolves to is recorded once React commits it.
74
+ * A skip render records nothing: it displayed the cache, it did not add to it.
94
75
  */
95
76
  export function SegmentOutlet({ segmentPath, children, skip }: SegmentOutletProps) {
96
77
  const updates = useContext(SegmentUpdateContext);
97
- const contentRef = useRef<ReactNode>(null);
98
- const slotCache = getSlotContentCache();
78
+ const cache = useContext(SlotContentCacheContext);
99
79
 
100
80
  const update = updates.get(segmentPath);
81
+ const hasUpdate = update !== undefined;
82
+ const content: ReactNode = hasUpdate
83
+ ? update
84
+ : skip
85
+ ? (cache?.get(segmentPath) ?? null)
86
+ : children;
101
87
 
102
- if (update !== undefined) {
103
- contentRef.current = update;
104
- slotCache.set(segmentPath, update);
105
- return update;
106
- }
107
-
108
- // Skipped slot: keep cached content. The server sends skip=true for
109
- // parallel route slots whose matched content hasn't changed. React
110
- // reconciles this SegmentOutlet with the previous one at the same
111
- // tree position, so the ref (cached content) is preserved.
112
- if (skip) {
113
- if (contentRef.current !== null) {
114
- return contentRef.current;
115
- }
116
- // Fiber was remounted (contentRef is fresh null). Fall back to the
117
- // globalThis-backed cache which persists across fiber lifecycles.
118
- if (slotCache.has(segmentPath)) {
119
- const cached = slotCache.get(segmentPath)!;
120
- contentRef.current = cached;
121
- return cached;
122
- }
123
- // No cached content anywhere — the SegmentOutlet instance is fresh
124
- // and no prior render has populated the cache. Fall through to render
125
- // children (null). The slot will be fully rendered next navigation.
126
- }
127
-
128
- if (contentRef.current === null) {
129
- contentRef.current = children;
130
- slotCache.set(segmentPath, children);
131
- return children;
132
- }
133
-
134
- if (children !== contentRef.current) {
135
- contentRef.current = children;
136
- slotCache.set(segmentPath, children);
137
- return children;
138
- }
88
+ // Commit-time write. `hasUpdate || !skip` is "this render brought its own
89
+ // content" — the one case where the cache learns something new.
90
+ const record = hasUpdate || !skip;
91
+ useLayoutEffect(() => {
92
+ if (record && cache !== null) cache.set(segmentPath, content);
93
+ }, [record, cache, segmentPath, content]);
139
94
 
140
- return contentRef.current;
95
+ return content;
141
96
  }
@@ -6,37 +6,18 @@
6
6
  * as the context value. Mounted SegmentOutlet components read from this
7
7
  * context to decide whether to render the update or their cached content.
8
8
  *
9
- * SINGLETON GUARANTEE: Uses globalThis + Symbol.for — same pattern as
10
- * NavigationContext. The RSC client bundler can duplicate this module
11
- * across chunks (browser-entry graph + client-reference graph). With
12
- * ESM output, each chunk gets its own module scope — a bare createContext
13
- * at module level would create separate instances per chunk. globalThis
14
- * guarantees a single instance regardless of duplication.
15
- *
9
+ * Singleton across bundler-duplicated chunks via `createGlobalContext`.
16
10
  * See design/19-client-navigation.md §"Singleton Guarantee via globalThis"
17
11
  */
18
12
 
19
13
  'use client';
20
14
 
21
- import React, { type ReactNode } from 'react';
15
+ import type { ReactNode } from 'react';
16
+ import { createGlobalContext } from './global-context.ts';
22
17
 
23
18
  export const EMPTY_SEGMENT_UPDATES = new Map<string, ReactNode>();
24
19
 
25
- const CTX_KEY = Symbol.for('__timber_segment_update_ctx');
26
-
27
- function getOrCreateContext(): React.Context<Map<string, ReactNode>> {
28
- const existing = (globalThis as Record<symbol, unknown>)[CTX_KEY] as
29
- | React.Context<Map<string, ReactNode>>
30
- | undefined;
31
- if (existing !== undefined) return existing;
32
- if (typeof React.createContext === 'function') {
33
- const ctx = React.createContext<Map<string, ReactNode>>(EMPTY_SEGMENT_UPDATES);
34
- (globalThis as Record<symbol, unknown>)[CTX_KEY] = ctx;
35
- return ctx;
36
- }
37
- // RSC environment — createContext not available. Return a dummy that
38
- // won't be used (outlets only render on the client).
39
- return undefined as unknown as React.Context<Map<string, ReactNode>>;
40
- }
41
-
42
- export const SegmentUpdateContext = getOrCreateContext();
20
+ export const SegmentUpdateContext = createGlobalContext<Map<string, ReactNode>>(
21
+ '__timber_segment_update_ctx',
22
+ EMPTY_SEGMENT_UPDATES
23
+ );
@@ -0,0 +1,43 @@
1
+ /**
2
+ * SlotContentCacheContext — the router-owned record of slot content the
3
+ * client has *committed*.
4
+ *
5
+ * The server omits a parallel slot's content when the client advertised a
6
+ * matching content key (`skip=true`, TIM-1370), and the outlet at that
7
+ * position must render what it displayed before. This Map, keyed by
8
+ * segmentPath, is where that content lives.
9
+ *
10
+ * Ownership and write discipline (TIM-1423):
11
+ *
12
+ * - **One instance per router.** `createTimberRouter` creates the Map and
13
+ * provides it above every tree it hands to React — hydration included,
14
+ * which builds its chain through the same `renderTree`. It is not a
15
+ * module or `globalThis` singleton: a process-wide Map executed during
16
+ * SSR would be shared by concurrent requests.
17
+ * - **Written only at commit.** `SegmentOutlet` writes from a layout effect,
18
+ * never during render. A render React discards — a transition superseded
19
+ * before it commits, a Suspense retry — leaves the Map untouched, so a
20
+ * later `skip=true` can only ever resolve to content the user actually saw.
21
+ * The content-key record the client advertises (`SegmentCache`, published
22
+ * by `NavigationRoot`'s layout effect on the same commit) and this Map
23
+ * are therefore written by the same React commit and cannot describe
24
+ * different trees.
25
+ * - **`null` outside a router.** SSR renders no provider and the outlet then
26
+ * neither reads nor writes: the server never emits `skip` during SSR, and
27
+ * layout effects do not run there. The default is `null` rather than a
28
+ * shared Map so that "no provider" cannot silently become "shared cache".
29
+ *
30
+ * Singleton across bundler-duplicated chunks via `createGlobalContext`.
31
+ */
32
+
33
+ 'use client';
34
+
35
+ import type { ReactNode } from 'react';
36
+ import { createGlobalContext } from './global-context.ts';
37
+
38
+ export type SlotContentCache = Map<string, ReactNode>;
39
+
40
+ export const SlotContentCacheContext = createGlobalContext<SlotContentCache | null>(
41
+ '__timber_slot_content_cache_ctx',
42
+ null
43
+ );
@@ -36,8 +36,14 @@ import type { SlotParamsRecord } from '../shared/slot-params.ts';
36
36
  export interface SsrData {
37
37
  /** The request's URL pathname (e.g. '/dashboard/settings') */
38
38
  pathname: string;
39
- /** The request's search params as a plain record */
40
- searchParams: Record<string, string>;
39
+ /**
40
+ * The request's raw app-visible query string (`''` or `?…`), matching what
41
+ * `window.location.search` will hold after hydration. The raw string is
42
+ * the only representation carried here: it preserves repeated keys
43
+ * (`?tag=a&tag=b`), which a collapsed `Record<string, string>` cannot
44
+ * (TIM-1428). Parse with `new URLSearchParams(search)` where needed.
45
+ */
46
+ search: string;
41
47
  /** The request's cookies as name→value pairs */
42
48
  cookies: Map<string, string>;
43
49
  /** The request's route params (e.g. { id: '123' }) */
@@ -19,8 +19,6 @@
19
19
  * §"Singleton State Registry".
20
20
  */
21
21
 
22
- import type { CoercedParams } from '../shared/param-value.ts';
23
- import type { SlotParamsRecord } from '../shared/slot-params.ts';
24
22
  import type { RouterInstance } from './router-types.ts';
25
23
  import type { SsrData } from './ssr-data.ts';
26
24
 
@@ -52,30 +50,6 @@ export function _setCurrentSsrData(data: SsrData | undefined): void {
52
50
  currentSsrData = data;
53
51
  }
54
52
 
55
- // ─── Route Params (from use-segment-params.ts) ──────────────────────────────────────
56
-
57
- /** Current route params snapshot — replaced (not mutated) on each navigation. */
58
- export let currentParams: CoercedParams = {};
59
-
60
- export function _setCurrentParams(params: CoercedParams): void {
61
- currentParams = params;
62
- }
63
-
64
- /**
65
- * Per-slot params snapshot, keyed by slot tree path — the module-level
66
- * fallback behind `useSegmentParams(slotPath)` when no NavigationContext is
67
- * mounted (tests, calls outside a component). Replaced, never mutated, on
68
- * each navigation. See TIM-1285.
69
- */
70
- export let currentSlotParams: SlotParamsRecord | null = null;
71
-
72
- export function _setCurrentSlotParams(slotParams: SlotParamsRecord | null): void {
73
- currentSlotParams = slotParams;
74
- }
75
-
76
- /** Listeners notified when currentParams changes. */
77
- export const paramsListeners = new Set<() => void>();
78
-
79
53
  // ─── Search Params Cache (from use-search-params.ts) ────────────────────────
80
54
 
81
55
  /** Cached search string — avoids reparsing when URL hasn't changed. */