@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
@@ -23,10 +23,20 @@ export interface SegmentNode {
23
23
  segment: string;
24
24
  /** The RSC flight payload for this segment (opaque to the cache) */
25
25
  payload: unknown;
26
- /** Whether the segment is async (async layouts always re-render on navigation) */
27
- isAsync: boolean;
26
+ /**
27
+ * Whether the segment/slot is request-dependent (calls getHeaders,
28
+ * getSearchParams, cookies, etc.). Request-dependent segments always
29
+ * re-render on navigation. For segments, this is still based on the
30
+ * AsyncFunction heuristic (to be replaced separately). For slots,
31
+ * this is taint-tracked via ALS.
32
+ */
33
+ isRequestDependent: boolean;
28
34
  /** Child segments keyed by segment path */
29
35
  children: Map<string, SegmentNode>;
36
+ /** Parallel route slots keyed by slot path (e.g., "/@sidebar") */
37
+ slots?: Map<string, SegmentNode>;
38
+ /** Whether this slot's access.ts denied on its last render. */
39
+ denied?: boolean;
30
40
  }
31
41
 
32
42
  /**
@@ -35,6 +45,7 @@ export interface SegmentNode {
35
45
  */
36
46
  export interface StateTree {
37
47
  segments: string[];
48
+ slots?: string[];
38
49
  }
39
50
 
40
51
  // ─── Segment Cache ───────────────────────────────────────────────
@@ -79,10 +90,16 @@ export class SegmentCache {
79
90
  */
80
91
  serializeStateTree(mergeableFilter?: Set<string>): StateTree {
81
92
  const segments: string[] = [];
93
+ const slots: string[] = [];
82
94
  if (this.root) {
83
95
  collectSyncSegments(this.root, segments, mergeableFilter);
96
+ collectSyncSlots(this.root, slots);
97
+ }
98
+ const tree: StateTree = { segments };
99
+ if (slots.length > 0) {
100
+ tree.slots = slots;
84
101
  }
85
- return { segments };
102
+ return tree;
86
103
  }
87
104
  }
88
105
 
