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

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 (314) 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/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
  41. package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
  42. package/dist/_chunks/{navigation-root-B29qg0_T.js → use-segment-params-C4r4BD9T.js} +129 -5
  43. package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
  44. package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
  45. package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
  46. package/dist/adapters/cloudflare-dev.js +1 -1
  47. package/dist/adapters/cloudflare-kv-cache.js +1 -1
  48. package/dist/adapters/cloudflare.d.ts.map +1 -1
  49. package/dist/adapters/cloudflare.js +1 -1
  50. package/dist/adapters/compress-module.d.ts +12 -0
  51. package/dist/adapters/compress-module.d.ts.map +1 -1
  52. package/dist/adapters/nitro.js +54 -2
  53. package/dist/adapters/nitro.js.map +1 -1
  54. package/dist/cache/index.js +1 -1
  55. package/dist/cdn/cloudflare-purge.js +30 -0
  56. package/dist/cdn/cloudflare-purge.js.map +1 -0
  57. package/dist/cdn/fastly-purge.js +33 -0
  58. package/dist/cdn/fastly-purge.js.map +1 -0
  59. package/dist/cdn/index.js +94 -0
  60. package/dist/cdn/index.js.map +1 -0
  61. package/dist/cdn/workers-cache-purge.js +35 -0
  62. package/dist/cdn/workers-cache-purge.js.map +1 -0
  63. package/dist/cli-check.d.ts +153 -0
  64. package/dist/cli-check.d.ts.map +1 -0
  65. package/dist/cli.d.ts +34 -8
  66. package/dist/cli.d.ts.map +1 -1
  67. package/dist/cli.js +46 -21
  68. package/dist/cli.js.map +1 -1
  69. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  70. package/dist/client/browser-entry/index.d.ts +1 -1
  71. package/dist/client/browser-entry/index.d.ts.map +1 -1
  72. package/dist/client/error-boundary.d.ts +6 -0
  73. package/dist/client/error-boundary.d.ts.map +1 -1
  74. package/dist/client/error-boundary.js +1 -1
  75. package/dist/client/index.d.ts +1 -0
  76. package/dist/client/index.d.ts.map +1 -1
  77. package/dist/client/index.js +27 -33
  78. package/dist/client/index.js.map +1 -1
  79. package/dist/client/internal.js +21 -14
  80. package/dist/client/internal.js.map +1 -1
  81. package/dist/client/link.d.ts +1 -7
  82. package/dist/client/link.d.ts.map +1 -1
  83. package/dist/client/navigation-commit.d.ts +16 -0
  84. package/dist/client/navigation-commit.d.ts.map +1 -1
  85. package/dist/client/router-pipeline.d.ts.map +1 -1
  86. package/dist/client/router.d.ts.map +1 -1
  87. package/dist/client/rsc-fetch.d.ts +9 -1
  88. package/dist/client/rsc-fetch.d.ts.map +1 -1
  89. package/dist/client/segment-cache.d.ts +8 -0
  90. package/dist/client/segment-cache.d.ts.map +1 -1
  91. package/dist/client/use-query-states.d.ts +9 -3
  92. package/dist/client/use-query-states.d.ts.map +1 -1
  93. package/dist/codec.js +1 -1
  94. package/dist/cookies/define-cookie.d.ts.map +1 -1
  95. package/dist/cookies/index.js +2 -2
  96. package/dist/cookies/index.js.map +1 -1
  97. package/dist/index.d.ts +4 -1
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +142 -477
  100. package/dist/index.js.map +1 -1
  101. package/dist/params/index.js +1 -1
  102. package/dist/plugin-context.d.ts +15 -0
  103. package/dist/plugin-context.d.ts.map +1 -1
  104. package/dist/plugins/routing.d.ts +0 -9
  105. package/dist/plugins/routing.d.ts.map +1 -1
  106. package/dist/plugins/shims.d.ts.map +1 -1
  107. package/dist/plugins/static-build.d.ts +24 -0
  108. package/dist/plugins/static-build.d.ts.map +1 -1
  109. package/dist/routing/codegen-shared.d.ts +3 -44
  110. package/dist/routing/codegen-shared.d.ts.map +1 -1
  111. package/dist/routing/codegen-types.d.ts +10 -31
  112. package/dist/routing/codegen-types.d.ts.map +1 -1
  113. package/dist/routing/codegen-write.d.ts +51 -0
  114. package/dist/routing/codegen-write.d.ts.map +1 -0
  115. package/dist/routing/codegen.d.ts.map +1 -1
  116. package/dist/routing/convention-lint.d.ts +18 -4
  117. package/dist/routing/convention-lint.d.ts.map +1 -1
  118. package/dist/routing/export-detect.d.ts +16 -0
  119. package/dist/routing/export-detect.d.ts.map +1 -1
  120. package/dist/routing/index.js +3 -2
  121. package/dist/routing/link-codegen.d.ts +19 -4
  122. package/dist/routing/link-codegen.d.ts.map +1 -1
  123. package/dist/routing/manifest-codegen.d.ts +1 -7
  124. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  125. package/dist/routing/types.d.ts +0 -6
  126. package/dist/routing/types.d.ts.map +1 -1
  127. package/dist/schema-bridge.d.ts +60 -9
  128. package/dist/schema-bridge.d.ts.map +1 -1
  129. package/dist/search-params/define.d.ts +62 -8
  130. package/dist/search-params/define.d.ts.map +1 -1
  131. package/dist/search-params/index.d.ts +0 -1
  132. package/dist/search-params/index.d.ts.map +1 -1
  133. package/dist/search-params/index.js +66 -29
  134. package/dist/search-params/index.js.map +1 -1
  135. package/dist/search-params/parse-total.d.ts +70 -0
  136. package/dist/search-params/parse-total.d.ts.map +1 -0
  137. package/dist/search-params/wrappers.d.ts +26 -3
  138. package/dist/search-params/wrappers.d.ts.map +1 -1
  139. package/dist/server/access-gate.d.ts +19 -8
  140. package/dist/server/access-gate.d.ts.map +1 -1
  141. package/dist/server/action-handler.d.ts.map +1 -1
  142. package/dist/server/als-registry.d.ts +16 -0
  143. package/dist/server/als-registry.d.ts.map +1 -1
  144. package/dist/server/compress.d.ts.map +1 -1
  145. package/dist/server/deny-boundary.d.ts +148 -15
  146. package/dist/server/deny-boundary.d.ts.map +1 -1
  147. package/dist/server/deny-renderer.d.ts +2 -2
  148. package/dist/server/deny-renderer.d.ts.map +1 -1
  149. package/dist/server/error-boundary-wrapper.d.ts +85 -15
  150. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  151. package/dist/server/index.d.ts +0 -1
  152. package/dist/server/index.d.ts.map +1 -1
  153. package/dist/server/index.js +3 -2
  154. package/dist/server/index.js.map +1 -1
  155. package/dist/server/internal.d.ts +3 -1
  156. package/dist/server/internal.d.ts.map +1 -1
  157. package/dist/server/internal.js +343 -230
  158. package/dist/server/internal.js.map +1 -1
  159. package/dist/server/metadata-collector.d.ts +52 -0
  160. package/dist/server/metadata-collector.d.ts.map +1 -0
  161. package/dist/server/param-coercion.d.ts +12 -5
  162. package/dist/server/param-coercion.d.ts.map +1 -1
  163. package/dist/server/pipeline-helpers.d.ts.map +1 -1
  164. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  165. package/dist/server/pipeline-phases.d.ts.map +1 -1
  166. package/dist/server/primitives.d.ts +23 -0
  167. package/dist/server/primitives.d.ts.map +1 -1
  168. package/dist/server/route-element-builder.d.ts +1 -12
  169. package/dist/server/route-element-builder.d.ts.map +1 -1
  170. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  171. package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
  172. package/dist/server/rsc-entry/error-renderer.d.ts +1 -1
  173. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  174. package/dist/server/rsc-entry/helpers.d.ts +0 -7
  175. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/index.d.ts +0 -1
  177. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  178. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  179. package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
  180. package/dist/server/rsc-entry/rsc-payload.d.ts +1 -3
  181. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  182. package/dist/server/rsc-entry/rsc-stream.d.ts +12 -0
  183. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  184. package/dist/server/rsc-entry/ssr-renderer.d.ts +0 -2
  185. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  186. package/dist/server/slot-resolver.d.ts.map +1 -1
  187. package/dist/server/ssr-bridge-types.d.ts +11 -0
  188. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  189. package/dist/server/ssr-entry.d.ts +0 -1
  190. package/dist/server/ssr-entry.d.ts.map +1 -1
  191. package/dist/server/static-generator.d.ts.map +1 -1
  192. package/dist/server/status-code-resolver.d.ts +8 -1
  193. package/dist/server/status-code-resolver.d.ts.map +1 -1
  194. package/dist/server/tree-builder.d.ts +28 -36
  195. package/dist/server/tree-builder.d.ts.map +1 -1
  196. package/dist/server/types.d.ts +12 -7
  197. package/dist/server/types.d.ts.map +1 -1
  198. package/dist/server/utils/element-type.d.ts +40 -0
  199. package/dist/server/utils/element-type.d.ts.map +1 -0
  200. package/dist/shared/rsc-media-type.d.ts +40 -0
  201. package/dist/shared/rsc-media-type.d.ts.map +1 -0
  202. package/docs/api/30-api-server.mdx +1 -1
  203. package/docs/api/33-api-search-params.mdx +38 -16
  204. package/docs/api/35-api-typescript.mdx +3 -3
  205. package/docs/api/36-cli.mdx +34 -7
  206. package/docs/learn/00-introduction.mdx +1 -1
  207. package/docs/learn/02-pages-and-layouts.mdx +1 -1
  208. package/docs/learn/05-typed-params.mdx +8 -8
  209. package/docs/learn/07-typed-routes.mdx +12 -7
  210. package/docs/learn/11-error-handling.mdx +16 -0
  211. package/docs/more/01-advanced-routing.mdx +1 -1
  212. package/docs/more/03-coming-from-nextjs.mdx +2 -2
  213. package/docs/more/50-ai-agent-instructions.mdx +6 -4
  214. package/package.json +8 -5
  215. package/src/adapters/cloudflare.ts +4 -1
  216. package/src/adapters/compress-module.ts +79 -1
  217. package/src/cli-check.ts +458 -0
  218. package/src/cli.ts +59 -24
  219. package/src/client/browser-entry/action-dispatch.ts +2 -1
  220. package/src/client/browser-entry/index.ts +0 -5
  221. package/src/client/error-boundary.tsx +65 -1
  222. package/src/client/index.ts +14 -3
  223. package/src/client/link.tsx +65 -64
  224. package/src/client/navigation-commit.ts +27 -4
  225. package/src/client/params-context.ts +4 -4
  226. package/src/client/router-pipeline.ts +4 -0
  227. package/src/client/router.ts +1 -0
  228. package/src/client/rsc-fetch.ts +14 -4
  229. package/src/client/segment-cache.ts +8 -0
  230. package/src/client/use-query-states.ts +102 -39
  231. package/src/cookies/define-cookie.ts +6 -1
  232. package/src/index.ts +20 -3
  233. package/src/plugin-context.ts +26 -0
  234. package/src/plugins/routing.ts +84 -146
  235. package/src/plugins/shims.ts +0 -1
  236. package/src/plugins/static-build.ts +78 -24
  237. package/src/routing/codegen-shared.ts +3 -79
  238. package/src/routing/codegen-types.ts +10 -31
  239. package/src/routing/codegen-write.ts +139 -0
  240. package/src/routing/codegen.ts +56 -182
  241. package/src/routing/convention-lint.ts +139 -40
  242. package/src/routing/export-detect.ts +151 -7
  243. package/src/routing/link-codegen.ts +32 -65
  244. package/src/routing/manifest-codegen.ts +1 -59
  245. package/src/routing/scanner.ts +3 -3
  246. package/src/routing/types.ts +0 -6
  247. package/src/schema-bridge.ts +180 -58
  248. package/src/search-params/define.ts +102 -37
  249. package/src/search-params/index.ts +0 -1
  250. package/src/search-params/parse-total.ts +78 -0
  251. package/src/search-params/wrappers.ts +60 -11
  252. package/src/server/access-gate.tsx +60 -40
  253. package/src/server/action-handler.ts +1 -4
  254. package/src/server/als-registry.ts +16 -0
  255. package/src/server/compress.ts +9 -1
  256. package/src/server/deny-boundary.ts +269 -41
  257. package/src/server/deny-renderer.ts +32 -21
  258. package/src/server/error-boundary-wrapper.ts +166 -79
  259. package/src/server/index.ts +1 -3
  260. package/src/server/internal.ts +2 -2
  261. package/src/server/metadata-collector.ts +115 -0
  262. package/src/server/param-coercion.ts +13 -61
  263. package/src/server/pipeline-helpers.ts +2 -2
  264. package/src/server/pipeline-outcome.ts +2 -1
  265. package/src/server/pipeline-phases.ts +9 -9
  266. package/src/server/primitives.ts +25 -0
  267. package/src/server/route-element-builder.ts +167 -170
  268. package/src/server/rsc-cache-key-guard.ts +2 -8
  269. package/src/server/rsc-entry/deny-fallback.ts +3 -2
  270. package/src/server/rsc-entry/error-renderer.ts +31 -11
  271. package/src/server/rsc-entry/helpers.ts +2 -12
  272. package/src/server/rsc-entry/index.ts +0 -5
  273. package/src/server/rsc-entry/render-route.ts +14 -11
  274. package/src/server/rsc-entry/revalidate-renderer.ts +2 -1
  275. package/src/server/rsc-entry/rsc-payload.ts +104 -20
  276. package/src/server/rsc-entry/rsc-stream.ts +25 -2
  277. package/src/server/rsc-entry/ssr-renderer.ts +28 -16
  278. package/src/server/slot-resolver.ts +10 -2
  279. package/src/server/ssr-bridge-types.ts +12 -0
  280. package/src/server/ssr-entry.ts +8 -6
  281. package/src/server/static-generator.ts +3 -2
  282. package/src/server/status-code-resolver.ts +28 -11
  283. package/src/server/tree-builder.ts +35 -218
  284. package/src/server/types.ts +12 -7
  285. package/src/server/utils/element-type.ts +72 -0
  286. package/src/shared/rsc-media-type.ts +43 -0
  287. package/dist/_chunks/cache-api-Cd0VZ_Pd.js.map +0 -1
  288. package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +0 -1
  289. package/dist/_chunks/error-boundary-DpYRI_I1.js.map +0 -1
  290. package/dist/_chunks/logger-N7e5auP0.js.map +0 -1
  291. package/dist/_chunks/navigation-root-B29qg0_T.js.map +0 -1
  292. package/dist/_chunks/param-value-C8TNYchQ.js.map +0 -1
  293. package/dist/_chunks/registry-DbJPKoBp.js +0 -20
  294. package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
  295. package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
  296. package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
  297. package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
  298. package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
  299. package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
  300. package/dist/_chunks/use-segment-params-ClyUNq4d.js +0 -128
  301. package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +0 -1
  302. package/dist/_chunks/walkers-BhhwI9TD.js +0 -936
  303. package/dist/_chunks/walkers-BhhwI9TD.js.map +0 -1
  304. package/dist/search-params/registry.d.ts +0 -20
  305. package/dist/search-params/registry.d.ts.map +0 -1
  306. package/dist/segment-params/define.d.ts +0 -83
  307. package/dist/segment-params/define.d.ts.map +0 -1
  308. package/dist/segment-params/index.d.ts +0 -3
  309. package/dist/segment-params/index.d.ts.map +0 -1
  310. package/dist/segment-params/index.js +0 -70
  311. package/dist/segment-params/index.js.map +0 -1
  312. package/src/search-params/registry.ts +0 -31
  313. package/src/segment-params/define.ts +0 -226
  314. package/src/segment-params/index.ts +0 -9
