@timber-js/app 0.2.0-alpha.146 → 0.2.0-alpha.148

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 (297) hide show
  1. package/dist/_chunks/{actions-OIZ8siQu.js → actions-a0RczDnB.js} +8 -34
  2. package/dist/_chunks/{actions-OIZ8siQu.js.map → actions-a0RczDnB.js.map} +1 -1
  3. package/dist/_chunks/cache-api-Co-yI-gP.js +424 -0
  4. package/dist/_chunks/cache-api-Co-yI-gP.js.map +1 -0
  5. package/dist/_chunks/{metadata-routes-BjepjefT.js → canonicalize-P41GR6tY.js} +65 -2
  6. package/dist/_chunks/canonicalize-P41GR6tY.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-D2IpXicg.js → cli-schema-sync-DJDdyEae.js} +157 -313
  8. package/dist/_chunks/cli-schema-sync-DJDdyEae.js.map +1 -0
  9. package/dist/_chunks/cli-schema-sync-D_IhHEUW.js +2 -0
  10. package/dist/_chunks/{define-DMVhsipD.js → define-Bssfp6ot.js} +3 -33
  11. package/dist/_chunks/define-Bssfp6ot.js.map +1 -0
  12. package/dist/_chunks/{define-C3RvHQPK.js → define-CFmvb4Bt.js} +15 -7
  13. package/dist/_chunks/define-CFmvb4Bt.js.map +1 -0
  14. package/dist/_chunks/{href-validation-DyZIzfMJ.js → href-validation-FiA_rToW.js} +2 -1
  15. package/dist/_chunks/{href-validation-DyZIzfMJ.js.map → href-validation-FiA_rToW.js.map} +1 -1
  16. package/dist/_chunks/{tracing-M_zKubhk.js → logger-CM0clugc.js} +394 -107
  17. package/dist/_chunks/logger-CM0clugc.js.map +1 -0
  18. package/dist/_chunks/navigation-root-bUDZLw74.js.map +1 -1
  19. package/dist/_chunks/plugin-context-DurqFBuY.js +2 -0
  20. package/dist/_chunks/plugin-context-GfDWsdd2.js +268 -0
  21. package/dist/_chunks/plugin-context-GfDWsdd2.js.map +1 -0
  22. package/dist/_chunks/{resolve-schema-Dj8tQloi.js → resolve-schema-3iUvBV5T.js} +3 -30
  23. package/dist/_chunks/resolve-schema-3iUvBV5T.js.map +1 -0
  24. package/dist/_chunks/{schema-bridge-DK_89h8O.js → schema-bridge-BY3QLBL7.js} +35 -2
  25. package/dist/_chunks/schema-bridge-BY3QLBL7.js.map +1 -0
  26. package/dist/_chunks/tree-match-D2l830j2.js +102 -0
  27. package/dist/_chunks/tree-match-D2l830j2.js.map +1 -0
  28. package/dist/_chunks/use-segment-params-D1CzlgBo.js.map +1 -1
  29. package/dist/_chunks/{walkers-7FBPM-hX.js → walkers-DvWDj453.js} +154 -88
  30. package/dist/_chunks/walkers-DvWDj453.js.map +1 -0
  31. package/dist/adapters/cloudflare-kv-cache.d.ts.map +1 -1
  32. package/dist/adapters/cloudflare-kv-cache.js +36 -14
  33. package/dist/adapters/cloudflare-kv-cache.js.map +1 -1
  34. package/dist/adapters/compress-module.d.ts +5 -4
  35. package/dist/adapters/compress-module.d.ts.map +1 -1
  36. package/dist/adapters/nitro.d.ts +1 -3
  37. package/dist/adapters/nitro.d.ts.map +1 -1
  38. package/dist/adapters/nitro.js +14 -134
  39. package/dist/adapters/nitro.js.map +1 -1
  40. package/dist/cache/cache-api.d.ts.map +1 -1
  41. package/dist/cache/index.d.ts +3 -1
  42. package/dist/cache/index.d.ts.map +1 -1
  43. package/dist/cache/index.js +20 -235
  44. package/dist/cache/index.js.map +1 -1
  45. package/dist/cache/invalidation-epoch.d.ts +43 -0
  46. package/dist/cache/invalidation-epoch.d.ts.map +1 -0
  47. package/dist/cache/redis-handler.d.ts +13 -0
  48. package/dist/cache/redis-handler.d.ts.map +1 -1
  49. package/dist/cache/timber-cache.d.ts +17 -9
  50. package/dist/cache/timber-cache.d.ts.map +1 -1
  51. package/dist/cli-schema-sync.d.ts +21 -1
  52. package/dist/cli-schema-sync.d.ts.map +1 -1
  53. package/dist/cli.d.ts +12 -0
  54. package/dist/cli.d.ts.map +1 -1
  55. package/dist/cli.js +26 -31
  56. package/dist/cli.js.map +1 -1
  57. package/dist/client/browser-entry/hmr.d.ts.map +1 -1
  58. package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
  59. package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
  60. package/dist/client/browser-entry/router-init.d.ts.map +1 -1
  61. package/dist/client/error-boundary.d.ts.map +1 -1
  62. package/dist/client/error-boundary.js +13 -4
  63. package/dist/client/error-boundary.js.map +1 -1
  64. package/dist/client/error-reconstituter.d.ts +8 -1
  65. package/dist/client/error-reconstituter.d.ts.map +1 -1
  66. package/dist/client/history.d.ts +10 -17
  67. package/dist/client/history.d.ts.map +1 -1
  68. package/dist/client/index.js +1 -1
  69. package/dist/client/index.js.map +1 -1
  70. package/dist/client/internal.js +30 -33
  71. package/dist/client/internal.js.map +1 -1
  72. package/dist/client/navigation-api.d.ts +9 -2
  73. package/dist/client/navigation-api.d.ts.map +1 -1
  74. package/dist/client/navigation-context.d.ts +0 -11
  75. package/dist/client/navigation-context.d.ts.map +1 -1
  76. package/dist/client/navigation-root.d.ts +0 -5
  77. package/dist/client/navigation-root.d.ts.map +1 -1
  78. package/dist/client/router.d.ts.map +1 -1
  79. package/dist/client/rsc-fetch.d.ts.map +1 -1
  80. package/dist/client/use-search-params.d.ts +10 -5
  81. package/dist/client/use-search-params.d.ts.map +1 -1
  82. package/dist/codec.js +1 -1
  83. package/dist/config-validation.d.ts.map +1 -1
  84. package/dist/cookies/define-cookie.d.ts.map +1 -1
  85. package/dist/cookies/index.js +2 -4
  86. package/dist/cookies/index.js.map +1 -1
  87. package/dist/dev-tools/dev-page-shell.d.ts +1 -0
  88. package/dist/dev-tools/dev-page-shell.d.ts.map +1 -1
  89. package/dist/dev-tools/hydration-overlay.d.ts +11 -0
  90. package/dist/dev-tools/hydration-overlay.d.ts.map +1 -0
  91. package/dist/dev-tools/index.d.ts +1 -2
  92. package/dist/dev-tools/index.d.ts.map +1 -1
  93. package/dist/fonts/ast-walk.d.ts +68 -0
  94. package/dist/fonts/ast-walk.d.ts.map +1 -0
  95. package/dist/fonts/ast.d.ts +23 -11
  96. package/dist/fonts/ast.d.ts.map +1 -1
  97. package/dist/fonts/bundle.d.ts +3 -1
  98. package/dist/fonts/bundle.d.ts.map +1 -1
  99. package/dist/fonts/dev-middleware.d.ts +1 -1
  100. package/dist/fonts/dev-middleware.d.ts.map +1 -1
  101. package/dist/fonts/fallbacks.d.ts +0 -7
  102. package/dist/fonts/fallbacks.d.ts.map +1 -1
  103. package/dist/fonts/google.d.ts +4 -2
  104. package/dist/fonts/google.d.ts.map +1 -1
  105. package/dist/fonts/local.d.ts +3 -3
  106. package/dist/fonts/local.d.ts.map +1 -1
  107. package/dist/fonts/paths.d.ts +43 -0
  108. package/dist/fonts/paths.d.ts.map +1 -0
  109. package/dist/fonts/pipeline.d.ts +7 -21
  110. package/dist/fonts/pipeline.d.ts.map +1 -1
  111. package/dist/fonts/transform.d.ts.map +1 -1
  112. package/dist/fonts/virtual-modules.d.ts +1 -18
  113. package/dist/fonts/virtual-modules.d.ts.map +1 -1
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +609 -406
  116. package/dist/index.js.map +1 -1
  117. package/dist/params/index.js +1 -1
  118. package/dist/params/resolve-schema.d.ts.map +1 -1
  119. package/dist/plugin-context.d.ts +28 -5
  120. package/dist/plugin-context.d.ts.map +1 -1
  121. package/dist/plugins/cache.d.ts +13 -5
  122. package/dist/plugins/cache.d.ts.map +1 -1
  123. package/dist/plugins/dev-server.d.ts.map +1 -1
  124. package/dist/plugins/fonts.d.ts +1 -2
  125. package/dist/plugins/fonts.d.ts.map +1 -1
  126. package/dist/plugins/routing.d.ts.map +1 -1
  127. package/dist/routing/codegen-types.d.ts +4 -0
  128. package/dist/routing/codegen-types.d.ts.map +1 -1
  129. package/dist/routing/codegen.d.ts.map +1 -1
  130. package/dist/routing/convention-lint.d.ts.map +1 -1
  131. package/dist/routing/file-cache.d.ts +8 -0
  132. package/dist/routing/file-cache.d.ts.map +1 -0
  133. package/dist/routing/index.js +2 -2
  134. package/dist/routing/interception.d.ts +0 -5
  135. package/dist/routing/interception.d.ts.map +1 -1
  136. package/dist/routing/manifest-codegen.d.ts +32 -0
  137. package/dist/routing/manifest-codegen.d.ts.map +1 -0
  138. package/dist/routing/scanner.d.ts.map +1 -1
  139. package/dist/schema-bridge.d.ts +15 -0
  140. package/dist/schema-bridge.d.ts.map +1 -1
  141. package/dist/search-params/define.d.ts.map +1 -1
  142. package/dist/search-params/index.js +7 -3
  143. package/dist/search-params/index.js.map +1 -1
  144. package/dist/search-params/wrappers.d.ts.map +1 -1
  145. package/dist/segment-params/define.d.ts.map +1 -1
  146. package/dist/segment-params/index.js +1 -1
  147. package/dist/server/access-gate.d.ts.map +1 -1
  148. package/dist/server/actions.d.ts +0 -14
  149. package/dist/server/actions.d.ts.map +1 -1
  150. package/dist/server/build-manifest.d.ts +0 -29
  151. package/dist/server/build-manifest.d.ts.map +1 -1
  152. package/dist/server/canonicalize.d.ts +10 -0
  153. package/dist/server/canonicalize.d.ts.map +1 -1
  154. package/dist/server/deny-boundary.d.ts +27 -2
  155. package/dist/server/deny-boundary.d.ts.map +1 -1
  156. package/dist/server/deny-renderer.d.ts +1 -1
  157. package/dist/server/deny-renderer.d.ts.map +1 -1
  158. package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
  159. package/dist/server/html-injector-core.d.ts +8 -0
  160. package/dist/server/html-injector-core.d.ts.map +1 -1
  161. package/dist/server/html-injectors.d.ts.map +1 -1
  162. package/dist/server/index.js +2 -2
  163. package/dist/server/internal.d.ts +1 -5
  164. package/dist/server/internal.d.ts.map +1 -1
  165. package/dist/server/internal.js +238 -349
  166. package/dist/server/internal.js.map +1 -1
  167. package/dist/server/node-stream-transforms.d.ts +0 -11
  168. package/dist/server/node-stream-transforms.d.ts.map +1 -1
  169. package/dist/server/render-utils.d.ts +25 -0
  170. package/dist/server/render-utils.d.ts.map +1 -0
  171. package/dist/server/route-element-builder.d.ts +11 -25
  172. package/dist/server/route-element-builder.d.ts.map +1 -1
  173. package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
  174. package/dist/server/rsc-entry/helpers.d.ts +2 -18
  175. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  176. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  177. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  178. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  179. package/dist/server/ssr-render.d.ts.map +1 -1
  180. package/dist/server/status-code-resolver.d.ts +6 -0
  181. package/dist/server/status-code-resolver.d.ts.map +1 -1
  182. package/dist/server/utils/mdx-file.d.ts +15 -0
  183. package/dist/server/utils/mdx-file.d.ts.map +1 -0
  184. package/dist/shared/href-validation.d.ts.map +1 -1
  185. package/dist/shims/image.d.ts +15 -15
  186. package/package.json +3 -3
  187. package/src/adapters/cloudflare-kv-cache.ts +56 -19
  188. package/src/adapters/compress-module.ts +27 -118
  189. package/src/adapters/nitro.ts +3 -41
  190. package/src/adapters/raw-imports.d.ts +5 -0
  191. package/src/cache/cache-api.ts +9 -1
  192. package/src/cache/index.ts +5 -2
  193. package/src/cache/invalidation-epoch.ts +93 -0
  194. package/src/cache/redis-handler.ts +33 -2
  195. package/src/cache/timber-cache.ts +109 -53
  196. package/src/cli-schema-sync.ts +123 -30
  197. package/src/cli.ts +33 -37
  198. package/src/client/browser-entry/hmr.ts +0 -11
  199. package/src/client/browser-entry/hydrate.ts +39 -5
  200. package/src/client/browser-entry/post-hydration.ts +6 -15
  201. package/src/client/browser-entry/router-init.ts +98 -3
  202. package/src/client/error-boundary.tsx +14 -0
  203. package/src/client/error-reconstituter.tsx +15 -1
  204. package/src/client/history.ts +10 -24
  205. package/src/client/navigation-api.ts +14 -9
  206. package/src/client/navigation-context.ts +1 -21
  207. package/src/client/navigation-root.tsx +0 -8
  208. package/src/client/router.ts +18 -8
  209. package/src/client/rsc-fetch.ts +7 -2
  210. package/src/client/use-search-params.ts +10 -5
  211. package/src/config-validation.ts +33 -25
  212. package/src/cookies/define-cookie.ts +2 -18
  213. package/src/dev-tools/dev-page-shell.ts +6 -2
  214. package/src/dev-tools/hydration-overlay.ts +158 -0
  215. package/src/dev-tools/index.ts +3 -16
  216. package/src/fonts/ast-walk.ts +235 -0
  217. package/src/fonts/ast.ts +91 -249
  218. package/src/fonts/bundle.ts +16 -9
  219. package/src/fonts/dev-middleware.ts +16 -5
  220. package/src/fonts/fallbacks.ts +0 -23
  221. package/src/fonts/google.ts +7 -3
  222. package/src/fonts/local.ts +7 -6
  223. package/src/fonts/paths.ts +58 -0
  224. package/src/fonts/pipeline.ts +27 -48
  225. package/src/fonts/transform.ts +27 -0
  226. package/src/fonts/virtual-modules.ts +1 -57
  227. package/src/index.ts +5 -11
  228. package/src/params/resolve-schema.ts +2 -34
  229. package/src/plugin-context.ts +95 -17
  230. package/src/plugins/cache.ts +64 -17
  231. package/src/plugins/dev-server.ts +61 -9
  232. package/src/plugins/fonts.ts +21 -6
  233. package/src/plugins/routing.ts +5 -348
  234. package/src/routing/codegen-types.ts +4 -0
  235. package/src/routing/codegen.ts +66 -8
  236. package/src/routing/convention-lint.ts +4 -3
  237. package/src/routing/file-cache.ts +23 -0
  238. package/src/routing/interception.ts +2 -21
  239. package/src/routing/manifest-codegen.ts +290 -0
  240. package/src/routing/scanner.ts +85 -80
  241. package/src/routing/schema-validation.ts +2 -2
  242. package/src/schema-bridge.ts +63 -2
  243. package/src/search-params/define.ts +34 -2
  244. package/src/search-params/wrappers.ts +21 -3
  245. package/src/segment-params/define.ts +2 -62
  246. package/src/server/access-gate.tsx +8 -0
  247. package/src/server/actions.ts +8 -31
  248. package/src/server/build-manifest.ts +0 -56
  249. package/src/server/canonicalize.ts +6 -3
  250. package/src/server/deny-boundary.ts +106 -13
  251. package/src/server/deny-renderer.ts +46 -10
  252. package/src/server/error-boundary-wrapper.ts +1 -14
  253. package/src/server/html-injector-core.ts +19 -0
  254. package/src/server/html-injectors.ts +14 -4
  255. package/src/server/internal.ts +1 -18
  256. package/src/server/node-stream-transforms.ts +5 -72
  257. package/src/server/render-utils.ts +31 -0
  258. package/src/server/route-element-builder.ts +77 -47
  259. package/src/server/route-matcher.ts +22 -6
  260. package/src/server/rsc-entry/error-renderer.ts +2 -12
  261. package/src/server/rsc-entry/helpers.ts +6 -34
  262. package/src/server/rsc-entry/index.ts +11 -0
  263. package/src/server/rsc-entry/render-route.ts +7 -9
  264. package/src/server/rsc-entry/rsc-payload.ts +2 -1
  265. package/src/server/rsc-entry/ssr-renderer.ts +2 -2
  266. package/src/server/ssr-render.ts +1 -11
  267. package/src/server/status-code-resolver.ts +3 -2
  268. package/src/server/tree-match.ts +15 -5
  269. package/src/server/utils/mdx-file.ts +22 -0
  270. package/src/shared/href-validation.ts +8 -0
  271. package/dist/_chunks/cli-schema-sync-D2IpXicg.js.map +0 -1
  272. package/dist/_chunks/cli-schema-sync-DH-M8Poy.js +0 -2
  273. package/dist/_chunks/define-C3RvHQPK.js.map +0 -1
  274. package/dist/_chunks/define-DMVhsipD.js.map +0 -1
  275. package/dist/_chunks/handler-store-BVDgyTh7.js +0 -78
  276. package/dist/_chunks/handler-store-BVDgyTh7.js.map +0 -1
  277. package/dist/_chunks/logger-Dt-jQaBO.js +0 -291
  278. package/dist/_chunks/logger-Dt-jQaBO.js.map +0 -1
  279. package/dist/_chunks/metadata-routes-BjepjefT.js.map +0 -1
  280. package/dist/_chunks/resolve-schema-Dj8tQloi.js.map +0 -1
  281. package/dist/_chunks/schema-bridge-DK_89h8O.js.map +0 -1
  282. package/dist/_chunks/tracing-M_zKubhk.js.map +0 -1
  283. package/dist/_chunks/walkers-7FBPM-hX.js.map +0 -1
  284. package/dist/_chunks/warnings-CNPXaypM.js +0 -125
  285. package/dist/_chunks/warnings-CNPXaypM.js.map +0 -1
  286. package/dist/client/link-pending-store.d.ts +0 -65
  287. package/dist/client/link-pending-store.d.ts.map +0 -1
  288. package/dist/client/nav-link-store.d.ts +0 -27
  289. package/dist/client/nav-link-store.d.ts.map +0 -1
  290. package/dist/dev-tools/warnings.d.ts +0 -109
  291. package/dist/dev-tools/warnings.d.ts.map +0 -1
  292. package/dist/server/flush.d.ts +0 -74
  293. package/dist/server/flush.d.ts.map +0 -1
  294. package/src/client/link-pending-store.ts +0 -116
  295. package/src/client/nav-link-store.ts +0 -37
  296. package/src/dev-tools/warnings.ts +0 -238
  297. package/src/server/flush.ts +0 -183
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { useQueryStates as clientUseQueryStates } from '../client/use-query-states.js';
14
- import { fromSchema, isStandardSchema, isCodec } from '../schema-bridge.js';
14
+ import { fromSchema, isStandardSchema, isCodec, validateSync } from '../schema-bridge.js';
15
15
  import type { StandardSchemaV1 } from '../schema-bridge.js';
