@timber-js/app 0.2.0-alpha.198 → 0.2.0-alpha.199

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 (246) hide show
  1. package/dist/_chunks/{actions-BS-m5SLv.js → actions-d1hCqnU3.js} +35 -8
  2. package/dist/_chunks/actions-d1hCqnU3.js.map +1 -0
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/{cache-api-DqzgTEqk.js → cache-api-ByagcC-J.js} +2 -2
  5. package/dist/_chunks/{cache-api-DqzgTEqk.js.map → cache-api-ByagcC-J.js.map} +1 -1
  6. package/dist/_chunks/canonicalize-CgHoscYO.js +66 -0
  7. package/dist/_chunks/canonicalize-CgHoscYO.js.map +1 -0
  8. package/dist/_chunks/{chains-CZG7E5zg.js → chains-Bpb0W4ax.js} +3 -3
  9. package/dist/_chunks/{chains-CZG7E5zg.js.map → chains-Bpb0W4ax.js.map} +1 -1
  10. package/dist/_chunks/{cli-check-dVDi1GQz.js → cli-check-D6VolrDV.js} +3 -3
  11. package/dist/_chunks/{cli-check-dVDi1GQz.js.map → cli-check-D6VolrDV.js.map} +1 -1
  12. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js → cli-schema-sync-D6rO-VcS.js} +2 -2
  13. package/dist/_chunks/{cli-schema-sync-DTy_-Msq.js.map → cli-schema-sync-D6rO-VcS.js.map} +1 -1
  14. package/dist/_chunks/{convention-lint-Ph6luW4c.js → convention-lint-fRkwVwEH.js} +25 -4
  15. package/dist/_chunks/convention-lint-fRkwVwEH.js.map +1 -0
  16. package/dist/_chunks/error-boundary-BfPHZjm0.js +1050 -0
  17. package/dist/_chunks/error-boundary-BfPHZjm0.js.map +1 -0
  18. package/dist/_chunks/{live-graph-BXDsdzBv.js → live-graph-D_2D32Ad.js} +3 -3
  19. package/dist/_chunks/{live-graph-BXDsdzBv.js.map → live-graph-D_2D32Ad.js.map} +1 -1
  20. package/dist/_chunks/{logger-DDirEsn7.js → logger-uLBuGKDI.js} +471 -440
  21. package/dist/_chunks/logger-uLBuGKDI.js.map +1 -0
  22. package/dist/_chunks/{navigation-root-B00jjGd5.js → navigation-context-D0TU0Jog.js} +3 -101
  23. package/dist/_chunks/navigation-context-D0TU0Jog.js.map +1 -0
  24. package/dist/_chunks/navigation-root-mHSK9psY.js +126 -0
  25. package/dist/_chunks/{navigation-root-B00jjGd5.js.map → navigation-root-mHSK9psY.js.map} +1 -1
  26. package/dist/_chunks/{poison-scan-BoDLgbix.js → poison-scan-Bm9Yyqk9.js} +2 -2
  27. package/dist/_chunks/{poison-scan-BoDLgbix.js.map → poison-scan-Bm9Yyqk9.js.map} +1 -1
  28. package/dist/_chunks/{scanner-tdFPvDYi.js → scanner-AiazgH_f.js} +6 -5
  29. package/dist/_chunks/scanner-AiazgH_f.js.map +1 -0
  30. package/dist/_chunks/{segment-keys-BhqoHiLc.js → segment-keys-lqtdookO.js} +2 -65
  31. package/dist/_chunks/segment-keys-lqtdookO.js.map +1 -0
  32. package/dist/_chunks/{ssr-data-BQGhTPAK.js → ssr-data-D6T6Y3ef.js} +4 -26
  33. package/dist/_chunks/ssr-data-D6T6Y3ef.js.map +1 -0
  34. package/dist/_chunks/state-FippDgxN.js +52 -0
  35. package/dist/_chunks/state-FippDgxN.js.map +1 -0
  36. package/dist/_chunks/status-page-marker-DwQBrLBz.js +496 -0
  37. package/dist/_chunks/status-page-marker-DwQBrLBz.js.map +1 -0
  38. package/dist/_chunks/{walkers-DNX05dC0.js → walkers-B6XUtmqK.js} +2 -2
  39. package/dist/_chunks/{walkers-DNX05dC0.js.map → walkers-B6XUtmqK.js.map} +1 -1
  40. package/dist/analyze/crawl-entry.js +2 -2
  41. package/dist/analyze/graph-command.js +2 -2
  42. package/dist/cache/index.js +1 -1
  43. package/dist/cli.js +2 -2
  44. package/dist/client/browser-entry/action-dispatch.d.ts +6 -4
  45. package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
  46. package/dist/client/browser-entry/action-queue.d.ts +44 -0
  47. package/dist/client/browser-entry/action-queue.d.ts.map +1 -0
  48. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  49. package/dist/client/deny-last-resort.d.ts +29 -0
  50. package/dist/client/deny-last-resort.d.ts.map +1 -0
  51. package/dist/client/error-boundary.d.ts +48 -2
  52. package/dist/client/error-boundary.d.ts.map +1 -1
  53. package/dist/client/error-boundary.js +2 -2
  54. package/dist/client/history.d.ts +21 -2
  55. package/dist/client/history.d.ts.map +1 -1
  56. package/dist/client/index.js +34 -14
  57. package/dist/client/index.js.map +1 -1
  58. package/dist/client/internal.d.ts +1 -0
  59. package/dist/client/internal.d.ts.map +1 -1
  60. package/dist/client/internal.js +272 -1225
  61. package/dist/client/internal.js.map +1 -1
  62. package/dist/client/link.d.ts.map +1 -1
  63. package/dist/client/navigation-commit.d.ts +12 -19
  64. package/dist/client/navigation-commit.d.ts.map +1 -1
  65. package/dist/client/navigation-transition.d.ts +62 -11
  66. package/dist/client/navigation-transition.d.ts.map +1 -1
  67. package/dist/client/router-effects.d.ts +9 -8
  68. package/dist/client/router-effects.d.ts.map +1 -1
  69. package/dist/client/router-lifecycle.d.ts +60 -17
  70. package/dist/client/router-lifecycle.d.ts.map +1 -1
  71. package/dist/client/router-pipeline.d.ts +7 -4
  72. package/dist/client/router-pipeline.d.ts.map +1 -1
  73. package/dist/client/router-types.d.ts +63 -8
  74. package/dist/client/router-types.d.ts.map +1 -1
  75. package/dist/client/router.d.ts.map +1 -1
  76. package/dist/client/rsc-fetch.d.ts +0 -9
  77. package/dist/client/rsc-fetch.d.ts.map +1 -1
  78. package/dist/client/segment-cache.d.ts +23 -8
  79. package/dist/client/segment-cache.d.ts.map +1 -1
  80. package/dist/client/state.d.ts +16 -0
  81. package/dist/client/state.d.ts.map +1 -1
  82. package/dist/client/status-page-marker.d.ts +25 -0
  83. package/dist/client/status-page-marker.d.ts.map +1 -0
  84. package/dist/cookies/index.js +1 -1
  85. package/dist/dev-tools/holding-server.d.ts +4 -17
  86. package/dist/dev-tools/holding-server.d.ts.map +1 -1
  87. package/dist/index.d.ts.map +1 -1
  88. package/dist/index.js +74 -175
  89. package/dist/index.js.map +1 -1
  90. package/dist/plugins/dev-server.d.ts.map +1 -1
  91. package/dist/routing/index.js +2 -2
  92. package/dist/routing/interception.d.ts +2 -2
  93. package/dist/routing/slot-placement.d.ts +2 -2
  94. package/dist/server/access-gate.d.ts +73 -1
  95. package/dist/server/access-gate.d.ts.map +1 -1
  96. package/dist/server/action-handler.d.ts.map +1 -1
  97. package/dist/server/actions.d.ts +16 -1
  98. package/dist/server/actions.d.ts.map +1 -1
  99. package/dist/server/als-registry.d.ts +3 -9
  100. package/dist/server/als-registry.d.ts.map +1 -1
  101. package/dist/server/children-interception.d.ts +1 -1
  102. package/dist/server/default-status-page.d.ts +2 -2
  103. package/dist/server/default-status-page.d.ts.map +1 -1
  104. package/dist/server/deny-boundary.d.ts +15 -9
  105. package/dist/server/deny-boundary.d.ts.map +1 -1
  106. package/dist/server/deny-renderer.d.ts.map +1 -1
  107. package/dist/server/error-boundary-wrapper.d.ts +21 -4
  108. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  109. package/dist/server/error-response-headers.d.ts +3 -0
  110. package/dist/server/error-response-headers.d.ts.map +1 -0
  111. package/dist/server/index.js +3 -3
  112. package/dist/server/index.js.map +1 -1
  113. package/dist/server/internal.d.ts +1 -2
  114. package/dist/server/internal.d.ts.map +1 -1
  115. package/dist/server/internal.js +2339 -2506
  116. package/dist/server/internal.js.map +1 -1
  117. package/dist/server/metadata-collector.d.ts +2 -5
  118. package/dist/server/metadata-collector.d.ts.map +1 -1
  119. package/dist/server/param-coercion.d.ts +10 -3
  120. package/dist/server/param-coercion.d.ts.map +1 -1
  121. package/dist/server/pipeline-outcome.d.ts.map +1 -1
  122. package/dist/server/pipeline-phases.d.ts +11 -0
  123. package/dist/server/pipeline-phases.d.ts.map +1 -1
  124. package/dist/server/port-resolution.d.ts +3 -89
  125. package/dist/server/port-resolution.d.ts.map +1 -1
  126. package/dist/server/primitives.d.ts +38 -10
  127. package/dist/server/primitives.d.ts.map +1 -1
  128. package/dist/server/response-cache-policy.d.ts +3 -0
  129. package/dist/server/response-cache-policy.d.ts.map +1 -0
  130. package/dist/server/route-element-builder.d.ts +11 -41
  131. package/dist/server/route-element-builder.d.ts.map +1 -1
  132. package/dist/server/route-element-helpers.d.ts +12 -0
  133. package/dist/server/route-element-helpers.d.ts.map +1 -0
  134. package/dist/server/route-module-loader.d.ts +37 -0
  135. package/dist/server/route-module-loader.d.ts.map +1 -0
  136. package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
  137. package/dist/server/rsc-entry/action-middleware-runner.d.ts.map +1 -1
  138. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  139. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  140. package/dist/server/rsc-entry/rsc-payload.d.ts +22 -1
  141. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  142. package/dist/server/rsc-entry/rsc-stream.d.ts +4 -11
  143. package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
  144. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  145. package/dist/server/rsc-error-envelope.d.ts +11 -0
  146. package/dist/server/rsc-error-envelope.d.ts.map +1 -0
  147. package/dist/server/skippable-prefix.d.ts +18 -15
  148. package/dist/server/skippable-prefix.d.ts.map +1 -1
  149. package/dist/server/slot-resolver.d.ts.map +1 -1
  150. package/dist/server/slot-subtree-contain.d.ts +54 -0
  151. package/dist/server/slot-subtree-contain.d.ts.map +1 -0
  152. package/dist/server/stream-utils.d.ts.map +1 -1
  153. package/dist/server/utils/element-type.d.ts +10 -0
  154. package/dist/server/utils/element-type.d.ts.map +1 -1
  155. package/dist/shared/rsc-error-envelope.d.ts +0 -9
  156. package/dist/shared/rsc-error-envelope.d.ts.map +1 -1
  157. package/dist/shared/status-reason-phrase.d.ts +26 -0
  158. package/dist/shared/status-reason-phrase.d.ts.map +1 -0
  159. package/docs/api/30-api-server.mdx +4 -2
  160. package/docs/api/31-api-client.mdx +5 -1
  161. package/docs/api/36-cli.mdx +5 -3
  162. package/docs/learn/12-error-handling.mdx +5 -1
  163. package/package.json +10 -10
  164. package/src/client/browser-entry/action-dispatch.ts +166 -99
  165. package/src/client/browser-entry/action-queue.ts +90 -0
  166. package/src/client/browser-entry/router-init.ts +60 -35
  167. package/src/client/deny-last-resort.tsx +54 -0
  168. package/src/client/error-boundary.tsx +144 -42
  169. package/src/client/history.ts +52 -3
  170. package/src/client/internal.ts +1 -0
  171. package/src/client/link.tsx +70 -35
  172. package/src/client/navigation-commit.ts +79 -27
  173. package/src/client/navigation-transition.ts +176 -127
  174. package/src/client/router-effects.ts +14 -17
  175. package/src/client/router-lifecycle.ts +181 -115
  176. package/src/client/router-pipeline.ts +94 -71
  177. package/src/client/router-types.ts +61 -7
  178. package/src/client/router.ts +147 -74
  179. package/src/client/rsc-fetch.ts +0 -13
  180. package/src/client/segment-cache.ts +43 -10
  181. package/src/client/state.ts +26 -0
  182. package/src/client/status-page-marker.tsx +32 -0
  183. package/src/dev-tools/holding-server.ts +4 -17
  184. package/src/index.ts +18 -34
  185. package/src/plugins/dev-server.ts +2 -1
  186. package/src/routing/interception.ts +2 -2
  187. package/src/routing/slot-placement.ts +2 -2
  188. package/src/server/access-gate.tsx +89 -21
  189. package/src/server/action-client.ts +2 -2
  190. package/src/server/action-handler.ts +23 -10
  191. package/src/server/actions.ts +81 -34
  192. package/src/server/als-registry.ts +3 -9
  193. package/src/server/children-interception.ts +1 -1
  194. package/src/server/default-status-page.ts +7 -47
  195. package/src/server/deny-boundary.ts +45 -28
  196. package/src/server/deny-renderer.ts +6 -2
  197. package/src/server/error-boundary-wrapper.ts +23 -4
  198. package/src/server/error-response-headers.ts +18 -0
  199. package/src/server/internal.ts +2 -10
  200. package/src/server/metadata-collector.ts +3 -18
  201. package/src/server/param-coercion.ts +13 -4
  202. package/src/server/pipeline-outcome.ts +35 -13
  203. package/src/server/pipeline-phases.ts +22 -16
  204. package/src/server/port-resolution.ts +3 -165
  205. package/src/server/prebuilt-builder.ts +4 -4
  206. package/src/server/primitives.ts +75 -11
  207. package/src/server/response-cache-policy.ts +45 -0
  208. package/src/server/route-element-builder.ts +149 -412
  209. package/src/server/route-element-helpers.ts +37 -0
  210. package/src/server/route-handler.ts +2 -2
  211. package/src/server/route-module-loader.ts +161 -0
  212. package/src/server/rsc-cache-key-guard.ts +2 -42
  213. package/src/server/rsc-entry/action-middleware-runner.ts +4 -4
  214. package/src/server/rsc-entry/api-handler.ts +5 -5
  215. package/src/server/rsc-entry/error-renderer.ts +3 -4
  216. package/src/server/rsc-entry/helpers.ts +1 -1
  217. package/src/server/rsc-entry/index.ts +3 -3
  218. package/src/server/rsc-entry/render-route.ts +4 -8
  219. package/src/server/rsc-entry/rsc-payload.ts +59 -42
  220. package/src/server/rsc-entry/rsc-stream.ts +48 -27
  221. package/src/server/rsc-entry/ssr-renderer.ts +6 -10
  222. package/src/server/rsc-error-envelope.ts +18 -0
  223. package/src/server/skippable-prefix.ts +105 -7
  224. package/src/server/slot-resolver.ts +43 -12
  225. package/src/server/slot-subtree-contain.ts +255 -0
  226. package/src/server/stream-utils.ts +12 -8
  227. package/src/server/utils/element-type.ts +18 -2
  228. package/src/shared/rsc-error-envelope.ts +0 -15
  229. package/src/shared/status-reason-phrase.ts +61 -0
  230. package/dist/_chunks/actions-BS-m5SLv.js.map +0 -1
  231. package/dist/_chunks/convention-lint-Ph6luW4c.js.map +0 -1
  232. package/dist/_chunks/error-boundary-BvRCCmbN.js +0 -353
  233. package/dist/_chunks/error-boundary-BvRCCmbN.js.map +0 -1
  234. package/dist/_chunks/logger-DDirEsn7.js.map +0 -1
  235. package/dist/_chunks/mdx-file-CXyHGUpS.js +0 -25
  236. package/dist/_chunks/mdx-file-CXyHGUpS.js.map +0 -1
  237. package/dist/_chunks/router-ref-8gr8qsxN.js +0 -28
  238. package/dist/_chunks/router-ref-8gr8qsxN.js.map +0 -1
  239. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js +0 -40
  240. package/dist/_chunks/rsc-error-envelope-tT5PJs4q.js.map +0 -1
  241. package/dist/_chunks/scanner-tdFPvDYi.js.map +0 -1
  242. package/dist/_chunks/segment-keys-BhqoHiLc.js.map +0 -1
  243. package/dist/_chunks/ssr-data-BQGhTPAK.js.map +0 -1
  244. package/dist/server/tree-builder.d.ts +0 -150
  245. package/dist/server/tree-builder.d.ts.map +0 -1
  246. package/src/server/tree-builder.ts +0 -313