@@ -1,106 +1,193 @@
1
1
  /**
2
- * Error boundary wrapper — wraps a React element in error boundaries from a route segment.
2
+ * Error boundary wrapper — wraps a React element in the error boundaries
3
+ * declared by a route segment (`{status}.tsx`, `4xx.tsx`/`5xx.tsx`, `error.tsx`).
3
4
  *
4
- * Extracted to allow reuse by both rsc-entry.ts and route-element-builder.ts.
5
- * See design/10-error-handling.md.
5
+ * This is the single implementation of that walk. It is driven by both
6
+ * construction sites:
7
+ *
8
+ * - `route-element-builder.ts` / `slot-resolver.ts` — the request-time
9
+ * renderer, walking the runtime manifest (`SegmentNode<ManifestFile>`).
10
+ * - `tree-builder.ts` — the build-time tree walker
11
+ * (`SegmentNode<RouteFile>`), with its loader and `createElement` injected.
12
+ *
13
+ * `errorBoundaryComponent` is injected rather than imported: `TimberErrorBoundary`
14
+ * is a `'use client'` module, and importing it here would pull client code into
15
+ * every consumer — including `tree-builder.ts`, which the server barrel reaches.
16
+ * `tests/use-client-pipeline.test.ts` walks the import graph to enforce this.
17
+ *
18
+ * See design/10-error-handling.md §"Status-Code File Variants".
6
19
  */
