@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
@@ -4,48 +4,38 @@
4
4
  * Returns the pathname portion of the current URL (e.g. '/dashboard/settings').
5
5
  * Updates when client-side navigation changes the URL.
6
6
  *
7
- * On the client, reads from NavigationContext which is updated atomically
8
- * with the RSC tree render. This replaces the previous useSyncExternalStore
9
- * approach which only subscribed to popstate events — meaning usePathname()
10
- * did NOT re-render on forward navigation (pushState). The context approach
11
- * fixes this: pathname updates in the same render pass as the new tree.
7
+ * One unconditional read of NavigationContext, on every side (TIM-1425):
12
8
  *
13
- * During SSR, reads the request pathname from the SSR ALS context
14
- * (populated by ssr-entry.ts) instead of window.location.
9
+ * - In the browser, the provider wraps the RSC payload in renderRoot(), so
10
+ * the pathname updates in the same render pass as the new tree.
11
+ * - During SSR, the wrapper chain mounts the same provider with the request's
12
+ * pathname (TIM-1424), so this is the identical code path — no ALS read,
13
+ * no fallback tiers.
14
+ * - In the RSC environment, this module is never evaluated — the shims plugin
15
+ * resolves next/navigation to navigation-rsc.ts, which has a throwing stub
16
+ * (TIM-1420).
17
+ * - Called outside a component entirely, React itself throws its
18
+ * invalid-hook-call error — loud, not guessed-at from window.location.
15
19
  *
16
20
  * Compatible with Next.js's `usePathname()` from `next/navigation`.
17
21
  */
18
22
 
19
- import { getSsrData } from './ssr-data.ts';
20
23
  import { useNavigationContext } from './navigation-context.ts';
21
24
 
22
25
  /**
23
26
  * Read the current URL pathname.
24
27
  *
25
- * On the client, reads from NavigationContext (provided by
26
- * NavigationProvider in renderRoot). During SSR, reads from the
27
- * ALS-backed SSR data context. Falls back to window.location.pathname
28
- * when called outside a React component (e.g., in tests).
28
+ * Throws when no NavigationProvider is above the caller (a component rendered
29
+ * outside the timber app — in tests, render inside the timber providers).
29
30
  */