@@ -37,8 +37,9 @@ import { createElement } from 'react';
37
37
 
38
38
  import { ErrorReconstituter } from '../client/error-reconstituter.tsx';
39
39
  import type { SerializableError } from '../client/error-reconstituter.tsx';
40
+ import { StatusPageMarker } from '../client/status-page-marker.tsx';
40
41
  import { requestContextAls } from './als-registry.ts';
41
- import { DenySignal } from './primitives.ts';
42
+ import { isDenySignal, DenySignal } from './primitives.ts';
42
43
  import { loadModule } from './safe-load.ts';
43
44
  import type { ManifestLoader } from './safe-load.ts';
44
45
  import { withSpan } from './tracing.ts';
@@ -252,22 +253,32 @@ export async function buildDenyPageChain(
252
253
  * `resolve5xx`. See TIM-1360.
253
254
  */
254
255
  export function matchDenyEntry(chain: DenyPageEntry[], status: number): DenyPageEntry | null {
255
- if (status >= 500) return matchDenyEntry5xx(chain, status);
256
+ return matchDenyEntries(chain, status)[0] ?? null;
257
+ }
256
258
 
259
+ /**
260
+ * Return ALL deny page entries whose status filter matches, in traversal
261
+ * order (best match first). `matchDenyEntry` returns only the first;
262
+ * late addressing (TIM-1450) needs the full list so the client can find
263
+ * the first *reachable* owner via its ancestry context.
264
+ */
265
+ export function matchDenyEntries(chain: DenyPageEntry[], status: number): DenyPageEntry[] {
266
+ if (status >= 500) return matchDenyEntries5xx(chain, status);
267
+
268
+ const matches: DenyPageEntry[] = [];
257
269
  for (const entry of chain) {
258
- if (entry.status === status) return entry;
259
- if (entry.status === 400 && status >= 400 && status <= 499) return entry;
260
- if (entry.status === null) return entry;
270
+ if (entry.status === status) matches.push(entry);
271
+ else if (entry.status === 400 && status >= 400 && status <= 499) matches.push(entry);
272
+ else if (entry.status === null) matches.push(entry);
261
273
  }
262
- return null;
274
+ return matches;
263
275
  }
264
276
 
265
277
  /**
266
278
  * 5xx per-segment walk: at each segment (inner→outer), check status files
267
279
  * then error.tsx before moving outward. Mirrors `resolve5xx`.
268
280
  */
269
- function matchDenyEntry5xx(chain: DenyPageEntry[], status: number): DenyPageEntry | null {
270
- // Collect unique segment indices, sorted descending (inner→outer).
281
+ function matchDenyEntries5xx(chain: DenyPageEntry[], status: number): DenyPageEntry[] {
271
282
  const segmentIndices: number[] = [];
272
283
  for (const entry of chain) {
273
284
  if (!segmentIndices.includes(entry.segmentIndex)) {
@@ -276,20 +287,19 @@ function matchDenyEntry5xx(chain: DenyPageEntry[], status: number): DenyPageEntr
276
287
  }
277
288
  segmentIndices.sort((a, b) => b - a);
278
289
 
290
+ const matches: DenyPageEntry[] = [];
279
291
  for (const seg of segmentIndices) {
280
- // Status files at this segment (exact then category).
281
292
  for (const entry of chain) {
282
293
  if (entry.segmentIndex !== seg || entry.kind === 'error') continue;
283
- if (entry.status === status) return entry;
284
- if (entry.status === 500 && status >= 500 && status <= 599) return entry;
294
+ if (entry.status === status) matches.push(entry);
295
+ else if (entry.status === 500 && status >= 500 && status <= 599) matches.push(entry);
285
296
  }
286
- // error.tsx at this segment.
287
297
  for (const entry of chain) {
288
298
  if (entry.segmentIndex !== seg || entry.kind !== 'error') continue;
289
- return entry;
299
+ matches.push(entry);
290
300
  }
291
301
  }
292
- return null;
302
+ return matches;
293
303
  }
294
304
 
295
305
  /**
@@ -355,7 +365,19 @@ export function renderDenyEntry(
355
365
  data: unknown
356
366
  ): React.ReactElement {
357
367
  const h = createElement as (...args: unknown[]) => React.ReactElement;
368
+ // Every deny page the server places — in-tree at the owning catch site, or
369
+ // through the fallback renderers — is a status page the client's segment
370
+ // cache must not learn from, and the marker is how it says so on commit.
371
+ // See client/status-page-marker.tsx (TIM-1495).
372
+ return h(StatusPageMarker, null, renderDenyPage(entry, status, data, h));
373
+ }
358
374
 
375
+ function renderDenyPage(
376
+ entry: DenyEntryShape,
377
+ status: number,
378
+ data: unknown,
379
+ h: (...args: unknown[]) => React.ReactElement
380
+ ): React.ReactElement {
359
381
  if (entry.kind !== 'error') {
360
382
  return h(entry.component, { status, dangerouslyPassData: data });
361
383
  }
@@ -391,15 +413,16 @@ export function renderDenyEntry(
391
413
  * file, bare when nothing matched at all.
392
414
  *
393
415
  * `setDenyStatus` runs on the hoist path too, and must run *before* the
394
- * throw: on the RSC payload path no boundary executes server-side, so the
395
- * ALS status is the only thing standing between the response and a 200
396
- * (`rsc-payload.ts` reads it after the stream settles).
416
+ * throw: on the document path the hoisted row fails the Fizz shell and the
417
+ * re-render fallback serves the page, and the ALS status is what the
418
+ * pipeline commits for it. The RSC payload path never reads it — a Flight
419
+ * payload response is always 200 and the row itself carries the deny (TIM-1495).
397
420
  *
398
421
  * Either branch leaves part of the matched chain unmounted — hoisting sends
399
422
  * the render to a boundary that discards everything below it, and a layout
400
423
  * that denies returns the deny page instead of its own child outlet. What the
401
- * response then says about the tree is decided from the deny status alone in
402
- * `rsc-payload.ts`, not tracked here; see the note there for why.
424
+ * client then learns about the tree is decided by the boundary that renders
425
+ * the page, at the commit that puts it on screen (`navigation-commit.ts`).
403
426
  */
404
427
  export function handleCaughtDeny(
405
428
  chain: DenyPageEntry[] | undefined,
@@ -414,7 +437,7 @@ export function handleCaughtDeny(
414
437
  }
415
438
  if (placement?.kind === 'hoist') {
416
439
  setDenyStatus(error.status);
417
- error.ownerKey = placement.ownerKey;
440
+ error.owners = [placement.ownerKey];
418
441
  }
419
442
  throw error;
420
443
  }
@@ -440,7 +463,6 @@ export async function PageDenyBoundary({
440
463
  route,
441
464
  denyPages,
442
465
  segmentKey,
443
- onSettled,
444
466
  }: {
445
467
  /** The page server component function. */
446
468
  Page: (...args: unknown[]) => unknown;
@@ -454,19 +476,15 @@ export async function PageDenyBoundary({
454
476
  * chain matched further up is hoisted to its owner.
455
477
  */
456
478
  segmentKey: string;
457
- /** Called when the page component finishes (success or deny). See TIM-1045. */
458
- onSettled?: () => void;
459
479
  }): Promise<React.ReactElement> {
460
480
  try {
461
481
  const result = await withSpan('timber.page', { 'timber.route': route }, () => Page({}));
462
482
  return result as React.ReactElement;
463
483
  } catch (error: unknown) {
464
- if (error instanceof DenySignal) {
484
+ if (isDenySignal(error)) {
465
485
  return handleCaughtDeny(denyPages, error, segmentKey);
466
486
  }
467
487
  throw error;
468
- } finally {
469
- onSettled?.();
470
488
  }
471
489
  }
472
490
 
@@ -475,13 +493,12 @@ export async function PageDenyBoundary({
475
493
  /**
476
494
  * Set the deny status in the request context ALS.
477
495
  * Called from AccessGate / PageDenyBoundary when a DenySignal is caught.
478
- * The pipeline reads this after render to set the HTTP status code.
496
+ * The document path reads this after render to set the HTTP status code.
479
497
  */
480
498
  export function setDenyStatus(status: number): void {
481
499
  const store = requestContextAls.getStore();
482
500
  if (store) {
483
501
  store.denyStatus = status;
484
- store.onDenyStatus?.();
485
502
  }
486
503
  }
487
504
 
@@ -32,7 +32,7 @@ import { resolveMetadata, renderMetadataToElements } from './metadata.ts';
32
32
  import { headElementsToReact } from './metadata.ts';
33
33
  import { resolveStatusFile } from './status-code-resolver.ts';
34
34
  import { defaultStatusPageResponse } from './default-status-page.ts';
35
- import { rscErrorEnvelope } from '../shared/rsc-error-envelope.ts';
35
+ import { rscErrorEnvelope } from './rsc-error-envelope.ts';
36
36
  import type { ManifestSegmentNode } from './route-matcher.ts';
37
37
  import type { RouteMatch } from './pipeline.ts';
38
38
  import type { NavContext } from './ssr-bridge-types.ts';
@@ -327,8 +327,12 @@ export async function renderDenyPageAsRsc(
327
327
  });
328
328
 
329
329
  responseHeaders.set('content-type', `${RSC_CONTENT_TYPE}; charset=utf-8`);
330
+ // 200, like every Flight payload response: the deny is the page in the body, and
331
+ // the client router — the only reader of this status — renders what it is
332
+ // sent. See design/04-authorization.md §"Status codes on the RSC payload
333
+ // path" (TIM-1495).
330
334
  return new Response(rscStream, {
331
- status: deny.status,
335
+ status: 200,
332
336
  headers: responseHeaders,
333
337
  });
334
338
  }
@@ -7,13 +7,11 @@
7
7
  *
8
8
  * - `route-element-builder.ts` / `slot-resolver.ts` — the request-time
9
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
10
  *
13
11
  * `errorBoundaryComponent` is injected rather than imported: `TimberErrorBoundary`
14
12
  * 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.
13
+ * every consumer. `tests/use-client-pipeline.test.ts` walks the import graph
14
+ * from this file to enforce that no `client/` module is reachable at any depth.
17
15
  *
18
16
  * See design/10-error-handling.md §"Status-Code File Variants".
19
17
  */
@@ -54,6 +52,21 @@ export interface ErrorBoundaryProps {
54
52
  * every owner-addressed deny. See TIM-1356.
55
53
  */
56
54
  segmentKey?: string;
55
+ /**
56
+ * Catch denies only; re-throw every other error. Set on the copy of a
57
+ * segment's boundaries that sits outside its layout, which exists to give a
58
+ * deny thrown from the layout's own chrome a catcher at the right position
59
+ * without changing where a 5xx from that chrome goes. See TIM-1450.
60
+ */
61
+ denyOnly?: boolean;
62
+ /**
63
+ * The boundary belongs to a parallel slot. A slot degrading is the page
64
+ * staying: its status pages never set the document status and never tell
65
+ * the segment cache the main route's chain is gone. Set on every boundary
66
+ * a slot's files declare, not only the catch-all outside them — the
67
+ * identity is the slot's, not the file's (codex on #1138).
68
+ */
69
+ isSlotBoundary?: boolean;
57
70
  children: ReactNode;
58
71
  }
59
72
 
@@ -75,6 +88,10 @@ export interface ErrorBoundaryWrapConfig<TFile, TElement> {
75
88
  errorBoundaryComponent: unknown;
76
89
  /** Segment key stamped on every boundary this walk creates. See `ErrorBoundaryProps`. */
77
90
  segmentKey?: string;
91
+ /** Stamped on every boundary this walk creates. See `ErrorBoundaryProps`. */
92
+ denyOnly?: boolean;
93
+ /** Stamped on every boundary this walk creates. See `ErrorBoundaryProps`. */
94
+ isSlotBoundary?: boolean;
78
95
  }
79
96
 
80
97
  /**
@@ -122,6 +139,8 @@ function wrapInBoundary<TFile extends BoundaryFile, TElement extends ReactNode>(
122
139
  : { fallbackComponent: Component, children: element };
123
140
  if (status !== undefined) props.status = status;
124
141
  if (config.segmentKey !== undefined) props.segmentKey = config.segmentKey;
142
+ if (config.denyOnly) props.denyOnly = true;
143
+ if (config.isSlotBoundary) props.isSlotBoundary = true;
125
144
  return config.createElement(config.errorBoundaryComponent, props);
126
145
  }
127
146
 
@@ -0,0 +1,18 @@
1
+ import { preventResponseCaching } from './response-cache-policy.ts';
2
+
3
+ /** Preserve request-wide metadata when replacing a failed response body. */
4
+ export function errorResponseHeaders(responseHeaders?: Headers): Headers {
5
+ const headers = new Headers(responseHeaders);
6
+ // These describe the abandoned body, not the replacement empty/JSON body.
7
+ headers.delete('Content-Length');
8
+ headers.delete('Content-Encoding');
9
+ headers.delete('Transfer-Encoding');
10
+ // A transient failure must not inherit the success page's cache lifetime
11
+ // or validators, including CDN overrides of Cache-Control.
12
+ preventResponseCaching(headers);
13
+ headers.delete('ETag');
14
+ headers.delete('Last-Modified');
15
+ headers.delete('Expires');
16
+ headers.delete('Age');
17
+ return headers;
18
+ }
@@ -60,21 +60,13 @@ export type { ProxyFn, ProxyExport } from './proxy.ts';
60
60
  export { runMiddleware } from './middleware-runner.ts';
61
61
  export type { MiddlewareFn } from './middleware-runner.ts';
62
62
 
63
- // ── Tree Builder ─────────────────────────────────────────────────────────
64
- export { buildElementTree } from './tree-builder.ts';
65
- export type {
66
- TreeBuilderConfig,
67
- TreeBuildResult,
68
- LoadedModule,
69
- ModuleLoader,
70
- AccessGateProps,
71
- SlotAccessGateProps,
72
- } from './tree-builder.ts';
63
+ // ── Element Tree Types ──────────────────────────────────────────────────
73
64
  export type { LoadedComponent } from './utils/element-type.ts';
74
65
  export type { ErrorBoundaryProps } from './error-boundary-wrapper.ts';
75
66
 
76
67
  // ── Access Gates ─────────────────────────────────────────────────────────
77
68
  export { AccessGate, SlotAccessGate } from './access-gate.tsx';
69
+ export type { AccessGateProps, SlotAccessGateProps, CreateElement } from './access-gate.tsx';
78
70
 
79
71
  // ── Status-Code Resolver ─────────────────────────────────────────────────
80
72
  export { resolveStatusFile, resolveSlotDenied } from './status-code-resolver.ts';
@@ -64,9 +64,6 @@ interface MetadataSettleProps {
64
64
  * children. Its sole job is error ownership: awaiting the metadata
65
65
  * promise so that rejections surface at THIS tree position (caught by
66
66
  * the owning segment's error boundary, not MetadataHead's).
67
- *
68
- * Shell settlement (onSettled) is handled by the collector attaching
69
- * directly to the promise — unconditional on render progress.
70
67
  */
71
68
  async function MetadataSettle(props: MetadataSettleProps): Promise<ReactNode> {
72
69
  await props.promise;
@@ -79,7 +76,6 @@ export interface MetadataCollectorProps {
79
76
  metadataExport: Metadata | (() => Promise<Metadata>);
80
77
  segmentName: string;
81
78
  isPage: boolean;
82
- onSettled?: () => void;
83
79
  children: ReactNode;
84
80
  }
85
81
 
@@ -92,13 +88,11 @@ export interface MetadataCollectorProps {
92
88
  * Static metadata objects are pushed synchronously (no side effects).
93
89
  * Dynamic metadata functions are started (not awaited) and pushed as
94
90
  * promises — React descends into children immediately while the I/O
95
- * runs concurrently (TIM-1367). Shell settlement (onSettled) is
96
- * attached directly to the promise so it fires unconditionally. A
97
- * sibling MetadataSettle component awaits the promise for error
98
- * ownership only.
91
+ * runs concurrently (TIM-1367). A sibling MetadataSettle component
92
+ * awaits the promise for error ownership.
99
93
  */
100
94
  export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
101
- const { metadataExport, segmentName, isPage, onSettled, children } = props;
95
+ const { metadataExport, segmentName, isPage, children } = props;
102
96
 
103
97
  if (typeof metadataExport === 'function') {
104
98
  const promise = withSpan('timber.metadata', { 'timber.segment': segmentName }, () =>
@@ -108,14 +102,6 @@ export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
108
102
  // both attach handlers, but a fast-rejecting promise can fire
109
103
  // Node's unhandledRejection before they do.
110
104
  promise.catch(() => {});
111
- // Shell settlement: unconditional on render progress. If React
112
- // never reaches MetadataSettle (e.g., parent error boundary fires
113
- // first), the HTTP status still commits on time.
114
- if (onSettled)
115
- promise.then(
116
- () => onSettled(),
117
- () => onSettled()
118
- );
119
105
 
120
106
  getDeferredMetadataEntries().push({ metadata: promise, isPage });
121
107
 
@@ -123,7 +109,6 @@ export function MetadataCollector(props: MetadataCollectorProps): ReactNode {
123
109
  }
124
110
 
125
111
  getDeferredMetadataEntries().push({ metadata: metadataExport, isPage });
126
- onSettled?.();
127
112
  return children;
128
113
  }
129
114
 
@@ -7,9 +7,9 @@
7
7
  * the revalidation renderer in `rsc-entry/index.ts`) without pulling
8
8
  * the entire pipeline phase module along with it.
9
9
  *
10
- * The function throws `ParamCoercionError` from `route-element-builder.ts`
11
- * on any codec failure; the pipeline catches that and dispatches to the
12
- * 404 page. See design/07-routing.md §"Where Coercion Runs".
10
+ * The function throws `ParamCoercionError` (defined below) on any codec
11
+ * failure; the pipeline catches that and dispatches to the 404 page.
12
+ * See design/07-routing.md §"Where Coercion Runs".
13
13
  */
14
14
 
15
15
  import type { Codec } from '../codec.ts';
@@ -18,7 +18,16 @@ import { effectiveUrlSegment } from '../routing/segment-classify.ts';
18
18
  import { toBracketKey } from '../params/resolve-schema.ts';
19
19
  import type { RouteMatch } from './pipeline.ts';
20
20
  import { normalizeParamValue, type CoercedParams } from '../shared/param-value.ts';
21
- import { ParamCoercionError } from './route-element-builder.ts';
21
+ /**
22
+ * Thrown when an app/schema.ts segment-param codec's parse() fails.
23
+ * The pipeline catches this and responds with 404.
24
+ */
25
+ export class ParamCoercionError extends Error {
26
+ constructor(message: string) {
27
+ super(message);
28
+ this.name = 'ParamCoercionError';
29
+ }
30
+ }
22
31
  import { isDebug } from './debug.ts';
23
32
 
24
33
  // ---------------------------------------------------------------------------
@@ -26,15 +26,40 @@ import {
26
26
  import { markResponseFlushed } from './request-context.ts';
27
27
  import { RedirectSignal, DenySignal } from './primitives.ts';
28
28
  import { isDebug } from './debug.ts';
29
- import { rscErrorEnvelope } from '../shared/rsc-error-envelope.ts';
29
+ import { errorResponseHeaders } from './error-response-headers.ts';
30
+ import { rscErrorEnvelope } from './rsc-error-envelope.ts';
30
31
  import { isRscRequest } from '../shared/rsc-media-type.ts';
31
32
  import type { PipelineConfig, RouteMatch } from './pipeline.ts';
32
33
 
33
34
  // ─── Helpers ───────────────────────────────────────────────────────────────
34
35
 
35
- function rscErrorResponse(isRsc: boolean, status: number, headers?: Headers): Response {
36
- if (!isRsc) return new Response(null, { status });
37
- return rscErrorEnvelope(status, headers);
36
+ function rscErrorResponse(isRsc: boolean, status: number, headers: Headers): Response {
37
+ if (isRsc) return rscErrorEnvelope(status, headers);
38
+ return new Response(null, { status, headers: errorResponseHeaders(headers) });
39
+ }
40
+
41
+ /**
42
+ * Resolve the header bag a signal/error outcome is written onto, with the
43
+ * cookie jar applied exactly once.
44
+ *
45
+ * Ownership rule (design/49 §P0b): the phase that owns `responseHeaders`
46
+ * applies the jar to it once; the translator applies it only for outcomes
47
+ * that arrive without it. Render-phase outcomes already carry the jar —
48
+ * `runRenderPhase()` applies it on entry, with or without a middleware
49
+ * chain — so re-applying here emitted every middleware `Set-Cookie` twice
50
+ * (TIM-1491).
51
+ *
52
+ * The guard is "not render", not "only middleware": proxy-phase signals
53
+ * reach the translator with no `responseHeaders` at all, and for a
54
+ * `proxy.ts` that awaits `next()` and then throws, this is the only place
55
+ * the cookies downstream middleware set can reach the wire
56
+ * (design/29-cookies.md §"proxy.ts Cookie Access"). Middleware-phase
57
+ * signals throw before that phase's own application, so they need it too.
58
+ */
59
+ function outcomeHeaders(outcome: PhaseOutcome, ctx: OutcomeContext): Headers {
60
+ const headers = ctx.responseHeaders ?? new Headers();
61
+ if (outcome.phase !== 'render') applyCookieJar(headers);
62
+ return headers;
38
63
  }
39
64
 
40
65
  // ─── Phase Outcome ─────────────────────────────────────────────────────────
@@ -100,14 +125,12 @@ export async function outcomeToResponse(
100
125
  }
101
126
 
102
127
  case 'redirect': {
103
- const headers = ctx.responseHeaders ?? new Headers();
104
- applyCookieJar(headers);
128
+ const headers = outcomeHeaders(outcome, ctx);
105
129
  return buildRedirectResponse(outcome.signal, ctx.req, headers);
106
130
  }
107
131
 
108
132
  case 'deny': {
109
- const headers = ctx.responseHeaders ?? new Headers();
110
- applyCookieJar(headers);
133
+ const headers = outcomeHeaders(outcome, ctx);
111
134
  if (config.renderDenyFallback) {
112
135
  try {
113
136
  // Clone user-supplied deny-page responses so downstream
@@ -149,7 +172,7 @@ export async function outcomeToResponse(
149
172
  await fireOnRequestError(outcome.error, ctx.req, 'proxy');
150
173
  if (config.onPipelineError && outcome.error instanceof Error)
151
174
  config.onPipelineError(outcome.error, 'proxy');
152
- return rscErrorResponse(isRsc, 500);
175
+ return rscErrorResponse(isRsc, 500, outcomeHeaders(outcome, ctx));
153
176
  }
154
177
 
155
178
  if (outcome.phase === 'middleware') {
@@ -158,11 +181,10 @@ export async function outcomeToResponse(
158
181
  if (config.onPipelineError && outcome.error instanceof Error) {
159
182
  config.onPipelineError(outcome.error, 'middleware');
160
183
  }
161
- return rscErrorResponse(isRsc, 500);
184
+ return rscErrorResponse(isRsc, 500, outcomeHeaders(outcome, ctx));
162
185
  }
163
186
 
164
- const headers = ctx.responseHeaders ?? new Headers();
165
- applyCookieJar(headers);
187
+ const headers = outcomeHeaders(outcome, ctx);
166
188
  logRenderError({ method: ctx.method, path: ctx.path, error: outcome.error });
167
189
  await fireOnRequestError(outcome.error, ctx.req, 'render');
168
190
  if (config.onPipelineError && outcome.error instanceof Error)
@@ -190,7 +212,7 @@ export async function outcomeToResponse(
190
212
  config.onPipelineError(fallbackRenderError, 'render');
191
213
  }
192
214
  }
193
- return new Response(null, { status: 500 });
215
+ return rscErrorResponse(false, 500, headers);
194
216
  }
195
217
  }
196
218
  }
@@ -25,8 +25,8 @@ import {
25
25
  import { withSpan } from './tracing.ts';
26
26
  import { requestCspNonce } from './csp-nonce.ts';
27
27
  import { logRenderError } from './logger.ts';
28
- import { RedirectSignal, DenySignal } from './primitives.ts';
29
- import { ParamCoercionError } from './route-element-builder.ts';
28
+ import { isDenySignal, isRedirectSignal } from './primitives.ts';
29
+ import { ParamCoercionError } from './param-coercion.ts';
30
30
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.ts';
31
31
  import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.ts';
32
32
  import { loadModule } from './safe-load.ts';
@@ -96,10 +96,10 @@ export async function runProxyPhase(
96
96
  );
97
97
  return { kind: 'response', phase: 'proxy', response };
98
98
  } catch (error) {
99
- if (error instanceof RedirectSignal) {
99
+ if (isRedirectSignal(error)) {
100
100
  return { kind: 'redirect', phase: 'proxy', signal: error };
101
101
  }
102
- if (error instanceof DenySignal) {
102
+ if (isDenySignal(error)) {
103
103
  return { kind: 'deny', phase: 'proxy', signal: error };
104
104
  }
105
105
  return { kind: 'error', phase: 'proxy', error };
@@ -169,18 +169,12 @@ export async function runMiddlewarePhase(
169
169
  // injected request headers so getHeaders() returns them downstream.
170
170
  applyRequestHeaderOverlay(requestHeaderOverlay);
171
171
 
172
- // Apply cookie jar to response headers before render commits them.
173
- // This preserves the historical ordering where middleware cookie writes
174
- // are visible to route-handler header merging, while handler Set-Cookie
175
- // values still come after middleware cookies and therefore take precedence.
176
- applyCookieJar(responseHeaders);
177
-
178
172
  return runRenderPhase(config, req, match, responseHeaders, requestHeaderOverlay, renderContext);
179
173
  } catch (error) {
180
- if (error instanceof RedirectSignal) {
174
+ if (isRedirectSignal(error)) {
181
175
  return { kind: 'redirect', phase: 'middleware', signal: error };
182
176
  }
183
- if (error instanceof DenySignal) {
177
+ if (isDenySignal(error)) {
184
178
  return { kind: 'deny', phase: 'middleware', signal: error };
185
179
  }
186
180
  return { kind: 'error', phase: 'middleware', error };
@@ -192,6 +186,17 @@ export async function runMiddlewarePhase(
192
186
  /**
193
187
  * Run the render phase. Wraps the configured renderer in a span and a
194
188
  * timing scope, and translates thrown signals into outcome variants.
189
+ *
190
+ * The render phase owns `responseHeaders` from here on, so it is the one
191
+ * place the cookie jar is applied to that bag (design/29-cookies.md
192
+ * §"Applied Exactly Once"). Doing it here rather than in the middleware
193
+ * phase means every render-phase outcome — a 200, or a deny / redirect /
194
+ * error thrown from render — carries the jar whether or not a middleware
195
+ * chain ran, and `outcomeToResponse` can skip render-phase outcomes
196
+ * unconditionally (TIM-1491). Application precedes `config.render` so
197
+ * middleware cookie writes are visible to route-handler header merging,
198
+ * while handler `Set-Cookie` values still come after them and take
199
+ * precedence.
195
200
  */
196
201
  export async function runRenderPhase(
197
202
  config: PipelineConfig,
@@ -202,6 +207,7 @@ export async function runRenderPhase(
202
207
  { canonicalPathname, interception }: RenderContext
203
208
  ): Promise<PhaseOutcome> {
204
209
  const detailed = config.serverTiming === 'detailed';
210
+ applyCookieJar(responseHeaders);
205
211
  try {
206
212
  const renderFn = () =>
207
213
  config.render(req, match, responseHeaders, requestHeaderOverlay, interception);
@@ -210,10 +216,10 @@ export async function runRenderPhase(
210
216
  );
211
217
  return { kind: 'response', phase: 'render', response };
212
218
  } catch (error) {
213
- if (error instanceof DenySignal) {
219
+ if (isDenySignal(error)) {
214
220
  return { kind: 'deny', phase: 'render', signal: error };
215
221
  }
216
- if (error instanceof RedirectSignal) {
222
+ if (isRedirectSignal(error)) {
217
223
  return { kind: 'redirect', phase: 'render', signal: error };
218
224
  }
219
225
  return { kind: 'error', phase: 'render', error };
@@ -331,13 +337,13 @@ export async function handleRequest(
331
337
  // calls deny(404) for an unknown tenant, or a dynamic icon that
332
338
  // redirects to a CDN-hosted asset) translate to proper HTTP responses
333
339
  // instead of being logged as 500s.
334
- if (error instanceof RedirectSignal) {
340
+ if (isRedirectSignal(error)) {
335
341
  return new Response(null, {
336
342
  status: error.status,
337
343
  headers: { Location: error.location },
338
344
  });
339
345
  }
340
- if (error instanceof DenySignal) {
346
+ if (isDenySignal(error)) {
341
347
  return new Response(null, { status: error.status });
342
348
  }
343
349
  logRenderError({ method, path, error });