@timber-js/app 0.2.0-alpha.170 → 0.2.0-alpha.172

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 (100) hide show
  1. package/dist/_chunks/{actions-O_LsyCE4.js → actions-pN8r5Vnh.js} +3 -3
  2. package/dist/_chunks/{actions-O_LsyCE4.js.map → actions-pN8r5Vnh.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-B-lhk9p4.js → cache-api-2hT5kfsr.js} +2 -2
  4. package/dist/_chunks/{cache-api-B-lhk9p4.js.map → cache-api-2hT5kfsr.js.map} +1 -1
  5. package/dist/_chunks/{canonicalize-Du3o_ptW.js → canonicalize-DQHyFClh.js} +2 -1
  6. package/dist/_chunks/canonicalize-DQHyFClh.js.map +1 -0
  7. package/dist/_chunks/{cli-schema-sync-B5FDplGI.js → cli-schema-sync-DvdvFwwE.js} +3 -3
  8. package/dist/_chunks/{cli-schema-sync-B5FDplGI.js.map → cli-schema-sync-DvdvFwwE.js.map} +1 -1
  9. package/dist/_chunks/{logger-AWfuX-KJ.js → logger-kUT0QH0K.js} +23 -1
  10. package/dist/_chunks/logger-kUT0QH0K.js.map +1 -0
  11. package/dist/_chunks/{walkers-DCoE-LJf.js → walkers-Bv63zfAC.js} +2 -2
  12. package/dist/_chunks/{walkers-DCoE-LJf.js.map → walkers-Bv63zfAC.js.map} +1 -1
  13. package/dist/cache/index.js +1 -1
  14. package/dist/cli.d.ts +3 -2
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +9 -5
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client/internal.js +40 -6
  19. package/dist/client/internal.js.map +1 -1
  20. package/dist/client/rsc-fetch.d.ts +1 -1
  21. package/dist/client/segment-cache.d.ts +20 -3
  22. package/dist/client/segment-cache.d.ts.map +1 -1
  23. package/dist/client/segment-outlet.d.ts +10 -2
  24. package/dist/client/segment-outlet.d.ts.map +1 -1
  25. package/dist/client/slot-context.d.ts +10 -8
  26. package/dist/client/slot-context.d.ts.map +1 -1
  27. package/dist/client/slot-provider.d.ts +5 -0
  28. package/dist/client/slot-provider.d.ts.map +1 -1
  29. package/dist/fonts/google.d.ts.map +1 -1
  30. package/dist/index.js +43 -23
  31. package/dist/index.js.map +1 -1
  32. package/dist/routing/index.js +2 -2
  33. package/dist/server/access-gate.d.ts.map +1 -1
  34. package/dist/server/als-registry.d.ts +26 -0
  35. package/dist/server/als-registry.d.ts.map +1 -1
  36. package/dist/server/cookie-context.d.ts.map +1 -1
  37. package/dist/server/index.js +2 -2
  38. package/dist/server/internal.js +1614 -1667
  39. package/dist/server/internal.js.map +1 -1
  40. package/dist/server/metadata-routes.d.ts +13 -0
  41. package/dist/server/metadata-routes.d.ts.map +1 -1
  42. package/dist/server/metadata.d.ts +8 -0
  43. package/dist/server/metadata.d.ts.map +1 -1
  44. package/dist/server/prebuilt/slots.d.ts +33 -8
  45. package/dist/server/prebuilt/slots.d.ts.map +1 -1
  46. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  47. package/dist/server/request-context.d.ts +15 -0
  48. package/dist/server/request-context.d.ts.map +1 -1
  49. package/dist/server/route-element-builder.d.ts +9 -11
  50. package/dist/server/route-element-builder.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/helpers.d.ts +18 -10
  52. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  53. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  54. package/dist/server/rsc-entry/rsc-payload.d.ts +2 -1
  55. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  56. package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
  57. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  58. package/dist/server/slot-resolver.d.ts +46 -1
  59. package/dist/server/slot-resolver.d.ts.map +1 -1
  60. package/dist/server/state-tree-diff.d.ts +36 -3
  61. package/dist/server/state-tree-diff.d.ts.map +1 -1
  62. package/dist/server/tree-builder.d.ts +7 -0
  63. package/dist/server/tree-builder.d.ts.map +1 -1
  64. package/package.json +2 -2
  65. package/src/cli.ts +15 -5
  66. package/src/client/rsc-fetch.ts +1 -1
  67. package/src/client/segment-cache.ts +83 -10
  68. package/src/client/segment-outlet.tsx +24 -2
  69. package/src/client/slot-context.ts +10 -8
  70. package/src/client/slot-provider.tsx +9 -2
  71. package/src/fonts/google.ts +63 -36
  72. package/src/server/access-gate.tsx +28 -1
  73. package/src/server/als-registry.ts +44 -0
  74. package/src/server/cookie-context.ts +7 -1
  75. package/src/server/deny-renderer.ts +1 -1
  76. package/src/server/metadata-routes.ts +95 -0
  77. package/src/server/metadata.ts +21 -0
  78. package/src/server/prebuilt/slots.ts +39 -16
  79. package/src/server/prebuilt-builder.ts +6 -5
  80. package/src/server/prebuilt-runtime.ts +11 -11
  81. package/src/server/request-context.ts +47 -1
  82. package/src/server/route-element-builder.ts +72 -144
  83. package/src/server/rsc-entry/helpers.ts +68 -14
  84. package/src/server/rsc-entry/render-route.ts +11 -3
  85. package/src/server/rsc-entry/rsc-payload.ts +16 -3
  86. package/src/server/rsc-entry/ssr-renderer.ts +3 -1
  87. package/src/server/slot-resolver.ts +299 -8
  88. package/src/server/state-tree-diff.ts +104 -8
  89. package/src/server/tree-builder.ts +10 -0
  90. package/dist/_chunks/canonicalize-Du3o_ptW.js.map +0 -1
  91. package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
  92. package/dist/client/child-segment-context.d.ts +0 -22
  93. package/dist/client/child-segment-context.d.ts.map +0 -1
  94. package/dist/client/child-segment-outlet.d.ts +0 -18
  95. package/dist/client/child-segment-outlet.d.ts.map +0 -1
  96. package/dist/client/child-segment-provider.d.ts +0 -21
  97. package/dist/client/child-segment-provider.d.ts.map +0 -1
  98. package/src/client/child-segment-context.ts +0 -40
  99. package/src/client/child-segment-outlet.tsx +0 -25
  100. package/src/client/child-segment-provider.tsx +0 -27