7
20
 
8
- import { TimberErrorBoundary } from '../client/error-boundary.js';
9
- import type { ManifestSegmentNode } from './route-matcher.js';
10
- import { loadModule } from './safe-load.js';
21
+ import type { ReactNode } from 'react';
22
+ import type { SegmentNode } from '../routing/types.js';
23
+ import { isValidElementType, type LoadedComponent } from './utils/element-type.js';
11
24
  import { isMdxFilePath } from './utils/mdx-file.js';
12
25
 
26
+ /** The minimum a route file must carry for the walk: a path to classify as MDX. */
27
+ interface BoundaryFile {
28
+ filePath: string;
29
+ }
30
+
13
31
  /**
14
- * Wrap an element in error boundaries defined by a route segment.
32
+ * Framework-injected error boundary wrapper.
33
+ * Wraps content with status-code error boundary handling.
15
34
  *
16
- * Processing order (innermost to outermost):
17
- * 1. Specific status files (e.g., 404.tsx, 500.tsx) — highest priority at runtime
18
- * 2. Category catch-alls (4xx.tsx, 5xx.tsx)
19
- * 3. error.tsx — catches anything not matched by status files
35
+ * Field types must agree with `TimberErrorBoundaryProps` in
36
+ * `client/error-boundary.tsx`. The two are kept structurally compatible by
37
+ * convention rather than by direct type import — this is the server-side
38
+ * construction site and may not import types from a 'use client' module.
39
+ */
40
+ export interface ErrorBoundaryProps {
41
+ /** The component to render when an error is caught (TSX status files). */
42
+ fallbackComponent?: LoadedComponent;
43
+ /** Pre-rendered fallback element (MDX status files — see TIM-503). */
44
+ fallbackElement?: ReactNode;
45
+ /** Status code filter: 400 = any 4xx, 500 = any 5xx, specific number = exact match. */
46
+ status?: number;
47
+ /**
48
+ * Segment key of the segment that declared this boundary. An in-tree deny
49
+ * catch site that matched a file it may not render in place re-throws with
50
+ * that file's owning key on the signal; boundaries whose key differs
51
+ * decline, so the deny page lands at its owner rather than at the first
52
+ * boundary the throw happens to pass. Unset for slot boundaries and for
53
+ * construction sites with no deny chains — an unkeyed boundary declines
54
+ * every owner-addressed deny. See TIM-1356.
55
+ */
56
+ segmentKey?: string;
57
+ children: ReactNode;
58
+ }
59
+
60
+ /** How to load a route file's module. Only `default` is read. */
61
+ type BoundaryModuleLoader<TFile> = (
62
+ file: TFile
63
+ ) => { default?: unknown } | Promise<{ default?: unknown }>;
64
+
65
+ /** Everything the walk needs from its host environment. */
66
+ export interface ErrorBoundaryWrapConfig<TFile, TElement> {
67
+ /** `React.createElement` or an equivalent element factory. */
68
+ createElement: (type: unknown, props: object) => TElement;
69
+ /** Loads a route file's module. Failures skip the boundary — see below. */
70
+ loadModule: BoundaryModuleLoader<TFile>;
71
+ /**
72
+ * The error boundary component to instantiate (`TimberErrorBoundary`, or a
73
+ * host-specific stand-in). Injected — see the module docstring.
74
+ */
75
+ errorBoundaryComponent: unknown;
76
+ /** When true, skip 4xx error boundaries — deny catching handles them in-tree. See TIM-666. */
77
+ skipDenyBoundaries?: boolean;
78
+ /** Segment key stamped on every boundary this walk creates. See `ErrorBoundaryProps`. */
79
+ segmentKey?: string;
80
+ }
81
+
82
+ /**
83
+ * Load a status/error file's component, or `null` if the boundary should be skipped.
84
+ *
85
+ * Two ways to get `null`, both deliberate:
86
+ * - the module fails to load (syntax error in the error page) — building an
87
+ * error boundary must not itself throw, so the boundary is skipped (TIM-584);
88
+ * - the default export is not a valid React component type, which would
89
+ * otherwise crash inside `createElement`.
90
+ */
91
+ async function loadBoundaryComponent<TFile>(
92
+ file: TFile,
93
+ loadModule: BoundaryModuleLoader<TFile>
94
+ ): Promise<LoadedComponent | null> {
95
+ let mod: { default?: unknown };
96
+ try {
97
+ mod = await loadModule(file);
98
+ } catch {
99
+ return null;
100
+ }
101
+ return isValidElementType(mod?.default) ? mod.default : null;
102
+ }
103
+
104
+ /**
105
+ * Wrap `element` in one error boundary rendering `Component` on failure.
20
106
  *
21
107
  * MDX status files are server components and cannot be passed as function
22
- * props to TimberErrorBoundary (a 'use client' component). Instead, they
23
- * are pre-rendered as elements and passed as fallbackElement. See TIM-503.
108
+ * props to `TimberErrorBoundary` (a 'use client' component). They are
109
+ * pre-rendered as elements and passed as `fallbackElement` instead. See TIM-503.
110
+ *
111
+ * @param status boundary status filter — omitted entirely for `error.tsx`
112
+ * @param fallbackProps props handed to an MDX fallback when it is pre-rendered
24
113
  */
