@timber-js/app 0.2.0-alpha.186 → 0.2.0-alpha.188

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 (331) hide show
  1. package/dist/_chunks/{actions-C-Rw9vPc.js → actions-35jnMdeJ.js} +4 -3
  2. package/dist/_chunks/{actions-C-Rw9vPc.js.map → actions-35jnMdeJ.js.map} +1 -1
  3. package/dist/_chunks/als-registry-C6kcfprT.js +41 -0
  4. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -0
  5. package/dist/_chunks/{als-slots-mFweg276.js → als-slots-BEEIPKYm.js} +3 -4
  6. package/dist/_chunks/{als-slots-mFweg276.js.map → als-slots-BEEIPKYm.js.map} +1 -1
  7. package/dist/_chunks/{cache-api-Cd0VZ_Pd.js → cache-api-DjNrIWRR.js} +7 -13
  8. package/dist/_chunks/cache-api-DjNrIWRR.js.map +1 -0
  9. package/dist/_chunks/cli-check-CpmN7Nh-.js +256 -0
  10. package/dist/_chunks/cli-check-CpmN7Nh-.js.map +1 -0
  11. package/dist/_chunks/cli-schema-sync-CGMp_Psg.js +298 -0
  12. package/dist/_chunks/cli-schema-sync-CGMp_Psg.js.map +1 -0
  13. package/dist/_chunks/{cloudflare-Cs0uZXea.js → cloudflare-CGP6BZKO.js} +4 -3
  14. package/dist/_chunks/{cloudflare-Cs0uZXea.js.map → cloudflare-CGP6BZKO.js.map} +1 -1
  15. package/dist/_chunks/convention-lint-kXsgc_-7.js +784 -0
  16. package/dist/_chunks/convention-lint-kXsgc_-7.js.map +1 -0
  17. package/dist/_chunks/{error-boundary-DpYRI_I1.js → error-boundary-D-ODYX41.js} +25 -2
  18. package/dist/_chunks/error-boundary-D-ODYX41.js.map +1 -0
  19. package/dist/_chunks/file-cache-DmX7OqZP.js +454 -0
  20. package/dist/_chunks/file-cache-DmX7OqZP.js.map +1 -0
  21. package/dist/_chunks/{logger-N7e5auP0.js → logger-D8xJZXIN.js} +27 -40
  22. package/dist/_chunks/logger-D8xJZXIN.js.map +1 -0
  23. package/dist/_chunks/mdx-file-C005ay-P.js +25 -0
  24. package/dist/_chunks/mdx-file-C005ay-P.js.map +1 -0
  25. package/dist/_chunks/{plugin-context-DEGLSJs3.js → plugin-context-rCinWLiE.js} +7 -2
  26. package/dist/_chunks/{plugin-context-DEGLSJs3.js.map → plugin-context-rCinWLiE.js.map} +1 -1
  27. package/dist/_chunks/purge-store-Byr8XOjU.js +14 -0
  28. package/dist/_chunks/purge-store-Byr8XOjU.js.map +1 -0
  29. package/dist/_chunks/{resolve-schema-Dz3fcFUo.js → resolve-schema-5ma5pp1b.js} +2 -2
  30. package/dist/_chunks/{resolve-schema-Dz3fcFUo.js.map → resolve-schema-5ma5pp1b.js.map} +1 -1
  31. package/dist/_chunks/rsc-media-type-DRqE_lD_.js +46 -0
  32. package/dist/_chunks/rsc-media-type-DRqE_lD_.js.map +1 -0
  33. package/dist/_chunks/{cli-schema-sync-CKgHC2MB.js → scanner-C8b0Gcw3.js} +5 -298
  34. package/dist/_chunks/scanner-C8b0Gcw3.js.map +1 -0
  35. package/dist/_chunks/schema-bridge-Cc2Gngu1.js +199 -0
  36. package/dist/_chunks/schema-bridge-Cc2Gngu1.js.map +1 -0
  37. package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
  38. package/dist/_chunks/{param-value-C8TNYchQ.js → segment-context-CjOlyB8Y.js} +33 -2
  39. package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -0
  40. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
  41. package/dist/_chunks/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
  42. package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
  43. package/dist/_chunks/{navigation-root-B29qg0_T.js → use-segment-params-C4r4BD9T.js} +129 -5
  44. package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
  45. package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
  46. package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
  47. package/dist/adapters/cloudflare-dev.js +1 -1
  48. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  49. package/dist/adapters/cloudflare.d.ts.map +1 -1
  50. package/dist/adapters/cloudflare.js +1 -1
  51. package/dist/adapters/compress-module.d.ts +12 -0
  52. package/dist/adapters/compress-module.d.ts.map +1 -1
  53. package/dist/adapters/nitro.js +54 -2
  54. package/dist/adapters/nitro.js.map +1 -1
  55. package/dist/cache/index.js +1 -1
  56. package/dist/cdn/cloudflare-purge.js +30 -0
  57. package/dist/cdn/cloudflare-purge.js.map +1 -0
  58. package/dist/cdn/fastly-purge.js +33 -0
  59. package/dist/cdn/fastly-purge.js.map +1 -0
  60. package/dist/cdn/index.js +94 -0
  61. package/dist/cdn/index.js.map +1 -0
  62. package/dist/cdn/workers-cache-purge.js +35 -0
  63. package/dist/cdn/workers-cache-purge.js.map +1 -0
  64. package/dist/cli-check.d.ts +153 -0
  65. package/dist/cli-check.d.ts.map +1 -0
  66. package/dist/cli.d.ts +34 -8
  67. package/dist/cli.d.ts.map +1 -1
  68. package/dist/cli.js +46 -21
  69. package/dist/cli.js.map +1 -1
  70. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  71. package/dist/client/browser-entry/index.d.ts +1 -1
  72. package/dist/client/browser-entry/index.d.ts.map +1 -1
  73. package/dist/client/error-boundary.d.ts +6 -0
  74. package/dist/client/error-boundary.d.ts.map +1 -1
  75. package/dist/client/error-boundary.js +1 -1
  76. package/dist/client/index.d.ts +1 -0
  77. package/dist/client/index.d.ts.map +1 -1
  78. package/dist/client/index.js +27 -33
  79. package/dist/client/index.js.map +1 -1
  80. package/dist/client/internal.js +33 -17
  81. package/dist/client/internal.js.map +1 -1
  82. package/dist/client/link.d.ts +1 -7
  83. package/dist/client/link.d.ts.map +1 -1
  84. package/dist/client/navigation-commit.d.ts +16 -0
  85. package/dist/client/navigation-commit.d.ts.map +1 -1
  86. package/dist/client/router-pipeline.d.ts.map +1 -1
  87. package/dist/client/router.d.ts.map +1 -1
  88. package/dist/client/rsc-fetch.d.ts +9 -1
  89. package/dist/client/rsc-fetch.d.ts.map +1 -1
  90. package/dist/client/segment-cache.d.ts +14 -0
  91. package/dist/client/segment-cache.d.ts.map +1 -1
  92. package/dist/client/use-query-states.d.ts +9 -3
  93. package/dist/client/use-query-states.d.ts.map +1 -1
  94. package/dist/codec.js +1 -1
  95. package/dist/cookies/define-cookie.d.ts.map +1 -1
  96. package/dist/cookies/index.js +2 -2
  97. package/dist/cookies/index.js.map +1 -1
  98. package/dist/index.d.ts +4 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js +142 -477
  101. package/dist/index.js.map +1 -1
  102. package/dist/params/index.js +1 -1
  103. package/dist/plugin-context.d.ts +15 -0
  104. package/dist/plugin-context.d.ts.map +1 -1
  105. package/dist/plugins/routing.d.ts +0 -9
  106. package/dist/plugins/routing.d.ts.map +1 -1
  107. package/dist/plugins/shims.d.ts.map +1 -1
  108. package/dist/plugins/static-build.d.ts +24 -0
  109. package/dist/plugins/static-build.d.ts.map +1 -1
  110. package/dist/routing/codegen-shared.d.ts +3 -44
  111. package/dist/routing/codegen-shared.d.ts.map +1 -1
  112. package/dist/routing/codegen-types.d.ts +10 -31
  113. package/dist/routing/codegen-types.d.ts.map +1 -1
  114. package/dist/routing/codegen-write.d.ts +51 -0
  115. package/dist/routing/codegen-write.d.ts.map +1 -0
  116. package/dist/routing/codegen.d.ts.map +1 -1
  117. package/dist/routing/convention-lint.d.ts +18 -4
  118. package/dist/routing/convention-lint.d.ts.map +1 -1
  119. package/dist/routing/export-detect.d.ts +16 -0
  120. package/dist/routing/export-detect.d.ts.map +1 -1
  121. package/dist/routing/index.js +3 -2
  122. package/dist/routing/link-codegen.d.ts +19 -4
  123. package/dist/routing/link-codegen.d.ts.map +1 -1
  124. package/dist/routing/manifest-codegen.d.ts +1 -7
  125. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  126. package/dist/routing/segment-keys.d.ts +22 -0
  127. package/dist/routing/segment-keys.d.ts.map +1 -1
  128. package/dist/routing/types.d.ts +0 -6
  129. package/dist/routing/types.d.ts.map +1 -1
  130. package/dist/schema-bridge.d.ts +60 -9
  131. package/dist/schema-bridge.d.ts.map +1 -1
  132. package/dist/search-params/define.d.ts +62 -8
  133. package/dist/search-params/define.d.ts.map +1 -1
  134. package/dist/search-params/index.d.ts +0 -1
  135. package/dist/search-params/index.d.ts.map +1 -1
  136. package/dist/search-params/index.js +66 -29
  137. package/dist/search-params/index.js.map +1 -1
  138. package/dist/search-params/parse-total.d.ts +70 -0
  139. package/dist/search-params/parse-total.d.ts.map +1 -0
  140. package/dist/search-params/wrappers.d.ts +26 -3
  141. package/dist/search-params/wrappers.d.ts.map +1 -1
  142. package/dist/server/access-gate.d.ts +19 -8
  143. package/dist/server/access-gate.d.ts.map +1 -1
  144. package/dist/server/action-handler.d.ts.map +1 -1
  145. package/dist/server/als-registry.d.ts +16 -0
  146. package/dist/server/als-registry.d.ts.map +1 -1
  147. package/dist/server/chain-url-parts.d.ts +2 -3
  148. package/dist/server/chain-url-parts.d.ts.map +1 -1
  149. package/dist/server/compress.d.ts.map +1 -1
  150. package/dist/server/deny-boundary.d.ts +148 -15
  151. package/dist/server/deny-boundary.d.ts.map +1 -1
  152. package/dist/server/deny-renderer.d.ts +2 -2
  153. package/dist/server/deny-renderer.d.ts.map +1 -1
  154. package/dist/server/error-boundary-wrapper.d.ts +85 -15
  155. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  156. package/dist/server/index.d.ts +0 -1
  157. package/dist/server/index.d.ts.map +1 -1
  158. package/dist/server/index.js +3 -2
  159. package/dist/server/index.js.map +1 -1
  160. package/dist/server/internal.d.ts +3 -1
  161. package/dist/server/internal.d.ts.map +1 -1
  162. package/dist/server/internal.js +344 -237
  163. package/dist/server/internal.js.map +1 -1
  164. package/dist/server/metadata-collector.d.ts +52 -0
  165. package/dist/server/metadata-collector.d.ts.map +1 -0
  166. package/dist/server/param-coercion.d.ts +12 -5
  167. package/dist/server/param-coercion.d.ts.map +1 -1
  168. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  169. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  170. package/dist/server/pipeline-phases.d.ts.map +1 -1
  171. package/dist/server/primitives.d.ts +23 -0
  172. package/dist/server/primitives.d.ts.map +1 -1
  173. package/dist/server/route-element-builder.d.ts +1 -12
  174. package/dist/server/route-element-builder.d.ts.map +1 -1
  175. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
  177. package/dist/server/rsc-entry/error-renderer.d.ts +1 -1
  178. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  179. package/dist/server/rsc-entry/helpers.d.ts +0 -7
  180. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  181. package/dist/server/rsc-entry/index.d.ts +0 -1
  182. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  183. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  184. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  185. package/dist/server/rsc-entry/rsc-payload.d.ts +1 -3
  186. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  187. package/dist/server/rsc-entry/rsc-stream.d.ts +12 -0
  188. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  189. package/dist/server/rsc-entry/ssr-renderer.d.ts +0 -2
  190. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  191. package/dist/server/skippable-prefix.d.ts +3 -0
  192. package/dist/server/skippable-prefix.d.ts.map +1 -1
  193. package/dist/server/slot-resolver.d.ts +18 -19
  194. package/dist/server/slot-resolver.d.ts.map +1 -1
  195. package/dist/server/ssr-bridge-types.d.ts +11 -0
  196. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  197. package/dist/server/ssr-entry.d.ts +0 -1
  198. package/dist/server/ssr-entry.d.ts.map +1 -1
  199. package/dist/server/state-tree-diff.d.ts +9 -16
  200. package/dist/server/state-tree-diff.d.ts.map +1 -1
  201. package/dist/server/static-generator.d.ts.map +1 -1
  202. package/dist/server/status-code-resolver.d.ts +8 -1
  203. package/dist/server/status-code-resolver.d.ts.map +1 -1
  204. package/dist/server/tree-builder.d.ts +28 -43
  205. package/dist/server/tree-builder.d.ts.map +1 -1
  206. package/dist/server/types.d.ts +12 -7
  207. package/dist/server/types.d.ts.map +1 -1
  208. package/dist/server/utils/element-type.d.ts +40 -0
  209. package/dist/server/utils/element-type.d.ts.map +1 -0
  210. package/dist/shared/rsc-media-type.d.ts +40 -0
  211. package/dist/shared/rsc-media-type.d.ts.map +1 -0
  212. package/dist/shared/segment-info.d.ts +7 -0
  213. package/dist/shared/segment-info.d.ts.map +1 -1
  214. package/docs/api/30-api-server.mdx +1 -1
  215. package/docs/api/33-api-search-params.mdx +38 -16
  216. package/docs/api/35-api-typescript.mdx +3 -3
  217. package/docs/api/36-cli.mdx +34 -7
  218. package/docs/learn/00-introduction.mdx +1 -1
  219. package/docs/learn/02-pages-and-layouts.mdx +1 -1
  220. package/docs/learn/05-typed-params.mdx +8 -8
  221. package/docs/learn/07-typed-routes.mdx +12 -7
  222. package/docs/learn/11-error-handling.mdx +16 -0
  223. package/docs/more/01-advanced-routing.mdx +1 -1
  224. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  225. package/docs/more/50-ai-agent-instructions.mdx +6 -4
  226. package/package.json +8 -5
  227. package/src/adapters/cloudflare.ts +4 -1
  228. package/src/adapters/compress-module.ts +79 -1
  229. package/src/cli-check.ts +458 -0
  230. package/src/cli.ts +59 -24
  231. package/src/client/browser-entry/action-dispatch.ts +2 -1
  232. package/src/client/browser-entry/index.ts +0 -5
  233. package/src/client/error-boundary.tsx +65 -1
  234. package/src/client/index.ts +14 -3
  235. package/src/client/link.tsx +65 -64
  236. package/src/client/navigation-commit.ts +27 -4
  237. package/src/client/params-context.ts +4 -4
  238. package/src/client/router-pipeline.ts +4 -0
  239. package/src/client/router.ts +1 -0
  240. package/src/client/rsc-fetch.ts +14 -4
  241. package/src/client/segment-cache.ts +26 -5
  242. package/src/client/use-query-states.ts +102 -39
  243. package/src/cookies/define-cookie.ts +6 -1
  244. package/src/index.ts +20 -3
  245. package/src/plugin-context.ts +26 -0
  246. package/src/plugins/routing.ts +84 -146
  247. package/src/plugins/shims.ts +0 -1
  248. package/src/plugins/static-build.ts +78 -24
  249. package/src/routing/codegen-shared.ts +3 -79
  250. package/src/routing/codegen-types.ts +10 -31
  251. package/src/routing/codegen-write.ts +139 -0
  252. package/src/routing/codegen.ts +56 -182
  253. package/src/routing/convention-lint.ts +139 -40
  254. package/src/routing/export-detect.ts +151 -7
  255. package/src/routing/link-codegen.ts +32 -65
  256. package/src/routing/manifest-codegen.ts +1 -59
  257. package/src/routing/scanner.ts +3 -3
  258. package/src/routing/segment-keys.ts +37 -0
  259. package/src/routing/types.ts +0 -6
  260. package/src/schema-bridge.ts +180 -58
  261. package/src/search-params/define.ts +102 -37
  262. package/src/search-params/index.ts +0 -1
  263. package/src/search-params/parse-total.ts +78 -0
  264. package/src/search-params/wrappers.ts +60 -11
  265. package/src/server/access-gate.tsx +61 -68
  266. package/src/server/action-handler.ts +1 -4
  267. package/src/server/als-registry.ts +16 -0
  268. package/src/server/chain-url-parts.ts +2 -3
  269. package/src/server/compress.ts +9 -1
  270. package/src/server/deny-boundary.ts +269 -41
  271. package/src/server/deny-renderer.ts +32 -21
  272. package/src/server/error-boundary-wrapper.ts +166 -79
  273. package/src/server/index.ts +1 -3
  274. package/src/server/internal.ts +2 -2
  275. package/src/server/metadata-collector.ts +115 -0
  276. package/src/server/param-coercion.ts +13 -61
  277. package/src/server/pipeline-helpers.ts +2 -2
  278. package/src/server/pipeline-outcome.ts +2 -1
  279. package/src/server/pipeline-phases.ts +9 -9
  280. package/src/server/primitives.ts +25 -0
  281. package/src/server/route-element-builder.ts +169 -194
  282. package/src/server/rsc-cache-key-guard.ts +2 -8
  283. package/src/server/rsc-entry/deny-fallback.ts +3 -2
  284. package/src/server/rsc-entry/error-renderer.ts +31 -11
  285. package/src/server/rsc-entry/helpers.ts +3 -12
  286. package/src/server/rsc-entry/index.ts +0 -5
  287. package/src/server/rsc-entry/render-route.ts +14 -11
  288. package/src/server/rsc-entry/revalidate-renderer.ts +2 -1
  289. package/src/server/rsc-entry/rsc-payload.ts +104 -20
  290. package/src/server/rsc-entry/rsc-stream.ts +25 -2
  291. package/src/server/rsc-entry/ssr-renderer.ts +28 -16
  292. package/src/server/skippable-prefix.ts +21 -39
  293. package/src/server/slot-resolver.ts +69 -189
  294. package/src/server/ssr-bridge-types.ts +12 -0
  295. package/src/server/ssr-entry.ts +8 -6
  296. package/src/server/state-tree-diff.ts +11 -62
  297. package/src/server/static-generator.ts +3 -2
  298. package/src/server/status-code-resolver.ts +28 -11
  299. package/src/server/tree-builder.ts +35 -228
  300. package/src/server/types.ts +12 -7
  301. package/src/server/utils/element-type.ts +72 -0
  302. package/src/shared/rsc-media-type.ts +43 -0
  303. package/src/shared/segment-info.ts +7 -0
  304. package/dist/_chunks/cache-api-Cd0VZ_Pd.js.map +0 -1
  305. package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +0 -1
  306. package/dist/_chunks/error-boundary-DpYRI_I1.js.map +0 -1
  307. package/dist/_chunks/logger-N7e5auP0.js.map +0 -1
  308. package/dist/_chunks/navigation-root-B29qg0_T.js.map +0 -1
  309. package/dist/_chunks/param-value-C8TNYchQ.js.map +0 -1
  310. package/dist/_chunks/registry-DbJPKoBp.js +0 -20
  311. package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
  312. package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
  313. package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
  314. package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
  315. package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
  316. package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
  317. package/dist/_chunks/use-segment-params-ClyUNq4d.js +0 -128
  318. package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +0 -1
  319. package/dist/_chunks/walkers-BhhwI9TD.js +0 -936
  320. package/dist/_chunks/walkers-BhhwI9TD.js.map +0 -1
  321. package/dist/search-params/registry.d.ts +0 -20
  322. package/dist/search-params/registry.d.ts.map +0 -1
  323. package/dist/segment-params/define.d.ts +0 -83
  324. package/dist/segment-params/define.d.ts.map +0 -1
  325. package/dist/segment-params/index.d.ts +0 -3
  326. package/dist/segment-params/index.d.ts.map +0 -1
  327. package/dist/segment-params/index.js +0 -70
  328. package/dist/segment-params/index.js.map +0 -1
  329. package/src/search-params/registry.ts +0 -31
  330. package/src/segment-params/define.ts +0 -226
  331. package/src/segment-params/index.ts +0 -9