@@ -5,8 +5,8 @@
5
5
  * as a shell with a stable <SlotOutlet slot={name} /> client-reference
6
6
  * hole per declared slot prop; live slot content composes in per instance
7
7
  * at request time via SlotsProvider/SlotContext. This generalizes the
8
- * TIM-1181 layout mechanism (ChildSegmentOutlet) to explicitly declared,
9
- * named slots on any cached component.
8
+ * TIM-1181 layout mechanism to explicitly declared, named slots on any
9
+ * cached component. Layouts use the same mechanism with a `children` slot.
10
10
  *
11
11
  * Both the production wrapper (cache path) and the dev wrapper (no cache,
12
12
  * same tree shape for dev/prod parity) build their render from these
@@ -16,27 +16,50 @@
16
16
  import { createElement } from 'react';
17
17
 
18
18
  import type { SlotValues } from '../../client/slot-context.js';
19
- import { ChildSegmentOutlet } from '../../client/child-segment-outlet.js';
20
19
  import { SlotOutlet } from '../../client/slot-outlet.js';
21
20
  import { SlotsProvider } from '../../client/slot-provider.js';
22
21
  import { splitSlotProps } from './cache-key.js';
23
22
  import type { PrebuiltComponentOptions } from '../prebuilt-runtime.js';
24
23
 
25
24
  /**
26
- * Is this value the framework-injected `<ChildSegmentOutlet />` element?
27
- * Total: the `in` probe and `.type` read run against a user-controlled
28
- * value, and a Proxy trap can throw — that must classify as "not the
29
- * outlet" (the canonicalizer then rejects the value as unkeyable → live
30
- * render), not abort the request (codex P2 on PR #890).
25
+ * Framework-internal slot name for layout children. Uses a prefix that
26
+ * cannot collide with user-declared slot names (which are React prop
27
+ * names). Layout children flow through SlotsProvider/SlotOutlet with
28
+ * this key instead of the bare "children" key, so a cached component
29
+ * with `slots: ['children']` inside a layout never shadows the layout's
30
+ * own children delivery.
31
+ *
32
+ * Defined here (a server module) rather than in `client/slot-context.ts`
33
+ * (`'use client'`) because RSC's client-reference proxying would replace
34
+ * the string value with a proxy object in the server environment. The
35
+ * client-side SlotOutlet receives this value via props, so it doesn't
36
+ * need the constant. See TIM-1191.
31
37
  */