16
16
  import type { Codec } from '../codec.js';
17
17
 
@@ -184,7 +184,7 @@ function normalizeRaw(
184
184
  raw: URLSearchParams | Record<string, string | string[] | undefined>
185
185
  ): Record<string, string | string[] | undefined> {
186
186
  if (raw instanceof URLSearchParams) {
187
- const result: Record<string, string | string[] | undefined> = {};
187
+ const result: Record<string, string | string[] | undefined> = Object.create(null);
188
188
  for (const key of new Set(raw.keys())) {
189
189
  const values = raw.getAll(key);
190
190
  result[key] = values.length === 1 ? values[0] : values;
@@ -220,6 +220,20 @@ function resolveField(
220
220
 
221
221
  // Auto-detect Standard Schema
222
222
  if (isStandardSchema(value)) {
223
+ // Validate that the schema handles absent params (undefined input).
224
+ // Search params are optional — the URL might not contain the param.
225
+ // If the schema rejects undefined and has no default, the codec would
226
+ // return `undefined as T`, making the type lie. Catch this at definition
227
+ // time instead. design/23-search-params.md §"Default Validation"
228
+ const absentCheck = validateSync(value, undefined);
229
+ if (absentCheck.issues) {
230
+ throw new Error(
231
+ `[timber] defineSearchParams: field '${fieldName}' has no default value and rejects undefined input.\n` +
232
+ ` Search params are optional — the URL might not contain ?${fieldName}=anything.\n` +
233
+ ` Add .default() to your schema: z.coerce.number().int().min(1).default(1)\n` +
234
+ ` Or use .optional() if the field is truly optional.`
235
+ );
236
+ }
223
237
  return { codec: fromSchema(value) };
224
238
  }
225
239
 
@@ -470,6 +484,11 @@ function buildDefinition<T extends Record<string, unknown>>(
470
484
  // Merge URL keys: base keys + new codec urlKeys from withUrlKey
471
485
  const combinedUrlKeys: Record<string, string> = { ...urlKeys, ...newUrlKeys };
472
486
 
487
+ // Same definition-time guard as defineSearchParams — a throw-on-absent
488
+ // codec added via extend() gets the friendly guidance error, not a
489
+ // crash on first parse (TIM-1066).
490
+ validateDefaults(combinedCodecs as Record<string, SearchParamCodec<unknown>>);
491
+
473
492
  return buildDefinition<Combined>(combinedCodecs, combinedUrlKeys);
474
493
  }
475
494
 
@@ -479,12 +498,25 @@ function buildDefinition<T extends Record<string, unknown>>(
479
498
  const pickedUrlKeys: Record<string, string> = {};
480
499
 
481
500
  for (const key of keys) {
501
+ // Explicit guard — TypeScript prevents this, but JS callers and
502
+ // casts reach here, and "Cannot read properties of undefined
503
+ // (reading 'serialize')" is no help (TIM-1066).
504
+ if (!(key in codecMap)) {
505
+ throw new Error(
506
+ `[timber] pick('${key}'): unknown key. ` +
507
+ `Available keys: ${Object.keys(codecMap)
508
+ .map((k) => `'${k}'`)
509
+ .join(', ')}.`
510
+ );
511
+ }
482
512
  pickedCodecs[key] = codecMap[key] as SearchParamCodec<unknown>;
483
513
  if (key in urlKeys) {
484
514
  pickedUrlKeys[key] = urlKeys[key];
485
515
  }
486
516
  }
487
517
 
518
+ validateDefaults(pickedCodecs);
519
+
488
520
  return buildDefinition<Pick<T, K>>(
489
521
  pickedCodecs as unknown as CodecMap<Pick<T, K>>,
490
522
  pickedUrlKeys
@@ -9,7 +9,23 @@
9
9
  */
10
10
 
11
11
  import type { SearchParamCodec, SearchParamCodecWithUrlKey, SearchParamField } from './define.js';
12
- import { isCodec, isStandardSchema, fromSchema } from '../schema-bridge.js';
12
+ import { isCodec, isStandardSchema, fromSchema, validateSync } from '../schema-bridge.js';
13
+ import type { StandardSchemaV1 } from '../schema-bridge.js';
14
+
15
+ function validateSchemaAbsentParam<T>(
16
+ schema: StandardSchemaV1<T>,
17
+ fieldHint: string
18
+ ): SearchParamCodec<T> {
19
+ const absentCheck = validateSync(schema, undefined);
20
+ if (absentCheck.issues) {
21
+ throw new Error(
22
+ `[timber] withUrlKey: schema for '${fieldHint}' has no default value and rejects undefined input.\n` +
23
+ ` Search params are optional — the URL might not contain ?${fieldHint}=anything.\n` +
24
+ ` Add .default() to your schema or use .optional().`
25
+ );
26
+ }
27
+ return fromSchema(schema);
28
+ }
13
29
 
14
30
  // ---------------------------------------------------------------------------
15
31
  // withDefault
@@ -78,11 +94,13 @@ export function withUrlKey<T>(
78
94
  codecOrSchema: SearchParamField<T>,
79
95
  urlKey: string
80
96
  ): SearchParamCodecWithUrlKey<T> {
81
- // Auto-detect Standard Schema (Zod, Valibot, ArkType) and wrap
97
+ // Auto-detect Standard Schema (Zod, Valibot, ArkType) and wrap.
98
+ // Validate absent-param handling here since withUrlKey converts to a codec
99
+ // before resolveField sees it. design/23-search-params.md §"Default Validation"
82
100
  const codec: SearchParamCodec<T> = isCodec(codecOrSchema)
83
101
  ? codecOrSchema
84
102
  : isStandardSchema(codecOrSchema)
85
- ? fromSchema(codecOrSchema)
103
+ ? validateSchemaAbsentParam(codecOrSchema, urlKey)
86
104
  : (codecOrSchema as SearchParamCodec<T>);
87
105
  return {
88
106
  parse: codec.parse.bind(codec),
@@ -14,12 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { Codec } from '../codec.js';
17
- import {
18
- type StandardSchemaV1,
19
- validateSync,
20
- isStandardSchema,
21
- isCodec,
22
- } from '../schema-bridge.js';
17
+ import { type StandardSchemaV1, resolveCodecOrSchema } from '../schema-bridge.js';
23
18
  import { useSegmentParams as clientUseSegmentParams } from '../client/use-segment-params.js';
24
19
 
25
20
  // ---------------------------------------------------------------------------
@@ -114,61 +109,6 @@ export interface ParamsDefinition<T extends Record<string, unknown>> {
114
109
  // Internal helpers
115
110
  // ---------------------------------------------------------------------------
116
111
 
117
- /**
118
- * Wrap a Standard Schema into a Codec for route params.
119
- *
120
- * Unlike fromSchema for search params:
121
- * - Parse throws on failure (no fallback to default)
122
- * - Serialize returns string (not null)
123
- */
124
- function fromParamSchema<T>(fieldName: string, schema: StandardSchemaV1<T>): Codec<T> {
125
- return {
126
- parse(value: string | string[] | undefined): T {
127
- // Route params are always strings (single segment) or string[] (catch-all)
128
- const input = Array.isArray(value) ? value : value;
129
-
130
- const result = validateSync(schema, input);
131
- if (!result.issues) {
132
- return result.value;
133
- }
134
-
135
- // For route params, parse failure means the param is invalid → throw
136
- const messages = result.issues.map((i) => i.message).join(', ');
137
- throw new Error(`[timber] Param '${fieldName}' coercion failed: ${messages}`);
138
- },
139
-
140
- serialize(value: T): string | null {
141
- if (value === null || value === undefined) {
142
- return null;
143
- }
144
- // Catch-all segments produce arrays — join with '/' for path reconstruction
145
- if (Array.isArray(value)) {
146
- return value.join('/');
147
- }
148
- return String(value);
149
- },
150
- };
151
- }
152
-
153
- /**
154
- * Resolve a field value to a Codec. Auto-detects Standard Schema objects.
155
- */
156
- function resolveField(fieldName: string, value: ParamField): Codec<unknown> {
157
- if (isCodec(value)) {
158
- return value;
159
- }
160
-
161
- if (isStandardSchema(value)) {
162
- return fromParamSchema(fieldName, value);
163
- }
164
-
165
- throw new Error(
166
- `[timber] defineSegmentParams: field '${fieldName}' is not a valid codec or Standard Schema. ` +
167
- `Expected an object with { parse, serialize } methods, or a Standard Schema object ` +
168
- `(Zod, Valibot, ArkType).`
169
- );
170
- }
171
-
172
112
  /**
173
113
  * Validate that no codec's serialize returns null.
174
114
  * Route params are structural — they must produce a valid path segment.
@@ -224,7 +164,7 @@ export function defineSegmentParams<C extends Record<string, ParamField>>(
224
164
  const resolvedCodecs: Record<string, Codec<unknown>> = {};
225
165
 
226
166
  for (const [key, value] of Object.entries(codecs)) {
227
- resolvedCodecs[key] = resolveField(key, value as ParamField);
167
+ resolvedCodecs[key] = resolveCodecOrSchema(key, value, 'param');
228
168
  }
229
169
 
230
170
  // Validate that serialize doesn't return null
@@ -45,6 +45,14 @@ export function AccessGate(props: AccessGateProps): ReactNode | Promise<ReactNod
45
45
  if (verdict === 'pass') {
46
46
  return children;
47
47
  }
48
+ // Render deny page in-tree when possible (same as the fallback path).
49
+ if (verdict instanceof DenySignal && denyPages) {
50
+ const denyElement = renderMatchingDenyPage(denyPages, verdict.status, verdict.data);
51
+ if (denyElement) {
52
+ setDenyStatus(verdict.status);
53
+ return denyElement;
54
+ }
55
+ }
48
56
  throw verdict;
49
57
  }
50
58
 
@@ -14,7 +14,7 @@
14
14
  * See design/08-forms-and-actions.md
15
15
  */
16
16
 
17
- import { getCacheHandler } from '../cache/handler-store';
17
+ import { cache } from '../cache/cache-api';
18
18
  import { DenySignal, RedirectSignal } from './primitives';
19
19
  import { withSpan } from './tracing';
20
20
  import { revalidationAls, type RevalidationState } from './als-registry.js';
@@ -161,12 +161,14 @@ export async function executeAction(
161
161
  }
162
162
  });
163
163
 
164
- // Process tag invalidation via the module-level cache handler singleton.
165
- // setCacheHandler() is called at boot from rsc-entry when timber.config.ts
166
- // provides a cacheHandler; otherwise falls back to in-memory LRU (TIM-599).
164
+ // Process tag invalidation through cache.invalidate so it records the
165
+ // invalidation epoch — an in-flight cached fn must not re-store data this
166
+ // mutation just invalidated (TIM-1028). cache.invalidate resolves the
167
+ // module-level handler singleton: setCacheHandler() is called at boot from
168
+ // rsc-entry when timber.config.ts provides a cacheHandler; otherwise falls
169
+ // back to in-memory LRU (TIM-599).
167
170
  if (state.tags.length > 0) {
168
- const handler = getCacheHandler();
169
- await Promise.all(state.tags.map((tag) => handler.invalidate({ tag })));
171
+ await Promise.all(state.tags.map((tag) => cache.invalidate({ tag })));
170
172
  }
171
173
 
172
174
  // Process path revalidation — build element tree (not yet serialized)
@@ -212,28 +214,3 @@ export async function executeAction(
212
214
  ...(redirectTo ? { redirectTo, redirectStatus } : {}),
213
215
  };
214
216
  }
215
-
216
- /**
217
- * Build an HTTP Response for a no-JS form submission.
218
- * Standard POST → 302 redirect pattern.
219
- *
220
- * @param redirectPath - Where to redirect after the action executes.
221
- */
222
- export function buildNoJsResponse(redirectPath: string, status: number = 302): Response {
223
- return new Response(null, {
224
- status,
225
- headers: { Location: redirectPath },
226
- });
227
- }
228
-
229
- /**
230
- * Detect whether the incoming request is an RSC action request (with JS)
231
- * or a plain HTML form POST (no JS).
232
- *
233
- * RSC action requests use Accept: text/x-component or Content-Type: text/x-component.
234
- */
235
- export function isRscActionRequest(req: Request): boolean {
236
- const accept = req.headers.get('Accept') ?? '';
237
- const contentType = req.headers.get('Content-Type') ?? '';
238
- return accept.includes('text/x-component') || contentType.includes('text/x-component');
239
- }
@@ -101,19 +101,6 @@ export function buildCssLinkTags(cssUrls: string[]): string {
101
101
  return cssUrls.map((url) => `<link rel="stylesheet" href="${url}">`).join('');
102
102
  }
103
103
 
104
- /**
105
- * Generate a Link header value for CSS preload hints.
106
- *
107
- * Cloudflare CDN automatically converts Link headers with rel=preload
108
- * into 103 Early Hints responses. This avoids platform-specific 103
109
- * sending code.
110
- *
111
- * Example output: `</assets/root.css>; as=style; rel=preload, </assets/page.css>; as=style; rel=preload`
112
- */
113
- export function buildLinkHeaders(cssUrls: string[]): string {
114
- return cssUrls.map((url) => `<${url}>; as=style; rel=preload`).join(', ');
115
- }
116
-
117
104
  // ─── Font utilities ──────────────────────────────────────────────────────
118
105
 
119
106
  /**
@@ -161,44 +148,8 @@ export function buildFontPreloadTags(fonts: ManifestFontEntry[]): string {
161
148
  .join('');
162
149
  }
163
150
 
164
- /**
165
- * Generate Link header values for font preload hints.
166
- *
167
- * Cloudflare CDN converts Link headers with rel=preload into 103 Early Hints.
168
- *
169
- * Example: `</fonts/inter.woff2>; as=font; rel=preload; crossorigin`
170
- */
171
- export function buildFontLinkHeaders(fonts: ManifestFontEntry[]): string {
172
- return fonts.map((f) => `<${f.href}>; as=font; rel=preload; crossorigin`).join(', ');
173
- }
174
-
175
151
  // ─── JS chunk utilities ──────────────────────────────────────────────────
176
152
 
177
- /**
178
- * Collect JS chunk URLs for a matched route's segment chain.
179
- *
180
- * Walks segments root → leaf, collecting the JS chunk for each layout
181
- * and page. Deduplicates while preserving order.
182
- */
183
- export function collectRouteJs(segments: SegmentWithFiles[], manifest: BuildManifest): string[] {
184
- const seen = new Set<string>();
185
- const result: string[] = [];
186
-
187
- for (const segment of segments) {
188
- for (const file of [segment.layout, segment.page]) {
189
- if (!file) continue;
190
- const jsUrl = manifest.js[file.filePath];
191
- if (!jsUrl) continue;
192
- if (!seen.has(jsUrl)) {
193
- seen.add(jsUrl);
194
- result.push(jsUrl);
195
- }
196
- }
197
- }
198
-
199
- return result;
200
- }
201
-
202
153
  /**
203
154
  * Collect modulepreload URLs for a matched route's segment chain.
204
155
  *
@@ -238,10 +189,3 @@ export function collectRouteModulepreloads(
238
189
  export function buildModulepreloadTags(urls: string[]): string {
239
190
  return urls.map((url) => `<link rel="modulepreload" href="${url}">`).join('');
240
191
  }
241
-
242
- /**
243
- * Generate a <script type="module"> tag for a JS entry point.
244
- */
245
- export function buildEntryScriptTag(url: string): string {
246
- return `<script type="module" src="${url}"></script>`;
247
- }
@@ -13,11 +13,14 @@ export type CanonicalizeResult = { ok: true; pathname: string } | { ok: false; s
13
13
  /**
14
14
  * Encoded separators that produce a 400 rejection.
15
15
  * %2f (/) and %5c (\) cause path-confusion attacks.
16
+ *
17
+ * Shared between the runtime canonicalizer and the build-time route scanner
18
+ * to ensure both enforce identical security rules. See design/13-security.md.
16
19
  */
17
- const ENCODED_SEPARATOR_RE = /%2f|%5c/i;
20
+ export const ENCODED_SEPARATOR_RE = /%2f|%5c/i;
18
21
 
19
- /** Null byte — rejected. */
20
- const NULL_BYTE_RE = /%00/i;
22
+ /** Null byte — rejected. Shared with the route scanner. */
23
+ export const NULL_BYTE_RE = /%00/i;
21
24
 
22
25
  /**
23
26
  * Canonicalize a URL pathname.
@@ -31,10 +31,14 @@
31
31
 
32
32
  import { createElement } from 'react';
33
33
 
34
+ import { ErrorReconstituter } from '../client/error-reconstituter.js';
35
+ import type { SerializableError } from '../client/error-reconstituter.js';
34
36
  import { requestContextAls } from './als-registry.js';
35
37
  import { DenySignal } from './primitives.js';
36
38
  import { loadModule } from './safe-load.js';
39
+ import { LEGACY_FILE_TO_STATUS } from './status-code-resolver.js';
37
40
  import { withSpan } from './tracing.js';
41
+ import { isMdxFilePath } from './utils/mdx-file.js';
38
42
  import type { ManifestSegmentNode } from './route-matcher.js';
39
43
 
40
44
  // ─── Types ────────────────────────────────────────────────────────────────
@@ -45,6 +49,15 @@ export interface DenyPageEntry {
45
49
  status: number | null;
46
50
  /** The component to render (server or client — both work). */
47
51
  component: (...args: unknown[]) => unknown;
52
+ /**
53
+ * How the entry matched: a status-code file (404.tsx/4xx.tsx), a legacy
54
+ * compat file (not-found.tsx/forbidden.tsx/unauthorized.tsx), or the
55
+ * error.tsx catch-all. error.tsx entries render with their documented
56
+ * { error, digest, reset } contract, not bare deny props. See TIM-1081.
57
+ */
58
+ kind: 'status' | 'legacy' | 'error';
59
+ /** MDX files are server components — rendered with plain props, never ErrorReconstituter. */
60
+ isMdx: boolean;
48
61
  }
49
62
 
50
63
  // ─── Chain Construction ──────────────────────────────────────────────────
@@ -56,10 +69,14 @@ export interface DenyPageEntry {
56
69
  * status-code file components in fallback order:
57
70
  * 1. Specific status files (403.tsx, 404.tsx) — exact match
58
71
  * 2. Category catch-alls (4xx.tsx) — matches any 4xx
59
- * 3. error.tsx — catches everything
72
+ * 3. Legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
73
+ * unauthorized.tsx → 401) — exact match
74
+ * 4. error.tsx — catches everything
60
75
  *
61
76
  * Each segment is checked in this order. The chain is ordered so the
62
- * FIRST match wins at catch time.
77
+ * FIRST match wins at catch time. This mirrors resolveStatusFile's 4xx
78
+ * component chain (status-code-resolver.ts) — the two walks must stay in
79
+ * sync or in-tree and re-render deny paths resolve different files.
63
80
  */
64
81
  export async function buildDenyPageChain(
65
82
  segments: ManifestSegmentNode[],
@@ -82,7 +99,12 @@ export async function buildDenyPageChain(
82
99
  if (!isNaN(status)) {
83
100
  const mod = await loadModule(file).catch(() => null);
84
101
  if (mod?.default) {
85
- chain.push({ status, component: mod.default as (...args: unknown[]) => unknown });
102
+ chain.push({
103
+ status,
104
+ component: mod.default as (...args: unknown[]) => unknown,
105
+ kind: 'status',
106
+ isMdx: isMdxFilePath(file.filePath),
107
+ });
86
108
  }
87
109
  }
88
110
  }
@@ -97,21 +119,50 @@ export async function buildDenyPageChain(
97
119
  chain.push({
98
120
  status: categoryStatus,
99
121
  component: mod.default as (...args: unknown[]) => unknown,
122
+ kind: 'status',
123
+ isMdx: isMdxFilePath(file.filePath),
100
124
  });
101
125
  }
102
126
  }
103
127
  }
104
128
  }
105
129
 
106
- // Pass 2: error.tsx files — lowest priority catch-all.
107
- // Only added AFTER all status files so they never shadow a specific
108
- // or category status file from an ancestor segment.
130
+ // Pass 2: legacy compat files (not-found.tsx → 404, forbidden.tsx → 403,
131
+ // unauthorized.tsx → 401). Lower priority than status files (a root
132
+ // 4xx.tsx beats a leaf not-found.tsx) but higher than error.tsx — the
133
+ // same ordering as resolveStatusFile's resolve4xx. See TIM-1081.
134
+ for (let i = startIndex; i >= 0; i--) {
135
+ const segment = segments[i];
136
+ if (!segment.legacyStatusFiles) continue;
137
+ for (const [name, status] of Object.entries(LEGACY_FILE_TO_STATUS)) {
138
+ const file = segment.legacyStatusFiles[name];
139
+ if (!file) continue;
140
+ const mod = await loadModule(file).catch(() => null);
141
+ if (mod?.default) {
142
+ chain.push({
143
+ status,
144
+ component: mod.default as (...args: unknown[]) => unknown,
145
+ kind: 'legacy',
146
+ isMdx: isMdxFilePath(file.filePath),
147
+ });
148
+ }
149
+ }
150
+ }
151
+
152
+ // Pass 3: error.tsx files — lowest priority catch-all.
153
+ // Only added AFTER all status and legacy files so they never shadow a
154
+ // more specific file from an ancestor segment.
109
155
  for (let i = startIndex; i >= 0; i--) {
110
156
  const segment = segments[i];
111
157
  if (segment.error) {
112
158
  const mod = await loadModule(segment.error).catch(() => null);
113
159
  if (mod?.default) {
114
- chain.push({ status: null, component: mod.default as (...args: unknown[]) => unknown });
160
+ chain.push({
161
+ status: null,
162
+ component: mod.default as (...args: unknown[]) => unknown,
163
+ kind: 'error',
164
+ isMdx: isMdxFilePath(segment.error.filePath),
165
+ });
115
166
  }
116
167
  }
117
168
  }
@@ -130,25 +181,67 @@ export function renderMatchingDenyPage(
130
181
  status: number,
131
182
  data: unknown
132
183
  ): React.ReactElement | null {
133
- const h = createElement as (...args: unknown[]) => React.ReactElement;
134
-
135
184
  for (const entry of chain) {
136
185
  if (entry.status === status) {
137
- return h(entry.component, { status, dangerouslyPassData: data });
186
+ return renderDenyEntry(entry, status, data);
138
187
  }
139
188
  if (entry.status === 400 && status >= 400 && status <= 499) {
140
- return h(entry.component, { status, dangerouslyPassData: data });
189
+ return renderDenyEntry(entry, status, data);
141
190
  }
142
191
  if (entry.status === 500 && status >= 500 && status <= 599) {
143
- return h(entry.component, { status, dangerouslyPassData: data });
192
+ return renderDenyEntry(entry, status, data);
144
193
  }
145
194
  if (entry.status === null) {
146
- return h(entry.component, { status, dangerouslyPassData: data });
195
+ return renderDenyEntry(entry, status, data);
147
196
  }
148
197
  }
149
198
  return null;
150
199
  }
151
200
 
201
+ /**
202
+ * Build the element for a matched deny chain entry with the props contract
203
+ * the file's convention documents:
204
+ *
205
+ * - Status-code and legacy files: { status, dangerouslyPassData }
206
+ * - error.tsx (TSX): { error, digest, reset } via ErrorReconstituter, plus
207
+ * { status, dangerouslyPassData } for dual-shape implementations. Without
208
+ * the Error prop, any error.tsx written per the docs (reading
209
+ * error.message) crashes — turning a clean deny(404) into a 500. TIM-1081.
210
+ * - error.mdx: plain { status } — server component, static content.
211
+ */
212
+ export function renderDenyEntry(
213
+ entry: DenyPageEntry,
214
+ status: number,
215
+ data: unknown
216
+ ): React.ReactElement {
217
+ const h = createElement as (...args: unknown[]) => React.ReactElement;
218
+
219
+ if (entry.kind !== 'error') {
220
+ return h(entry.component, { status, dangerouslyPassData: data });
221
+ }
222
+
223
+ // MDX error pages receive plain props — no Error serialization possible.
224
+ if (entry.isMdx) {
225
+ return h(entry.component, { status });
226
+ }
227
+
228
+ // The message is derived solely from the status code (already on the
229
+ // wire) — safe to cross the RSC→client boundary in production. No stack,
230
+ // no user data. See design/13-security.md §"Errors don't leak".
231
+ const serializableDeny: SerializableError = {
232
+ message: `Access denied with status ${status}`,
233
+ name: 'DenySignal',
234
+ };
235
+ return h(ErrorReconstituter, {
236
+ error: serializableDeny,
237
+ digest: null,
238
+ reset: undefined,
239
+ component: entry.component,
240
+ status,
241
+ dangerouslyPassData: data,
242
+ });
243
+ }
244
+
152
245
  // ─── Page Boundary ────────────────────────────────────────────────────────
153
246
 
154
247
  /**
@@ -19,8 +19,11 @@ import { createElement } from 'react';
19
19
  import { renderToReadableStream } from '../rsc-runtime/rsc.js';
20
20
 
21
21
  import { DenySignal } from './primitives.js';
22
+ import { renderDenyEntry, type DenyPageEntry } from './deny-boundary.js';
23
+ import { getCookiesForSsr } from './cookie-context.js';
22
24
  import { logRenderError } from './logger.js';
23
25
  import { loadModule } from './safe-load.js';
26
+ import { isMdxFilePath } from './utils/mdx-file.js';
24
27
  import { isDebug } from './debug.js';
25
28
  import { escapeHtml } from './utils/escape-html.js';
26
29
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
@@ -62,6 +65,29 @@ function isApiRoute(segments: ReadonlyArray<ManifestSegmentNode>): boolean {
62
65
  return !!leaf?.route;
63
66
  }
64
67
 
68
+ /**
69
+ * Build the deny page element for a resolved status file with the correct
70
+ * props contract for the file's convention. Delegates to renderDenyEntry
71
+ * (deny-boundary.ts) so the in-tree and re-render deny paths render
72
+ * identical elements — in particular, error.tsx serving a deny gets
73
+ * { error, digest, reset } via ErrorReconstituter, not bare deny props.
74
+ * See TIM-1081.
75
+ */
76
+ function buildDenyElement(
77
+ deny: DenySignal,
78
+ component: (...args: unknown[]) => unknown,
79
+ resolutionKind: 'exact' | 'category' | 'legacy' | 'error',
80
+ filePath: string
81
+ ): React.ReactElement {
82
+ const entry: DenyPageEntry = {
83
+ status: deny.status,
84
+ component,
85
+ kind: resolutionKind === 'error' ? 'error' : resolutionKind === 'legacy' ? 'legacy' : 'status',
86
+ isMdx: isMdxFilePath(filePath),
87
+ };
88
+ return renderDenyEntry(entry, deny.status, deny.data);
89
+ }
90
+
65
91
  /**
66
92
  * Render a status-code error page for a DenySignal.
67
93
  *
@@ -120,12 +146,15 @@ export async function renderDenyPage(
120
146
  const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
121
147
  const h = createElement as (...args: unknown[]) => React.ReactElement;
122
148
 
123
- // 4xx status-code pages receive { status, dangerouslyPassData }
124
- // per design/10-error-handling.md §"Status-Code File Props"
125
- let element = h(ErrorPageComponent, {
126
- status: deny.status,
127
- dangerouslyPassData: deny.data,
128
- });
149
+ // 4xx status-code pages receive { status, dangerouslyPassData }; an
150
+ // error.tsx match gets { error, digest, reset } via ErrorReconstituter.
151
+ // See design/10-error-handling.md §"Status-Code File Props" and TIM-1081.
152
+ let element = buildDenyElement(
153
+ deny,
154
+ ErrorPageComponent,
155
+ resolution.kind,
156
+ resolution.file.filePath
157
+ );
129
158
 
130
159
  // Always wrap in layout shell.
131
160
  // NOTE: Shell opt-out (export const shell = false) is a future feature
@@ -156,6 +185,7 @@ export async function renderDenyPage(
156
185
 
157
186
  // Render the error page to a fresh RSC Flight stream.
158
187
  const rscStream = renderToReadableStream(element, {
188
+ signal: req.signal,
159
189
  onError(error: unknown) {
160
190
  logRenderError({ method: req.method, path: new URL(req.url).pathname, error });
161
191
  },
@@ -173,6 +203,8 @@ export async function renderDenyPage(
173
203
  headHtml: headHtml + flightInitScript(),
174
204
  bootstrapScriptContent: clientBootstrap.bootstrapScriptContent,
175
205
  rscStream: inlineStream,
206
+ signal: req.signal,
207
+ cookies: getCookiesForSsr(),
176
208
  };
177
209
 
178
210
  return callSsr(ssrStream, navContext);
@@ -188,6 +220,7 @@ export async function renderDenyPageAsRsc(
188
220
  deny: DenySignal,
189
221
  segments: ManifestSegmentNode[],
190
222
  layoutComponents: LayoutEntry[],
223
+ req: Request,
191
224
  responseHeaders: Headers,
192
225
  createDebugChannelSink: DebugChannelFactory
193
226
  ): Promise<Response> {
@@ -207,10 +240,12 @@ export async function renderDenyPageAsRsc(
207
240
  const ErrorPageComponent = mod.default as (...args: unknown[]) => unknown;
208
241
  const h = createElement as (...args: unknown[]) => React.ReactElement;
209
242
 
210
- let element = h(ErrorPageComponent, {
211
- status: deny.status,
212
- dangerouslyPassData: deny.data,
213
- });
243
+ let element = buildDenyElement(
244
+ deny,
245
+ ErrorPageComponent,
246
+ resolution.kind,
247
+ resolution.file.filePath
248
+ );
214
249
 
215
250
  // Always wrap in layout shell.
216
251
  const resolvedSegments = new Set(segments.slice(0, resolution.segmentIndex + 1));
@@ -221,6 +256,7 @@ export async function renderDenyPageAsRsc(
221
256
  }
222
257
 
223
258
  const rscStream = renderToReadableStream(element, {
259
+ signal: req.signal,
224
260
  onError(error: unknown) {
225
261
  console.error('[timber] Error page RSC render error:', error);
226
262
  },