@@ -11,14 +11,14 @@
11
11
  'use client';
12
12
 
13
13
  import { useQueryStates as nuqsUseQueryStates } from 'nuqs';
14
- import type { SingleParser } from 'nuqs';
14
+ import type { MultiParser } from 'nuqs';
15
15
  import type {
16
16
  SearchParamCodec,
17
17
  SearchParamsDefinition,
18
18
  SetParams,
19
19
  QueryStatesOptions,
20
20
  } from '../search-params/define.js';
21
- import { getSearchParamsDefinition } from '../search-params/registry.js';
21
+ import { parseTotal } from '../search-params/parse-total.js';
22
22
 
23
23
  // ─── Codec Bridge ─────────────────────────────────────────────────
24
24
 
@@ -50,32 +50,80 @@ function unwrapNuqsValue(value: unknown): unknown {
50
50
  }
51
51
 
52
52
  /**
53
- * Bridge a timber SearchParamCodec to a nuqs-compatible SingleParser.
53
+ * Bridge a timber SearchParamCodec to a nuqs-compatible MultiParser.
54
54
  *
55
55
  * nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }
56
56
  * timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }
57
57
  *
58
- * The defaultValue is computed eagerly. Codecs are documented to return a
59
- * default rather than throw, but a throwing codec must not crash every
60
- * component that mounts the hook — treat its default as undefined and let
61
- * its error surface from server-side parse() instead.
58
+ * The defaultValue is computed eagerly, through `parseTotal` — the same
59
+ * entry point server-side `parse()` uses, so the hook and the server agree
60
+ * on what an absent param means (a bare nuqs parser answers `null`, not
61
+ * `undefined`; TIM-1350). Codecs are documented to return a default rather
62
+ * than throw, but a throwing codec must not crash every component that
63
+ * mounts the hook — treat its default as undefined and let its error
64
+ * surface from server-side parse() instead.
65
+ *
66
+ * A `null` absent-value is NOT registered as the nuqs default. nuqs
67
+ * already represents an absent key as `null`, so the hook reads the same
68
+ * value either way — but registering it makes `clearOnDefault` fire on
69
+ * `setParams({ q: null })` and delete the key before the bridged
70
+ * `serialize` runs. For a codec that encodes `null` as a real query value
71
+ * (`serialize(null) === 'none'`), that silently disagrees with
72
+ * `buildSearchParams({ q: null })`, which writes it. Same reasoning as
73
+ * `getDefaultSerialized` on the server: a codec with no value for an
74
+ * absent param has no default to register.
62
75
  */