30
31
  export function usePathname(): string {
31
- // Try reading from NavigationContext (client-side, inside React tree).
32
- // During SSR, no NavigationProvider is mounted, so this returns null.
33
- try {
34
- // eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on environment, not render path
35
- const navContext = useNavigationContext();
36
- if (navContext !== null) {
37
- return navContext.pathname;
38
- }
39
- } catch {
40
- // No React dispatcher available (called outside a component).
41
- // Fall through to SSR/fallback below.
32
+ const nav = useNavigationContext();
33
+ if (nav === null) {
34
+ throw new Error(
35
+ '[timber] usePathname() was called outside the timber app tree ' +
36
+ '(no NavigationProvider found). In tests, render the component ' +
37
+ 'inside the timber providers.'
38
+ );
42
39
  }
43
-
44
- // SSR path: read from ALS-backed SSR data context.
45
- const ssrData = getSsrData();
46
- if (ssrData) return ssrData.pathname ?? '/';
47
-
48
- // Final fallback: window.location (tests, edge cases).
49
- if (typeof window !== 'undefined') return window.location.pathname;
50
- return '/';
40
+ return nav.pathname;
51
41
  }
@@ -4,25 +4,23 @@
4
4
  * Returns a read-only URLSearchParams instance reflecting the current
5
5
  * URL's query string. Updates when client-side navigation changes the URL.
6
6
  *
7
- * On the client, reads from NavigationContext which is updated atomically
8
- * with the RSC tree render during full navigations, AND by
9
- * syncShallowSearch() for shallow URL updates (nuqs shallow: true,
10
- * replaceUrl, or any external pushState/replaceState that changes the
11
- * query string). See router-init.ts.
7
+ * One unconditional read of NavigationContext, on every side (TIM-1425):
12
8
  *
13
- * This replaces the previous useSyncExternalStore approach which read
14
- * window.location.search directly — causing React to detect external
15
- * store tearing during transitions and fall back to synchronous rendering
16
- * (renderRootSync instead of renderRootConcurrent), blocking the main
17
- * thread and freezing animations.
9
+ * - In the browser, the provider is updated atomically with the RSC tree
10
+ * during full navigations, AND by syncShallowSearch() for shallow URL
11
+ * updates (nuqs shallow: true, replaceUrl, or any external
12
+ * pushState/replaceState that changes the query string). See router-init.ts.
13
+ * - During SSR, the wrapper chain mounts the same provider carrying the
14
+ * request's RAW query string (TIM-1424), so repeated keys
15
+ * (`?tag=a&tag=b`) survive — the collapsed record the ALS context holds
16
+ * cannot represent them.
17
+ * - In the RSC environment, this module is never evaluated — the shims plugin
18
+ * resolves next/navigation to navigation-rsc.ts (TIM-1420).
18
19
  *
19
20
  * Unlike Next.js's ReadonlyURLSearchParams, this returns a standard
20
21
  * URLSearchParams. Mutation methods (set, delete, append) work on the
21
22
  * local copy but do NOT affect the URL — use the router or nuqs for that.
22
23
  *
23
- * During SSR, reads the request search params from the SSR ALS context
24
- * (populated by ssr-entry.ts) instead of window.location.
25
- *
26
24
  * Compatible with Next.js's `useSearchParams()` from `next/navigation`.
27
25
  */
28
26
 
@@ -42,24 +40,28 @@ function getSearchParams(search: string): URLSearchParams {
42
40
  /**
43
41
  * Read the current URL search params.
44
42
  *
43
+ * Throws when no NavigationProvider is above the caller.
44
+ *
45
45
  * Compatible with Next.js's `useSearchParams()` from `next/navigation`.
46
46
  */
47
47
  export function useSearchParams(): URLSearchParams {
48
- try {
49
- // eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on environment, not render path
50
- const navContext = useNavigationContext();
51
- if (navContext !== null) {
52
- return getSearchParams(navContext.search);
53
- }
54
- } catch {
55
- // No React dispatcher available (called outside a component).
48
+ const nav = useNavigationContext();
49
+ if (nav === null) {
50
+ throw new Error(
51
+ '[timber] useSearchParams() was called outside the timber app tree ' +
52
+ '(no NavigationProvider found). In tests, render the component ' +
53
+ 'inside the timber providers.'
54
+ );
56
55
  }
57
-
58
- // SSR path: read from ALS-backed SSR data context.
59
- const ssrData = getSsrData();
60
- if (ssrData) return new URLSearchParams(ssrData.searchParams);
61
-
62
- // Final fallback: window.location (tests, edge cases).
63
- if (typeof window !== 'undefined') return getSearchParams(window.location.search);
64
- return new URLSearchParams();
56
+ // On the server, return a fresh instance per call. The module-level cache
57
+ // below exists to give client code stable identity for effect deps; on the
58
+ // server it would be shared mutable state across concurrent requests
59
+ // (design/13-security.md, "no global fallback state"). Both halves of the
60
+ // check matter: `window` is absent in a plain Node SSR render, and
61
+ // getSsrData() catches SSR environments that do define `window` (handleSsr
62
+ // always runs inside the ALS scope). Pinned by tests/ssr-provider-hooks.test.ts.
63
+ if (typeof window === 'undefined' || getSsrData()) {
64
+ return new URLSearchParams(nav.search);
65
+ }
66
+ return getSearchParams(nav.search);
65
67
  }
@@ -14,126 +14,39 @@
14
14
  * When the Routes interface is empty (no codegen yet), the generic
15
15
  * overload has `keyof Routes = never`, so only the fallback matches.
16
16
  *
17
- * During SSR, params are read from the ALS-backed SSR data context
18
- * (populated by ssr-entry.ts) to ensure correct per-request isolation
19
- * across concurrent requests with streaming Suspense.
20
- *
21
- * Reactivity: On the client, useParams() reads from ParamsContext, published
22
- * by the one provider the client renders above the merge point
23
- * (`PayloadRoot`). Params update atomically with the tree because they travel
24
- * on the same payload root — there is no separate channel that could be
25
- * seeded a render early or late (TIM-1294, TIM-1297).
26
- *
27
- * All mutable state is delegated to client/state.ts for singleton guarantees.
28
- * See design/18-build-system.md §"Singleton State Registry"
17
+ * One unconditional read of ParamsContext, on every side (TIM-1425):
18
+ *
19
+ * - In the browser, `PayloadRoot` publishes the payload's params above the
20
+ * merge point on every render path. Params update atomically with the RSC
21
+ * tree — no timing gap (TIM-1294, TIM-1297).
22
+ * - During SSR, the wrapper chain mounts the same `PayloadRoot`, fed the
23
+ * `params` half of `splitPayloadRoot(root)` — the identical derivation
24
+ * the browser performs at hydration (TIM-1424).
25
+ * - In the RSC environment, this module is never evaluated — the shims plugin
26
+ * resolves next/navigation to navigation-rsc.ts (TIM-1420).
27
+ * - Called outside a component entirely, React itself throws its
28
+ * invalid-hook-call error.
29
+ *
30
+ * The module-level subscribe/notify machinery and the `currentParams`
31
+ * snapshot that used to back a fourth fallback tier are gone (TIM-1425):
32
+ * the provider is unconditional on every render path, so nothing read them.
29
33
  *
30
34
  * Design doc: design/09-typescript.md §"Typed Routes"
31
35
  */
32
36
 
33
37
  import type { CoercedParams } from '../shared/param-value.ts';
34
38
  import type { Routes } from '../index.ts';
35
- import { getSsrData } from './ssr-data.ts';
36
- import {
37
- currentParams,
38
- currentSlotParams,
39
- _setCurrentParams,
40
- _setCurrentSlotParams,
41
- paramsListeners,
42
- } from './state.ts';
43
- import { resolveSegmentParams, type SlotParamsRecord } from '../shared/slot-params.ts';
39
+ import { resolveSegmentParams } from '../shared/slot-params.ts';
44
40
  import { useParamsContext } from './params-context.ts';
45
41
 
46
- // ---------------------------------------------------------------------------
47
- // Module-level subscribe/notify pattern — kept for backward compat and tests
48
- // ---------------------------------------------------------------------------
49
-
50
- /**
51
- * Subscribe to params changes.
52
- * Retained for backward compatibility with tests that verify the
53
- * subscribe/notify contract. On the client, useParams() reads from
54
- * NavigationContext instead.
55
- */
56
- export function subscribe(callback: () => void): () => void {
57
- paramsListeners.add(callback);
58
- return () => paramsListeners.delete(callback);
59
- }
60
-
61
- /**
62
- * Get the current params snapshot (module-level fallback).
63
- * Used by tests and by the hook when called outside a React component.
64
- */
65
- export function getSnapshot(): CoercedParams {
66
- return currentParams;
67
- }
68
-
69
- // ---------------------------------------------------------------------------
70
- // Framework API — called by the segment router on each navigation
71
- // ---------------------------------------------------------------------------
72
-
73
- /**
74
- * Set the current route params in the module-level store.
75
- *
76
- * Called by the router on each navigation. This updates the fallback
77
- * snapshot used by tests and by the hook when called outside a React
78
- * component (no NavigationContext available).
79
- *
80
- * On the client, the primary reactivity path is NavigationContext —
81
- * the router calls setNavigationState() then renderRoot() which wraps
82
- * the element in NavigationProvider. setCurrentParams is still called
83
- * for the module-level fallback.
84
- *
85
- * During SSR, params are also available via getSsrData().params
86
- * (ALS-backed).
87
- */
88
- export function setCurrentParams(params: CoercedParams): void {
89
- _setCurrentParams(params);
90
- }
91
-
92
- /**
93
- * Set the per-slot params snapshot in the module-level store.
94
- *
95
- * Paired with `setCurrentParams`: the router calls both on every navigation,
96
- * including with `null` when a response carries no slot params, so a slot's
97
- * params from the *previous* route cannot be read on the next one. Fill and
98
- * serve are paired; so are fill and clear. See TIM-1285.
99
- */
100
- export function setCurrentSlotParams(slotParams: SlotParamsRecord | null): void {
101
- _setCurrentSlotParams(slotParams);
102
- }
103
-
104
- /**
105
- * Notify all legacy subscribers that params have changed.
106
- *
107
- * Retained for backward compatibility with tests. On the client,
108
- * the NavigationContext + renderRoot pattern replaces this — params
109
- * update atomically with the tree render, so explicit notification
110
- * is no longer needed.
111
- */
112
- export function notifyParamsListeners(): void {
113
- for (const listener of paramsListeners) {
114
- listener();
115
- }
116
- }
117
-
118
- // ---------------------------------------------------------------------------
119
- // Public hook
120
- // ---------------------------------------------------------------------------
121
-
122
42
  /**
123
43
  * Read the current route's dynamic params.
124
44
  *
125
45
  * The optional `_route` argument exists only for TypeScript narrowing —
126
46
  * it does not affect the runtime return value.
127
47
  *
128
- * On the client, reads from ParamsContext, published by `PayloadRoot` above
129
- * everything the navigation renders. Params update atomically with the RSC
130
- * tree — no timing gap.
131
- *
132
- * During SSR, reads from the ALS-backed SSR data context to ensure
133
- * per-request isolation across concurrent requests with streaming Suspense.
134
- *
135
- * When called outside a React component (e.g., in test assertions),
136
- * falls back to the module-level snapshot.
48
+ * Throws when no `PayloadRoot` is above the caller (a component rendered
49
+ * outside the timber app — in tests, render inside the timber providers).
137
50
  *
138
51
  * @overload Typed — when a known segment path is passed, returns the
139
52
  * exact params shape from the generated Routes interface.
@@ -144,25 +57,13 @@ export function useSegmentParams<R extends keyof Routes>(
144
57
  ): Routes[R] extends { segmentParams: infer P } ? P : CoercedParams;
145
58
  export function useSegmentParams(segmentPath?: string): CoercedParams;
146
59
  export function useSegmentParams(segmentPath?: string): CoercedParams {
147
- // Try the client-owned provider first. It sits above everything a navigation
148
- // renders, so any component on the page — initial document, full navigation,
149
- // or a layout the server skipped — has one above it. Absent during SSR,
150
- // where the ALS path below is the answer. When called outside a React
151
- // component, useContext throws — caught below.
152
- try {
153
- // eslint-disable-next-line react-hooks/rules-of-hooks -- conditional on environment, not render path
154
- const paramsContext = useParamsContext();
155
- if (paramsContext !== null) {
156
- return resolveSegmentParams(paramsContext.params, paramsContext.slotParams, segmentPath);
157
- }
158
- } catch {
159
- // No React dispatcher available (called outside a component).
160
- // Fall through to module-level snapshot below.
60
+ const paramsContext = useParamsContext();
61
+ if (paramsContext === null) {
62
+ throw new Error(
63
+ '[timber] useSegmentParams() was called outside the timber app tree ' +
64
+ '(no params provider found). In tests, render the component inside ' +
65
+ 'the timber providers.'
66
+ );
161
67
  }
162
-
163
- // SSR path: read from ALS-backed SSR data context.
164
- // Falls back to module-level currentParams for tests.
165
- const ssrData = getSsrData();
166
- if (ssrData) return resolveSegmentParams(ssrData.params, ssrData.slotParams, segmentPath);
167
- return resolveSegmentParams(currentParams, currentSlotParams, segmentPath);
68
+ return resolveSegmentParams(paramsContext.params, paramsContext.slotParams, segmentPath);
168
69
  }
@@ -288,6 +288,23 @@ export interface TimberUserConfig {
288
288
  * See design/19-client-navigation.md §"X-Timber-State-Tree Header"
289
289
  */
290
290
  clientSegmentCache?: boolean;
291
+ /**
292
+ * Cache behavior configuration.
293
+ */
294
+ cache?: {
295
+ /**
296
+ * Promote the runtime-tier cache.component key-discipline warning to a
297
+ * hard error. When true, a cached component that reads request context
298
+ * (cookies, headers, searchParams) during a capture render throws
299
+ * instead of logging — the tainted payload is never cached.
300
+ *
301
+ * Default: false (structured error-level diagnostic, rate-limited per
302
+ * component). Recommended true for any app serving authenticated content.
303
+ *
304
+ * See design/45-cache-lifetimes.md §Enforcement.
305
+ */
306
+ strictKeyDiscipline?: boolean;
307
+ };
291
308
  topLoader?: {
292
309
  /** Whether the top-loader is enabled. Default: true. */
293
310
  enabled?: boolean;
@@ -46,6 +46,7 @@ const KNOWN_CONFIG_KEYS = {
46
46
  reactCompiler: true,
47
47
  sitemap: true,
48
48
  buildDir: true,
49
+ cache: true,
49
50
  clientSegmentCache: true,
50
51
  topLoader: true,
51
52
  budget: true,
@@ -195,6 +196,22 @@ export function validateConfig(config: TimberUserConfig): ConfigError[] {
195
196
  }
196
197
  }
197
198
 
199
+ // cache
200
+ if (config.cache != null && typeof config.cache === 'object') {
201
+ if (
202
+ config.cache.strictKeyDiscipline !== undefined &&
203
+ typeof config.cache.strictKeyDiscipline !== 'boolean'
204
+ ) {
205
+ errors.push({
206
+ field: 'cache.strictKeyDiscipline',
207
+ message: `cache.strictKeyDiscipline must be a boolean (got ${JSON.stringify(config.cache.strictKeyDiscipline)}).`,
208
+ value: config.cache.strictKeyDiscipline,
209
+ suggestion:
210
+ 'Use cache: { strictKeyDiscipline: true } to promote key-discipline warnings to errors.',
211
+ });
212
+ }
213
+ }
214
+
198
215
  const MOVED_TO_CACHE_FILE = new Set(['cacheHandler', 'cdnPurge']);
199
216
 
200
217
  for (const key of Object.keys(config)) {
@@ -13,8 +13,10 @@
13
13
  *
14
14
  * The holding server is started in rootSync's config() hook (the
15
15
  * earliest Vite plugin lifecycle point where we know the port) and
16
- * closed in timber-dev-server's configureServer() hook (the last
17
- * plugin, right before Vite calls server.listen()).
16
+ * closed directly in timber-dev-server's configureServer() hook
17
+ * (the last plugin, fire-and-forget). configureServer runs during
18
+ * createServer(), well before Vite's port probe and bind, so the
19
+ * port is free by the time httpServerStart() runs. (TIM-1419)
18
20
  *
19
21
  * Browser requests (Accept: text/html) get the HTML holding page.
20
22
  * API requests (Accept: application/json) get a 503 JSON response.
@@ -29,10 +31,12 @@ import http from 'node:http';
29
31
 
30
32
  export interface HoldingServer {
31
33
  /**
32
- * Start listening on the given port.
34
+ * Start listening on the given port and host.
35
+ * The host must match Vite's resolved `server.host` so the probe
36
+ * faithfully detects conflicts on the same address Vite will bind.
33
37
  * Returns the actual bound port (useful when port is 0 for OS assignment).
34
38
  */
35
- listen(port: number): Promise<number>;
39
+ listen(port: number, host?: string): Promise<number>;
36
40
 
37
41
  /** Gracefully close the server and stop accepting connections. */
38
42
  close(): Promise<void>;
@@ -125,12 +129,13 @@ const HOLDING_PAGE_HTML = [
125
129
  * const holding = createHoldingServer();
126
130
  * await bindWithBump((p) => holding.listen(p), { startPort: 3000, autoBump: true });
127
131
  *
128
- * // In last plugin's configureServer() — wrap listen for seamless handoff
129
- * const originalListen = server.listen.bind(server);
130
- * server.listen = async (...args) => {
131
- * await holding.close();
132
- * return originalListen(...args);
133
- * };
132
+ * // In last plugin's configureServer() — close directly so Vite 8's
133
+ * // isPortAvailable() probe and httpServer.listen() both see a free
134
+ * // port. SO_REUSEADDR allows immediate rebind. (TIM-1419)
135
+ * if (ctx.holdingServer) {
136
+ * ctx.holdingServer.close().catch(() => {});
137
+ * ctx.holdingServer = null;
138
+ * }
134
139
  * ```
135
140
  */
136
141
  export function createHoldingServer(): HoldingServer {
@@ -163,10 +168,10 @@ export function createHoldingServer(): HoldingServer {
163
168
  });
164
169
 
165
170
  return {
166
- listen(port: number): Promise<number> {
171
+ listen(port: number, host?: string): Promise<number> {
167
172
  return new Promise((resolve, reject) => {
168
173
  server.once('error', reject);
169
- server.listen(port, () => {
174
+ server.listen(port, host, () => {
170
175
  server.removeListener('error', reject);
171
176
  const addr = server.address();
172
177
  const boundPort = typeof addr === 'object' && addr ? addr.port : port;
@@ -176,6 +181,12 @@ export function createHoldingServer(): HoldingServer {
176
181
  },
177
182
 
178
183
  close(): Promise<void> {
184
+ // Kill lingering keep-alive sockets immediately — the holding
185
+ // page's meta-refresh keeps browser connections alive, and
186
+ // server.close() waits for all sockets to drain before the
187
+ // 'close' event fires. Without this, the close can stall for
188
+ // the full keepAliveTimeout (~5s), blocking the port handoff.
189
+ server.closeAllConnections();
179
190
  return new Promise((resolve, reject) => {
180
191
  server.close((err) => {
181
192
  if (err) reject(err);
package/src/index.ts CHANGED
@@ -343,6 +343,16 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
343
343
  // that closes the holding server never runs — starting it would
344
344
  // occupy the default port for the embedding's lifetime.
345
345
  const isMiddlewareMode = Boolean(userConfig.server?.middlewareMode);
346
+ // Resolve the host Vite will bind so the holding server probes the
347
+ // same address. Vite defaults to 'localhost' when server.host is
348
+ // unset; binding wildcard (no host) would mask conflicts on
349
+ // localhost due to SO_REUSEADDR address-family differences. TIM-1419.
350
+ const resolvedHost =
351
+ typeof userConfig.server?.host === 'string'
352
+ ? userConfig.server.host
353
+ : userConfig.server?.host === true
354
+ ? undefined
355
+ : 'localhost';
346
356
  if (command === 'serve' && !isPreview && !isMiddlewareMode) {
347
357
  ctx.holdingServer = createHoldingServer();
348
358
  const holdingRef = ctx.holdingServer;
@@ -350,7 +360,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
350
360
  configPort:
351
361
  typeof userConfig.server?.port === 'number' ? userConfig.server.port : undefined,
352
362
  envPort: process.env.PORT,
353
- listen: (p) => holdingRef.listen(p),
363
+ listen: (p) => holdingRef.listen(p, resolvedHost),
354
364
  });
355
365
  if (!result.bound) {
356
366
  // Holding server failed to bind. Drop the reference so the
@@ -358,13 +368,17 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
358
368
  // never listening.
359
369
  ctx.holdingServer = null;
360
370
  }
361
- if (result.bound || result.explicit) {
362
- // When bound, use the probed port with strictPort so Vite
363
- // doesn't re-bump. When unbound but explicit, still forward
364
- // the port so Vite surfaces the conflict via strictPort.
365
- // When unbound and implicit, skip — let Vite auto-bump.
371
+ if (result.bound) {
372
+ // Forward the probed port so Vite starts there. strictPort
373
+ // matches explicit — explicit ports fail loudly on a race
374
+ // between close and bind; implicit ones let Vite re-bump.
366
375
  resolvedDevPort = result.port;
367
376
  resolvedDevPortExplicit = result.explicit;
377
+ } else if (result.explicit) {
378
+ // Explicit port failed to bind (another process holds it).
379
+ // Forward it as the starting point but without strictPort
380
+ // so Vite auto-bumps to the next free port. TIM-1419.
381
+ resolvedDevPort = result.port;
368
382
  }
369
383
  } else if (command === 'serve' && isPreview) {
370
384
  // Preview mode: resolve the starting port from config/env/
@@ -431,10 +445,11 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
431
445
  // Dev/preview mode: set outDir so dev-time references are
432
446
  // consistent, and surface the resolved port to Vite.
433
447
  //
434
- // For dev, we set strictPort: true so Vite uses exactly the port
435
- // the holding server probed for — without strictPort, Vite would
436
- // run its own auto-bump starting from `port`, which would skip
437
- // straight past whatever holding-server-bumped port we picked.
448
+ // For dev with an explicit port, we set strictPort: true so Vite
449
+ // fails loudly on conflict. For auto-bumped (implicit) ports, we
450
+ // leave strictPort false so Vite can re-bump if the holding server's
451
+ // port becomes unavailable between close and bind (e.g., a second
452
+ // app instance starting on the same default port). TIM-1419.
438
453
  //
439
454
  // For `vite preview`, we set `preview.strictPort` based on
440
455
  // whether the user explicitly set the port. Implicit (default)
@@ -451,7 +466,7 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
451
466
  if (resolvedDevPort != null) {
452
467
  if (!isPreview) {
453
468
  serverConfig.port = resolvedDevPort;
454
- serverConfig.strictPort = true;
469
+ serverConfig.strictPort = resolvedDevPortExplicit;
455
470
  }
456
471
  previewConfig.port = resolvedDevPort;
457
472
  previewConfig.strictPort = resolvedDevPortExplicit;
@@ -118,7 +118,7 @@ export function timberDevServer(ctx: PluginContext): Plugin {
118
118
  * rewritten /index.html. Vite-internal and asset requests are
119
119
  * filtered out explicitly and passed through to Vite.
120
120
  */
121
- configureServer(server: ViteDevServer) {
121
+ async configureServer(server: ViteDevServer) {
122
122
  // Ensure config files are in the watch set. Vite's watcher covers
123
123
  // the project root, but `server.watcher.add()` is a no-op for files
124
124
  // already watched, and ensures newly-created config files (e.g. from
@@ -191,19 +191,16 @@ export function timberDevServer(ctx: PluginContext): Plugin {
191
191
  // discover it. See design/47-module-environment-tooling.md §5.
192
192
  registerDevDiscovery(server.httpServer, ctx.root, server.config);
193
193
 
194
- // Wrap server.listen() to close the holding server immediately
195
- // before Vite binds the port — no gap where nothing is listening.
196
- // The holding server was started in rootSync's config() hook to
197
- // serve a loading page during initialization.
198
- // See design/21-dev-server.md §"Startup Holding Server", TIM-665.
194
+ // Close the holding server and await the port release so Vite's
195
+ // port probe and bind see a free port. configureServer is awaited
196
+ // by Vite during createServer(), before startServer() runs
197
+ // isPortAvailable() and httpServer.listen().
198
+ // closeAllConnections() (in HoldingServer.close) kills keep-alive
199
+ // sockets from the meta-refresh page so close() resolves promptly.
200
+ // See design/21-dev-server.md §"Startup Holding Server", TIM-1419.
199
201
  if (ctx.holdingServer) {
200
- const originalListen = server.listen.bind(server);
201
- const holdingRef = ctx.holdingServer;
202
+ await ctx.holdingServer.close().catch(() => {});
202
203
  ctx.holdingServer = null;
203
- server.listen = async (port?: number, isRestart?: boolean) => {
204
- await holdingRef.close().catch(() => {});
205
- return originalListen(port, isRestart);
206
- };
207
204
  }
208
205
 
209
206
  // Log startup timing summary. configureServer runs on all plugins
@@ -159,6 +159,9 @@ function generateConfigModule(ctx: PluginContext): string {
159
159
  // Null in dev mode — HMR handles code updates without full reloads.
160
160
  deploymentId: ctx.deploymentId ?? null,
161
161
  clientSegmentCache: ctx.config.clientSegmentCache ?? false,
162
+ cache: {
163
+ strictKeyDiscipline: ctx.config.cache?.strictKeyDiscipline ?? false,
164
+ },
162
165
  // Browser log forwarding threshold — used by buildClientScripts to
163
166
  // decide whether /@vite/client is needed when clientJavascript is
164
167
  // disabled. See TIM-1152.
@@ -198,14 +198,15 @@ export function timberShims(_ctx: PluginContext): Plugin {
198
198
  const cleanId = stripJsExtension(id);
199
199
 
200
200
  // Check next/* shim map.
201
- // In the client (browser) environment, next/navigation resolves to
202
- // navigation-client.ts which only re-exports client hooks — not server
203
- // functions like redirect() and deny(). This prevents server/primitives.ts
204
- // from being pulled into the browser bundle.
201
+ // next/navigation resolves per environment (TIM-1420):
202
+ // RSC → navigation-rsc.ts (throwing stubs — no useContext)
203
+ // SSR → navigation.ts (real hooks + server functions)
204
+ // client → navigation-client.ts (real hooks, no server imports)
205
205
  if (cleanId in SHIM_MAP) {
206
- const env = envName(this);
207
- if (env === 'client' && cleanId === 'next/navigation') {
208
- return resolve(SHIMS_DIR, 'navigation-client.ts');
206
+ if (cleanId === 'next/navigation') {
207
+ const env = envName(this);
208
+ if (env === 'client') return resolve(SHIMS_DIR, 'navigation-client.ts');
209
+ if (env === 'rsc') return resolve(SHIMS_DIR, 'navigation-rsc.ts');
209
210
  }
210
211
  return SHIM_MAP[cleanId];
211
212
  }
@@ -228,7 +228,11 @@ export class StaticOutputPaths {
228
228
  * asked, not assumed. Folding unconditionally would reject `/Foo` beside
229
229
  * `/foo` on Linux, where both are perfectly servable files.
230
230
  */
231
- constructor(private readonly identity: FsIdentity = EXACT_FS_IDENTITY) {}
231
+ private readonly identity: FsIdentity;
232
+
233
+ constructor(identity: FsIdentity = EXACT_FS_IDENTITY) {
234
+ this.identity = identity;
235
+ }
232
236
 
233
237
  /** The identity under which the filesystem will compare this path. */
234
238
  private key(relPath: string): string {
@@ -309,10 +313,10 @@ export class StaticSiteWriter {
309
313
  readonly contentTypes: Record<string, string> = {};
310
314
  private readonly paths: StaticOutputPaths;
311
315
 
312
- constructor(
313
- private readonly staticDir: string,
314
- identity: FsIdentity = EXACT_FS_IDENTITY
315
- ) {
316
+ private readonly staticDir: string;
317
+
318
+ constructor(staticDir: string, identity: FsIdentity = EXACT_FS_IDENTITY) {
319
+ this.staticDir = staticDir;
316
320
  this.paths = new StaticOutputPaths(identity);
317
321
  }
318
322