@@ -92,7 +109,7 @@ function collectSyncSegments(
92
109
  out: string[],
93
110
  mergeableFilter?: Set<string>
94
111
  ): void {
95
- if (!node.isAsync && (!mergeableFilter || mergeableFilter.has(node.segment))) {
112
+ if (!node.isRequestDependent && (!mergeableFilter || mergeableFilter.has(node.segment))) {
96
113
  out.push(node.segment);
97
114
  }
98
115
  for (const child of node.children.values()) {
@@ -100,6 +117,22 @@ function collectSyncSegments(
100
117
  }
101
118
  }
102
119
 
120
+ /** Recursively collect cacheable slot paths from the tree */
121
+ function collectSyncSlots(node: SegmentNode, out: string[]): void {
122
+ if (node.slots) {
123
+ for (const slot of node.slots.values()) {
124
+ // Exclude request-dependent slots (they must re-render every nav)
125
+ // and denied slots (their cached content is denial fallback, not real content)
126
+ if (!slot.isRequestDependent && !slot.denied) {
127
+ out.push(slot.segment);
128
+ }
129
+ }
130
+ }
131
+ for (const child of node.children.values()) {
132
+ collectSyncSlots(child, out);
133
+ }
134
+ }
135
+
103
136
  // ─── Segment Tree Builder ────────────────────────────────────────
104
137
 
105
138
  /**
@@ -110,7 +143,13 @@ export interface SegmentInfo {
110
143
  path: string;
111
144
  /** Outlet key — includes route group name when applicable (e.g., "/(marketing)"). */
112
145
  segmentId?: string;
113
- isAsync: boolean;
146
+ isRequestDependent: boolean;
147
+ /** True for parallel route slot entries. Slots are keyed by their slot path (e.g., "/@sidebar"). */
148
+ slot?: boolean;
149
+ /** Parent segment path for slot entries. Used to attach the slot to the correct SegmentNode. */
150
+ parentSegment?: string;
151
+ /** True when the slot's access.ts denied on this render. Denied slots are excluded from the state tree. */
152
+ denied?: boolean;
114
153
  }
115
154
 
116
155
  /**
@@ -128,21 +167,34 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
128
167
  // Need at least a root segment to build a tree
129
168
  if (segments.length === 0) return undefined;
130
169
 
131
- // All entries are layout segments — the server filters out layoutless
132
- // segments (including the page leaf) in buildSegmentInfo. Cache every
133
- // entry; pages are never sent.
170
+ // Separate slot entries from segment entries. Slots are attached to
171
+ // their parent segment node after the main chain is built.
172
+ const segmentEntries: SegmentInfo[] = [];
173
+ const slotEntries: SegmentInfo[] = [];
174
+ for (const info of segments) {
175
+ if (info.slot) {
176
+ slotEntries.push(info);
177
+ } else {
178
+ segmentEntries.push(info);
179
+ }
180
+ }
181
+
182
+ // Build the main segment chain.
134
183
  let root: SegmentNode | undefined;
135
184
  let parent: SegmentNode | undefined;
185
+ const nodeById = new Map<string, SegmentNode>();
136
186
 
137
- for (const info of segments) {
187
+ for (const info of segmentEntries) {
138
188
  const id = info.segmentId ?? info.path;
139
189
  const node: SegmentNode = {
140
190
  segment: id,
141
191
  payload: null,
142
- isAsync: info.isAsync,
192
+ isRequestDependent: info.isRequestDependent,
143
193
  children: new Map(),
144
194
  };
145
195
 
196
+ nodeById.set(id, node);
197
+
146
198
  if (!root) {
147
199
  root = node;
148
200
  }
@@ -154,6 +206,27 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
154
206
  parent = node;
155
207
  }
156
208
 
209
+ // Attach slot entries to their parent segment nodes.
210
+ for (const slotInfo of slotEntries) {
211
+ const parentId = slotInfo.parentSegment;
212
+ const parentNode = parentId ? nodeById.get(parentId) : root;
213
+ if (!parentNode) continue;
214
+
215
+ const slotId = slotInfo.segmentId ?? slotInfo.path;
216
+ const slotNode: SegmentNode = {
217
+ segment: slotId,
218
+ payload: null,
219
+ isRequestDependent: slotInfo.isRequestDependent,
220
+ children: new Map(),
221
+ denied: slotInfo.denied,
222
+ };
223
+
224
+ if (!parentNode.slots) {
225
+ parentNode.slots = new Map();
226
+ }
227
+ parentNode.slots.set(slotId, slotNode);
228
+ }
229
+
157
230
  return root;
158
231
  }
159
232
 
@@ -32,12 +32,21 @@ export interface SegmentOutletProps {
32
32
  * Unique identifier for this segment. For normal segments this is the
33
33
  * urlPath (e.g., "/", "/dashboard"). For route groups this includes the
34
34
  * group name (e.g., "/(marketing)") to distinguish siblings that share
35
- * the same urlPath. Must match the segmentId used in state-tree-diff.ts.
35
+ * the same urlPath. For slots this includes the slot name (e.g., "/@sidebar").
36
+ * Must match the segmentId used in state-tree-diff.ts.
36
37
  */
37
38
  segmentPath: string;
38
39
 
39
40
  /** The segment's React subtree (layout + inner content). */
40
41
  children: ReactNode;
42
+
43
+ /**
44
+ * When true, the outlet returns cached content instead of rendering
45
+ * new children. Used for skipped parallel route slots — the server
46
+ * sends an empty SegmentOutlet with skip=true, and the client keeps
47
+ * the previously cached slot content.
48
+ */
49
+ skip?: boolean;
41
50
  }
42
51
 
43
52
  /**
@@ -52,7 +61,7 @@ export interface SegmentOutletProps {
52
61
  * when the same type appears at the same tree position. The ref persists
53
62
  * across navigations because SegmentOutlet is reconciled, not remounted.
54
63
  */
