@timber-js/app 0.2.0-alpha.171 → 0.2.0-alpha.173

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 (97) 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/index.js +5 -5
  30. package/dist/routing/index.js +2 -2
  31. package/dist/server/access-gate.d.ts.map +1 -1
  32. package/dist/server/als-registry.d.ts +26 -0
  33. package/dist/server/als-registry.d.ts.map +1 -1
  34. package/dist/server/cookie-context.d.ts.map +1 -1
  35. package/dist/server/index.js +2 -2
  36. package/dist/server/internal.js +1614 -1667
  37. package/dist/server/internal.js.map +1 -1
  38. package/dist/server/metadata-routes.d.ts +13 -0
  39. package/dist/server/metadata-routes.d.ts.map +1 -1
  40. package/dist/server/metadata.d.ts +8 -0
  41. package/dist/server/metadata.d.ts.map +1 -1
  42. package/dist/server/prebuilt/slots.d.ts +33 -8
  43. package/dist/server/prebuilt/slots.d.ts.map +1 -1
  44. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  45. package/dist/server/request-context.d.ts +15 -0
  46. package/dist/server/request-context.d.ts.map +1 -1
  47. package/dist/server/route-element-builder.d.ts +9 -11
  48. package/dist/server/route-element-builder.d.ts.map +1 -1
  49. package/dist/server/rsc-entry/helpers.d.ts +18 -10
  50. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  51. package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
  52. package/dist/server/rsc-entry/rsc-payload.d.ts +2 -1
  53. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  54. package/dist/server/rsc-entry/ssr-renderer.d.ts +2 -0
  55. package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
  56. package/dist/server/slot-resolver.d.ts +46 -1
  57. package/dist/server/slot-resolver.d.ts.map +1 -1
  58. package/dist/server/state-tree-diff.d.ts +36 -3
  59. package/dist/server/state-tree-diff.d.ts.map +1 -1
  60. package/dist/server/tree-builder.d.ts +7 -0
  61. package/dist/server/tree-builder.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/cli.ts +15 -5
  64. package/src/client/rsc-fetch.ts +1 -1
  65. package/src/client/segment-cache.ts +83 -10
  66. package/src/client/segment-outlet.tsx +24 -2
  67. package/src/client/slot-context.ts +10 -8
  68. package/src/client/slot-provider.tsx +9 -2
  69. package/src/server/access-gate.tsx +28 -1
  70. package/src/server/als-registry.ts +44 -0
  71. package/src/server/cookie-context.ts +7 -1
  72. package/src/server/deny-renderer.ts +1 -1
  73. package/src/server/metadata-routes.ts +95 -0
  74. package/src/server/metadata.ts +21 -0
  75. package/src/server/prebuilt/slots.ts +39 -16
  76. package/src/server/prebuilt-builder.ts +6 -5
  77. package/src/server/prebuilt-runtime.ts +11 -11
  78. package/src/server/request-context.ts +53 -1
  79. package/src/server/route-element-builder.ts +72 -144
  80. package/src/server/rsc-entry/helpers.ts +68 -14
  81. package/src/server/rsc-entry/render-route.ts +11 -3
  82. package/src/server/rsc-entry/rsc-payload.ts +16 -3
  83. package/src/server/rsc-entry/ssr-renderer.ts +3 -1
  84. package/src/server/slot-resolver.ts +321 -12
  85. package/src/server/state-tree-diff.ts +104 -8
  86. package/src/server/tree-builder.ts +10 -0
  87. package/dist/_chunks/canonicalize-Du3o_ptW.js.map +0 -1
  88. package/dist/_chunks/logger-AWfuX-KJ.js.map +0 -1
  89. package/dist/client/child-segment-context.d.ts +0 -22
  90. package/dist/client/child-segment-context.d.ts.map +0 -1
  91. package/dist/client/child-segment-outlet.d.ts +0 -18
  92. package/dist/client/child-segment-outlet.d.ts.map +0 -1
  93. package/dist/client/child-segment-provider.d.ts +0 -21
  94. package/dist/client/child-segment-provider.d.ts.map +0 -1
  95. package/src/client/child-segment-context.ts +0 -40
  96. package/src/client/child-segment-outlet.tsx +0 -25
  97. package/src/client/child-segment-provider.tsx +0 -27