63
- function bridgeCodec<T>(codec: SearchParamCodec<T>): SingleParser<T> & { defaultValue: T } {
64
- let defaultValue: unknown;
76
+ function bridgeCodec<T>(codec: SearchParamCodec<T>): MultiParser<T> & { defaultValue: T } {
77
+ let absent: unknown;
65
78
  try {
66
- defaultValue = codec.parse(undefined);
79
+ absent = parseTotal(codec, undefined);
67
80
  } catch {
68
- defaultValue = undefined;
81
+ absent = undefined;
69
82
  }
70
- return {
71
- parse: (v: string) => wrapNuqsValue(codec.parse(v)),
83
+
84
+ const parser = {
85
+ // `multi`, so nuqs reads the key with `searchParams.getAll()` and hands
86
+ // us EVERY value. A single parser reads `.get()` — the first value only
87
+ // — which is not the domain a timber codec is defined over. The server
88
+ // parses `?tags=a&tags=b` as `['a','b']`; a single parser made the hook
89
+ // answer `['a']` for the same URL, under a declared `string[]` that
90
+ // admitted no such disagreement (TIM-1352). Scalar codecs are unaffected:
91
+ // they receive the array and take `value[0]`, exactly as they do on the
92
+ // server, so first-value-wins is preserved through the same code path
93
+ // rather than through nuqs's reader.
94
+ //
95
+ // nuqs never calls this with an empty array — `isAbsentFromUrl` treats
96
+ // `[]` as absent and answers `defaultValue` directly — which is what
97
+ // keeps the absent case agreeing with the server's `undefined`.
98
+ //
99
+ // Reading every value is only half of it: the values must arrive in the
100
+ // SAME SHAPE the server would have produced, or the divergence just
101
+ // moves. `normalizeRaw` (search-params/define.ts) collapses a
102
+ // single-valued key to a bare string and keeps an array only for a
103
+ // repeated one, so this mirrors that rule exactly. Handing a codec
104
+ // `['3']` where the server hands it `'3'` breaks every codec whose
105
+ // `parse` is written for the scalar case — which is most hand-written
106
+ // ones, contract or no contract.
107
+ type: 'multi' as const,
108
+ // Through parseTotal, not codec.parse. nuqs's own `.withDefault(d)`
109
+ // overrides ONLY `parseServerSide`, so `parseAsInteger.withDefault(1)`
110
+ // on `?page=abc` returned 1 from the server and null from the hook —
111
+ // a divergence the declared non-nullable `number` did not admit.
112
+ parse: (v: readonly string[]) =>
113
+ wrapNuqsValue(parseTotal(codec, v.length === 1 ? v[0] : [...v])),
72
114
  serialize: (v: unknown) => {
73
115
  const value = unwrapNuqsValue(v);
74
116
  // Delegate null to the codec — some codecs encode null as a real
75
117
  // query value. undefined has no encoding; nuqs requires a string.
76
- return value === undefined ? '' : (codec.serialize(value as T) ?? '');
118
+ //
119
+ // ONE element, always. A multi parser may return several — nuqs
120
+ // appends one key per entry — but `Codec.serialize` returns a single
121
+ // string by construction, so timber cannot emit repeated keys here
122
+ // any more than `buildSearchParams` can. Widening that protocol is
123
+ // TIM-1353. Wrapping the same string keeps the URL the hook writes
124
+ // byte-identical to the one `buildSearchParams` writes.
125
+ return [value === undefined ? '' : (codec.serialize(value as T) ?? '')];
77
126
  },
78
- defaultValue: wrapNuqsValue(defaultValue),
79
127
  eq: (a: unknown, b: unknown) => {
80
128
  if (a === b) return true;
81
129
  try {
@@ -86,7 +134,26 @@ function bridgeCodec<T>(codec: SearchParamCodec<T>): SingleParser<T> & { default
86
134
  return false;
87
135
  }
88
136
  },
89
- } as SingleParser<T> & { defaultValue: T };
137
+ } as MultiParser<T> & { defaultValue: T };
138
+
139
+ if (absent !== null) parser.defaultValue = wrapNuqsValue(absent) as T;
140
+ return parser;
141
+ }
142
+
143
+ /**
144
+ * Collect `withUrlKey` aliases off a codec map.
145
+ *
146
+ * `withUrlKey(codec, 'q')` returns a codec carrying `urlKey: 'q'`, so the map
147
+ * alone is enough to reconstruct the aliases — `defineSearchParams` builds its
148
+ * own `urlKeys` from exactly this property.
149
+ */
150
+ function deriveUrlKeys(codecs: Record<string, SearchParamCodec<unknown>>): Record<string, string> {
151
+ const result: Record<string, string> = {};
152
+ for (const key of Object.keys(codecs)) {
153
+ const alias = codecs[key]?.urlKey;
154
+ if (alias) result[key] = alias;
155
+ }
156
+ return result;
90
157
  }
91
158
 
92
159
  /**
@@ -95,12 +162,12 @@ function bridgeCodec<T>(codec: SearchParamCodec<T>): SingleParser<T> & { default
95
162
  function bridgeCodecs<T extends Record<string, unknown>>(codecs: {
96
163
  [K in keyof T]: SearchParamCodec<T[K]>;
97
164
  }) {
98
- const result: Record<string, SingleParser<unknown> & { defaultValue: unknown }> = {};
165
+ const result: Record<string, MultiParser<unknown> & { defaultValue: unknown }> = {};
99
166
  for (const key of Object.keys(codecs)) {
100
167
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
168
  result[key] = bridgeCodec(codecs[key as keyof T]) as any;
102
169
  }
103
- return result as { [K in keyof T]: SingleParser<T[K]> & { defaultValue: T[K] } };
170
+ return result as { [K in keyof T]: MultiParser<T[K]> & { defaultValue: T[K] } };
104
171
  }
105
172
 
106
173
  // ─── Hook ─────────────────────────────────────────────────────────
@@ -113,7 +180,7 @@ function bridgeCodecs<T extends Record<string, unknown>>(codecs: {
113
180
  *
114
181
  * Usage:
115
182
  * ```ts
116
- * // Via a SearchParamsDefinition
183
+ * // Via a SearchParamsDefinition imported from the route's params.ts
117
184
  * const [params, setParams] = definition.useQueryStates()
118
185
  *
119
186
  * // Standalone with inline codecs
@@ -121,30 +188,18 @@ function bridgeCodecs<T extends Record<string, unknown>>(codecs: {
121
188
  * page: fromSchema(z.coerce.number().int().min(1).default(1)),
122
189
  * })
123
190
  * ```
191
+ *
192
+ * There is deliberately no route-string form (`useQueryStates('/products')`).
193
+ * Importing the definition from `params.ts` is the documented way to reach
194
+ * another route's codecs — it needs no runtime registry lookup and so has no
195
+ * "not registered yet" failure mode. See design/23-search-params.md
196
+ * §"Client Access".
124
197
  */
125
198
  export function useQueryStates<T extends Record<string, unknown>>(
126
- codecsOrRoute: { [K in keyof T]: SearchParamCodec<T[K]> } | string,
199
+ codecs: { [K in keyof T]: SearchParamCodec<T[K]> },
127
200
  _options?: QueryStatesOptions,
128
201
  urlKeys?: Readonly<Record<string, string>>
129
202
  ): [T, SetParams<T>] {
130
- // Route-string overload: resolve codecs from the registry
131
- let codecs: { [K in keyof T]: SearchParamCodec<T[K]> };
132
- let resolvedUrlKeys = urlKeys;
133
- if (typeof codecsOrRoute === 'string') {
134
- const definition = getSearchParamsDefinition(codecsOrRoute);
135
- if (!definition) {
136
- throw new Error(
137
- `useQueryStates('${codecsOrRoute}'): no search params registered for this route. ` +
138
- `Either the route has no search-params.ts file, or it hasn't been loaded yet. ` +
139
- `For cross-route usage, import the definition explicitly.`
140
- );
141
- }
142
- codecs = definition.codecs as { [K in keyof T]: SearchParamCodec<T[K]> };
143
- resolvedUrlKeys = definition.urlKeys;
144
- } else {
145
- codecs = codecsOrRoute;
146
- }
147
-
148
203
  const bridged = bridgeCodecs(codecs);
149
204
 
150
205
  // Forward hook-level options (shallow, scroll, history) to nuqs.
@@ -155,7 +210,15 @@ export function useQueryStates<T extends Record<string, unknown>>(
155
210
  if (_options?.shallow !== undefined) nuqsOptions.shallow = _options.shallow;
156
211
  if (_options?.scroll !== undefined) nuqsOptions.scroll = _options.scroll;
157
212
  if (_options?.history !== undefined) nuqsOptions.history = _options.history;
158
- if (resolvedUrlKeys && Object.keys(resolvedUrlKeys).length > 0) {
213
+ // `withUrlKey` attaches the alias to the codec itself — that is the design's
214
+ // "URL keys travel with codecs" principle — so the aliases are derivable
215
+ // here and must be, for the inline codec-map form: nobody passes `urlKeys`
216
+ // on that path, and without this an aliased bundle silently read and wrote
217
+ // the property name instead of the alias. `bindUseQueryStates` still passes
218
+ // the definition's precomputed map, which wins on conflict; it is built from
219
+ // these same codecs, so the two agree by construction rather than by luck.
220
+ const resolvedUrlKeys = { ...deriveUrlKeys(codecs), ...urlKeys };
221
+ if (Object.keys(resolvedUrlKeys).length > 0) {
159
222
  nuqsOptions.urlKeys = resolvedUrlKeys;
160
223
  }
161
224
 
@@ -147,7 +147,12 @@ function deleteClientCookie(name: string, options?: ClientCookieOptions): void {
147
147
  // ─── Standard Schema Auto-Detection ───────────────────────────────────────
148
148
 
149
149
  function resolveCodec<T>(codecOrSchema: CookieCodec<T> | StandardSchemaV1<T>): CookieCodec<T> {
150
- return resolveCodecOrSchema('codec', codecOrSchema, 'search') as CookieCodec<T>;
150
+ // 'cookie', not 'search': a cookie carries one string per name, so the
151
+ // repeated-key array shape the search bridge tolerates does not exist
152
+ // here — and `serialize` returning `null` means DELETE for a cookie
153
+ // where it means "omit the key" for a search param. See
154
+ // schema-bridge.ts §fromCookieSchema.
155
+ return resolveCodecOrSchema('codec', codecOrSchema, 'cookie') as CookieCodec<T>;
151
156
  }
152
157
 
153
158
  // ─── Factory ──────────────────────────────────────────────────────────────
package/src/index.ts CHANGED
@@ -42,7 +42,7 @@ import { resolveEncryptionKeyExpression, shouldEnableEncryption } from './server
42
42
  import { createHoldingServer } from './dev-tools/holding-server.js';
43
43
  import { resolveStartPort, startDevServerPort } from './server/port-resolution.js';
44
44
  import type { TimberUserConfig } from './config-types.js';
45
- import type { PluginContext } from './plugin-context.js';
45
+ import type { PluginContext, TimberPluginApi } from './plugin-context.js';
46
46
  import {
47
47
  createPluginContext,
48
48
  loadTimberConfigFile,
@@ -69,7 +69,10 @@ export type { Metadata, MetadataRoute, MetadataHandler, MetadataResult } from '.
69
69
  *
70
70
  * Each key is a route path pattern. Values have:
71
71
  * segmentParams: shape of URL segment params (e.g. { id: string })
72
- * searchParams: parsed type from search-params.ts, or {} if none
72
+ *
73
+ * There is no `searchParams` member: since TIM-1343 the framework does not
74
+ * scan for search-param definitions, so it has nothing to type them with. A
75
+ * definition carries its own type to call sites through an ordinary import.
73
76
  *
74
77
  * This interface is empty by default and populated via codegen.
75
78
  * See design/09-typescript.md §"Typed Routes".
@@ -245,6 +248,21 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
245
248
  // config() hook must NOT return a `plugins` field (Vite ignores it).
246
249
  const rootSync: Plugin = {
247
250
  name: 'timber-root-sync',
251
+
252
+ // The plugin context, published for out-of-band callers that need the
253
+ // *resolved* configuration rather than a second reading of the files it
254
+ // came from. `timber check` is the one today: it runs Vite's
255
+ // `resolveConfig()` and reads root, appDir, and pageExtensions from here,
256
+ // so it scans exactly the tree `timber build` builds — including config
257
+ // passed inline as `timber({ ... })`, which lives only in this closure and
258
+ // is invisible to anything that re-reads `timber.config.ts`. See
259
+ // `cli-check.ts` and design/11-platform.md §`timber check`.
260
+ //
261
+ // Valid from `configResolved` onward; `appDir` and `root` are unset before
262
+ // that, which is why the reader runs a full `resolveConfig()` rather than
263
+ // just loading the config file.
264
+ api: { getContext: () => ctx } satisfies TimberPluginApi,
265
+
248
266
  async config(userConfig, { command, isPreview }) {
249
267
  // ── Load timber.config.ts from the correct root ───────────────
250
268
  // Vite's `config` hook fires before `configResolved`. The user's
@@ -388,7 +406,6 @@ export function timber(config?: TimberUserConfig): PluginOption[] {
388
406
  '@timber-js/app/cookies',
389
407
  '@timber-js/app/cache',
390
408
  '@timber-js/app/search-params',
391
- '@timber-js/app/segment-params',
392
409
  '@timber-js/app/params',
393
410
  '@timber-js/app/codec',
394
411
  ];
@@ -248,6 +248,32 @@ export class ParseMemo {
248
248
 
249
249
  // ── App directory resolution ──────────────────────────────────────────────
250
250
 
251
+ // ── Plugin API (Vite `plugin.api`) ────────────────────────────────────────
252
+
253
+ /**
254
+ * What `timber-root-sync` publishes on its Vite `plugin.api`.
255
+ *
256
+ * The single supported way to read timber's *resolved* configuration from
257
+ * outside the plugin pipeline. Re-reading `timber.config.ts` is not equivalent:
258
+ * config passed inline as `timber({ ... })` in `vite.config.ts` exists only in
259
+ * the plugin's closure, and `root`/`appDir` are resolved against Vite's root
260
+ * rather than the caller's cwd.
261
+ */
262
+ export interface TimberPluginApi {
263
+ /** The live plugin context. Fully populated from `configResolved` onward. */
264
+ getContext(): PluginContext;
265
+ }
266
+
267
+ /** Narrow an unknown Vite `plugin.api` to timber's. */
268
+ export function isTimberPluginApi(api: unknown): api is TimberPluginApi {
269
+ return (
270
+ typeof api === 'object' &&
271
+ api !== null &&
272
+ 'getContext' in api &&
273
+ typeof api.getContext === 'function'
274
+ );
275
+ }
276
+
251
277
  /**
252
278
  * Resolve the app directory. Checks (in order):
253
279
  * 1. Explicit `configAppDir` from timber.config.ts
@@ -9,21 +9,12 @@
9
9
  */
10
10
 
11
11
  import type { Plugin, ViteDevServer } from 'vite';
12
- import { writeFile, mkdir } from 'node:fs/promises';
13
12
  import { join } from 'node:path';
14
13
  import { scanRoutes } from '../routing/scanner.js';
15
- import { generateRouteMap } from '../routing/codegen.js';
16
- import {
17
- buildDirToRouteMap,
18
- writeSegmentTypes,
19
- type SegmentInfo,
20
- } from '../routing/segment-codegen.js';
14
+ import { buildDirToRouteMap, type SegmentInfo } from '../routing/segment-codegen.js';
15
+ import { writeRouteCodegen, flushCodegenWrites } from '../routing/codegen-write.js';
21
16
  import { validateSchemaAgainstRoutes } from '../routing/schema-validation.js';
22
- import {
23
- generateManifestModule,
24
- generateSearchParamsRegistryModule,
25
- generateSchemaModule,
26
- } from '../routing/manifest-codegen.js';
17
+ import { generateManifestModule, generateSchemaModule } from '../routing/manifest-codegen.js';
27
18
  import {
28
19
  lintConventions,
29
20
  checkAppDirExists,
@@ -34,21 +25,6 @@ import type { PluginContext } from '../plugin-context.js';
34
25
  const VIRTUAL_MODULE_ID = 'virtual:timber-route-manifest';
35
26
  const RESOLVED_VIRTUAL_ID = `\0${VIRTUAL_MODULE_ID}`;
36
27
 
37
- // TIM-830: Search-params registry virtual module.
38
- //
39
- // Statically imports the `searchParams` named export from every page
40
- // that exports one, and registers each definition into the shared
41
- // search-params registry keyed by the un-interpolated route pattern
42
- // (e.g. '/products/[id]'). This lets `<Link>` serialize flat
43
- // `Partial<T>` values at runtime without callers importing the
44
- // definition at the call site.
45
- //
46
- // The imports are STATIC (`import { searchParams as r0 } from '...'`)
47
- // so Vite/Rolldown tree-shakes each page's component body out of the
48
- // registry chunk — only the `searchParams` named export is pulled in.
49
- const SEARCH_PARAMS_REGISTRY_ID = 'virtual:timber-search-params-registry';
50
- const RESOLVED_SEARCH_PARAMS_REGISTRY_ID = `\0${SEARCH_PARAMS_REGISTRY_ID}`;
51
-
52
28
  // TIM-931: Schema virtual module.
53
29
  //
54
30
  // When app/schema.ts exists, this module imports it and resolves the
@@ -69,112 +45,26 @@ const SEGMENT_PREFIX = '\0$segment:';
69
45
  * File convention names we track for changes that require manifest regeneration.
70
46
  */
71
47
  const ROUTE_FILE_PATTERNS =
72
- /\/(page|layout|middleware|access|route|error|global-error|default|denied|params|schema|\d{3}|[45]xx|sitemap|robots|manifest|favicon|icon|opengraph-image|apple-icon)\./;
48
+ /\/(page|layout|middleware|access|route|error|global-error|default|denied|schema|\d{3}|[45]xx|sitemap|robots|manifest|favicon|icon|opengraph-image|apple-icon)\./;
73
49
 
74
50
  /**
75
51
  * Create the timber-routing Vite plugin.
76
52
  *
77
53
  * Hooks: resolveId, load, buildStart, configureServer
78
54
  */
79
- /** Absolute path to the generated route map declaration file. */
80
- const CODEGEN_OUTPUT = '.timber/timber-routes.d.ts';
81
-
82
- /**
83
- * Content of the timber-env.d.ts file written to the project root.
84
- *
85
- * Uses a triple-slash reference so TypeScript auto-discovers the generated
86
- * route map without the user needing to edit tsconfig.json — same pattern
87
- * as Next.js's next-env.d.ts.
88
- *
89
- * Also references `vite/client` so side-effect imports like
90
- * `import './globals.css'` type-check without ts(2882), and so
91
- * `import.meta.env`, `import.meta.hot`, `import.meta.glob`, asset
92
- * imports (`*.png`, `*.svg`, …), CSS modules, and `?url`/`?raw`/
93
- * `?inline` query variants all resolve without the user editing
94
- * tsconfig.json themselves. Next.js's next-env.d.ts plays the same
95
- * role via `next/types/global`.
96
- *
97
- * `vite` is a peer dep of `@timber-js/app`, so the reference always
98
- * resolves in a correctly-installed project.
99
- */
100
- const TIMBER_ENV_DTS = [
101
- '// This file is auto-generated by timber.js. Do not edit.',
102
- '// It ensures TypeScript picks up the generated route types and',
103
- '// Vite-provided ambient types (CSS, assets, import.meta.env, HMR).',
104
- '/* eslint-disable @typescript-eslint/triple-slash-reference */',
105
- '/// <reference path=".timber/timber-routes.d.ts" />',
106
- '/// <reference types="vite/client" />',
107
- '',
108
- ].join('\n');
109
-
110
- /**
111
- * Codegen writes that have been started but have not yet landed on disk.
112
- *
113
- * `writeCodegen` is deliberately unawaited on the hot path, which leaves a
114
- * caller no way to know the files exist yet. Anything that tears down the
115
- * project root — a build process exiting, a test removing its temp root —
116
- * then races the writes: `rmSync` empties a directory that a pending `mkdir`
117
- * immediately recreates, and the removal fails with ENOTEMPTY.
118
- *
119
- * Every started write registers here and deregisters when it settles, so
120
- * `flushCodegenWrites()` can await them instead of guessing at a delay.
121
- */
122
- const pendingCodegenWrites = new Set<Promise<void>>();
123
-
124
- /**
125
- * Resolve once every codegen write started so far has landed on disk.
126
- *
127
- * Safe to call at any time and from anywhere — writes are tracked per process,
128
- * not per plugin instance, so a caller does not need a handle on the plugin
129
- * that started them. Never rejects: individual write failures are already
130
- * swallowed and logged by `writeCodegen`.
131
- */
132
- export async function flushCodegenWrites(): Promise<void> {
133
- // Loop rather than a single Promise.all: a write started while we were
134
- // awaiting would otherwise be missed, and the set would still be non-empty
135
- // on return.
136
- //
137
- // Each round drops what it awaited rather than trusting the per-write
138
- // `finally` to have run, so the two removal paths are independent — a
139
- // deregistration that stopped firing would leak memory in dev, but it can
140
- // never turn this into a spin.
141
- while (pendingCodegenWrites.size > 0) {
142
- const inFlight = [...pendingCodegenWrites];
143
- await Promise.all(inFlight);
144
- for (const write of inFlight) pendingCodegenWrites.delete(write);
145
- }
146
- }
147
-
148
55
  /**
149
56
  * Write the generated route map and the timber-env.d.ts reference file.
150
57
  *
151
- * Not awaited by callers, to avoid blocking the hot path — use
152
- * `flushCodegenWrites()` to wait for the writes to land.
153
- * Errors are swallowed; a missing .d.ts is a developer UX issue, not a runtime failure.
58
+ * Not awaited, to avoid blocking the hot path — use `flushCodegenWrites()` to
59
+ * wait for the writes to land. Failures are logged, not thrown: a missing .d.ts
60
+ * is a developer UX issue in dev/build, not a runtime failure. (`timber check`
61
+ * calls `writeRouteCodegen` directly and does treat a failure as fatal.)
154
62
  */
155
63
  function writeCodegen(ctx: PluginContext): void {
156
64
  if (!ctx.routeTree) return;
157
- const timberDir = join(ctx.root, '.timber');
158
- const content = generateRouteMap(ctx.routeTree, { appDir: ctx.appDir, outputDir: timberDir });
159
- const routesPath = join(ctx.root, CODEGEN_OUTPUT);
160
- const envPath = join(ctx.root, 'timber-env.d.ts');
161
- const done = mkdir(timberDir, { recursive: true })
162
- .then(() =>
163
- Promise.all([
164
- writeFile(routesPath, content, 'utf-8'),
165
- writeFile(envPath, TIMBER_ENV_DTS, 'utf-8'),
166
- writeSegmentTypes(ctx.routeTree!, ctx.appDir, ctx.root),
167
- ])
168
- )
169
- .then(() => undefined)
170
- .catch((err) => {
171
- // Non-fatal — types are a dev convenience, but log so issues are visible
172
- console.warn('[timber] Failed to write codegen output:', err);
173
- });
174
-
175
- pendingCodegenWrites.add(done);
176
- // `done` has a catch attached, so it never rejects and this stays quiet.
177
- void done.finally(() => pendingCodegenWrites.delete(done));
65
+ void writeRouteCodegen(ctx.routeTree, ctx.appDir, ctx.root).catch((err) => {
66
+ console.warn('[timber] Failed to write codegen output:', err);
67
+ });
178
68
  }
179
69
 
180
70
  export function timberRouting(ctx: PluginContext): Plugin {
@@ -204,10 +94,26 @@ export function timberRouting(ctx: PluginContext): Plugin {
204
94
  }
205
95
 
206
96
  ctx.timer.start('route-scan');
207
- ctx.routeTree = scanRoutes(ctx.appDir, {
97
+ const tree = scanRoutes(ctx.appDir, {
208
98
  pageExtensions: ctx.config.pageExtensions,
209
99
  });
210
100
  ctx.timer.end('route-scan');
101
+
102
+ // Lint conventions (empty app, missing methods, missing root layout).
103
+ // Runs on the scanned tree *before* it is published: error-level findings
104
+ // are proven-fatal misconfigurations — the app cannot render at all — so
105
+ // the tree must not reach codegen, the manifest, or a live dev server's
106
+ // module graph. Fail loudly rather than let the developer discover it as
107
+ // an unattributed React error on an unrelated page (TIM-1329). Never
108
+ // deduplicated against `warnedFiles`: an error must not be downgraded to
109
+ // silence by an earlier scan in the same process.
110
+ const conventionWarnings = lintConventions(tree, ctx.appDir);
111
+ const fatal = conventionWarnings.filter((w) => w.level === 'error');
112
+ if (fatal.length > 0) {
113
+ throw new Error(formatConventionWarnings(fatal, { color: false }));
114
+ }
115
+
116
+ ctx.routeTree = tree;
211
117
  dirToRoute = buildDirToRouteMap(ctx.routeTree, ctx.appDir);
212
118
  writeCodegen(ctx);
213
119
 
@@ -216,8 +122,6 @@ export function timberRouting(ctx: PluginContext): Plugin {
216
122
  // See design/41-global-params.md §Auto-Scaffolding
217
123
  warnSchemaValidation(ctx);
218
124
 
219
- // Lint conventions (empty app, missing methods, missing root layout)
220
- const conventionWarnings = lintConventions(ctx.routeTree, ctx.appDir);
221
125
  const newConventionWarnings = conventionWarnings.filter((w) => !warnedFiles.has(w.id));
222
126
  if (newConventionWarnings.length > 0) {
223
127
  for (const w of newConventionWarnings) warnedFiles.add(w.id);
@@ -250,14 +154,6 @@ export function timberRouting(ctx: PluginContext): Plugin {
250
154
  return RESOLVED_VIRTUAL_ID;
251
155
  }
252
156
 
253
- // TIM-830: Search-params registry virtual module
254
- if (
255
- cleanId === SEARCH_PARAMS_REGISTRY_ID ||
256
- cleanId.endsWith(`/${SEARCH_PARAMS_REGISTRY_ID}`)
257
- ) {
258
- return RESOLVED_SEARCH_PARAMS_REGISTRY_ID;
259
- }
260
-
261
157
  // TIM-931: Schema virtual module
262
158
  if (cleanId === SCHEMA_ID || cleanId.endsWith(`/${SCHEMA_ID}`)) {
263
159
  return RESOLVED_SCHEMA_ID;
@@ -297,13 +193,6 @@ export function timberRouting(ctx: PluginContext): Plugin {
297
193
  return generateManifestModule(ctx.routeTree!, ctx.root);
298
194
  }
299
195
 
300
- if (id === RESOLVED_SEARCH_PARAMS_REGISTRY_ID) {
301
- if (!ctx.routeTree) {
302
- rescan();
303
- }
304
- return generateSearchParamsRegistryModule(ctx.routeTree!);
305
- }
306
-
307
196
  if (id === RESOLVED_SCHEMA_ID) {
308
197
  return generateSchemaModule(ctx.appDir);
309
198
  }
@@ -362,6 +251,59 @@ export function timberRouting(ctx: PluginContext): Plugin {
362
251
  /** Snapshot of the last generated manifest, used to detect structural changes. */
363
252
  let lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
364
253
 
254
+ /**
255
+ * Rescan without letting a rejected route tree kill the dev server.
256
+ *
257
+ * `rescan()` throws on hard misconfigurations — duplicate conventions,
258
+ * route collisions, a status file that is not a client component. Those
259
+ * throws are correct at startup and in `vite build`, but here they run
260
+ * inside a chokidar listener, where an exception is an unhandled
261
+ * rejection that takes the process down mid-edit. Report and keep the
262
+ * last good tree so the server stays up and the fix is one save away.
263
+ *
264
+ * Returns false when the scan failed, so callers skip the manifest
265
+ * regeneration that would otherwise publish a stale tree as fresh.
266
+ */
267
+ /**
268
+ * True while the last rescan is still failing.
269
+ *
270
+ * Recovery has to force a reload even when the manifest is byte-identical.
271
+ * Adding a missing `'use client'` back to a status file changes no route
272
+ * metadata, so the content-change path would send nothing and leave the
273
+ * error overlay on screen over a page that is now fine.
274
+ */
275
+ let scanFailed = false;
276
+
277
+ const rescanSafely = (): boolean => {
278
+ try {
279
+ rescan();
280
+ if (scanFailed) {
281
+ scanFailed = false;
282
+ // Force the reload: see `scanFailed`.
283
+ lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
284
+ invalidateManifest(devServer);
285
+ }
286
+ return true;
287
+ } catch (err) {
288
+ const message = err instanceof Error ? err.message : String(err);
289
+ process.stderr.write(
290
+ `[timber] Route scan failed — keeping previous routes.\n${message}\n`
291
+ );
292
+ scanFailed = true;
293
+
294
+ // The terminal is not the only surface that needs this. Keeping the
295
+ // previous tree keeps the *server* alive, but it does not un-edit the
296
+ // file: Vite still re-transforms it, so the very next render hits the
297
+ // failure this scan just diagnosed — and the browser's version of it
298
+ // names nothing. Put the diagnosis where the developer is looking.
299
+ devServer.hot.send({
300
+ type: 'error',
301
+ err: { message, stack: '', plugin: 'timber-routing' },
302
+ });
303
+ return false;
304
+ }
305
+ };
306
+
365
307
  /**
366
308
  * Handle a route-significant file being added or removed.
367
309
  * Always triggers a full-reload since the route tree structure changed.
@@ -370,7 +312,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
370
312
  if (!filePath.startsWith(ctx.appDir)) return;
371
313
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
372
314
 
373
- rescan();
315
+ if (!rescanSafely()) return;
374
316
  lastManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
375
317
  invalidateManifest(devServer);
376
318
  };
@@ -387,7 +329,7 @@ export function timberRouting(ctx: PluginContext): Plugin {
387
329
  if (!filePath.startsWith(ctx.appDir)) return;
388
330
  if (!ROUTE_FILE_PATTERNS.test(filePath)) return;
389
331
 
390
- rescan();
332
+ if (!rescanSafely()) return;
391
333
  const newManifest = ctx.routeTree ? generateManifestModule(ctx.routeTree, ctx.root) : '';
392
334
  if (newManifest !== lastManifest) {
393
335
  lastManifest = newManifest;
@@ -449,11 +391,7 @@ function invalidateManifest(server: ViteDevServer): void {
449
391
  const env = server.environments[envName];
450
392
  if (!env?.moduleGraph) continue;
451
393
 
452
- for (const virtualId of [
453
- RESOLVED_VIRTUAL_ID,
454
- RESOLVED_SEARCH_PARAMS_REGISTRY_ID,
455
- RESOLVED_SCHEMA_ID,
456
- ]) {
394
+ for (const virtualId of [RESOLVED_VIRTUAL_ID, RESOLVED_SCHEMA_ID]) {
457
395
  const mod = env.moduleGraph.getModuleById(virtualId);
458
396
  if (mod) {
459
397
  env.moduleGraph.invalidateModule(mod);
@@ -55,7 +55,6 @@ export const SUBPATH_SRC_MAP: Record<string, string> = {
55
55
  'cookies': 'cookies/index.ts',
56
56
  'params': 'params/index.ts',
57
57
  'search-params': 'search-params/index.ts',
58
- 'segment-params': 'segment-params/index.ts',
59
58
 
60
59
  'cache/stores/memory': 'cache/stores/memory.ts',
61
60
  'cache/stores/redis': 'cache/stores/redis.ts',