@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
@@ -26,7 +26,8 @@ import { flightInitScript, htmlEscapeJsonString } from '../flight-scripts.js';
26
26
  import { renderDenyPage, renderDenyPageAsRsc } from '../deny-renderer.js';
27
27
  import type { DefaultPageRenderer, LayoutEntry } from '../deny-renderer.js';
28
28
  import type { NavContext } from '../ssr-bridge-types.js';
29
- import { createDebugChannelSink, isRscPayloadRequest } from './helpers.js';
29
+ import { createDebugChannelSink } from './helpers.js';
30
+ import { isRscRequest } from '../../shared/rsc-media-type.js';
30
31
  import { withPublishedParams } from '../publish-params.js';
31
32
  import { getCookiesForSsr } from '../cookie-context.js';
32
33
  import { callSsr } from './ssr-bridge.js';
@@ -72,9 +73,12 @@ interface ErrorFileResolution {
72
73
  */
73
74
  async function resolveErrorFile(
74
75
  status: number,
75
- segments: ManifestSegmentNode[]
76
+ segments: ManifestSegmentNode[],
77
+ maxIndex?: number
76
78
  ): Promise<ErrorFileResolution | null> {
77
- for (let i = segments.length - 1; i >= 0; i--) {
79
+ const start =
80
+ maxIndex !== undefined ? Math.min(segments.length - 1, maxIndex) : segments.length - 1;
81
+ for (let i = start; i >= 0; i--) {
78
82
  const segment = segments[i];
79
83
 
80
84
  if (segment.statusFiles) {
@@ -131,9 +135,14 @@ async function resolveErrorFile(
131
135
  function getLayoutsForErrorFile(
132
136
  segments: ManifestSegmentNode[],
133
137
  layoutComponents: LayoutEntry[],
134
- foundSegmentIndex: number
138
+ foundSegmentIndex: number,
139
+ firstDeniedIndex = Infinity
135
140
  ): LayoutEntry[] {
136
- const resolvedSegments = new Set(segments.slice(0, foundSegmentIndex + 1));
141
+ // Clamp so a denied segment's layout never wraps the error page.
142
+ // The file resolution is unbounded (errors may use leaf files), but the
143
+ // layout wrapping must still respect the denial boundary. TIM-1358.
144
+ const ceiling = Math.min(foundSegmentIndex, firstDeniedIndex - 1);
145
+ const resolvedSegments = new Set(segments.slice(0, ceiling + 1));
137
146
  return layoutComponents.filter((lc) => resolvedSegments.has(lc.segment));
138
147
  }
139
148
 
@@ -182,12 +191,13 @@ export async function renderErrorPage(
182
191
  match: RouteMatch,
183
192
  responseHeaders: Headers,
184
193
  clientBootstrap: ClientBootstrapConfig,
185
- globalError?: GlobalErrorFile
194
+ globalError?: GlobalErrorFile,
195
+ firstDeniedIndex = Infinity
186
196
  ): Promise<Response> {
187
197
  // The error itself is not logged here — every producer of a `renderError`
188
198
  // signal already logged it at the point it was caught (`rsc-stream.ts`'s
189
199
  // `onError`, `outcomeToResponse`), so a log on this exit would double up.
190
- if (isRscPayloadRequest(req)) {
200
+ if (isRscRequest(req)) {
191
201
  return rscErrorEnvelope(status, responseHeaders);
192
202
  }
193
203
 
@@ -195,8 +205,13 @@ export async function renderErrorPage(
195
205
  // shows original source positions instead of transpiled/bundled ones.
196
206
  // See TIM-811.
197
207
  sourceMapError(error);
198
- // Walk segments from leaf to root to find the error component
199
- const resolution = await resolveErrorFile(status, segments);
208
+ // Walk segments from leaf to root to find the error component.
209
+ // When firstDeniedIndex is finite, bound the walk so error files below
210
+ // the denied segment are not selected — matching the deny renderer's
211
+ // behavior. For plain errors (firstDeniedIndex = Infinity), the full
212
+ // chain is searched. See TIM-1358.
213
+ const errorMaxIndex = firstDeniedIndex < Infinity ? firstDeniedIndex : undefined;
214
+ const resolution = await resolveErrorFile(status, segments, errorMaxIndex);
200
215
 
201
216
  // Tier 2: No segment-level error file — try global-error.tsx
202
217
  if (!resolution && globalError) {
@@ -221,7 +236,12 @@ export async function renderErrorPage(
221
236
  }
222
237
 
223
238
  const { component: errorComponent, segmentIndex, isMdx } = resolution;
224
- const layoutsToWrap = getLayoutsForErrorFile(segments, layoutComponents, segmentIndex);
239
+ const layoutsToWrap = getLayoutsForErrorFile(
240
+ segments,
241
+ layoutComponents,
242
+ segmentIndex,
243
+ firstDeniedIndex
244
+ );
225
245
 
226
246
  return renderErrorPageViaRsc(
227
247
  error,
@@ -407,7 +427,7 @@ export async function renderNoMatchPage(
407
427
  const deny = new DenySignal(404);
408
428
  const match: RouteMatch = { segments, segmentParams: {}, middlewareChain: [] };
409
429
 
410
- if (isRscPayloadRequest(req)) {
430
+ if (isRscRequest(req)) {
411
431
  return renderDenyPageAsRsc(
412
432
  deny,
413
433
  segments,
@@ -12,9 +12,6 @@ import type { SlotSkipEntry } from '../slot-resolver.js';
12
12
  import { isClientReference } from '../route-element-builder.js';
13
13
  import { isStaticRequestDependent } from '../request-dep.js';
14
14
 
15
- /** RSC content type for client navigation payload requests. */
16
- export const RSC_CONTENT_TYPE = 'text/x-component';
17
-
18
15
  /**
19
16
  * Create a debug channel sink that discards all debug data.
20
17
  *
@@ -205,6 +202,8 @@ export function buildSegmentInfo(
205
202
  const skippedSet = skippedSegmentKeys ? new Set(skippedSegmentKeys) : null;
206
203
  const result: SegmentInfo[] = [];
207
204
 
205
+ // Deepest segment that actually mounted, exclusive.
206
+ //
208
207
  for (let i = 0; i < segments.length; i++) {
209
208
  // A denied segment's AccessGate renders the deny page in place of the
210
209
  // layout, so this segment and everything below it never mounted. Their
@@ -259,15 +258,6 @@ export function buildSegmentInfo(
259
258
  return result;
260
259
  }
261
260
 
262
- /**
263
- * Check if a request is asking for an RSC payload (client navigation)
264
- * rather than full HTML. Client-side navigation sends Accept: text/x-component.
265
- */
266
- export function isRscPayloadRequest(req: Request): boolean {
267
- const accept = req.headers.get('Accept') ?? '';
268
- return accept.includes(RSC_CONTENT_TYPE);
269
- }
270
-
271
261
  // Re-export consolidated helpers from their single source of truth.
272
262
  // Callers within rsc-entry/ import from this file; the implementations
273
263
  // live in pipeline-helpers.ts (buildRedirectResponse) and render-utils.ts
@@ -25,11 +25,6 @@
25
25
 
26
26
  // @ts-expect-error — virtual module provided by timber-routing plugin
27
27
  import routeManifest from 'virtual:timber-route-manifest';
28
- // TIM-830: Populate the search-params registry eagerly so <Link> can
29
- // serialize flat `Partial<T>` values synchronously in RSC renders.
30
- // The module has side effects only — no exports consumed here.
31
- // @ts-expect-error — virtual module provided by timber-routing plugin
32
- import 'virtual:timber-search-params-registry';
33
28
  // @ts-expect-error — virtual module provided by timber-entries plugin
34
29
  import config from 'virtual:timber-config';
35
30
  // @ts-expect-error — virtual module provided by timber-build-manifest plugin
@@ -41,9 +41,11 @@ import { recordTiming } from '../server-timing.js';
41
41
  import { requestContextAls } from '../als-registry.js';
42
42
  import { parseClientStateTree } from '../state-tree-diff.js';
43
43
 
44
+ import { getFirstDeniedSegmentIndex } from '../deny-boundary.js';
44
45
  import { handleApiRoute } from './api-handler.js';
45
46
  import { renderErrorPage, renderNoMatchPage } from './error-renderer.js';
46
- import { buildRedirectResponse, createDebugChannelSink, isRscPayloadRequest } from './helpers.js';
47
+ import { buildRedirectResponse, createDebugChannelSink } from './helpers.js';
48
+ import { isRscRequest } from '../../shared/rsc-media-type.js';
47
49
  import { buildRscPayloadResponse } from './rsc-payload.js';
48
50
  import { renderRscStream } from './rsc-stream.js';
49
51
  import { renderSsrResponse } from './ssr-renderer.js';
@@ -97,7 +99,7 @@ export async function renderRoute(
97
99
  // Only used for RSC requests — HTML requests always get a full render.
98
100
  // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
99
101
  const clientStateTree =
100
- deps.clientSegmentCache && isRscPayloadRequest(req) ? parseClientStateTree(req) : null;
102
+ deps.clientSegmentCache && isRscRequest(req) ? parseClientStateTree(req) : null;
101
103
 
102
104
  // Build the React element tree — runs access checks eagerly to gate
103
105
  // metadata resolution (TIM-1027), then loads modules and resolves
@@ -146,7 +148,6 @@ export async function renderRoute(
146
148
  skippedSegments,
147
149
  shellSettled,
148
150
  slotSkipInfo,
149
- firstDeniedIndex,
150
151
  } = routeResult;
151
152
 
152
153
  // Build head HTML for injection into the SSR output.
@@ -225,14 +226,15 @@ export async function renderRoute(
225
226
  // Synchronous deny — deny() in a non-async component throws during
226
227
  // renderToReadableStream creation, caught in the try/catch above.
227
228
  if (signals.denySignal) {
228
- if (isRscPayloadRequest(req)) {
229
+ if (isRscRequest(req)) {
229
230
  return renderDenyPageAsRsc(
230
231
  signals.denySignal,
231
232
  segments,
232
233
  layoutComponents as LayoutEntry[],
233
234
  req,
234
235
  responseHeaders,
235
- createDebugChannelSink
236
+ createDebugChannelSink,
237
+ getFirstDeniedSegmentIndex()
236
238
  );
237
239
  }
238
240
  return renderDenyPage(
@@ -244,7 +246,9 @@ export async function renderRoute(
244
246
  responseHeaders,
245
247
  clientBootstrap,
246
248
  createDebugChannelSink,
247
- callSsr
249
+ callSsr,
250
+ undefined,
251
+ getFirstDeniedSegmentIndex()
248
252
  );
249
253
  }
250
254
 
@@ -264,14 +268,15 @@ export async function renderRoute(
264
268
  match,
265
269
  responseHeaders,
266
270
  clientBootstrap,
267
- globalError
271
+ globalError,
272
+ getFirstDeniedSegmentIndex()
268
273
  );
269
274
  }
270
275
 
271
276
  // For RSC payload requests (client navigation), return the RSC Flight
272
277
  // stream directly — skip SSR HTML rendering entirely.
273
278
  // See design/19-client-navigation.md §"RSC Payload Handling"
274
- if (isRscPayloadRequest(req)) {
279
+ if (isRscRequest(req)) {
275
280
  return buildRscPayloadResponse(
276
281
  req,
277
282
  rscStream!,
@@ -280,8 +285,7 @@ export async function renderRoute(
280
285
  layoutComponents,
281
286
  responseHeaders,
282
287
  skippedSegments,
283
- slotSkipInfo,
284
- firstDeniedIndex
288
+ slotSkipInfo
285
289
  );
286
290
  }
287
291
 
@@ -300,6 +304,5 @@ export async function renderRoute(
300
304
  deferSuspenseFor,
301
305
  globalError,
302
306
  slotSkipInfo,
303
- firstDeniedIndex,
304
307
  });
305
308
  }
@@ -40,6 +40,7 @@ import { buildRouteElement } from '../route-element-builder.js';
40
40
  import type { RevalidateRenderer, RevalidationResult } from '../actions.js';
41
41
 
42
42
  import { runMiddlewareForAction, type CoerceSegmentParamsFn } from './action-middleware-runner.js';
43
+ import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.js';
43
44
 
44
45
  /**
45
46
  * Sentinel thrown when revalidation cannot proceed because middleware
@@ -214,7 +215,7 @@ export function createRevalidateRendererFactory(
214
215
  revalidateUrl.pathname = validation.pathname;
215
216
  revalidateUrl.search = validation.search;
216
217
  const revalidateHeaders = new Headers(req.headers);
217
- revalidateHeaders.set('Accept', 'text/x-component');
218
+ revalidateHeaders.set('Accept', RSC_CONTENT_TYPE);
218
219
  // Snapshot the action's post-mutation cookie map (deletions
219
220
  // included) and serialize it into a `Cookie:` header value the
220
221
  // target middleware can read off `ctx.req.headers.get('cookie')`.
@@ -15,17 +15,22 @@ import type { LayoutComponentEntry } from '../route-element-builder.js';
15
15
  import type { ManifestSegmentNode } from '../route-matcher.js';
16
16
  import type { SlotSkipEntry } from '../slot-resolver.js';
17
17
 
18
- import {
19
- buildRedirectResponse,
20
- buildSegmentInfo,
21
- createDebugChannelSink,
22
- RSC_CONTENT_TYPE,
23
- } from './helpers.js';
18
+ import { buildRedirectResponse, buildSegmentInfo, createDebugChannelSink } from './helpers.js';
19
+ import { RSC_CONTENT_TYPE } from '../../shared/rsc-media-type.js';
24
20
 
25
21
  import { rscErrorEnvelope } from '../../shared/rsc-error-envelope.js';
26
22
 
27
23
  import type { RenderSignals } from './rsc-stream.js';
28
24
 
25
+ /**
26
+ * Ceiling on waiting for a first chunk that is already in flight, used only
27
+ * when an addressed deny won the race against it. Matches the 50ms ceiling
28
+ * documented for the race itself: long enough that the error row — which is
29
+ * what the render just produced — arrives, short enough that a stalled stream
30
+ * cannot hold the response open.
31
+ */
32
+ const FIRST_CHUNK_CEILING_MS = 50;
33
+
29
34
  /**
30
35
  * Build an RSC payload Response for a client-side navigation request.
31
36
  *
@@ -43,9 +48,7 @@ export async function buildRscPayloadResponse(
43
48
  layoutComponents: LayoutComponentEntry[],
44
49
  responseHeaders: Headers,
45
50
  skippedSegments?: string[],
46
- slotSkipInfo?: SlotSkipEntry[],
47
- /** Index of the first denied segment — see RouteElementResult.firstDeniedIndex. */
48
- firstDeniedIndex = Infinity
51
+ slotSkipInfo?: SlotSkipEntry[]
49
52
  ): Promise<Response> {
50
53
  // Read the first chunk from the RSC stream before committing headers.
51
54
  // Race the first read against signal detection — if an async component
@@ -72,10 +75,18 @@ export async function buildRscPayloadResponse(
72
75
  | { type: 'data'; chunk: ReadableStreamReadResult<Uint8Array> }
73
76
  | { type: 'signal' };
74
77
 
78
+ // Retained rather than inlined into the race below. When a signal wins, the
79
+ // usual answer is to cancel — but an ADDRESSED deny is one whose error row
80
+ // the client's boundary is going to render, so that payload has to survive
81
+ // (TIM-1356). Taking delivery of the read already in flight is not the
82
+ // "bare read() after a signal won" the async rules forbid; issuing a second
83
+ // one would be. It still gets a ceiling below.
84
+ const pendingRead = reader.read();
85
+
75
86
  let first: RaceResult;
76
87
  try {
77
88
  first = await Promise.race([
78
- reader.read().then((chunk) => ({ type: 'data' as const, chunk })),
89
+ pendingRead.then((chunk) => ({ type: 'data' as const, chunk })),
79
90
  signalDetected.then(() => ({ type: 'signal' as const })),
80
91
  ]);
81
92
  } catch (_readError) {
@@ -121,15 +132,42 @@ export async function buildRscPayloadResponse(
121
132
  reader.cancel().catch(() => {});
122
133
  return buildRedirectResponse(trackedRedirect, req, responseHeaders);
123
134
  }
124
- if (signals.denySignal) {
135
+ // An addressed deny needs no fallback: the payload already carries its error
136
+ // row, aimed at the boundary the client has mounted at that segment, which
137
+ // renders it. Cancelling would throw a finished render away and re-run the
138
+ // whole route — the exact cost TIM-666 removed. The status still lands,
139
+ // because the catch site called setDenyStatus() before re-throwing (read
140
+ // below via getDenyStatus).
141
+ //
142
+ // The question is asked of `unplacedDeny`, not of `denySignal.ownerKey`,
143
+ // because one render can produce both kinds and `denySignal` holds only one
144
+ // of them. A deny nothing could place has no boundary that may claim it, so
145
+ // its row would otherwise stream to a client with nothing to render it —
146
+ // and it needs the fallback whether or not some other deny was addressed
147
+ // first. See TIM-1356.
148
+ if (signals.unplacedDeny) {
125
149
  reader.cancel().catch(() => {});
150
+
151
+ // A skipped segment whose access just flipped to deny marks the signal
152
+ // for hard-navigate: rendering a deny page here would execute the denied
153
+ // segment's protected layout and place it inside cached client chrome
154
+ // (TIM-1074). Return rscErrorEnvelope so the client hard-navigates for
155
+ // a clean full render with correct deny page placement. See TIM-1363.
156
+ if (signals.unplacedDeny._hardNavigate) {
157
+ return rscErrorEnvelope(signals.unplacedDeny.status, responseHeaders);
158
+ }
159
+
160
+ // Read the denied segment index from ALS — set by AccessGate during
161
+ // render. See TIM-1358, TIM-1365.
162
+ const { getFirstDeniedSegmentIndex: getDeniedIdx } = await import('../deny-boundary.js');
126
163
  return renderDenyPageAsRsc(
127
- signals.denySignal,
164
+ signals.unplacedDeny,
128
165
  segments,
129
166
  layoutComponents as LayoutEntry[],
130
167
  req,
131
168
  responseHeaders,
132
- createDebugChannelSink
169
+ createDebugChannelSink,
170
+ getDeniedIdx()
133
171
  );
134
172
  }
135
173
 
@@ -151,6 +189,41 @@ export async function buildRscPayloadResponse(
151
189
  let firstRead: ReadableStreamReadResult<Uint8Array>;
152
190
  if (first.type === 'data') {
153
191
  firstRead = first.chunk;
192
+ } else if (signals.denySignal?.ownerKey) {
193
+ // An addressed deny won the race before its own error row surfaced.
194
+ // Cancelling here would hand the client a 4xx payload with nothing in it
195
+ // — no row for the owning boundary to render, so the navigation shows
196
+ // nothing at all. Take delivery of the read already in flight instead,
197
+ // under a ceiling so a stalled stream still cannot hang the response.
198
+ const TIMED_OUT = Symbol('first-chunk-timeout');
199
+ const raced = await Promise.race([
200
+ pendingRead,
201
+ new Promise<typeof TIMED_OUT>((resolve) =>
202
+ setTimeout(() => resolve(TIMED_OUT), FIRST_CHUNK_CEILING_MS)
203
+ ),
204
+ ]);
205
+
206
+ // Missing the ceiling must not be reported as a finished stream. Under
207
+ // event-loop contention or slow serialization the row is late rather than
208
+ // absent, and fabricating `done: true` here would produce the very empty
209
+ // 4xx this branch exists to prevent.
210
+ //
211
+ // Nor can it fall to `renderDenyPageAsRsc`. That renderer re-resolves from
212
+ // the FULL leaf chain and wraps whatever it finds, ignoring `ownerKey` —
213
+ // so an `/admin` denial addressed to a root `4xx.tsx` would come back as
214
+ // `/admin/secret/403.tsx` with the denied layouts executed, which is the
215
+ // TIM-1074 leak TIM-1358 still owns. A deny this path already placed
216
+ // correctly must not be handed to it.
217
+ //
218
+ // So: hard-navigate. The document request renders the same deny through
219
+ // the boundary at its owner, with the placement intact. A full page load
220
+ // is a real cost, on a path that needs a status page and a stalled stream
221
+ // to reach at all.
222
+ if (raced === TIMED_OUT) {
223
+ reader.cancel().catch(() => {});
224
+ return rscErrorEnvelope(signals.denySignal.status, responseHeaders);
225
+ }
226
+ firstRead = raced;
154
227
  } else {
155
228
  await reader.cancel();
156
229
  firstRead = { done: true, value: undefined };
@@ -189,12 +262,29 @@ export async function buildRscPayloadResponse(
189
262
  // for state tree diffing on subsequent navigations. Request-dependency
190
263
  // is determined by static module-graph analysis (not runtime taint).
191
264
  // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
265
+ // Read the deny state before describing the tree — what mounted depends on it.
266
+ // Both values are recorded in ALS during render by AccessGate's catch path.
267
+ const { getDenyStatus, getFirstDeniedSegmentIndex } = await import('../deny-boundary.js');
268
+ const denyStatus = getDenyStatus();
269
+ const resolvedDenyIndex = getFirstDeniedSegmentIndex();
270
+
271
+ // Emitted in full even when a deny rendered, because this header answers two
272
+ // questions and they diverge here. The client picks its merge point out of
273
+ // this list (`buildSegmentUpdates`), and a partial payload with no merge
274
+ // point is never inserted at all — the URL commits over the stale previous
275
+ // UI. So it has to keep describing the chain the payload was BUILT against.
276
+ //
277
+ // What it must not also do on a deny is seed the segment cache, since the
278
+ // deny page replaced a subtree and part of that chain never mounted. That
279
+ // half is handled at the commit site from the response status, where the
280
+ // merge point has already been chosen. See `client/router-pipeline.ts` and
281
+ // TIM-1356.
192
282
  const segmentInfo = buildSegmentInfo(
193
283
  segments,
194
284
  layoutComponents,
195
285
  slotSkipInfo,
196
286
  skippedSegments,
197
- firstDeniedIndex
287
+ resolvedDenyIndex
198
288
  );
199
289
  responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
200
290
 
@@ -204,12 +294,6 @@ export async function buildRscPayloadResponse(
204
294
  responseHeaders.set('X-Timber-Skipped-Segments', JSON.stringify(skippedSegments));
205
295
  }
206
296
 
207
- // Check if a DenySignal was caught in-tree by AccessGate or
208
- // PageDenyBoundary. The deny page is in the Flight stream as a normal
209
- // element — use the deny status for the HTTP response. See TIM-666.
210
- const { getDenyStatus } = await import('../deny-boundary.js');
211
- const denyStatus = getDenyStatus();
212
-
213
297
  return new Response(patchedStream, {
214
298
  status: denyStatus ?? 200,
215
299
  headers: responseHeaders,
@@ -40,6 +40,18 @@ import { isDevMode } from '../debug.js';
40
40
  */
41
41
  export interface RenderSignals {
42
42
  denySignal: DenySignal | null;
43
+ /**
44
+ * The first deny that no in-tree catch site could place — no `ownerKey`, so
45
+ * no boundary may claim its error row and only the re-render fallback can
46
+ * produce a page for it.
47
+ *
48
+ * Tracked apart from `denySignal` because one render can produce both. A
49
+ * deep page can hoist a 404 to a boundary that will render it while an
50
+ * outer layout separately throws a 403 nothing matches; whichever lands in
51
+ * `denySignal` first, the unplaced one still needs the fallback, and
52
+ * deciding from `denySignal.ownerKey` alone would strand it. See TIM-1356.
53
+ */
54
+ unplacedDeny: DenySignal | null;
43
55
  redirectSignal: RedirectSignal | null;
44
56
  renderError: { error: unknown; status: number } | null;
45
57
  /**
@@ -88,6 +100,7 @@ export interface RscStreamResult {
88
100
  export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStreamResult {
89
101
  const signals: RenderSignals = {
90
102
  denySignal: null,
103
+ unplacedDeny: null,
91
104
  redirectSignal: null,
92
105
  renderError: null,
93
106
  lastUnhandledError: null,
@@ -110,9 +123,18 @@ export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStrea
110
123
  if (isAbortError(error) || req.signal?.aborted) return;
111
124
  if (error instanceof DenySignal) {
112
125
  signals.denySignal = error;
126
+ if (!error.ownerKey) signals.unplacedDeny ??= error;
113
127
  signals.onSignal?.();
114
- // Return structured digest for client-side error boundaries
115
- return JSON.stringify({ type: 'deny', status: error.status, data: error.data });
128
+ // Return structured digest for client-side error boundaries.
129
+ // `ownerKey` is set when an in-tree catch site hoisted the deny
130
+ // to the segment owning the matched file — it addresses the
131
+ // boundary that may render it. See TIM-1356.
132
+ return JSON.stringify({
133
+ type: 'deny',
134
+ status: error.status,
135
+ data: error.data,
136
+ ownerKey: error.ownerKey,
137
+ });
116
138
  }
117
139
  if (error instanceof RedirectSignal) {
118
140
  signals.redirectSignal = error;
@@ -218,6 +240,7 @@ export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStrea
218
240
  } catch (error) {
219
241
  if (error instanceof DenySignal) {
220
242
  signals.denySignal = error;
243
+ if (!error.ownerKey) signals.unplacedDeny ??= error;
221
244
  } else if (error instanceof RedirectSignal) {
222
245
  signals.redirectSignal = error;
223
246
  } else {
@@ -33,7 +33,7 @@ import {
33
33
  isAbortError,
34
34
  } from './helpers.js';
35
35
  import { getCookiesForSsr } from '../cookie-context.js';
36
- import { getDenyStatus } from '../deny-boundary.js';
36
+ import { getDenyStatus, getFirstDeniedSegmentIndex } from '../deny-boundary.js';
37
37
  import { renderErrorPage, type GlobalErrorFile } from './error-renderer.js';
38
38
  import { callSsr } from './ssr-bridge.js';
39
39
  import type { RenderSignals } from './rsc-stream.js';
@@ -104,8 +104,6 @@ interface SsrRenderOptions {
104
104
  /** Tier 2 global-error.tsx file, if present in app/. */
105
105
  globalError?: GlobalErrorFile;
106
106
  slotSkipInfo?: SlotSkipEntry[];
107
- /** Index of the first denied segment — see RouteElementResult.firstDeniedIndex. */
108
- firstDeniedIndex?: number;
109
107
  }
110
108
 
111
109
  /**
@@ -168,13 +166,11 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
168
166
  const errorAbort = new AbortController();
169
167
  signals.onUnhandledError = () => errorAbort.abort();
170
168
 
171
- // Embed segment metadata in HTML for initial hydration.
172
- // The client reads this to populate its segment cache before the
173
- // first navigation, enabling state tree diffing from the start.
174
- // Skipped when client JS is disabled — no client JS to consume it.
175
- const segmentScript = clientJsDisabled
176
- ? ''
177
- : `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents, opts.slotSkipInfo, undefined, opts.firstDeniedIndex)))}</script>`;
169
+ // Segment metadata for initial hydration: deferred to shell-flush time.
170
+ // The SSR entry invokes this thunk AFTER renderSsrNodeStream /
171
+ // renderSsrStream returns (shell ready ⇒ AccessGates settled), so
172
+ // getFirstDeniedSegmentIndex() returns the authoritative ALS value
173
+ // rather than the pre-render Infinity. See TIM-1365, TIM-1359.
178
174
 
179
175
  // Params reach the browser inside the payload — a field on the root row of
180
176
  // the same Flight stream that is inlined for hydration. SSR itself renders
@@ -193,10 +189,13 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
193
189
  headHtml:
194
190
  headHtml +
195
191
  clientBootstrap.preloadLinks +
196
- segmentScript +
197
192
  // Initialize __timber_f in <head> so it exists before any streaming
198
193
  // chunk scripts arrive in <body>. See flight-scripts.ts, LOCAL-415.
199
194
  (clientJsDisabled ? '' : flightInitScript()),
195
+ getHeadSegmentScript: clientJsDisabled
196
+ ? undefined
197
+ : () =>
198
+ `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents, opts.slotSkipInfo, undefined, getFirstDeniedSegmentIndex())))}</script>`,
200
199
  bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
201
200
  // Skip RSC inline stream when client JS is disabled — no client to hydrate.
202
201
  rscStream: clientJsDisabled ? undefined : inlineStream,
@@ -215,6 +214,12 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
215
214
  // The boundary renders the deny page as its fallback AND sets the correct
216
215
  // HTTP status code on navContext — no redundant renderDenyPage() needed.
217
216
  // See TIM-664, LOCAL-298.
217
+ // Read the denied segment index from ALS — set by AccessGate during render
218
+ // (setFirstDeniedSegmentIndex). See TIM-1358, TIM-1365.
219
+ function resolvedDeniedIndex(): number {
220
+ return getFirstDeniedSegmentIndex();
221
+ }
222
+
218
223
  function checkCapturedSignals(skipHandledDeny = false): Response | Promise<Response> | null {
219
224
  if (signals.redirectSignal) {
220
225
  return buildRedirectResponse(signals.redirectSignal, req, responseHeaders);
@@ -233,7 +238,9 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
233
238
  responseHeaders,
234
239
  clientBootstrap,
235
240
  createDebugChannelSink,
236
- callSsr
241
+ callSsr,
242
+ undefined,
243
+ resolvedDeniedIndex()
237
244
  );
238
245
  }
239
246
  if (signals.renderError) {
@@ -246,7 +253,8 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
246
253
  match,
247
254
  responseHeaders,
248
255
  clientBootstrap,
249
- globalError
256
+ globalError,
257
+ resolvedDeniedIndex()
250
258
  );
251
259
  }
252
260
  return null;
@@ -421,7 +429,9 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
421
429
  responseHeaders,
422
430
  clientBootstrap,
423
431
  createDebugChannelSink,
424
- callSsr
432
+ callSsr,
433
+ undefined,
434
+ resolvedDeniedIndex()
425
435
  );
426
436
  }
427
437
  if (signals.renderError) {
@@ -436,7 +446,8 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
436
446
  match,
437
447
  responseHeaders,
438
448
  clientBootstrap,
439
- globalError
449
+ globalError,
450
+ resolvedDeniedIndex()
440
451
  );
441
452
  }
442
453
  // No captured signal — unhandled error in the RSC stream.
@@ -454,7 +465,8 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
454
465
  match,
455
466
  responseHeaders,
456
467
  clientBootstrap,
457
- globalError
468
+ globalError,
469
+ resolvedDeniedIndex()
458
470
  );
459
471
  }
460
472
 
@@ -198,7 +198,11 @@ export async function resolveSlotElement(
198
198
  element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts, scope);
199
199
 
200
200
  // Wrap with slot root's error boundaries (outermost)
201
- element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
201
+ element = await wrapSegmentWithErrorBoundaries(slotNode, element, {
202
+ loadModule,
203
+ createElement: h,
204
+ errorBoundaryComponent: TimberErrorBoundary,
205
+ });
202
206
 
203
207
  // Catch-all error boundary: ensures slot errors NEVER propagate to the
204
208
  // parent layout. Without this, a slot without error.tsx that throws
@@ -278,7 +282,11 @@ async function wrapWithIntermediateSegments(
278
282
  ): Promise<React.ReactElement> {
279
283
  for (let i = chain.length - 1; i > 0; i--) {
280
284
  const seg = chain[i];
281
- element = await wrapSegmentWithErrorBoundaries(seg, element, h);
285
+ element = await wrapSegmentWithErrorBoundaries(seg, element, {
286
+ loadModule,
287
+ createElement: h,
288
+ errorBoundaryComponent: TimberErrorBoundary,
289
+ });
282
290
  element = await wrapWithLayout(seg, element, h, scope);
283
291
  element = await wrapWithAccessGate(seg, element, h, accessVerdicts, scope);
284
292
  }
@@ -70,6 +70,18 @@ export interface NavContext {
70
70
  /** Request cookies as name→value pairs. Used by useCookie() during SSR
71
71
  * to return correct cookie values before hydration. */
72
72
  cookies?: Map<string, string>;
73
+ /**
74
+ * Lazy thunk that produces the `<script>self.__timber_segments=…</script>`
75
+ * tag. Invoked by the SSR entry AFTER `renderSsrNodeStream` /
76
+ * `renderSsrStream` returns (shell ready ⇒ AccessGates settled), so the
77
+ * `getFirstDeniedSegmentIndex()` ALS value is authoritative. The result
78
+ * is appended to headHtml before the head injector runs.
79
+ *
80
+ * Undefined when client JS is disabled (no client to consume it).
81
+ * See TIM-1365, TIM-1359.
82
+ */
83
+ getHeadSegmentScript?: () => string;
84
+
73
85
  /**
74
86
  * Mutable flag: set by TimberErrorBoundary during SSR when it catches
75
87
  * a DenySignal (via digest). This tells the RSC entry that the denial