@@ -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,49 @@ 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
+ // OR-merge: once a key is tainted, it stays tainted. A second scope
255
+ // for the same key (e.g. default.tsx fallback after a denied slot page)
256
+ // must not overwrite an earlier true with false.
257
+ store.taintResults.set(
258
+ segmentKey,
259
+ scope.touched || (store.taintResults.get(segmentKey) ?? false)
260
+ );
261
+ }
262
+ return result;
263
+ } catch (error) {
264
+ if (store) {
265
+ if (!store.taintResults) store.taintResults = new Map();
266
+ store.taintResults.set(segmentKey, true);
267
+ }
268
+ throw error;
269
+ }
270
+ }
271
+
220
272
  // ─── Types ────────────────────────────────────────────────────────────────
221
273
 
222
274
  /**
@@ -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
  }
@@ -7,6 +7,8 @@
7
7
  import type { ManifestSegmentNode } from '../route-matcher.js';
8
8
  import { swallow } from '../logger.js';
9
9
  import { computeSegmentKeys } from '../state-tree-diff.js';
10
+ import type { SlotSkipEntry } from '../slot-resolver.js';
11
+ import { isClientReference } from '../route-element-builder.js';
10
12
 
11
13
  /** RSC content type for client navigation payload requests. */
12
14
  export const RSC_CONTENT_TYPE = 'text/x-component';
