@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
@@ -15,8 +15,16 @@ import type { RscPayloadRoot } from '../../shared/payload-root.ts';
15
15
 
16
16
  import { randomUUID } from 'node:crypto';
17
17
 
18
+ import { matchDenyEntries, type DenyPageEntry } from '../deny-boundary.ts';
18
19
  import { logRenderError } from '../logger.ts';
19
- import { DenySignal, RedirectSignal, RenderError } from '../primitives.ts';
20
+ import {
21
+ isDenySignal,
22
+ isRedirectSignal,
23
+ isRenderError,
24
+ DenySignal,
25
+ RedirectSignal,
26
+ RenderError,
27
+ } from '../primitives.ts';
20
28
  import { checkAndWarnRscPropError } from '../rsc-prop-warnings.ts';
21
29
 
22
30
  import {
@@ -41,7 +49,7 @@ import { isDevMode } from '../debug.ts';
41
49
  export interface RenderSignals {
42
50
  denySignal: DenySignal | null;
43
51
  /**
44
- * The first deny that no in-tree catch site could place — no `ownerKey`, so
52
+ * The first deny that no in-tree catch site could place — no `owners`, so
45
53
  * no boundary may claim its error row and only the re-render fallback can
46
54
  * produce a page for it.
47
55
  *
@@ -49,7 +57,7 @@ export interface RenderSignals {
49
57
  * deep page can hoist a 404 to a boundary that will render it while an
50
58
  * outer layout separately throws a 403 nothing matches; whichever lands in
51
59
  * `denySignal` first, the unplaced one still needs the fallback, and
52
- * deciding from `denySignal.ownerKey` alone would strand it. See TIM-1356.
60
+ * deciding from `denySignal.owners` alone would strand it. See TIM-1356.
53
61
  */
54
62
  unplacedDeny: DenySignal | null;
55
63
  redirectSignal: RedirectSignal | null;
@@ -66,14 +74,6 @@ export interface RenderSignals {
66
74
  lastUnhandledError: unknown | null;
67
75
  /** Callback fired when a redirect or deny signal is captured in onError. */
68
76
  onSignal?: () => void;
69
- /**
70
- * Resolves when every deny-capable shell component (PageDenyBoundary +
71
- * TracedLayouts with deny chains) has settled, OR immediately when any
72
- * catches a DenySignal in-tree (status is known). Set by renderRoute
73
- * after element-tree construction. Awaited by buildRscPayloadResponse
74
- * before committing HTTP status. See TIM-1045, TIM-1208.
75
- */
76
- shellSettled?: Promise<void>;
77
77
  /** Callback fired when an unhandled error (non-signal) is captured. Used
78
78
  * to halt Flight data injection into the inline stream. */
79
79
  onUnhandledError?: () => void;
@@ -97,7 +97,11 @@ export interface RscStreamResult {
97
97
  * fires onError during stream consumption. Signals are captured in the
98
98
  * returned `signals` object for the caller to handle.
99
99
  */
100
- export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStreamResult {
100
+ export function renderRscStream(
101
+ payload: RscPayloadRoot,
102
+ req: Request,
103
+ leafDenyChain?: DenyPageEntry[]
104
+ ): RscStreamResult {
101
105
  const signals: RenderSignals = {
102
106
  denySignal: null,
103
107
  unplacedDeny: null,
@@ -121,22 +125,39 @@ export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStrea
121
125
  // Connection abort (user refreshed or navigated away) — suppress.
122
126
  // Not an application error; no need to track or log.
123
127
  if (isAbortError(error) || req.signal?.aborted) return;
124
- if (error instanceof DenySignal) {
125
- signals.denySignal = error;
126
- if (!error.ownerKey) signals.unplacedDeny ??= error;
128
+ if (isDenySignal(error)) {
129
+ // Late addressing (TIM-1450): a nested-component deny escapes
130
+ // all in-tree catch sites and arrives here without owners.
131
+ // Resolve it against the leaf deny chain — when the first match
132
+ // is non-MDX, stamp the full candidate list so the client can
133
+ // find the first reachable owner via its ancestry context.
134
+ if (!error.owners && leafDenyChain) {
135
+ const entries = matchDenyEntries(leafDenyChain, error.status);
136
+ if (entries.length > 0 && !entries[0].isMdx) {
137
+ // Only non-MDX entries are addressable — MDX boundaries hold
138
+ // pre-rendered fallbackElements without runtime props. TIM-1451
139
+ // will widen exact-code MDX; until then they stay on the
140
+ // re-render fallback path.
141
+ const nonMdx = entries.filter((e) => !e.isMdx);
142
+ if (nonMdx.length > 0) {
143
+ error.owners = nonMdx.map((e) => e.ownerKey);
144
+ }
145
+ }
146
+ }
147
+ // First-wins: two concurrent denies from separately serialized
148
+ // subtrees must commit a deterministic status. See
149
+ // spike-TIM-1441 §"two-concurrent-denials scenario".
150
+ signals.denySignal ??= error;
151
+ if (!error.owners) signals.unplacedDeny ??= error;
127
152
  signals.onSignal?.();
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
153
  return JSON.stringify({
133
154
  type: 'deny',
134
155
  status: error.status,
135
156
  data: error.data,
136
- ownerKey: error.ownerKey,
157
+ owners: error.owners,
137
158
  });
138
159
  }
139
- if (error instanceof RedirectSignal) {
160
+ if (isRedirectSignal(error)) {
140
161
  signals.redirectSignal = error;
141
162
  signals.onSignal?.();
142
163
  return JSON.stringify({
@@ -145,7 +166,7 @@ export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStrea
145
166
  status: error.status,
146
167
  });
147
168
  }
148
- if (error instanceof RenderError) {
169
+ if (isRenderError(error)) {
149
170
  // Track the first render error for pre-flush handling
150
171
  if (!signals.renderError) {
151
172
  signals.renderError = { error, status: error.status };
@@ -238,10 +259,10 @@ export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStrea
238
259
  }
239
260
  );
240
261
  } catch (error) {
241
- if (error instanceof DenySignal) {
242
- signals.denySignal = error;
243
- if (!error.ownerKey) signals.unplacedDeny ??= error;
244
- } else if (error instanceof RedirectSignal) {
262
+ if (isDenySignal(error)) {
263
+ signals.denySignal ??= error;
264
+ if (!error.owners) signals.unplacedDeny ??= error;
265
+ } else if (isRedirectSignal(error)) {
245
266
  signals.redirectSignal = error;
246
267
  } else {
247
268
  // Synchronous render error — component threw during
@@ -249,7 +270,7 @@ export function renderRscStream(payload: RscPayloadRoot, req: Request): RscStrea
249
270
  // the server; the error page will be rendered below.
250
271
  signals.renderError = {
251
272
  error,
252
- status: error instanceof RenderError ? error.status : 500,
273
+ status: isRenderError(error) ? error.status : 500,
253
274
  };
254
275
  logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
255
276
  }
@@ -20,7 +20,7 @@ import { getSlotParamsForClient } from '../request-context.ts';
20
20
  import type { LayoutEntry } from '../deny-renderer.ts';
21
21
  import { renderDenyPage } from '../deny-renderer.ts';
22
22
  import type { RouteMatch } from '../pipeline.ts';
23
- import { SsrStreamError } from '../primitives.ts';
23
+ import { isSsrStreamError } from '../primitives.ts';
24
24
  import type { LayoutComponentEntry } from '../route-element-builder.ts';
25
25
  import type { ManifestSegmentNode } from '../route-matcher.ts';
26
26
  import type { SlotSkipEntry } from '../slot-resolver.ts';
@@ -405,15 +405,11 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
405
405
  // Deny pages still go through RSC → SSR (plain props), so they pass empty
406
406
  // layouts to avoid re-executing server components in the RSC environment's
407
407
  // stale ALS scope. See LOCAL-293.
408
- // Name-based check: SsrStreamError is thrown in the SSR environment
409
- // but caught in the RSC environment. Since RSC and SSR are separate
410
- // Vite module graphs, `instanceof` fails (different class identities).
411
- // Use error.name instead. See design/18-build-system.md §"Virtual Module
412
- // Resolution Quirks".
413
- const isSsrStreamError =
414
- ssrError instanceof SsrStreamError ||
415
- (ssrError instanceof Error && ssrError.name === 'SsrStreamError');
416
- if (isSsrStreamError) {
408
+ // SsrStreamError is thrown in the SSR environment but caught here in
409
+ // RSC — separate Vite module graphs, so `instanceof` can never match.
410
+ // isSsrStreamError() checks the Symbol.for brand instead. See
411
+ // design/18-build-system.md §"Dev Mode: Signal Identity Across Program Reloads".
412
+ if (isSsrStreamError(ssrError)) {
417
413
  // Cancel the teed inline stream — SSR failed so this response will
418
414
  // be replaced by renderErrorPage which creates its own clean streams.
419
415
  // Without explicit cancellation the orphaned tee branch keeps the
@@ -0,0 +1,18 @@
1
+ import { TIMBER_ERROR_HEADER } from '../shared/rsc-error-envelope.ts';
2
+ import { errorResponseHeaders } from './error-response-headers.ts';
3
+
4
+ /**
5
+ * Build the hard-navigate envelope for a status code.
6
+ *
7
+ * Preserve middleware's request-wide headers and cookies, but discard the
8
+ * abandoned body's framing, validators, and cache policy. Every producer,
9
+ * including errors returned normally during render, gets this cleanup here.
10
+ * The input header bag is left intact. The body deliberately carries no message —
11
+ * only the status already on the wire (design/13-security.md principle 4).
12
+ */
13
+ export function rscErrorEnvelope(status: number, responseHeaders?: Headers): Response {
14
+ const headers = errorResponseHeaders(responseHeaders);
15
+ headers.set(TIMBER_ERROR_HEADER, '1');
16
+ headers.set('content-type', 'application/json; charset=utf-8');
17
+ return new Response(JSON.stringify({ error: true, status }), { status, headers });
18
+ }
@@ -1,20 +1,118 @@
1
1
  /**
2
- * Skippable-prefix truncation.
2
+ * Skippable-prefix computation.
3
3
  *
4
- * Extracted from `route-element-builder.ts`, which was over the 500-line limit
5
- * (design/18-build-system.md). This is a self-contained question — "may this
6
- * segment's layout still be skipped, given what its slots would render?" — with
7
- * no dependency on element construction.
4
+ * Determines which layouts can be skipped on a partial navigation by
5
+ * computing a contiguous prefix of segments whose cached layouts need
6
+ * not re-render. Extracted from `route-element-builder.ts` to keep it
7
+ * under the 500-line limit (design/18-build-system.md).
8
+ *
9
+ * See design/19-client-navigation.md §"X-Timber-State-Tree Header".
8
10
  */
9
11
 
10
12
  import type { ManifestSegmentNode } from './route-matcher.ts';
11
13
  import type { RouteMatch } from './pipeline.ts';
12
- import { shouldSkipSlot, type ClientStateTree } from './state-tree-diff.ts';
14
+ import { shouldSkipSegment, shouldSkipSlot, type ClientStateTree } from './state-tree-diff.ts';
13
15
  import { consumedUrlParts } from './chain-url-parts.ts';
14
16
  import { computeSlotContentKey, computeSlotKey } from '../routing/segment-keys.ts';
15
17
  import { matchUrlParts } from './tree-match.ts';
16
18
  import { slotUrlParts } from './slot-resolver.ts';
17
19
 
20
+ /**
21
+ * Compute the set of segment indices whose layouts can be skipped.
22
+ *
23
+ * Three sub-rules in order:
24
+ * (a) Contiguous prefix via `shouldSkipSegment` — stop at the first
25
+ * non-skippable layout.
26
+ * (b) If every layout landed in the prefix, remove the innermost one
27
+ * as the merge point (the client needs a mounted outlet there).
28
+ * (c) Cross-section guard — clear the whole set unless at least one
29
+ * non-skipped layout's key is in `clientStateTree`.
30
+ *
31
+ * After the prefix is computed, `truncateSkippableForChangedSlots`
32
+ * removes any segment whose parallel slots changed content.
33
+ */
34
+ export function computeSkippableSet(
35
+ segments: ManifestSegmentNode[],
36
+ segmentKeys: string[],
37
+ layoutBySegment: Map<ManifestSegmentNode, unknown>,
38
+ clientStateTree: ClientStateTree | null | undefined,
39
+ match: RouteMatch,
40
+ req: Request
41
+ ): Set<number> {
42
+ const skippableSet = new Set<number>();
43
+ if (!clientStateTree) return skippableSet;
44
+
45
+ // (a) Contiguous prefix via shouldSkipSegment.
46
+ for (let i = 0; i < segments.length; i++) {
47
+ const isLeaf = i === segments.length - 1;
48
+ const layoutComponent = layoutBySegment.get(segments[i]);
49
+
50
+ if (
51
+ shouldSkipSegment(
52
+ segmentKeys[i],
53
+ layoutComponent as ((...args: unknown[]) => unknown) | undefined,
54
+ isLeaf,
55
+ clientStateTree
56
+ )
57
+ ) {
58
+ skippableSet.add(i);
59
+ } else if (layoutComponent) {
60
+ break;
61
+ }
62
+ }
63
+
64
+ // (b) Ensure there's a merge point: if all layouts are in the prefix,
65
+ // keep the innermost as merge point.
66
+ if (skippableSet.size > 0) {
67
+ let mergePointIndex = -1;
68
+ for (let i = 0; i < segments.length; i++) {
69
+ if (skippableSet.has(i)) continue;
70
+ if (layoutBySegment.has(segments[i])) {
71
+ mergePointIndex = i;
72
+ break;
73
+ }
74
+ }
75
+
76
+ if (mergePointIndex === -1) {
77
+ for (let i = segments.length - 1; i >= 0; i--) {
78
+ if (skippableSet.has(i) && layoutBySegment.has(segments[i])) {
79
+ skippableSet.delete(i);
80
+ mergePointIndex = i;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+
86
+ // (c) Cross-section guard: at least one rendered layout below the
87
+ // prefix must exist in the client's state tree.
88
+ let hasClientOutlet = false;
89
+ for (let j = 0; j < segments.length; j++) {
90
+ if (skippableSet.has(j)) continue;
91
+ if (layoutBySegment.has(segments[j]) && clientStateTree.has(segmentKeys[j])) {
92
+ hasClientOutlet = true;
93
+ break;
94
+ }
95
+ }
96
+ if (!hasClientOutlet) {
97
+ skippableSet.clear();
98
+ }
99
+ }
100
+
101
+ // Slot-change guard (TIM-1232).
102
+ if (skippableSet.size > 0) {
103
+ truncateSkippableForChangedSlots(
104
+ skippableSet,
105
+ segments,
106
+ segmentKeys,
107
+ match,
108
+ clientStateTree,
109
+ req
110
+ );
111
+ }
112
+
113
+ return skippableSet;
114
+ }
115
+
18
116
  /**
19
117
  * Truncate the skippable prefix when a skippable layout has slots whose
20
118
  * content changed between the departing and destination URLs (TIM-1232).
@@ -28,7 +126,7 @@ import { slotUrlParts } from './slot-resolver.ts';
28
126
  * Uses key-based diffing (TIM-1370): computes the destination content key
29
127
  * per slot and checks if the client's advertised set contains it.
30
128
  */
31
- export function truncateSkippableForChangedSlots(
129
+ function truncateSkippableForChangedSlots(
32
130
  skippableSet: Set<number>,
33
131
  segments: ManifestSegmentNode[],
34
132
  segmentKeys: string[],
@@ -20,10 +20,11 @@ import { TimberErrorBoundary } from '../client/error-boundary.tsx';
20
20
  import SlotErrorFallback from '../client/slot-error-fallback.tsx';
21
21
  import { SlotAccessGate } from './access-gate.tsx';
22
22
  import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.ts';
23
- import { isClientReference } from './route-element-builder.ts';
23
+ import { isClientReference } from './utils/element-type.ts';
24
+ import { containSlotSubtree } from './slot-subtree-contain.ts';
24
25
  import { loadModule } from './safe-load.ts';
25
26
  import type { InterceptionContext, RouteMatch } from './pipeline.ts';
26
- import { DenySignal, RedirectSignal } from './primitives.ts';
27
+ import { isDenySignal, isRedirectSignal } from './primitives.ts';
27
28
  import { logRenderError } from './logger.ts';
28
29
  import type { ManifestSegmentNode } from './route-matcher.ts';
29
30
  import { setSlotParams, runWithSlotSegmentParams } from './request-context.ts';
@@ -140,23 +141,30 @@ export async function resolveSlotElement(
140
141
  // For server components, wrap in SafeSlotPage to catch ALL errors at the
141
142
  // component level. This prevents errors from leaving unresolved Flight
142
143
  // rows in the RSC stream — see TIM-524 for details.
144
+ //
145
+ // The catch below covers the page function. The tree it RETURNS is
146
+ // contained separately (`containSlotSubtree`): a server component
147
+ // nested in that tree runs as its own Flight task, outside this
148
+ // try/catch, and a deny there would otherwise reach Flight's global
149
+ // onError as a page-level 4xx. See slot-subtree-contain.ts, TIM-1450.
143
150
  let element: React.ReactElement;
144
151
  if (isClientReference(SlotPage)) {
145
152
  element = h(SlotPage, {});
146
153
  } else {
147
154
  const SafeSlotPage = async (props: Record<string, unknown>) => {
148
155
  try {
149
- return await scope(() =>
156
+ const rendered = await scope(() =>
150
157
  (SlotPage as (props: Record<string, unknown>) => unknown)(props)
151
158
  );
159
+ return containSlotSubtree(rendered as React.ReactNode, denyFallback);
152
160
  } catch (error) {
153
161
  // RedirectSignal must propagate — the pipeline handles redirects
154
162
  // at the top level. Swallowing it here would silently return
155
163
  // fallback content instead of redirecting. See TIM-554.
156
- if (error instanceof RedirectSignal) {
164
+ if (isRedirectSignal(error)) {
157
165
  throw error;
158
166
  }
159
- if (error instanceof DenySignal) {
167
+ if (isDenySignal(error)) {
160
168
  return denyFallback;
161
169
  }
162
170
  // Log the error but don't re-throw — returning fallback ensures
@@ -176,11 +184,17 @@ export async function resolveSlotElement(
176
184
  // intermediate slot segments (everything between slot root and leaf).
177
185
  // Process innermost-first, same order as route-element-builder.ts
178
186
  // handles main segments. The slot root (index 0) is handled below.
179
- element = await wrapWithIntermediateSegments(slotMatch.chain, element, h, scope);
187
+ element = await wrapWithIntermediateSegments(
188
+ slotMatch.chain,
189
+ element,
190
+ h,
191
+ scope,
192
+ denyFallback
193
+ );
180
194
 
181
195
  // Wrap with slot root's layout — INSIDE the access gate, so the layout
182
196
  // server component never executes when access.ts denies. See TIM-1074.
183
- element = await wrapWithLayout(slotNode, element, h, scope);
197
+ element = await wrapWithLayout(slotNode, element, h, scope, denyFallback);
184
198
 
185
199
  // Wrap in SlotAccessGate if slot root has access.ts. The gate is
186
200
  // OUTSIDE the layout, matching the segment model in
@@ -191,11 +205,15 @@ export async function resolveSlotElement(
191
205
  // See design/04-authorization.md §"Slot-Level Auth".
192
206
  element = await wrapWithAccessGate(slotNode, element, h, scope);
193
207
 
194
- // Wrap with slot root's error boundaries (outermost)
208
+ // Wrap with slot root's error boundaries (outermost). Slot identity on
209
+ // every one of them, not only the catch-all below: a slot's own
210
+ // status page must not set the document status or empty the main
211
+ // route's segment cache.
195
212
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, {
196
213
  loadModule,
197
214
  createElement: h,
198
215
  errorBoundaryComponent: TimberErrorBoundary,
216
+ isSlotBoundary: true,
199
217
  });
200
218
 
201
219
  // Catch-all error boundary: ensures slot errors NEVER propagate to the
@@ -271,7 +289,8 @@ async function wrapWithIntermediateSegments(
271
289
  chain: ManifestSegmentNode[],
272
290
  element: React.ReactElement,
273
291
  h: CreateElementFn,
274
- scope?: SlotParamScope
292
+ scope?: SlotParamScope,
293
+ denyFallback?: React.ReactNode
275
294
  ): Promise<React.ReactElement> {
276
295
  for (let i = chain.length - 1; i > 0; i--) {
277
296
  const seg = chain[i];
@@ -279,8 +298,9 @@ async function wrapWithIntermediateSegments(
279
298
  loadModule,
280
299
  createElement: h,
281
300
  errorBoundaryComponent: TimberErrorBoundary,
301
+ isSlotBoundary: true,
282
302
  });
283
- element = await wrapWithLayout(seg, element, h, scope);
303
+ element = await wrapWithLayout(seg, element, h, scope, denyFallback);
284
304
  element = await wrapWithAccessGate(seg, element, h, scope);
285
305
  }
286
306
  return element;
@@ -309,13 +329,22 @@ type SlotParamScope = <T>(fn: () => T) => T;
309
329
  * Server component: the function-valued props are called here on the server
310
330
  * and never reach the Flight stream, the same way `SlotAccessGate` takes
311
331
  * `accessFn`.
332
+ *
333
+ * The layout's own chrome is part of the slot's subtree, so the tree it
334
+ * returns is contained like the page's: a server component the layout
335
+ * renders beside `children` that denies becomes the slot fallback in place,
336
+ * not a page-level 4xx. `children` is the element the resolver built for the
337
+ * level below, already contained there, and is passed to the walk as the one
338
+ * subtree to leave alone. See slot-subtree-contain.ts.
312
339
  */
313
340
  function ScopedSlotLayout(props: {
314
341
  layout: (props: Record<string, unknown>) => unknown;
315
342
  scope: SlotParamScope;
343
+ denyFallback: React.ReactNode;
316
344
  children: React.ReactElement;
317
345
  }): unknown {
318
- return props.scope(() => props.layout({ children: props.children }));
346
+ const rendered = props.scope(() => props.layout({ children: props.children }));
347
+ return containSlotSubtree(rendered as React.ReactNode, props.denyFallback, props.children);
319
348
  }
320
349
 
321
350
  /**
@@ -332,7 +361,8 @@ async function wrapWithLayout(
332
361
  node: ManifestSegmentNode,
333
362
  element: React.ReactElement,
334
363
  h: CreateElementFn,
335
- scope?: SlotParamScope
364
+ scope?: SlotParamScope,
365
+ denyFallback: React.ReactNode = null
336
366
  ): Promise<React.ReactElement> {
337
367
  if (!node.layout) return element;
338
368
  const Layout = await loadComponent(node.layout);
@@ -342,6 +372,7 @@ async function wrapWithLayout(
342
372
  return h(ScopedSlotLayout, {
343
373
  layout: Layout as (props: Record<string, unknown>) => unknown,
344
374
  scope,
375
+ denyFallback,
345
376
  children: element,
346
377
  });
347
378
  }