@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
@@ -36,6 +36,7 @@ import {
36
36
  type GraphUpdateChannel,
37
37
  } from './graph-endpoint.ts';
38
38
  import { compressResponse } from '../server/compress.ts';
39
+ import { isSsrStreamError } from '../server/primitives.ts';
39
40
  import { isMetadataRouteServePath } from '../server/metadata-routes.ts';
40
41
  import { canonicalize } from '../server/canonicalize.ts';
41
42
  import { matchUrlParts } from '../server/tree-match.ts';
@@ -401,7 +402,7 @@ function createTimberMiddleware(server: ViteDevServer, ctx: PluginContext) {
401
402
  // message; the developer needs to see the actual error (e.g.,
402
403
  // "useSegmentParams is not a function"), not the wrapper.
403
404
  const displayError =
404
- error.name === 'SsrStreamError' && error.cause instanceof Error ? error.cause : error;
405
+ isSsrStreamError(error) && error.cause instanceof Error ? error.cause : error;
405
406
  const phase = classifyErrorPhase(displayError, projectRoot);
406
407
  sendErrorToOverlay(server, displayError, phase, projectRoot);
407
408
  respond500(res, displayError, phase, projectRoot, hmrOptions);
@@ -109,8 +109,8 @@ export interface SlotInterceptionRewrite extends InterceptionRewriteBase {
109
109
  * either. The server resolves the owner by `interceptingScope` — a tree path,
110
110
  * which identifies exactly one directory — and builds the rendered chain as
111
111
  * `[…owner ancestors, interceptingNode, …its matched descendants]`. That is an
112
- * ordinary segment chain, so `tree-builder.ts` renders it with no knowledge
113
- * that interception happened.
112
+ * ordinary segment chain, so `route-element-builder.ts` renders it with no
113
+ * knowledge that interception happened.
114
114
  *
115
115
  * Resolving the owner from the tree rather than from a URL is what makes this
116
116
  * shape work where the modal shape cannot: `interceptingPrefix` is
@@ -15,8 +15,8 @@ import type { SegmentNode } from './types.ts';
15
15
  * Reject every parallel slot that no renderer reaches.
16
16
  *
17
17
  * **Where slots are resolved.** Two places read `segment.slots`, and both walk
18
- * the main route chain: `route-element-builder.ts` (the RSC render) and
19
- * `tree-builder.ts`. Neither is reachable from inside a slot. The slot's own
18
+ * the main route chain: `route-element-builder.ts` (the RSC render).
19
+ * It is not reachable from inside a slot. The slot's own
20
20
  * subtree is rendered by `slot-resolver.ts`, which walks the slot's matched
21
21
  * chain to apply that chain's layouts, error boundaries and access gates
22
22
  * (`wrapWithIntermediateSegments`, `wrapWithLayout`) and never reads `.slots`
@@ -13,13 +13,93 @@
13
13
  * See design/04-authorization.md and design/02-rendering-pipeline.md §"AccessGate"
14
14
  */
15
15
 
16
- import { DenySignal, RedirectSignal } from './primitives.ts';
17
- import type { AccessGateProps, SlotAccessGateProps } from './tree-builder.ts';
16
+ import { isDenySignal, isRedirectSignal, DenySignal, RedirectSignal } from './primitives.ts';
18
17
  import { withSpan, setSpanAttribute } from './tracing.ts';
19
18
  import { isDebug } from './debug.ts';
20
19
  import type { DenyPageEntry } from './deny-boundary.ts';
21
20
  import { handleCaughtDeny, setFirstDeniedSegmentIndex } from './deny-boundary.ts';
22
21
  import type { ReactNode } from 'react';
22
+ import type { LoadedComponent } from './utils/element-type.ts';
23
+
24
+ // ─── Types ─────────────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Function that creates a React element. Matches React.createElement signature.
28
+ *
29
+ * `props` is typed as `object | null` rather than `Record<string, unknown>` so
30
+ * that interface types with known keys (e.g. `AccessGateProps`,
31
+ * `ErrorBoundaryProps`) flow through without an explicit index-signature cast.
32
+ */
33
+ export type CreateElement = (
34
+ type: unknown,
35
+ props: object | null,
36
+ ...children: unknown[]
37
+ ) => ReactNode;
38
+
39
+ /**
40
+ * Framework-injected access gate component.
41
+ *
42
+ * When `verdict` is provided (from the pre-render pass), AccessGate replays
43
+ * the stored result synchronously — no re-execution, no async, immune to
44
+ * Suspense timing. When `verdict` is absent, falls back to calling `accessFn`
45
+ * (backward compat for construction sites that don't run a pre-render pass).
46
+ */
47
+ export interface AccessGateProps {
48
+ accessFn: () => unknown;
49
+ /** Segment name for dev logging (e.g. "authenticated", "dashboard"). */
50
+ segmentName?: string;
51
+ /**
52
+ * Segment key of the gated segment (`computeSegmentKeys`). Decides whether
53
+ * a matched deny page renders here or is hoisted to the segment that owns
54
+ * it. Omitted by construction sites that build no deny chains — with no
55
+ * chain there is nothing to place. See TIM-1356.
56
+ */
57
+ segmentKey?: string;
58
+ /**
59
+ * Index of this segment in the matched segment chain. Recorded in ALS
60
+ * when the gate catches a DenySignal so post-render consumers
61
+ * (buildSegmentInfo, X-Timber-Segments) can bound the segment list
62
+ * without a pre-render access pass. See TIM-1363.
63
+ */
64
+ segmentIndex?: number;
65
+ /**
66
+ * Deny page fallback chain. When provided and a DenySignal is caught,
67
+ * AccessGate renders the matching deny page in-tree instead of throwing.
68
+ * This prevents the error from reaching React Flight, eliminating the
69
+ * second render pass. See TIM-666.
70
+ *
71
+ * Omitted for skipped segments — a deny there must hard-navigate to
72
+ * avoid rendering the deny page inside cached layout chrome (TIM-1074).
73
+ */
74
+ denyPages?: DenyPageEntry[];
75
+ /**
76
+ * When true, this gate wraps a skipped segment. On deny, the gate
77
+ * records the deny in ALS and rethrows so the response path can
78
+ * return rscErrorEnvelope for a client hard-navigate.
79
+ */
80
+ skippedSegment?: boolean;
81
+ children: ReactNode;
82
+ }
83
+
84
+ /**
85
+ * Framework-injected slot access gate component.
86
+ * On denial, renders denied.tsx → default.tsx → null instead of failing the page.
87
+ *
88
+ * DeniedComponent is passed instead of a pre-built element so that
89
+ * SlotAccessGate can forward DenySignal.data as dangerouslyPassData
90
+ * and slotName as the slot prop after catching the signal.
91
+ */
92
+ export interface SlotAccessGateProps {
93
+ accessFn: () => unknown;
94
+ /** The denied.tsx component (not a pre-built element). null if no denied.tsx exists. */
95
+ DeniedComponent: LoadedComponent | null;
96
+ /** Slot directory name without @ prefix (e.g. "admin", "sidebar"). */
97
+ slotName: string;
98
+ /** createElement function for building elements dynamically. */
99
+ createElement: CreateElement;
100
+ defaultFallback: ReactNode;
101
+ children: ReactNode;
102
+ }
23
103
 
24
104
  // ─── AccessGate ─────────────────────────────────────────────────────────────
25
105
 
@@ -49,16 +129,8 @@ import type { ReactNode } from 'react';
49
129
  * can return rscErrorEnvelope for a client hard-navigate.
50
130
  */
51
131
  export function AccessGate(props: AccessGateProps): Promise<ReactNode> {
52
- const {
53
- accessFn,
54
- segmentName,
55
- segmentKey,
56
- segmentIndex,
57
- denyPages,
58
- skippedSegment,
59
- onSettled,
60
- children,
61
- } = props;
132
+ const { accessFn, segmentName, segmentKey, segmentIndex, denyPages, skippedSegment, children } =
133
+ props;
62
134
 
63
135
  return accessGateFallback(
64
136
  accessFn,
@@ -67,7 +139,6 @@ export function AccessGate(props: AccessGateProps): Promise<ReactNode> {
67
139
  segmentIndex,
68
140
  skippedSegment ? undefined : denyPages,
69
141
  skippedSegment,
70
- onSettled,
71
142
  children
72
143
  );
73
144
  }
@@ -83,7 +154,6 @@ async function accessGateFallback(
83
154
  segmentIndex: AccessGateProps['segmentIndex'],
84
155
  denyPages: DenyPageEntry[] | undefined,
85
156
  skippedSegment: boolean | undefined,
86
- onSettled: (() => void) | undefined,
87
157
  children: ReactNode
88
158
  ): Promise<ReactNode> {
89
159
  try {
@@ -92,20 +162,20 @@ async function accessGateFallback(
92
162
  await accessFn();
93
163
  await setSpanAttribute('timber.result', 'pass');
94
164
  } catch (error: unknown) {
95
- if (error instanceof DenySignal) {
165
+ if (isDenySignal(error)) {
96
166
  await setSpanAttribute('timber.result', 'deny');
97
167
  await setSpanAttribute('timber.deny_status', error.status);
98
168
  if (error.sourceFile) {
99
169
  await setSpanAttribute('timber.deny_file', error.sourceFile);
100
170
  }
101
- } else if (error instanceof RedirectSignal) {
171
+ } else if (isRedirectSignal(error)) {
102
172
  await setSpanAttribute('timber.result', 'redirect');
103
173
  }
104
174
  throw error;
105
175
  }
106
176
  });
107
177
  } catch (error: unknown) {
108
- if (error instanceof DenySignal) {
178
+ if (isDenySignal(error)) {
109
179
  if (segmentIndex !== undefined) {
110
180
  setFirstDeniedSegmentIndex(segmentIndex);
111
181
  }
@@ -120,8 +190,6 @@ async function accessGateFallback(
120
190
  }
121
191
  }
122
192
  throw error;
123
- } finally {
124
- onSettled?.();
125
193
  }
126
194
 
127
195
  return children;
@@ -151,7 +219,7 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactN
151
219
  } catch (error: unknown) {
152
220
  // DenySignal → graceful degradation (denied.tsx → default.tsx → null)
153
221
  // Build the denied element dynamically so DenySignal.data is forwarded.
154
- if (error instanceof DenySignal) {
222
+ if (isDenySignal(error)) {
155
223
  return (
156
224
  buildDeniedFallback(DeniedComponent, slotName, error.data, createElement) ??
157
225
  defaultFallback ??
@@ -163,7 +231,7 @@ export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactN
163
231
  // Slot access should use deny(), not redirect(). Redirecting from a
164
232
  // slot would redirect the entire page, which breaks the contract that
165
233
  // slot failure is graceful degradation.
166
- if (error instanceof RedirectSignal) {
234
+ if (isRedirectSignal(error)) {
167
235
  if (isDebug()) {
168
236
  console.error(
169
237
  '[timber] redirect() is not allowed in slot access.ts. ' +
@@ -223,7 +223,7 @@ async function runActionMiddleware<TCtx>(
223
223
  import { parseFormData } from './form-data.ts';
224
224
  import { formatSize } from '../utils/format.ts';
225
225
  import { isDebug, isDevMode } from './debug.ts';
226
- import { RedirectSignal, DenySignal } from './primitives.ts';
226
+ import { isDenySignal, isRedirectSignal } from './primitives.ts';
227
227
  import {
228
228
  stripSensitiveFields,
229
229
  resolveSensitivePredicate,
@@ -432,7 +432,7 @@ export function createActionClient<TCtx = Record<string, never>>(
432
432
  // They must propagate to executeAction() which converts them to proper
433
433
  // HTTP responses (302 redirect, 4xx deny). Catching them here would
434
434
  // wrap them as INTERNAL_ERROR and break redirect()/redirectExternal()/deny().
435
- if (error instanceof RedirectSignal || error instanceof DenySignal) {
435
+ if (isRedirectSignal(error) || isDenySignal(error)) {
436
436
  throw error;
437
437
  }
438
438
  return handleActionError(error);
@@ -21,7 +21,7 @@ import {
21
21
  } from '../rsc-runtime/rsc.ts';
22
22
 
23
23
  import { validateCsrf, type CsrfConfig } from './csrf.ts';
24
- import { DenySignal, RedirectSignal } from './primitives.ts';
24
+ import { isDenySignal, isRedirectSignal } from './primitives.ts';
25
25
  import { executeAction, type RevalidateRenderer } from './actions.ts';
26
26
  import { runWithRequestContext, setMutableCookieContext } from './request-context.ts';
27
27
  import { getSetCookieHeaders, getCookiesForSsr } from './cookie-context.ts';
@@ -38,6 +38,7 @@ import type { FormFlashData } from './form-flash.ts';
38
38
  import { checkVersionSkew, applyReloadHeaders } from './version-skew.ts';
39
39
  import { logActionError } from './logger.ts';
40
40
  import { withTimeout, RenderTimeoutError } from './render-timeout.ts';
41
+ import { canonicalize } from './canonicalize.ts';
41
42
  import { RSC_CONTENT_TYPE } from '../shared/rsc-media-type.ts';
42
43
 
43
44
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -246,8 +247,14 @@ async function handleRscAction(
246
247
  // of leaking stack traces as 500s.
247
248
  let result;
248
249
  try {
250
+ const requestUrl = new URL(req.url);
251
+ // Canonicalize the request pathname so it matches the decoded form
252
+ // that revalidationPathMatches uses on the target side.
253
+ const canonical = canonicalize(requestUrl.pathname, true);
249
254
  result = await executeAction(actionFn, args, {
250
255
  renderer: config.revalidateRenderer,
256
+ requestPathname: canonical.ok ? canonical.pathname : requestUrl.pathname,
257
+ requestSearch: requestUrl.search,
251
258
  });
252
259
  } catch (error) {
253
260
  // Log full error server-side for debugging
@@ -272,13 +279,14 @@ async function handleRscAction(
272
279
  _status: result.redirectStatus ?? 302,
273
280
  };
274
281
  const rscStream = renderToReadableStream(redirectPayload);
275
- return new Response(rscStream, {
276
- status: 200,
277
- headers: {
278
- 'Content-Type': RSC_CONTENT_TYPE,
279
- 'X-Timber-Redirect': result.redirectTo,
280
- },
281
- });
282
+ const redirectHeaders: Record<string, string> = {
283
+ 'Content-Type': RSC_CONTENT_TYPE,
284
+ 'X-Timber-Redirect': result.redirectTo,
285
+ };
286
+ if (result.invalidatedPaths && result.invalidatedPaths.length > 0) {
287
+ redirectHeaders['X-Timber-Revalidation-Path'] = result.invalidatedPaths.join(' ');
288
+ }
289
+ return new Response(rscStream, { status: 200, headers: redirectHeaders });
282
290
  }
283
291
 
284
292
  // Render the action result as an RSC stream.
@@ -289,6 +297,11 @@ async function handleRscAction(
289
297
  'Content-Type': RSC_CONTENT_TYPE,
290
298
  };
291
299
 
300
+ // TIM-1454: Tell the client which paths were invalidated (non-matching).
301
+ if (result.invalidatedPaths && result.invalidatedPaths.length > 0) {
302
+ headers['X-Timber-Revalidation-Path'] = result.invalidatedPaths.join(' ');
303
+ }
304
+
292
305
  if (result.revalidation) {
293
306
  // Wrapper object — Next.js-style pattern: action result + element tree
294
307
  // serialized together so React Flight handles both in one stream.
@@ -313,9 +326,9 @@ async function handleRscAction(
313
326
  signal: req.signal,
314
327
  onError(error: unknown) {
315
328
  // ??= keeps the first signal (shallowest AccessGate in top-down render).
316
- if (error instanceof DenySignal) {
329
+ if (isDenySignal(error)) {
317
330
  serializationSignal ??= { kind: 'deny', status: error.status };
318
- } else if (error instanceof RedirectSignal) {
331
+ } else if (isRedirectSignal(error)) {
319
332
  serializationSignal ??= {
320
333
  kind: 'redirect',
321
334
  location: error.location,
@@ -15,7 +15,8 @@
15
15
  */
16
16
 
17
17
  import { cache } from '../cache/cache-api.ts';
18
- import { DenySignal, RedirectSignal } from './primitives.ts';
18
+ import { canonicalize } from './canonicalize.ts';
19
+ import { isDenySignal, isRedirectSignal } from './primitives.ts';
19
20
  import { withSpan } from './tracing.ts';
20
21
  import { revalidationAls, type RevalidationState } from './als-registry.ts';
21
22
  // ─── Types ───────────────────────────────────────────────────────────────
@@ -24,7 +25,7 @@ import { revalidationAls, type RevalidationState } from './als-registry.ts';
24
25
  export interface RevalidationResult {
25
26
  /**
26
27
  * The payload root — tree plus the params it rendered with, exactly as an
27
- * ordinary route payload. The client splits it in `applyRevalidation`.
28
+ * ordinary route payload. The client splits it in `applyActionResult`.
28
29
  */
29
30
  payload: unknown;
30
31
  }
@@ -39,6 +40,15 @@ export type { RevalidationState } from './als-registry.ts';
39
40
  export interface ActionHandlerConfig {
40
41
  /** Renderer for producing RSC payloads during revalidation. */
41
42
  renderer?: RevalidateRenderer;
43
+ /**
44
+ * Pathname of the page the action was submitted from. When set,
45
+ * revalidatePath renders are skipped for non-matching paths — the
46
+ * server cache is already invalidated, so the next navigation fetches
47
+ * fresh data. See TIM-1454.
48
+ */
49
+ requestPathname?: string;
50
+ /** Search string of the action request URL (e.g. `?page=1`). */
51
+ requestSearch?: string;
42
52
  }
43
53
 
44
54
  /** Result of handling a server action request. */
@@ -47,6 +57,12 @@ export interface ActionHandlerResult {
47
57
  actionResult: unknown;
48
58
  /** Revalidation result if revalidatePath was called (element tree, not yet serialized). */
49
59
  revalidation?: RevalidationResult;
60
+ /**
61
+ * Paths passed to `revalidatePath()` that don't match the current page
62
+ * (TIM-1454). The client invalidates its caches for these so the next
63
+ * navigation fetches fresh data.
64
+ */
65
+ invalidatedPaths?: string[];
50
66
  /** Redirect location if a RedirectSignal was thrown during revalidation. */
51
67
  redirectTo?: string;
52
68
  /** Redirect status code. */
@@ -112,6 +128,13 @@ export function revalidateTag(tag: string): void {
112
128
  }
113
129
  }
114
130
 
131
+ // ─── Helpers ────────────────────────────────────────────────────────────
132
+
133
+ function stripFragment(path: string): string {
134
+ const idx = path.indexOf('#');
135
+ return idx >= 0 ? path.slice(0, idx) : path;
136
+ }
137
+
115
138
  // ─── Action Handler ──────────────────────────────────────────────────────
116
139
 
117
140
  /**
@@ -151,7 +174,7 @@ export async function executeAction(
151
174
  () => actionFn(...args)
152
175
  );
153
176
  } catch (error) {
154
- if (error instanceof RedirectSignal) {
177
+ if (isRedirectSignal(error)) {
155
178
  redirectTo = error.location;
156
179
  redirectStatus = error.status;
157
180
  } else {
@@ -182,39 +205,36 @@ export async function executeAction(
182
205
  }
183
206
  }
184
207
 
185
- // Process path revalidation — build element tree (not yet serialized)
208
+ // Process path revalidation — build element tree (not yet serialized).
209
+ //
210
+ // TIM-1454: Only render the path if it matches the current page. Non-matching
211
+ // paths are collected as `invalidatedPaths` — the client evicts them from its
212
+ // caches so the next navigation fetches fresh data.
186
213
  let revalidation: RevalidationResult | undefined;
187
- if (state.paths.length > 0 && config.renderer) {
188
- // For now, render the first revalidated path.
189
- // Multiple paths could be supported via multipart streaming in the future.
214
+ let invalidatedPaths: string[] | undefined;
215
+
216
+ if (state.paths.length > 0) {
190
217
  const path = state.paths[0];
191
- try {
192
- revalidation = await config.renderer(path);
193
- } catch (renderError) {
194
- if (renderError instanceof RedirectSignal) {
195
- // Revalidation triggered a redirect (e.g., session expired,
196
- // middleware on the target path called `redirect()`).
197
- redirectTo = renderError.location;
198
- redirectStatus = renderError.status;
199
- } else if (renderError instanceof DenySignal) {
200
- // Target path's middleware/access denied this user.
201
- //
202
- // Dropping the revalidation here is the security boundary added
203
- // in TIM-996: without this branch, a DenySignal would fall into
204
- // the `console.error` path AND the element tree would have
205
- // already been built and serialized — leaking a privileged RSC
206
- // payload to the action submitter. By the time the signal
207
- // surfaces, the renderer has thrown BEFORE building the tree,
208
- // so we simply drop `revalidation` and ship the action result
209
- // alone. The action itself still succeeded.
210
- console.error(
211
- `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
212
- );
213
- } else {
214
- // Log but don't fail the action — revalidation is best-effort.
215
- // Includes input-validation rejections from the renderer
216
- // (e.g., revalidatePath('//evil.com/admin')).
217
- console.error('[timber] revalidatePath render failed:', renderError);
218
+
219
+ if (
220
+ config.requestPathname &&
221
+ !revalidationPathMatches(path, config.requestPathname, config.requestSearch)
222
+ ) {
223
+ invalidatedPaths = [stripFragment(path)];
224
+ } else if (config.renderer) {
225
+ try {
226
+ revalidation = await config.renderer(path);
227
+ } catch (renderError) {
228
+ if (isRedirectSignal(renderError)) {
229
+ redirectTo = renderError.location;
230
+ redirectStatus = renderError.status;
231
+ } else if (isDenySignal(renderError)) {
232
+ console.error(
233
+ `[timber] revalidatePath dropped — target middleware denied (status ${renderError.status})`
234
+ );
235
+ } else {
236
+ console.error('[timber] revalidatePath render failed:', renderError);
237
+ }
218
238
  }
219
239
  }
220
240
  }
@@ -222,6 +242,33 @@ export async function executeAction(
222
242
  return {
223
243
  actionResult,
224
244
  revalidation,
245
+ invalidatedPaths,
225
246
  ...(redirectTo ? { redirectTo, redirectStatus } : {}),
226
247
  };
227
248
  }
249
+
250
+ /**
251
+ * Check if a `revalidatePath(path)` argument matches the request pathname.
252
+ /**
253
+ * Canonicalizes the revalidated path the same way the renderer does so
254
+ * equivalent paths compare equal. Returns true when the paths match or
255
+ * when comparison is inconclusive (validation failure → render to be safe).
256
+ */
257
+ function revalidationPathMatches(
258
+ revalidatedPath: string,
259
+ requestPathname: string,
260
+ requestSearch?: string
261
+ ): boolean {
262
+ const hashIdx = revalidatedPath.indexOf('#');
263
+ const noFragment = hashIdx >= 0 ? revalidatedPath.slice(0, hashIdx) : revalidatedPath;
264
+ const queryIdx = noFragment.indexOf('?');
265
+ const pathnameOnly = queryIdx >= 0 ? noFragment.slice(0, queryIdx) : noFragment;
266
+ const search = queryIdx >= 0 ? noFragment.slice(queryIdx) : '';
267
+ const canonical = canonicalize(pathnameOnly, true);
268
+ if (!canonical.ok) return true;
269
+ if (canonical.pathname !== requestPathname) return false;
270
+ // revalidatePath('/dashboard') (no search) matches any query variant.
271
+ // revalidatePath('/dashboard?tab=x') only matches that exact search.
272
+ if (search && search !== (requestSearch ?? '')) return false;
273
+ return true;
274
+ }
@@ -131,17 +131,11 @@ export interface RequestContextStore {
131
131
  /**
132
132
  * Set by AccessGate or PageDenyBoundary when a DenySignal is caught
133
133
  * server-side (inside the React tree, before React Flight sees it).
134
- * The pipeline reads this after render to set the HTTP status code.
135
- * See TIM-666.
134
+ * The document path reads this after render to set the HTTP status code;
135
+ * the RSC payload path does not read it (a Flight payload response is always 200).
136
+ * See TIM-666, TIM-1495.
136
137
  */
137
138
  denyStatus?: number;
138
- /**
139
- * Callback fired when setDenyStatus() is called. Used by shellSettled
140
- * to short-circuit when any component catches a DenySignal in-tree,
141
- * regardless of which component caught it (AccessGate, TracedLayout,
142
- * or PageDenyBoundary). See TIM-1208.
143
- */
144
- onDenyStatus?: () => void;
145
139
  /**
146
140
  * Dev-only: getter for the current request's RSC debug components.
147
141
  * Set by renderRoute() so onPipelineError can include component tree
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * The result is an ordinary `RouteMatch` whose chain runs
23
23
  * `[…owner ancestors, interceptingNode, …its matched descendants]`. Every
24
- * consumer downstream — `tree-builder.ts`, the element builder, access gates,
24
+ * consumer downstream — `route-element-builder.ts`, access gates,
25
25
  * middleware — sees a segment chain and needs to know nothing about
26
26
  * interception. That is why this feature adds no rendering path: the chain
27
27
  * *is* the feature.
@@ -18,57 +18,17 @@
18
18
  * error message, no stack — see design/13-security.md principle 4.
19
19
  */
20
20
 
21
- /**
22
- * Canonical reason phrases (RFC 9110 §15) for the statuses an app is likely to
23
- * `deny()` with. Anything else falls back to a generic category phrase — the
24
- * phrase is decoration, never a place to echo caller-supplied text.
25
- */
26
- const REASON_PHRASES: Record<number, string> = {
27
- 400: 'Bad Request',
28
- 401: 'Unauthorized',
29
- 402: 'Payment Required',
30
- 403: 'Forbidden',
31
- 404: 'Not Found',
32
- 405: 'Method Not Allowed',
33
- 406: 'Not Acceptable',
34
- 408: 'Request Timeout',
35
- 409: 'Conflict',
36
- 410: 'Gone',
37
- 413: 'Content Too Large',
38
- 415: 'Unsupported Media Type',
39
- 418: "I'm a Teapot",
40
- 422: 'Unprocessable Content',
41
- 429: 'Too Many Requests',
42
- 451: 'Unavailable For Legal Reasons',
43
- 500: 'Internal Server Error',
44
- 501: 'Not Implemented',
45
- 502: 'Bad Gateway',
46
- 503: 'Service Unavailable',
47
- 504: 'Gateway Timeout',
48
- };
21
+ import { displayStatus, statusReasonPhrase } from '../shared/status-reason-phrase.ts';
22
+
23
+ // The phrase table and status clamp live in `shared/status-reason-phrase.ts`:
24
+ // the client-form terminal (`client/deny-last-resort.tsx`) renders the same
25
+ // words for a deny that streamed past every boundary, and one table keeps the
26
+ // two surfaces from drifting.
27
+ export { statusReasonPhrase };
49
28
 
50
29
  /** HTML content type used for every framework default page response. */
51
30
  export const DEFAULT_STATUS_PAGE_CONTENT_TYPE = 'text/html; charset=utf-8';
52
31
 
53
- /**
54
- * Coerce a status into something safe to put in markup.
55
- *
56
- * `DenySignal.status` is typed `number` but nothing validates it at
57
- * construction, and JS callers can hand us anything. Rather than escaping a
58
- * value that should never have been a string, collapse non-integers to 500 —
59
- * the page is decoration for a status already on the wire.
60
- */
61
- function displayStatus(status: number): number {
62
- return Number.isInteger(status) && status >= 100 && status <= 599 ? status : 500;
63
- }
64
-
65
- /** Human-readable phrase for a status code. Never caller-supplied text. */
66
- export function statusReasonPhrase(status: number): string {
67
- const code = displayStatus(status);
68
- if (REASON_PHRASES[code]) return REASON_PHRASES[code];
69
- return code >= 500 ? 'Server Error' : 'Request Error';
70
- }
71
-
72
32
  /**
73
33
  * Build the framework default page for a status code as a full HTML document.
74
34
  *