@@ -166,43 +168,76 @@ export function parseDebugRows(text: string): DebugComponentEntry[] {
166
168
 
167
169
  /**
168
170
  * Build segment metadata for the X-Timber-Segments response header.
169
- * Describes the rendered segment chain with async status, enabling
170
- * the client to populate its segment cache for state tree diffing.
171
+ * Describes the rendered segment chain with request-dependency status,
172
+ * enabling the client to populate its segment cache for state tree diffing.
173
+ *
174
+ * Request-dependency is determined by render-time taint tracking via
175
+ * runInTaintScope(). If taint results are available (RSC payload path),
176
+ * segments/slots that called getHeaders()/getSearchParams()/cookies()
177
+ * are marked request-dependent. Missing taint data (SSR path) defaults
178
+ * to true (safe fallback — never incorrectly cached).
171
179
  *
172
- * Async detection: server components defined as `async function` have
173
- * constructor.name === 'AsyncFunction'. These layouts always re-render
174
- * on navigation (they may depend on request context like cookies/params).
175
180
  * See design/07-routing.md §"Server Diffing Rules".
176
181
  */
182
+ interface SegmentInfoEntry {
183
+ path: string;
184
+ segmentId?: string;
185
+ isRequestDependent: boolean;
186
+ slot?: boolean;
187
+ parentSegment?: string;
188
+ denied?: boolean;
189
+ }
190
+
177
191
  export function buildSegmentInfo(
178
192
  segments: ManifestSegmentNode[],
179
193
  layoutComponents: Array<{
180
194
  component: (...args: unknown[]) => unknown;
181
195
  segment: ManifestSegmentNode;
182
- }>
183
- ): Array<{ path: string; segmentId?: string; isAsync: boolean }> {
196
+ }>,
197
+ slotSkipInfo?: SlotSkipEntry[],
198
+ taintResults?: Map<string, boolean>,
199
+ orphanTaint?: boolean,
200
+ skippedSegmentKeys?: string[]
201
+ ): SegmentInfoEntry[] {
184
202
  const layoutBySegment = new Map(
185
203
  layoutComponents.map(({ component, segment }) => [segment, component])
186
204
  );
187
205
 
188
206
  const segmentKeys = computeSegmentKeys(segments);
189
- const result: Array<{ path: string; segmentId?: string; isAsync: boolean }> = [];
207
+ const skippedSet = skippedSegmentKeys ? new Set(skippedSegmentKeys) : null;
208
+ const result: SegmentInfoEntry[] = [];
190
209
 
191
210
  for (let i = 0; i < segments.length; i++) {
192
211
  const segment = segments[i];
193
212
  const component = layoutBySegment.get(segment);
194
213
 
195
- // Only emit entries for segments with layouts. Layoutless segments
196
- // have no SegmentOutlet and must not appear in the client's merge
197
- // target search (buildSegmentUpdates).
198
214
  if (!component) continue;
199
215
 
200
- const isAsync = component.constructor?.name === 'AsyncFunction';
201
216
  const segmentId = segmentKeys[i];
217
+ let isRequestDependent: boolean;
218
+ if (isClientReference(component)) {
219
+ // Client components never execute on the server — they can't
220
+ // call getHeaders()/cookies()/getSearchParams(). Always cacheable.
221
+ isRequestDependent = false;
222
+ } else if (orphanTaint) {
223
+ isRequestDependent = true;
224
+ } else if (taintResults?.has(segmentId)) {
225
+ isRequestDependent = taintResults.get(segmentId)!;
226
+ } else if (skippedSet?.has(segmentId)) {
227
+ // Skipped segments have no taint data (runInTaintScope never ran).
228
+ // They're safe to mark non-request-dependent: a segment is only
229
+ // skippable because the client's state tree listed it, and the
230
+ // client only serializes non-request-dependent segments.
231
+ isRequestDependent = false;
232
+ } else {
233
+ // No taint data (SSR path or segment not yet rendered).
234
+ // Default to true — safe fallback that degrades to current behavior.
235
+ isRequestDependent = true;
236
+ }
202
237
 
203
- const entry: { path: string; segmentId?: string; isAsync: boolean } = {
238
+ const entry: SegmentInfoEntry = {
204
239
  path: segment.urlPath,
205
- isAsync,
240
+ isRequestDependent,
206
241
  };
207
242
  if (segmentId !== segment.urlPath) {
208
243
  entry.segmentId = segmentId;
@@ -210,6 +245,25 @@ export function buildSegmentInfo(
210
245
  result.push(entry);
211
246
  }
212
247
 
248
+ if (slotSkipInfo) {
249
+ for (const slot of slotSkipInfo) {
250
+ let slotRequestDependent = slot.isRequestDependent;
251
+ if (orphanTaint) {
252
+ slotRequestDependent = true;
253
+ } else if (taintResults?.has(slot.slotKey)) {
254
+ slotRequestDependent = taintResults.get(slot.slotKey)!;
255
+ }
256
+ const entry: SegmentInfoEntry = {
257
+ path: slot.slotKey,
258
+ isRequestDependent: slotRequestDependent,
259
+ slot: true,
260
+ parentSegment: slot.parentSegmentId,
261
+ };
262
+ if (slot.denied) entry.denied = true;
263
+ result.push(entry);
264
+ }
265
+ }
266
+
213
267
  return result;
214
268
  }
215
269
 
@@ -139,8 +139,14 @@ export async function renderRoute(
139
139
  desc: 'build element tree',
140
140
  });
141
141
 
142
- const { element, layoutComponents, deferSuspenseFor, skippedSegments, shellSettled } =
143
- routeResult;
142
+ const {
143
+ element,
144
+ layoutComponents,
145
+ deferSuspenseFor,
146
+ skippedSegments,
147
+ shellSettled,
148
+ slotSkipInfo,
149
+ } = routeResult;
144
150
 
145
151
  // Build head HTML for injection into the SSR output.
146
152
  // Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
@@ -270,7 +276,8 @@ export async function renderRoute(
270
276
  layoutComponents,
271
277
  match,
272
278
  responseHeaders,
273
- skippedSegments
279
+ skippedSegments,
280
+ slotSkipInfo
274
281
  );
275
282
  }
276
283
 
@@ -288,5 +295,6 @@ export async function renderRoute(
288
295
  headHtml,
289
296
  deferSuspenseFor,
290
297
  globalError,
298
+ slotSkipInfo,
291
299
  });
292
300
  }