25
- export async function wrapSegmentWithErrorBoundaries(
26
- segment: ManifestSegmentNode,
27
- element: React.ReactElement,
28
- h: (...args: unknown[]) => React.ReactElement,
29
- /** When true, skip 4xx error boundaries — deny catching handles them in-tree. See TIM-666. */
30
- skipDenyBoundaries = false
31
- ): Promise<React.ReactElement> {
32
- // Specific status files (innermost — highest priority at runtime)
114
+ function wrapInBoundary<TFile extends BoundaryFile, TElement extends ReactNode>(
115
+ element: TElement,
116
+ Component: LoadedComponent,
117
+ file: TFile,
118
+ status: number | undefined,
119
+ fallbackProps: object,
120
+ config: ErrorBoundaryWrapConfig<TFile, TElement>
121
+ ): TElement {
122
+ const props: ErrorBoundaryProps = isMdxFilePath(file.filePath)
123
+ ? { fallbackElement: config.createElement(Component, fallbackProps), children: element }
124
+ : { fallbackComponent: Component, children: element };
125
+ if (status !== undefined) props.status = status;
126
+ if (config.segmentKey !== undefined) props.segmentKey = config.segmentKey;
127
+ return config.createElement(config.errorBoundaryComponent, props);
128
+ }
129
+
130
+ /**
131
+ * Wrap an element in the error boundaries declared by a route segment.
132
+ *
133
+ * Wrapping is applied inside-out — the last wrap produces the outermost
134
+ * boundary — so the processing order below is innermost to outermost:
135
+ *
136
+ * 1. Specific status files (e.g. `404.tsx`, `503.tsx`) — highest priority at runtime
137
+ * 2. Category catch-alls (`4xx.tsx`, `5xx.tsx`)
138
+ * 3. `error.tsx` — catches anything not matched by a status file
139
+ *
140
+ * The order is load-bearing: it decides which boundary catches a given throw.
141
+ *
142
+ * See design/10-error-handling.md §"Format-Aware Fallback Chains".
143
+ */
144
+ export async function wrapSegmentWithErrorBoundaries<
145
+ TFile extends BoundaryFile,
146
+ TElement extends ReactNode,
147
+ >(
148
+ segment: SegmentNode<TFile>,
149
+ element: TElement,
150
+ config: ErrorBoundaryWrapConfig<TFile, TElement>
151
+ ): Promise<TElement> {
152
+ const { loadModule, skipDenyBoundaries = false } = config;
153
+
33
154
  if (segment.statusFiles) {
155
+ // Specific status files (innermost — highest priority at runtime)
34
156
  for (const [key, file] of Object.entries(segment.statusFiles)) {
35
- if (key !== '4xx' && key !== '5xx') {
36
- const status = parseInt(key, 10);
37
- if (!isNaN(status)) {
38
- // Skip 4xx boundaries when deny catching is active — the deny
39
- // page is rendered by AccessGate/PageDenyBoundary instead.
40
- if (skipDenyBoundaries && status >= 400 && status <= 499) continue;
41
- // .catch: error boundary construction must not fail if the
42
- // error page module has a syntax error — skip this boundary.
43
- const mod = await loadModule(file).catch(() => null);
44
- if (mod?.default) {
45
- if (isMdxFilePath(file.filePath)) {
46
- // MDX: pre-render as element (server component can't be a function prop)
47
- element = h(TimberErrorBoundary, {
48
- fallbackElement: h(mod.default as never, { status }),
49
- status,
50
- children: element,
51
- });
52
- } else {
53
- element = h(TimberErrorBoundary, {
54
- fallbackComponent: mod.default,
55
- status,
56
- children: element,
57
- });
58
- }
59
- }
60
- }
157
+ if (key === '4xx' || key === '5xx') continue;
158
+ const status = parseInt(key, 10);
159
+ if (isNaN(status)) continue;
160
+ // Skip 4xx boundaries when deny catching is active — the deny
161
+ // page is rendered by AccessGate/PageDenyBoundary instead.
162
+ if (skipDenyBoundaries && status >= 400 && status <= 499) continue;
163
+
164
+ const Component = await loadBoundaryComponent(file, loadModule);
165
+ if (Component) {
166
+ element = wrapInBoundary(element, Component, file, status, { status }, config);
61
167
  }
62
168
  }
63
169
 
64
170
  // Category catch-alls (4xx.tsx, 5xx.tsx)
65
171
  for (const [key, file] of Object.entries(segment.statusFiles)) {
172
+ if (key !== '4xx' && key !== '5xx') continue;
66
173
  if (skipDenyBoundaries && key === '4xx') continue;
67
- if (key === '4xx' || key === '5xx') {
68
- const mod = await loadModule(file).catch(() => null);
69
- if (mod?.default) {
70
- const categoryStatus = key === '4xx' ? 400 : 500;
71
- if (isMdxFilePath(file.filePath)) {
72
- element = h(TimberErrorBoundary, {
73
- fallbackElement: h(mod.default as never, {}),
74
- status: categoryStatus,
75
- children: element,
76
- });
77
- } else {
78
- element = h(TimberErrorBoundary, {
79
- fallbackComponent: mod.default,
80
- status: categoryStatus,
81
- children: element,
82
- });
83
- }
84
- }
174
+
175
+ const Component = await loadBoundaryComponent(file, loadModule);
176
+ if (Component) {
177
+ const categoryStatus = key === '4xx' ? 400 : 500;
178
+ element = wrapInBoundary(element, Component, file, categoryStatus, {}, config);
85
179
  }
86
180
  }
87
181
  }
88
182
 
89
- // error.tsx (outermost — catches anything not matched by status files)
183
+ // error.tsx (outermost — catches anything not matched by status files).
184
+ // error.tsx/error.mdx receives { error, digest, reset } props from the
185
+ // boundary at catch time; MDX error files are pre-rendered without them
186
+ // (they're static content).
90
187
  if (segment.error) {
91
- const mod = await loadModule(segment.error).catch(() => null);
92
- if (mod?.default) {
93
- if (isMdxFilePath(segment.error.filePath)) {
94
- element = h(TimberErrorBoundary, {
95
- fallbackElement: h(mod.default as never, {}),
96
- children: element,
97
- });
98
- } else {
99
- element = h(TimberErrorBoundary, {
100
- fallbackComponent: mod.default,
101
- children: element,
102
- });
103
- }
188
+ const Component = await loadBoundaryComponent(segment.error, loadModule);
189
+ if (Component) {
190
+ element = wrapInBoundary(element, Component, segment.error, undefined, {}, config);
104
191
  }
105
192
  }
106
193
 
@@ -10,7 +10,7 @@ export type { RouteContext } from './types';
10
10
  export type { Metadata, MetadataRoute, MetadataHandler, MetadataResult } from './types';
11
11
 
12
12
  // Request Context — ALS-backed accessors (all sync)
13
- // Prefer defineSearchParams().get() and defineSegmentParams().get() for typed access.
13
+ // Prefer defineSearchParams().get() and getSegmentParams(SEGMENT_PATH) for typed access.
14
14
  // Design doc: design/04-authorization.md §"AccessContext does not include cookies or headers"
15
15
  export { getHeaders, getSegmentParams } from './request-context';
16
16
  export type { ReadonlyHeaders } from './request-context';
@@ -65,5 +65,3 @@ export { revalidatePath, revalidateTag } from './actions';
65
65
  export { getTraceId, getSpanId, withSpan, addSpanEvent } from './tracing';
66
66
 
67
67
  // Segment params types — re-exported for convenience.
68
- // defineSegmentParams itself lives at @timber-js/app/segment-params.
69
- export type { ParamsDefinition, InferParamField, ParamField } from '../segment-params/define.js';
@@ -66,12 +66,12 @@ export type {
66
66
  TreeBuilderConfig,
67
67
  TreeBuildResult,
68
68
  LoadedModule,
69
- LoadedComponent,
70
69
  ModuleLoader,
71
70
  AccessGateProps,
72
71
  SlotAccessGateProps,
73
- ErrorBoundaryProps,
74
72
  } from './tree-builder.js';
73
+ export type { LoadedComponent } from './utils/element-type.js';
74
+ export type { ErrorBoundaryProps } from './error-boundary-wrapper.js';
75
75
 
76
76
  // ── Access Gates ─────────────────────────────────────────────────────────
77
77
  export { AccessGate, SlotAccessGate } from './access-gate.js';
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Render-time metadata resolution components.
3
+ *
4
+ * MetadataCollector runs below AccessGate for each segment, collecting
5
+ * metadata entries into a request-scoped array. MetadataHead runs at
6
+ * the page level, merging all collected entries and rendering Float-
7
+ * hoisted head elements.
8
+ *
9
+ * This structural approach replaces the eager pre-render metadata
10
+ * resolution: a denied segment's AccessGate prevents MetadataCollector
11
+ * from rendering, so metadata() never executes for denied segments.
12
+ * No `firstDeniedIndex` bookkeeping needed.
13
+ *
14
+ * See TIM-1363, design/16-metadata.md.
15
+ */
16
+
17
+ import { createElement, Fragment } from 'react';
18
+ import type { ReactNode } from 'react';
19
+
20
+ import { requestContextAls } from './als-registry.js';
21
+ import type { Metadata } from './types.js';
22
+ import type { ManifestSegmentNode } from './route-matcher.js';
23
+ import { withSpan } from './tracing.js';
24
+ import { resolveMetadata, renderMetadataToElements, headElementsToReact } from './metadata.js';
25
+ import type { SegmentMetadataEntry } from './metadata.js';
26
+ import { collectMetadataRouteHeadElements } from './metadata-routes.js';
27
+
28
+ // ─── Request-scoped metadata entry store ────────────────────────────────
29
+
30
+ function getMetadataEntries(): SegmentMetadataEntry[] {
31
+ const store = requestContextAls.getStore();
32
+ if (!store) return [];
33
+ store._metadataEntries ??= [];
34
+ return store._metadataEntries;
35
+ }
36
+
37
+ // ─── MetadataCollector ──────────────────────────────────────────────────
38
+
39
+ export interface MetadataCollectorProps {
40
+ metadataExport: Metadata | (() => Promise<Metadata>);
41
+ segmentName: string;
42
+ isPage: boolean;
43
+ onSettled?: () => void;
44
+ children: ReactNode;
45
+ }
46
+
47
+ /**
48
+ * Async server component that collects one segment's metadata during
49
+ * render. Placed between AccessGate and the layout/page. When
50
+ * AccessGate denies, this component never renders, so dynamic
51
+ * metadata() never executes for denied segments (TIM-1027).
52
+ *
53
+ * Static metadata objects are pushed synchronously (no side effects).
54
+ * Dynamic metadata functions are awaited with OTEL tracing.
55
+ */
56
+ export async function MetadataCollector(props: MetadataCollectorProps): Promise<ReactNode> {
57
+ const { metadataExport, segmentName, isPage, onSettled, children } = props;
58
+
59
+ try {
60
+ let metadata: Metadata | null = null;
61
+ if (typeof metadataExport === 'function') {
62
+ metadata =
63
+ (await withSpan('timber.metadata', { 'timber.segment': segmentName }, () =>
64
+ (metadataExport as () => Promise<Metadata>)()
65
+ )) ?? null;
66
+ } else {
67
+ metadata = metadataExport;
68
+ }
69
+
70
+ if (metadata) {
71
+ getMetadataEntries().push({ metadata, isPage });
72
+ }
73
+
74
+ return children;
75
+ } finally {
76
+ onSettled?.();
77
+ }
78
+ }
79
+
80
+ // ─── MetadataHead ───────────────────────────────────────────────────────
81
+
82
+ export interface MetadataHeadProps {
83
+ segments: ManifestSegmentNode[];
84
+ requestUrl: string;
85
+ metadataRouteHashes?: Record<string, string>;
86
+ children: ReactNode;
87
+ }
88
+
89
+ /**
90
+ * Server component at the page level. Reads all metadata entries
91
+ * collected by MetadataCollector components above it, merges them,
92
+ * auto-links metadata routes, and renders Float-hoisted head elements.
93
+ *
94
+ * Placed as a sibling of the page element inside a Fragment so the
95
+ * Float elements are always at the page segment level (re-rendered
96
+ * on every SPA navigation, never skipped by segment tree diffing).
97
+ */
98
+ export function MetadataHead(props: MetadataHeadProps): ReactNode {
99
+ const { segments, requestUrl, metadataRouteHashes, children } = props;
100
+ const entries = getMetadataEntries();
101
+ const resolved = resolveMetadata(entries);
102
+ const headElements = renderMetadataToElements(resolved);
103
+
104
+ const autoLinked = collectMetadataRouteHeadElements(
105
+ segments,
106
+ Infinity,
107
+ resolved,
108
+ new URL(requestUrl),
109
+ metadataRouteHashes
110
+ );
111
+ headElements.push(...autoLinked);
112
+
113
+ const metadataElement = headElementsToReact(headElements);
114
+ return createElement(Fragment, null, metadataElement, children);
115
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Segment param coercion — runs the matched route's `params.ts` codecs
3
- * over the raw matcher output before middleware and rendering.
2
+ * Segment param coercion — runs the app/schema.ts codecs over the raw
3
+ * matcher output before middleware and rendering.
4
4
  *
5
5
  * Lifted out of `pipeline-phases.ts` (TIM-853) so the coercer can be
6
6
  * imported directly by other entry points (the action-dispatch wrapper,
@@ -18,7 +18,6 @@ import { effectiveUrlSegment } from '../routing/segment-classify.js';
18
18
  import { toBracketKey } from '../params/resolve-schema.js';
19
19
  import type { RouteMatch } from './pipeline.js';
20
20
  import { normalizeParamValue } from '../shared/param-value.js';
21
- import { loadModule } from './safe-load.js';
22
21
  import { ParamCoercionError } from './route-element-builder.js';
23
22
  import { isDebug } from './debug.js';
24
23
 
@@ -112,16 +111,24 @@ export function coerceSlotParams(
112
111
  /**
113
112
  * Run segment param coercion on the matched route's segments.
114
113
  *
115
- * When `globalCodecs` is provided (from app/schema.ts), uses the global
116
- * codec map keyed by bare param name. Otherwise falls back to loading
117
- * per-segment params.ts modules.
114
+ * Codecs come from `app/schema.ts` and nowhere else (TIM-1342 completed
115
+ * design/41-global-params.md migration step 9). A route with no schema, or
116
+ * a param the schema does not declare, keeps its raw string — which is the
117
+ * honest type for an uninterpreted URL segment.
118
118
  *
119
119
  * Throws ParamCoercionError if any codec fails (→ 404).
120
120
  *
121
121
  * This runs BEFORE middleware, so ctx.segmentParams is already typed.
122
122
  * See design/07-routing.md §"Where Coercion Runs"
123
123
  * See design/41-global-params.md §"Pipeline Integration"
124
+ *
125
+ * Stays `async` with nothing to await: it is injected as a
126
+ * `CoerceSegmentParamsFn` into the revalidation renderer and the action
127
+ * middleware runner, both of which await it. Narrowing the return type to
128
+ * `void` would break those contracts and remove a microtask boundary the
129
+ * pipeline's ordering has always had, for no gain.
124
130
  */
131
+ // eslint-disable-next-line @typescript-eslint/require-await -- see above
125
132
  export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
126
133
  const globalCodecs = _globalCodecs;
127
134
  // Unconditionally install a null-prototype target so the invariant
@@ -178,60 +185,5 @@ export async function coerceSegmentParams(match: RouteMatch): Promise<void> {
178
185
  // inside would report the second as the first.
179
186
  mergeTarget[key] = normalizeParamValue(parsed, key);
180
187
  }
181
- return;
182
- }
183
-
184
- // Legacy path: per-segment params.ts coercion
185
- for (const segment of match.segments) {
186
- // Only process segments that have a params.ts convention file
187
- if (!segment.params) continue;
188
-
189
- let mod: Record<string, unknown>;
190
- try {
191
- mod = await loadModule(segment.params);
192
- } catch (err) {
193
- const message = `Failed to load params module for segment "${segment.segmentName}": ${err instanceof Error ? err.message : String(err)}`;
194
- if (isDebug()) {
195
- console.warn(
196
- `[timber] Param coercion error: ${message}\n` +
197
- ` Segment: ${segment.segmentName}\n` +
198
- ` Params file: ${segment.params}`
199
- );
200
- }
201
- throw new ParamCoercionError(message);
202
- }
203
-
204
- const segmentParamsDef = mod.segmentParams as
205
- | { parse(raw: Record<string, string | string[]>): Record<string, unknown> }
206
- | undefined;
207
-
208
- if (!segmentParamsDef || typeof segmentParamsDef.parse !== 'function') continue;
209
-
210
- let coerced: Record<string, unknown>;
211
- try {
212
- coerced = segmentParamsDef.parse(match.segmentParams) as Record<string, unknown>;
213
- } catch (err) {
214
- const message = err instanceof Error ? err.message : String(err);
215
- if (isDebug()) {
216
- const rawKeys = Object.keys(match.segmentParams).join(', ');
217
- console.warn(
218
- `[timber] Param codec rejected values for segment "${segment.segmentName}"\n` +
219
- ` Error: ${message}\n` +
220
- ` Available raw params: { ${rawKeys} }\n` +
221
- ` Params file: ${segment.params}\n` +
222
- ` Hint: this usually means a codec threw for the raw URL value.\n` +
223
- ` Check that the regex/schema accepts the actual path segment string.`
224
- );
225
- }
226
- throw new ParamCoercionError(message);
227
- }
228
-
229
- // Normalize outside the catch, for the same reason as above. `__proto__`
230
- // is skipped here as well as inside `normalizeParamValue`, because this
231
- // loop assigns into `mergeTarget` directly.
232
- for (const key of Object.keys(coerced)) {
233
- if (key === '__proto__') continue;
234
- mergeTarget[key] = normalizeParamValue(coerced[key], key);
235
- }
236
188
  }
237
189
  }
@@ -15,6 +15,7 @@ import { callOnRequestError } from './instrumentation.js';
15
15
  import { getTraceId } from './tracing.js';
16
16
  import { RedirectSignal } from './primitives.js';
17
17
  import type { ProxyConfig } from './pipeline.js';
18
+ import { isRscRequest } from '../shared/rsc-media-type.js';
18
19
 
19
20
  // ─── Prototype-Pollution-Safe Sanitizer ────────────────────────────────────
20
21
 
@@ -125,8 +126,7 @@ export function buildRedirectResponse(
125
126
  req: Request,
126
127
  headers: Headers
127
128
  ): Response {
128
- const isRsc = (req.headers.get('Accept') ?? '').includes('text/x-component');
129
- if (isRsc) {
129
+ if (isRscRequest(req)) {
130
130
  headers.set('X-Timber-Redirect', signal.location);
131
131
  return new Response(null, { status: 204, headers });
132
132
  }
@@ -27,6 +27,7 @@ import { markResponseFlushed } from './request-context.js';
27
27
  import { RedirectSignal, DenySignal } from './primitives.js';
28
28
  import { isDebug } from './debug.js';
29
29
  import { rscErrorEnvelope } from '../shared/rsc-error-envelope.js';
30
+ import { isRscRequest } from '../shared/rsc-media-type.js';
30
31
  import type { PipelineConfig, RouteMatch } from './pipeline.js';
31
32
 
32
33
  // ─── Helpers ───────────────────────────────────────────────────────────────
@@ -141,7 +142,7 @@ export async function outcomeToResponse(
141
142
  // Signal the error via X-Timber-Error so the client hard-navigates
142
143
  // to the server-rendered error page instead of feeding HTML to the
143
144
  // Flight decoder (which crashes with "enqueueModel is not a function").
144
- const isRsc = (ctx.req.headers.get('Accept') ?? '').includes('text/x-component');
145
+ const isRsc = isRscRequest(ctx.req);
145
146
 
146
147
  if (outcome.phase === 'proxy') {
147
148
  logProxyError({ error: outcome.error });
@@ -39,6 +39,7 @@ import type { InterceptionContext, PipelineConfig, RouteMatch } from './pipeline
39
39
  import type { MetadataHandler, MetadataRoute, MiddlewareContext } from './types.js';
40
40
  import { swallow } from './logger.js';
41
41
  import { isDebug } from './debug.js';
42
+ import { isRscRequest } from '../shared/rsc-media-type.js';
42
43
 
43
44
  interface RenderContext {
44
45
  canonicalPathname: string;
@@ -367,8 +368,8 @@ export async function handleRequest(
367
368
  // deployment ID matches the current build. On mismatch, signal the
368
369
  // client to do a full page reload instead of returning an RSC payload
369
370
  // that references mismatched module IDs.
370
- const isRscRequest = (req.headers.get('Accept') ?? '').includes('text/x-component');
371
- if (isRscRequest) {
371
+ const isRsc = isRscRequest(req);
372
+ if (isRsc) {
372
373
  const skewCheck = checkVersionSkew(req);
373
374
  if (!skewCheck.ok) {
374
375
  const reloadHeaders = new Headers();
@@ -386,7 +387,7 @@ export async function handleRequest(
386
387
  // to prevent spoofing from plain HTML navigations or raw HTTP clients.
387
388
  // The header value must be a valid relative pathname — reject schemes,
388
389
  // authority, and control characters. Canonicalize before matching.
389
- if (isRscRequest && config.interceptionRewrites?.length) {
390
+ if (isRsc && config.interceptionRewrites?.length) {
390
391
  const rawSourceUrl = req.headers.get('X-Timber-URL');
391
392
  const validatedSourceUrl = rawSourceUrl
392
393
  ? validateInterceptionHeader(rawSourceUrl, stripTrailingSlash)
@@ -511,7 +512,7 @@ export async function handleRequest(
511
512
  | { route?: unknown; page?: unknown }
512
513
  | undefined;
513
514
  const isApiRoute = leafSegment?.route && !leafSegment?.page;
514
- if (config.earlyHints && !isRscRequest && !isApiRoute) {
515
+ if (config.earlyHints && !isRsc && !isApiRoute) {
515
516
  try {
516
517
  await config.earlyHints(match, req, responseHeaders);
517
518
  } catch (err) {
@@ -520,8 +521,8 @@ export async function handleRequest(
520
521
  }
521
522
 
522
523
  // Stage 2c: Param coercion (before middleware)
523
- // Load params.ts modules from matched segments and coerce raw string
524
- // params through defineSegmentParams codecs. Coercion failure → 404
524
+ // Coerce raw string params through the app/schema.ts codecs.
525
+ // Coercion failure → 404
525
526
  // (middleware never runs). See design/07-routing.md §"Where Coercion Runs"
526
527
  //
527
528
  // Snapshot raw params before coercion — slot resolution needs the
@@ -540,9 +541,8 @@ export async function handleRequest(
540
541
  `[timber] Param coercion failed for ${method} ${canonicalPathname} — responding with 404\n` +
541
542
  ` Matched segments: ${segmentChain}\n` +
542
543
  ` Error: ${error.message}\n` +
543
- ` This usually means a params.ts codec rejected the URL params.\n` +
544
- ` Check that all fields in defineSegmentParams() are optional for params\n` +
545
- ` that don't appear at every route depth (e.g. year, month, day).`
544
+ ` This usually means a codec in app/schema.ts rejected the URL value.\n` +
545
+ ` Check the codec for the bracket key of the segment named above.`
546
546
  );
547
547
  }
548
548
  // For API routes (route.ts), return a bare 404 — not an HTML page.
@@ -113,6 +113,31 @@ export class DenySignal extends Error {
113
113
  readonly status: number;
114
114
  readonly data: JsonSerializable | undefined;
115
115
 
116
+ /**
117
+ * Segment key of the segment that owns the deny page matched for this
118
+ * signal — set by a framework catch site that matched an entry it may not
119
+ * render at its own position, and re-threw so the boundary at the owning
120
+ * segment renders it instead.
121
+ *
122
+ * Written only by the three in-tree catch sites (`PageDenyBoundary`,
123
+ * `TracedLayout`, `AccessGate`), never by user code. Two readers depend on
124
+ * it: `TimberErrorBoundary`, which declines any deny whose `ownerKey` is
125
+ * not its own `segmentKey`, and `buildRscPayloadResponse`, which keeps
126
+ * streaming instead of re-rendering when it is set.
127
+ *
128
+ * See design/04-authorization.md §"Where a Deny Page Renders", TIM-1356.
129
+ */
130
+ ownerKey?: string;
131
+
132
+ /**
133
+ * When true, the pipeline must respond with `rscErrorEnvelope` so the
134
+ * client hard-navigates instead of attempting to render a deny page
135
+ * from the RSC payload. Set by AccessGate when a skipped segment
136
+ * denies — rendering the deny page in-tree would appear inside the
137
+ * client's cached layout chrome (TIM-1074). See TIM-1363.
138
+ */
139
+ _hardNavigate?: boolean;
140
+
116
141
  constructor(status: number, data?: JsonSerializable) {
117
142
  super(`Access denied with status ${status}`);
118
143
  this.name = 'DenySignal';