32
- export function isChildSegmentOutletElement(value: unknown): boolean {
38
+ export const LAYOUT_CHILDREN_SLOT = '\0timber:children';
39
+
40
+ /**
41
+ * Is this value the framework-injected layout children slot outlet
42
+ * (`<SlotOutlet slot={LAYOUT_CHILDREN_SLOT} />`)? Layout components
43
+ * receive this as their `children` prop — a stable client reference
44
+ * whose cache key is stripped so layout `cache.component` hits work
45
+ * (design/45 §Layout Propagation).
46
+ *
47
+ * Uses a framework-reserved slot name (LAYOUT_CHILDREN_SLOT) that
48
+ * cannot collide with user-declared slot names, preventing context
49
+ * shadowing when a cached component with its own SlotsProvider is
50
+ * nested inside a layout. See TIM-1191.
51
+ *
52
+ * Total: the probes run against a user-controlled value, and a Proxy
53
+ * trap can throw — that must classify as "not the outlet" (the
54
+ * canonicalizer then rejects the value as unkeyable → live render), not
55
+ * abort the request (codex P2 on PR #890).
56
+ */
57
+ export function isChildrenSlotOutletElement(value: unknown): boolean {
33
58
  try {
34
- return (
35
- value != null &&
36
- typeof value === 'object' &&
37
- 'type' in value &&
38
- (value as { type: unknown }).type === ChildSegmentOutlet
39
- );
59
+ if (value == null || typeof value !== 'object') return false;
60
+ if (!('type' in value)) return false;
61
+ const el = value as { type: unknown; props?: { slot?: unknown } };
62
+ return el.type === SlotOutlet && el.props?.slot === LAYOUT_CHILDREN_SLOT;
40
63
  } catch {
41
64
  return false;
42
65
  }
@@ -161,7 +184,7 @@ export function wrapInSlotsProvider(
161
184
  ): React.ReactElement {
162
185
  // children passed as a prop, not a JSX child — the shell is `unknown`
163
186
  // to the type system. Same pattern as route-element-builder's `h` alias
164
- // for ChildSegmentProvider.
187
+ // for SlotsProvider.
165
188
  const h = createElement as (...args: unknown[]) => React.ReactElement;
166
189
  return h(SlotsProvider, { values: slotValues as SlotValues, children: shell });
167
190
  }
@@ -46,7 +46,7 @@ import { createElement } from 'react';
46
46
 
47
47
  import { requestContextAls } from './als-registry.js';
48
48
  import { ParamCoercionError } from './route-element-builder.js';
49
- import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
49
+ import { SlotOutlet } from '../client/slot-outlet.js';
50
50
  import { coerceSegmentParams } from './param-coercion.js';
51
51
  import { DenySignal, RedirectSignal } from './primitives.js';
52
52
  import {
@@ -56,6 +56,7 @@ import {
56
56
  type NestedWrapperRender,
57
57
  } from './prebuilt/capture-state.js';
58
58
  import { computeComponentCacheKey } from './prebuilt/cache-key.js';
59
+ import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
59
60
  import { BuildTimeContextError, createBuildTimeStore } from './prebuilt/synthetic-store.js';
60
61
  import {
61
62
  getPrebuiltRegistration,
@@ -155,7 +156,7 @@ interface CandidateRoute {
155
156
  layoutsToLoad: ManifestFile[];
156
157
  /** Component IDs attributed to this route's page + layout chain. */
157
158
  componentIds: string[];
158
- /** Component IDs from layout files (receive ChildSegmentOutlet as children). */
159
+ /** Component IDs from layout files (receive SlotOutlet as children). */
159
160
  layoutComponentIds: Set<string>;
160
161
  }
161
162
 
@@ -356,11 +357,11 @@ async function renderEntry(
356
357
  // synthetic store (segmentParams.get() inside the component).
357
358
  // Tag resolution also runs inside this scope so function-form tags
358
359
  // that read segment params see the synthetic store (codex P2 on PR #858).
359
- // Layout captures get <ChildSegmentOutlet /> as children so the
360
- // cached payload has a context-driven hole (design/45, TIM-1181).
360
+ // Layout captures get <SlotOutlet slot="children" /> as children so
361
+ // the cached payload has a context-driven hole (design/45, TIM-1181).
361
362
  const renderProps =
362
363
  isLayout && !('children' in props)
363
- ? { ...props, children: createElement(ChildSegmentOutlet as React.FC) }
364
+ ? { ...props, children: createElement(SlotOutlet, { slot: LAYOUT_CHILDREN_SLOT }) }
364
365
  : props;
365
366
  bytes = await requestContextAls.run(store, async () => {
366
367
  const stream = renderToReadableStream(
@@ -38,7 +38,7 @@ import { createElement } from 'react';
38
38
  import { requestContextAls, type RequestContextStore } from './als-registry.js';
39
39
  import { computeComponentCacheKey, splitSlotProps, tryCanonicalize } from './prebuilt/cache-key.js';
40
40
  import {
41
- isChildSegmentOutletElement,
41
+ isChildrenSlotOutletElement,
42
42
  resolveSlotNames,
43
43
  prepareSlotRender,
44
44
  wrapInSlotsProvider,
@@ -196,7 +196,7 @@ function resolveKeyParams(store: {
196
196
  * exactly when production would serve the cache path rather than the
197
197
  * live fallback (dev/prod parity, codex P2 on PR #890): requires an ALS
198
198
  * store, keyable params (slot-param divergence unkeys), and keyable data
199
- * props (after the ChildSegmentOutlet strip).
199
+ * props (after the children slot outlet strip).
200
200
  */
201
201
  function isSlotRenderKeyable(dataProps: Record<string, unknown>): boolean {
202
202
  const store = requestContextAls.getStore();
@@ -204,7 +204,7 @@ function isSlotRenderKeyable(dataProps: Record<string, unknown>): boolean {
204
204
  const keyParams = resolveKeyParams(store);
205
205
  if (keyParams === null) return false;
206
206
  let keyProps = dataProps;
207
- if (isChildSegmentOutletElement(keyProps.children)) {
207
+ if (isChildrenSlotOutletElement(keyProps.children)) {
208
208
  const { children: _children, ...rest } = keyProps;
209
209
  keyProps = rest;
210
210
  }
@@ -547,23 +547,23 @@ async function tryRevivePayload(
547
547
  if (keyParams === null) return null;
548
548
 
549
549
  // Strip `children` from the cache key ONLY when it is the framework-
550
- // injected ChildSegmentOutlet. Layout components receive
551
- // <ChildSegmentOutlet /> as children — a deterministic client reference
552
- // that is the same on every request, so it should not vary the key.
553
- // User-provided React element children (e.g., <Cached><A /></Cached>)
554
- // are NOT stripped — they remain unkeyable so the component renders
555
- // live, which is correct: the cache cannot distinguish <A> from <B>.
550
+ // injected children slot outlet (<SlotOutlet slot="children" />).
551
+ // Layout components receive this as children — a deterministic client
552
+ // reference that is the same on every request, so it should not vary
553
+ // the key. User-provided React element children are NOT stripped —
554
+ // they remain unkeyable so the component renders live, which is
555
+ // correct: the cache cannot distinguish <A> from <B>.
556
556
  // See design/45-cache-lifetimes.md §Layout Propagation, TIM-1181.
557
557
  // keyPropsOverride = slot component (TIM-1173): the wrapper already
558
558
  // split out slot props; only data props participate in the key. The
559
- // ChildSegmentOutlet strip applies to EITHER source — a cached layout
559
+ // children outlet strip applies to EITHER source — a cached layout
560
560
  // declaring only named slots (slots: ['modal']) still receives the
561
561
  // framework-injected children outlet in its data props, and it must not
562
562
  // unkey the render there any more than on the non-slot path (codex P2
563
563
  // on PR #890).
564
564
  const allProps = (props ?? {}) as Record<string, unknown>;
565
565
  let keyProps = keyPropsOverride ?? allProps;
566
- if (isChildSegmentOutletElement(keyProps.children)) {
566
+ if (isChildrenSlotOutletElement(keyProps.children)) {
567
567
  const { children: _children, ...rest } = keyProps;
568
568
  keyProps = rest;
569
569
  }
@@ -14,7 +14,13 @@
14
14
  * and design/11-platform.md §"AsyncLocalStorage".
15
15
  */
16
16
 
17
- import { requestContextAls, type RequestContextStore } from './als-registry.js';
17
+ import {
18
+ requestContextAls,
19
+ taintScopeAls,
20
+ markAccessorTouched,
21
+ type RequestContextStore,
22
+ type TaintScope,
23
+ } from './als-registry.js';
18
24
  import { _setGetSearchParamsFn } from '../search-params/define.js';
19
25
  import { _setGetSegmentParamsFn } from '../segment-params/define.js';
20
26
  import { consumeSeededCookies } from './cookie-context.js';
@@ -39,6 +45,7 @@ export function getHeaders(): ReadonlyHeaders {
39
45
  'It can only be used in middleware, access checks, server components, and server actions.'
40
46
  );
41
47
  }
48
+ markAccessorTouched();
42
49
  return store.headers;
43
50
  }
44
51
 
@@ -51,6 +58,7 @@ export function getHeaders(): ReadonlyHeaders {
51
58
  * @internal — not part of the public API. Use `getHeaders().get(name)` instead.
52
59
  */
53
60
  export function getHeader(name: string): string | undefined {
61
+ // getHeaders() already calls markAccessorTouched()
54
62
  const headers = getHeaders();
55
63
  return headers.get(name) ?? undefined;
56
64
  }
@@ -68,6 +76,7 @@ export function getSearchParams(): URLSearchParams {
68
76
  'It can only be used in middleware, access checks, server components, and server actions.'
69
77
  );
70
78
  }
79
+ markAccessorTouched();
71
80
  return store.searchParams;
72
81
  }
73
82
 
@@ -217,6 +226,43 @@ export function getRequestSearchString(): string {
217
226
  return store?.searchString ?? '';
218
227
  }
219
228
 
229
+ // ─── Request Accessor Taint Tracking ─────────────────────────────────────
230
+ // Used by segment/slot skip decisions to determine whether a component
231
+ // actually reads per-request data, replacing the AsyncFunction heuristic.
232
+
233
+ /**
234
+ * Run a callback inside a taint tracking scope. Returns whether any
235
+ * request accessor (getHeaders, getSearchParams, cookies) was called
236
+ * during the callback's execution.
237
+ *
238
+ * Uses a dedicated ALS so concurrent async components (Flight renders
239
+ * siblings in parallel) each get their own isolated scope. The shared
240
+ * push/pop stack model was unsound under concurrency.
241
+ *
242
+ * Results are stored in the request context's taintResults map, keyed
243
+ * by segment/slot ID, for buildSegmentInfo() to consume.
244
+ *
245
+ * @internal — framework use only (route-element-builder.ts, slot-resolver.ts)
246
+ */
247
+ export async function runInTaintScope<T>(segmentKey: string, fn: () => T | Promise<T>): Promise<T> {
248
+ const store = requestContextAls.getStore();
249
+ const scope: TaintScope = { touched: false };
250
+ try {
251
+ const result = await taintScopeAls.run(scope, fn);
252
+ if (store) {
253
+ if (!store.taintResults) store.taintResults = new Map();
254
+ store.taintResults.set(segmentKey, scope.touched);
255
+ }
256
+ return result;
257
+ } catch (error) {
258
+ if (store) {
259
+ if (!store.taintResults) store.taintResults = new Map();
260
+ store.taintResults.set(segmentKey, true);
261
+ }
262
+ throw error;
263
+ }
264
+ }
265
+
220
266
  // ─── Types ────────────────────────────────────────────────────────────────
221
267
 
222
268
  /**
@@ -16,26 +16,13 @@
16
16
  */
17
17
 
18
18
  import { createElement, Fragment } from 'react';
19
- import { randomUUID } from 'node:crypto';
20
19
 
21
20
  import { withSpan } from './tracing.js';
22
21
  import type { RouteMatch } from './pipeline.js';
23
22
  import type { ManifestSegmentNode } from './route-matcher.js';
24
23
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
25
24
  import type { Metadata } from './types.js';
26
- import {
27
- METADATA_ROUTE_CONVENTIONS,
28
- getMetadataRouteAutoLink,
29
- resolveServePathForFile,
30
- } from './metadata-routes.js';
31
-
32
- // In dev mode, use a per-startup nonce for metadata route cache busting
33
- // instead of per-file content hashes (avoids rehashing on every request).
34
- let _devNonce: string | undefined;
35
- function getDevNonce(): string {
36
- _devNonce ??= randomUUID().slice(0, 8);
37
- return _devNonce;
38
- }
25
+ import { collectMetadataRouteHeadElements } from './metadata-routes.js';
39
26
  import { DenySignal, RedirectSignal } from './primitives.js';
40
27
  import { AccessGate } from './access-gate.js';
41
28
  import { requestContextAls } from './als-registry.js';
@@ -46,37 +33,18 @@ import {
46
33
  setDenyStatus,
47
34
  } from './deny-boundary.js';
48
35
  import type { DenyPageEntry } from './deny-boundary.js';
49
- import { resolveSlotElement } from './slot-resolver.js';
36
+ import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
37
+ import { resolveSlotProps, type SlotSkipEntry } from './slot-resolver.js';
50
38
  import { SegmentProvider } from '../client/segment-context.js';
51
39
  import { SegmentOutlet } from '../client/segment-outlet.js';
52
- import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
53
- import { ChildSegmentProvider } from '../client/child-segment-provider.js';
40
+ import { SlotOutlet } from '../client/slot-outlet.js';
41
+ import { SlotsProvider } from '../client/slot-provider.js';
54
42
 
55
43
  import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
56
44
  import type { InterceptionContext } from './pipeline.js';
57
- import { shouldSkipSegment, computeSegmentKeys } from './state-tree-diff.js';
58
- import type { HeadElement } from './metadata.js';
59
-
60
- /**
61
- * Convert HeadElement descriptors to React elements for Float hoisting.
62
- *
63
- * React 19 Float hoists <title>, <meta>, and <link> rendered anywhere in
64
- * the tree into <head> during SSR, and manages them in document.head
65
- * during client-side rendering. This replaces the X-Timber-Head header
66
- * side-channel and the imperative client DOM applier.
67
- */
68
- export function headElementsToReact(elements: HeadElement[]): React.ReactElement {
69
- const children: React.ReactElement[] = [];
70
- for (let i = 0; i < elements.length; i++) {
71
- const el = elements[i];
72
- if (el.tag === 'title' && el.content !== undefined) {
73
- children.push(createElement('title', { key: `t${i}` }, el.content));
74
- } else if (el.attrs) {
75
- children.push(createElement(el.tag, { key: `h${i}`, ...el.attrs }));
76
- }
77
- }
78
- return createElement(Fragment, null, ...children);
79
- }
45
+ import { shouldSkipSegment, computeSegmentKeys, type ClientStateTree } from './state-tree-diff.js';
46
+ import { runInTaintScope } from './request-context.js';
47
+ import { headElementsToReact } from './metadata.js';
80
48
  import { loadModule } from './safe-load.js';
81
49
 
82
50
  /**
@@ -191,6 +159,12 @@ export interface RouteElementResult {
191
159
  * See TIM-1045 (pages), TIM-1208 (layouts).
192
160
  */
193
161
  shellSettled?: Promise<void>;
162
+ /**
163
+ * Slot metadata for the X-Timber-Segments header. Contains info about
164
+ * all parallel route slots encountered during element tree construction,
165
+ * enabling the client to populate its segment cache with slot entries.
166
+ */
167
+ slotSkipInfo: SlotSkipEntry[];
194
168
  }
195
169
 
196
170
  // ─── Module Processing Helpers ─────────────────────────────────────────────
@@ -273,7 +247,7 @@ export async function buildRouteElement(
273
247
  req: Request,
274
248
  match: RouteMatch,
275
249
  interception?: InterceptionContext,
276
- clientStateTree?: Set<string> | null,
250
+ clientStateTree?: ClientStateTree | null,
277
251
  metadataRouteHashes?: Record<string, string>
278
252
  ): Promise<RouteElementResult> {
279
253
  const segments = match.segments;
@@ -408,79 +382,15 @@ export async function buildRouteElement(
408
382
  const headElements = renderMetadataToElements(resolvedMetadata);
409
383
 
410
384
  // Auto-link metadata route files (icon, apple-icon, manifest, opengraph-image).
411
- // opengraph-image emits both og:image and twitter:image (no separate twitter-image convention).
412
- // Skip OG auto-linking when the user already declared images in metadata.
413
385
  // See design/16-metadata.md §"Auto-Linking"
414
- const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
415
- const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);
416
- const requestUrl = new URL(req.url);
417
- const requestPathname = requestUrl.pathname;
418
- // In dev mode, use the request origin so OG URLs resolve to localhost.
419
- // In production, use metadataBase (the canonical domain).
420
- const ogBase =
421
- process.env.NODE_ENV !== 'production'
422
- ? new URL(requestUrl.origin)
423
- : resolvedMetadata.metadataBase;
424
-
425
- for (let si = 0; si < segments.length; si++) {
426
- const segment = segments[si];
427
- if (!segment.metadataRoutes) continue;
428
- // Skip auto-linking for denied segments — same gate as metadata().
429
- if (si >= firstDeniedIndex) continue;
430
- for (const baseName of Object.keys(segment.metadataRoutes)) {
431
- const convention = METADATA_ROUTE_CONVENTIONS[baseName];
432
- if (!convention) continue;
433
- // Non-nestable routes only auto-link from root
434
- if (!convention.nestable && segment.urlPath !== '/') continue;
435
- // Skip auto-linking entirely if user declared openGraph.images (covers both og + twitter)
436
- if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
437
- // Build the href using the actual request path (not the pattern with [param]).
438
- // For nestable routes, the metadata route sits under the same resolved path
439
- // as the page. For root-only routes, use '/'.
440
- const resolvedPrefix = convention.nestable
441
- ? requestPathname === '/'
442
- ? ''
443
- : requestPathname
444
- : '';
445
- const metaFile = segment.metadataRoutes[baseName];
446
- const fileServePath = metaFile?.filePath
447
- ? resolveServePathForFile(baseName, metaFile.filePath)
448
- : convention.serveExtension
449
- ? `${convention.servePath}.${convention.serveExtension}`
450
- : convention.servePath;
451
- let href = `${resolvedPrefix}/${fileServePath}`;
452
- // Append cache-bust query param for image metadata routes
453
- if (convention.type === 'opengraph-image') {
454
- const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
455
- const cacheBust = fileHash ?? getDevNonce();
456
- href = `${href}?${cacheBust}`;
457
- }
458
- // Resolve to absolute URL for og:image/twitter:image (social crawlers need full URLs)
459
- if (ogBase && convention.type === 'opengraph-image') {
460
- href = new URL(href, ogBase).toString();
461
- }
462
- for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {
463
- // Skip auto-linked twitter:image when user declared twitter.images
464
- if (
465
- hasUserTwitterImage &&
466
- autoLink.tag === 'meta' &&
467
- 'name' in autoLink &&
468
- autoLink.name === 'twitter:image'
469
- )
470
- continue;
471
- if (autoLink.tag === 'link') {
472
- const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
473
- if (autoLink.type) attrs.type = autoLink.type;
474
- headElements.push({ tag: 'link', attrs });
475
- } else {
476
- const attrs: Record<string, string> = { content: autoLink.content };
477
- if (autoLink.property) attrs.property = autoLink.property;
478
- if (autoLink.name) attrs.name = autoLink.name;
479
- headElements.push({ tag: 'meta', attrs });
480
- }
481
- }
482
- }
483
- }
386
+ const autoLinkedElements = collectMetadataRouteHeadElements(
387
+ segments,
388
+ firstDeniedIndex,
389
+ resolvedMetadata,
390
+ new URL(req.url),
391
+ metadataRouteHashes
392
+ );
393
+ headElements.push(...autoLinkedElements);
484
394
 
485
395
  // Convert HeadElement descriptors to React elements for Float hoisting.
486
396
  // Placed at the page segment level so they're always re-rendered on
@@ -491,7 +401,7 @@ export async function buildRouteElement(
491
401
  const h = createElement as (...args: unknown[]) => React.ReactElement;
492
402
 
493
403
  // Shell settlement tracking (TIM-1045 pages, TIM-1208 layouts).
494
- // ChildSegmentProvider is 'use client', so React Flight eagerly
404
+ // SlotsProvider is 'use client', so React Flight eagerly
495
405
  // serializes both the layout and child content as independent subtrees —
496
406
  // the page can settle before an outer layout. We need all deny-capable
497
407
  // components to settle before committing the HTTP status.
@@ -637,6 +547,14 @@ export async function buildRouteElement(
637
547
 
638
548
  let outermostSegmentProvider: React.ReactElement | null = null;
639
549
 
550
+ // Slot caching: the departing URL (X-Timber-URL header) and the
551
+ // destination URL (request URL) are compared to determine which
552
+ // slots' content has changed. Full URLs (with search) are passed
553
+ // so slot pages reading getSearchParams() are correctly re-rendered.
554
+ const departingUrl = req.headers.get('X-Timber-URL');
555
+ const destinationUrl = req.url;
556
+ const slotSkipInfo: SlotSkipEntry[] = [];
557
+
640
558
  for (let i = segments.length - 1; i >= 0; i--) {
641
559
  const segment = segments[i];
642
560
  const isLeaf = i === segments.length - 1;
@@ -682,9 +600,15 @@ export async function buildRouteElement(
682
600
  // This prevents leaking layout UI (sidebars, nav) on denied pages.
683
601
  // See design/04-authorization.md §"Access Failure".
684
602
  if (layoutComponent) {
685
- // Resolve parallel slots for this layout.
686
- // Compute the parent tree path so slots can build their full
687
- // segment path for per-slot param storage in ALS.
603
+ // Resolve parallel slots for this layout, wrapping each in
604
+ // SegmentOutlet for client-side caching. Unchanged slots get
605
+ // skip=true so the client keeps its cached content.
606
+ //
607
+ // Skip slot resolution for denied segments (TIM-1229): when a
608
+ // parent segment's AccessGate denies, the layout never renders,
609
+ // so slot props are never consumed. Resolving them is wasted work
610
+ // and could trigger side effects in slot access.ts functions.
611
+ const segmentId = segmentKeys[i];
688
612
  const parentTreePath =
689
613
  '/' +
690
614
  segments
@@ -692,17 +616,21 @@ export async function buildRouteElement(
692
616
  .map((s) => s.segmentName)
693
617
  .filter(Boolean)
694
618
  .join('/');
695
- const slotProps: Record<string, unknown> = {};
696
- const slotEntries = Object.entries(segment.slots ?? {});
697
- for (const [slotName, slotNode] of slotEntries) {
698
- slotProps[slotName] = await resolveSlotElement(
699
- slotNode as ManifestSegmentNode,
700
- match,
701
- h,
702
- interception,
703
- parentTreePath
704
- );
705
- }
619
+ const slotProps =
620
+ i >= firstDeniedIndex
621
+ ? {}
622
+ : await resolveSlotProps({
623
+ segment,
624
+ segmentId,
625
+ match,
626
+ h,
627
+ interception,
628
+ parentTreePath,
629
+ departingUrl,
630
+ destinationUrl,
631
+ clientStateTree: clientStateTree as ClientStateTree | null,
632
+ slotSkipInfo,
633
+ });
706
634
 
707
635
  // '/'.split('/') → ['', ''] (length 2), but root should be [''] (length 1).
708
636
  // The extra empty string makes useSelectedLayoutSegment skip the first URL segment.
@@ -710,16 +638,14 @@ export async function buildRouteElement(
710
638
  const segmentPath = rawPath.length === 2 && rawPath[1] === '' ? [''] : rawPath;
711
639
  const parallelRouteKeys = Object.keys(segment.slots ?? {});
712
640
 
713
- const segmentId = segmentKeys[i];
714
-
715
641
  // Build the layout element.
716
- // Layouts receive <ChildSegmentOutlet /> as children — a stable client
717
- // reference — instead of the varying inner content. The actual child
718
- // content is provided via ChildSegmentProvider above the layout.
719
- // This makes layout cache.component hits possible: the cached payload
720
- // contains a deterministic children reference, not varying content.
642
+ // Layouts receive <SlotOutlet slot={LAYOUT_CHILDREN_SLOT} /> as
643
+ // children — a stable client reference — instead of the varying
644
+ // inner content. The actual child content is provided via
645
+ // SlotsProvider above the layout. Uses a reserved slot name to
646
+ // avoid collision with user-declared cache.component slots.
721
647
  // See design/45-cache-lifetimes.md §Layout Propagation.
722
- const childOutlet = h(ChildSegmentOutlet, {});
648
+ const childOutlet = h(SlotOutlet, { slot: LAYOUT_CHILDREN_SLOT });
723
649
  let layoutElement: React.ReactElement;
724
650
  if (isClientReference(layoutComponent)) {
725
651
  layoutElement = h(layoutComponent, {
@@ -737,10 +663,13 @@ export async function buildRouteElement(
737
663
  const layoutDenyPages = denyPageChains.get(i);
738
664
  const hasLayoutDenyChain = !!layoutDenyPages && layoutDenyPages.length > 0;
739
665
  const onLayoutSettled = hasLayoutDenyChain ? trackShellComponent() : undefined;
666
+ const taintKey = segmentId;
740
667
  const TracedLayout = async (props: Record<string, unknown>) => {
741
668
  try {
742
- return await withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
743
- (layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
669
+ return await runInTaintScope(taintKey, () =>
670
+ withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
671
+ (layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
672
+ )
744
673
  );
745
674
  } catch (error: unknown) {
746
675
  if (error instanceof DenySignal && layoutDenyPages) {
@@ -750,8 +679,6 @@ export async function buildRouteElement(
750
679
  return denyElement;
751
680
  }
752
681
  }
753
- // Non-deny errors (RedirectSignal, runtime errors) propagate normally
754
- // to onError — signalDetected / renderError handling covers those.
755
682
  throw error;
756
683
  } finally {
757
684
  onLayoutSettled?.();
@@ -763,10 +690,10 @@ export async function buildRouteElement(
763
690
  });
764
691
  }
765
692
 
766
- // Wrap the layout in ChildSegmentProvider so ChildSegmentOutlet
767
- // (inside the layout's children) can read the actual child content.
768
- layoutElement = h(ChildSegmentProvider, {
769
- childContent: element,
693
+ // Wrap the layout in SlotsProvider so the SlotOutlet inside the
694
+ // layout's children can read the actual child content.
695
+ layoutElement = h(SlotsProvider, {
696
+ values: { [LAYOUT_CHILDREN_SLOT]: element },
770
697
  children: layoutElement,
771
698
  });
772
699
 
@@ -841,5 +768,6 @@ export async function buildRouteElement(
841
768
  deferSuspenseFor,
842
769
  skippedSegments,
843
770
  shellSettled,
771
+ slotSkipInfo,
844
772
  };
845
773
  }