55
- export function SegmentOutlet({ segmentPath, children }: SegmentOutletProps) {
64
+ export function SegmentOutlet({ segmentPath, children, skip }: SegmentOutletProps) {
56
65
  const updates = useContext(SegmentUpdateContext);
57
66
  const contentRef = useRef<ReactNode>(null);
58
67
 
@@ -63,6 +72,19 @@ export function SegmentOutlet({ segmentPath, children }: SegmentOutletProps) {
63
72
  return update;
64
73
  }
65
74
 
75
+ // Skipped slot: keep cached content. The server sends skip=true for
76
+ // parallel route slots whose matched content hasn't changed. React
77
+ // reconciles this SegmentOutlet with the previous one at the same
78
+ // tree position, so the ref (cached content) is preserved.
79
+ if (skip) {
80
+ if (contentRef.current !== null) {
81
+ return contentRef.current;
82
+ }
83
+ // No cached content — the SegmentOutlet instance is fresh (not
84
+ // reconciled from a previous render). Fall through to render
85
+ // children (null). The slot will be fully rendered next navigation.
86
+ }
87
+
66
88
  if (contentRef.current === null) {
67
89
  contentRef.current = children;
68
90
  return children;
@@ -8,18 +8,20 @@
8
8
  * SlotsProvider carrying the live slot values; each outlet reads its
9
9
  * value from this context by name.
10
10
  *
11
- * The value is a per-instance record — the NEAREST provider wins, so two
12
- * instances of the same cached component on one page each resolve their
13
- * own children, and a slot component nested inside another slot
14
- * component's children reads its own provider, not the outer one.
11
+ * Providers MERGE per-key with their parent: an inner SlotsProvider
12
+ * inherits the outer's values and overrides only the keys it declares.
13
+ * Two sibling instances each have their own provider and resolve
14
+ * independently. Layouts use a reserved slot name (LAYOUT_CHILDREN_SLOT)
15
+ * that cannot collide with user-declared slot names (TIM-1191).
15
16
  *
16
- * Generalizes the ChildSegmentContext pattern (TIM-1181) from a single
17
- * implicit `children` hole on layouts to explicitly declared, named slot
18
- * props on any cached component.
17
+ * Generalizes the original layout children pattern (TIM-1181) from a
18
+ * single implicit `children` hole on layouts to explicitly declared,
19
+ * named slot props on any cached component. Layouts use this same
20
+ * context with the reserved LAYOUT_CHILDREN_SLOT key (TIM-1191).
19
21
  *
20
22
  * SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
21
23
  * can duplicate this module across chunks; globalThis guarantees a single
22
- * context instance. Same pattern as ChildSegmentContext.
24
+ * context instance.
23
25
  *
24
26
  * See design/45-cache-lifetimes.md §Slot Components.
25
27
  */
@@ -2,6 +2,11 @@
2
2
  * SlotsProvider — wraps a revived cached shell to supply live slot content
3
3
  * to the SlotOutlet holes inside it (TIM-1173).
4
4
  *
5
+ * Values MERGE with the nearest parent provider so that nesting a
6
+ * cache.component SlotsProvider inside a layout SlotsProvider preserves
7
+ * the layout's `children` slot while adding the component's own slots.
8
+ * Inner values override outer ones with the same name (spread order).
9
+ *
5
10
  * Tree structure per cached-component instance:
6
11
  * SlotsProvider(values={children: <Live />})
7
12
  * └── revived shell
@@ -12,7 +17,7 @@
12
17
 
13
18
  'use client';
14
19
 
15
- import { createElement, type ReactNode } from 'react';
20
+ import { createElement, useContext, type ReactNode } from 'react';
16
21
  import { SlotContext, type SlotValues } from './slot-context.js';
17
22
 
18
23
  interface SlotsProviderProps {
@@ -21,5 +26,7 @@ interface SlotsProviderProps {
21
26
  }
22
27
 
23
28
  export function SlotsProvider({ values, children }: SlotsProviderProps) {
24
- return createElement(SlotContext.Provider, { value: values }, children);
29
+ const parent = useContext(SlotContext);
30
+ const merged = parent ? { ...parent, ...values } : values;
31
+ return createElement(SlotContext.Provider, { value: merged }, children);
25
32
  }
@@ -124,8 +124,35 @@ async function accessGateFallback(
124
124
  * slot doesn't make architectural sense.
125
125
  */
126
126
  export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactNode> {
127
- const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
127
+ const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children, verdict } =
128
+ props;
128
129
 
130
+ // Fast path: replay pre-computed verdict from eager evaluation in resolveSlotProps.
131
+ if (verdict !== undefined) {
132
+ if (verdict === 'pass') return children;
133
+ if (verdict instanceof DenySignal) {
134
+ return (
135
+ buildDeniedFallback(DeniedComponent, slotName, verdict.data, createElement) ??
136
+ defaultFallback ??
137
+ null
138
+ );
139
+ }
140
+ // RedirectSignal: treat as deny in production (same as existing behavior)
141
+ if (isDebug()) {
142
+ console.error(
143
+ '[timber] redirect() is not allowed in slot access.ts. ' +
144
+ 'Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. ' +
145
+ "If you need to redirect, move the logic to the parent segment's access.ts."
146
+ );
147
+ }
148
+ return (
149
+ buildDeniedFallback(DeniedComponent, slotName, undefined, createElement) ??
150
+ defaultFallback ??
151
+ null
152
+ );
153
+ }
154
+
155
+ // Fallback path: call accessFn during render (no pre-computed verdict).
129
156
  try {
130
157
  await accessFn();
131
158
  } catch (error: unknown) {
@@ -140,6 +140,18 @@ export interface RequestContextStore {
140
140
  * context for render-phase errors without module-level shared state.
141
141
  */
142
142
  debugComponentsGetter?: () => DebugComponentEntry[];
143
+ /**
144
+ * Taint tracking results from render-time accessor scope tracking.
145
+ * Keyed by segment/slot ID, true = accessor was called during render.
146
+ * Populated by runInTaintScope() wrappers in TracedLayout and slot
147
+ * resolution; consumed by buildSegmentInfo() for X-Timber-Segments.
148
+ */
149
+ taintResults?: Map<string, boolean>;
150
+ /**
151
+ * Set to true if an accessor is called outside any taint scope.
152
+ * When true, all segments are marked request-dependent (safe fallback).
153
+ */
154
+ orphanTaint?: boolean;
143
155
  }
144
156
 
145
157
  /** A single outgoing cookie entry in the cookie jar. */
@@ -149,6 +161,38 @@ export interface CookieEntry {
149
161
  options: import('./cookie-context.js').CookieOptions;
150
162
  }
151
163
 
164
+ // ─── Taint Scope ─────────────────────────────────────────────────────────
165
+ // Per-component accessor taint tracking. Each scope runs inside its own
166
+ // ALS context so concurrent async components (Flight renders siblings in
167
+ // parallel) don't corrupt each other's touched flags.
168
+
169
+ export interface TaintScope {
170
+ touched: boolean;
171
+ }
172
+
173
+ export const taintScopeAls = getOrCreateAls<TaintScope>(Symbol.for('timber:taint-scope-als'));
174
+
175
+ /**
176
+ * Mark that a request accessor was called in the current scope.
177
+ * If a taint scope is active, marks that scope. If no scope is active
178
+ * but a request context exists, sets the orphan taint flag (all segments
179
+ * become request-dependent as a safety fallback).
180
+ *
181
+ * Lives here (not request-context.ts) to avoid circular deps with
182
+ * cookie-context.ts which also needs to call it.
183
+ */
184
+ export function markAccessorTouched(): void {
185
+ const scope = taintScopeAls.getStore();
186
+ if (scope) {
187
+ scope.touched = true;
188
+ return;
189
+ }
190
+ const store = requestContextAls.getStore();
191
+ if (store) {
192
+ store.orphanTaint = true;
193
+ }
194
+ }
195
+
152
196
  // ─── Tracing ──────────────────────────────────────────────────────────────
153
197
  // Used by: tracing.ts (getTraceId(), getSpanId())
154
198
  // Design doc: design/17-logging.md
@@ -12,7 +12,11 @@
12
12
  * smuggling primitive that the encoding contract closes.
13
13
  */
14
14
 
15
- import { requestContextAls, type RequestContextStore } from './als-registry.js';
15
+ import {
16
+ requestContextAls,
17
+ markAccessorTouched,
18
+ type RequestContextStore,
19
+ } from './als-registry.js';
16
20
  import { isDebug } from './debug.js';
17
21
  import {
18
22
  assertValidCookieName,
@@ -62,6 +66,8 @@ export function getCookieJar(): RequestCookies {
62
66
  );
63
67
  }
64
68
 
69
+ markAccessorTouched();
70
+
65
71
  // Parse cookies lazily on first access
66
72
  if (!store.parsedCookies) {
67
73
  store.parsedCookies = parseCookieHeader(store.cookieHeader);
@@ -26,7 +26,7 @@ import { loadModule } from './safe-load.js';
26
26
  import { isMdxFilePath } from './utils/mdx-file.js';
27
27
  import { isDebug } from './debug.js';
28
28
  import { resolveMetadata, renderMetadataToElements } from './metadata.js';
29
- import { headElementsToReact } from './route-element-builder.js';
29
+ import { headElementsToReact } from './metadata.js';
30
30
  import { resolveStatusFile } from './status-code-resolver.js';
31
31
  import type { ManifestSegmentNode } from './route-matcher.js';
32
32
  import type { RouteMatch } from './pipeline.js';
@@ -10,6 +10,11 @@
10
10
  * See design/16-metadata.md §"Metadata Routes"
11
11
  */
12
12
 
13
+ import { randomUUID } from 'node:crypto';
14
+ import type { HeadElement } from './metadata.js';
15
+ import type { Metadata } from './types.js';
16
+ import type { ManifestSegmentNode } from './route-matcher.js';
17
+
13
18
  // ─── Types ───────────────────────────────────────────────────────────────────
14
19
 
15
20
  /** Classification of a metadata route file. */
@@ -334,3 +339,93 @@ export function getMetadataRouteAutoLink(type: MetadataRouteType, href: string):
334
339
  return [];
335
340
  }
336
341
  }
342
+
343
+ // ─── Auto-Linking ──────────────────────────────────────────────────────────
344
+
345
+ // In dev mode, use a per-startup nonce for metadata route cache busting
346
+ // instead of per-file content hashes (avoids rehashing on every request).
347
+ let _devNonce: string | undefined;
348
+ function getDevNonce(): string {
349
+ _devNonce ??= randomUUID().slice(0, 8);
350
+ return _devNonce;
351
+ }
352
+
353
+ /**
354
+ * Collect auto-linked head elements from metadata route files in the segment chain.
355
+ *
356
+ * Walks each segment's metadataRoutes, resolves serve paths and URLs, and
357
+ * emits HeadElement descriptors for <link> and <meta> tags that React Float
358
+ * hoists into <head>.
359
+ *
360
+ * See design/16-metadata.md §"Auto-Linking"
361
+ */
362
+ export function collectMetadataRouteHeadElements(
363
+ segments: ManifestSegmentNode[],
364
+ firstDeniedIndex: number,
365
+ resolvedMetadata: Metadata,
366
+ requestUrl: URL,
367
+ metadataRouteHashes?: Record<string, string>
368
+ ): HeadElement[] {
369
+ const elements: HeadElement[] = [];
370
+ const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
371
+ const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);
372
+ const requestPathname = requestUrl.pathname;
373
+ // In dev mode, use the request origin so OG URLs resolve to localhost.
374
+ // In production, use metadataBase (the canonical domain).
375
+ const ogBase =
376
+ process.env.NODE_ENV !== 'production'
377
+ ? new URL(requestUrl.origin)
378
+ : resolvedMetadata.metadataBase;
379
+
380
+ for (let si = 0; si < segments.length; si++) {
381
+ const segment = segments[si];
382
+ if (!segment.metadataRoutes) continue;
383
+ if (si >= firstDeniedIndex) continue;
384
+ for (const baseName of Object.keys(segment.metadataRoutes)) {
385
+ const convention = METADATA_ROUTE_CONVENTIONS[baseName];
386
+ if (!convention) continue;
387
+ if (!convention.nestable && segment.urlPath !== '/') continue;
388
+ if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
389
+ const resolvedPrefix = convention.nestable
390
+ ? requestPathname === '/'
391
+ ? ''
392
+ : requestPathname
393
+ : '';
394
+ const metaFile = segment.metadataRoutes[baseName];
395
+ const fileServePath = metaFile?.filePath
396
+ ? resolveServePathForFile(baseName, metaFile.filePath)
397
+ : convention.serveExtension
398
+ ? `${convention.servePath}.${convention.serveExtension}`
399
+ : convention.servePath;
400
+ let href = `${resolvedPrefix}/${fileServePath}`;
401
+ if (convention.type === 'opengraph-image') {
402
+ const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
403
+ const cacheBust = fileHash ?? getDevNonce();
404
+ href = `${href}?${cacheBust}`;
405
+ }
406
+ if (ogBase && convention.type === 'opengraph-image') {
407
+ href = new URL(href, ogBase).toString();
408
+ }
409
+ for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {
410
+ if (
411
+ hasUserTwitterImage &&
412
+ autoLink.tag === 'meta' &&
413
+ 'name' in autoLink &&
414
+ autoLink.name === 'twitter:image'
415
+ )
416
+ continue;
417
+ if (autoLink.tag === 'link') {
418
+ const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
419
+ if (autoLink.type) attrs.type = autoLink.type;
420
+ elements.push({ tag: 'link', attrs });
421
+ } else {
422
+ const attrs: Record<string, string> = { content: autoLink.content };
423
+ if (autoLink.property) attrs.property = autoLink.property;
424
+ if (autoLink.name) attrs.name = autoLink.name;
425
+ elements.push({ tag: 'meta', attrs });
426
+ }
427
+ }
428
+ }
429
+ }
430
+ return elements;
431
+ }
@@ -12,12 +12,33 @@
12
12
  * See design/16-metadata.md
13
13
  */
14
14
 
15
+ import { createElement, Fragment } from 'react';
15
16
  import type { Metadata } from './types.js';
16
17
 
17
18
  // Re-export renderMetadataToElements from the rendering module so existing
18
19
  // consumers (route-element-builder, tests) can keep importing from here.
19
20
  export { renderMetadataToElements } from './metadata-render.js';
20
21
 
22
+ /**
23
+ * Convert HeadElement descriptors to React elements for Float hoisting.
24
+ *
25
+ * React 19 Float hoists <title>, <meta>, and <link> rendered anywhere in
26
+ * the tree into <head> during SSR, and manages them in document.head
27
+ * during client-side rendering.
28
+ */
29
+ export function headElementsToReact(elements: HeadElement[]): React.ReactElement {
30
+ const children: React.ReactElement[] = [];
31
+ for (let i = 0; i < elements.length; i++) {
32
+ const el = elements[i];
33
+ if (el.tag === 'title' && el.content !== undefined) {
34
+ children.push(createElement('title', { key: `t${i}` }, el.content));
35
+ } else if (el.attrs) {
36
+ children.push(createElement(el.tag, { key: `h${i}`, ...el.attrs }));
37
+ }
38
+ }
39
+ return createElement(Fragment, null, ...children);
40
+ }
41
+
21
42
  // ─── Types ───────────────────────────────────────────────────────────────────
22
43
 
23
44
  /** A single metadata entry from a layout or page module. */
@@ -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(