@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
@@ -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
  }
@@ -43,6 +43,28 @@ function extractNetworkErrorCode(err: unknown): string | undefined {
43
43
  return undefined;
44
44
  }
45
45
 
46
+ const MAX_RETRIES = 3;
47
+ const MIN_TIMEOUT_MS = 100;
48
+
49
+ async function retry<T>(fn: () => Promise<T>): Promise<T> {
50
+ let lastError: unknown;
51
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
52
+ try {
53
+ return await fn();
54
+ } catch (err) {
55
+ lastError = err;
56
+ if (attempt < MAX_RETRIES) {
57
+ const delay = MIN_TIMEOUT_MS * 2 ** attempt;
58
+ console.error(
59
+ `${err instanceof Error ? err.message : String(err)}\n\nRetrying ${attempt + 1}/${MAX_RETRIES}...`
60
+ );
61
+ await new Promise((resolve) => setTimeout(resolve, delay));
62
+ }
63
+ }
64
+ }
65
+ throw lastError;
66
+ }
67
+
46
68
  /** Google Fonts CSS API v2 base URL. */
47
69
  const GOOGLE_FONTS_API = 'https://fonts.googleapis.com/css2';
48
70
 
@@ -134,28 +156,31 @@ export function buildGoogleFontsUrl(font: ExtractedFont): string {
134
156
  * We parse each block to extract the font URL, unicode-range, and subset label.
135
157
  */
136
158
  export async function fetchGoogleFontsCss(url: string): Promise<GoogleFontFace[]> {
137
- let response: Response;
138
- try {
139
- response = await fetch(url, {
140
- headers: { 'User-Agent': WOFF2_USER_AGENT },
141
- });
142
- } catch (err) {
143
- const code = extractNetworkErrorCode(err);
144
- throw new Error(
145
- `[timber] Failed to fetch Google Fonts CSS from ${url}` +
146
- (code ? ` (${code})` : '') +
147
- `. Build requires internet access for font optimization.` +
148
- ` If building offline, set TIMBER_SKIP_FONTS=1 to skip font downloading.`
149
- );
150
- }
159
+ const css = await retry(async () => {
160
+ let response: Response;
161
+ try {
162
+ response = await fetch(url, {
163
+ headers: { 'User-Agent': WOFF2_USER_AGENT },
164
+ });
165
+ } catch (err) {
166
+ const code = extractNetworkErrorCode(err);
167
+ throw new Error(
168
+ `[timber] Failed to fetch Google Fonts CSS from ${url}` +
169
+ (code ? ` (${code})` : '') +
170
+ `. Build requires internet access for font optimization.` +
171
+ ` If building offline, set TIMBER_SKIP_FONTS=1 to skip font downloading.`
172
+ );
173
+ }
151
174
 
152
- if (!response.ok) {
153
- throw new Error(
154
- `Google Fonts API returned ${response.status}: ${response.statusText} for ${url}`
155
- );
156
- }
175
+ if (!response.ok) {
176
+ throw new Error(
177
+ `Google Fonts API returned ${response.status}: ${response.statusText} for ${url}`
178
+ );
179
+ }
180
+
181
+ return response.text();
182
+ });
157
183
 
158
- const css = await response.text();
159
184
  return parseGoogleFontsCss(css);
160
185
  }
161
186
 
@@ -238,22 +263,24 @@ export function cacheKey(face: GoogleFontFace): string {
238
263
  * Download a single font file from its URL.
239
264
  */
240
265
  export async function downloadFontFile(url: string): Promise<Buffer> {
241
- let response: Response;
242
- try {
243
- response = await fetch(url);
244
- } catch (err) {
245
- const code = extractNetworkErrorCode(err);
246
- throw new Error(
247
- `[timber] Failed to download font file from ${url}` +
248
- (code ? ` (${code})` : '') +
249
- `. Build requires internet access for font optimization.` +
250
- ` If building offline, set TIMBER_SKIP_FONTS=1 to skip font downloading.`
251
- );
252
- }
253
- if (!response.ok) {
254
- throw new Error(`Failed to download font from ${url}: ${response.status}`);
255
- }
256
- return Buffer.from(await response.arrayBuffer());
266
+ return retry(async () => {
267
+ let response: Response;
268
+ try {
269
+ response = await fetch(url);
270
+ } catch (err) {
271
+ const code = extractNetworkErrorCode(err);
272
+ throw new Error(
273
+ `[timber] Failed to download font file from ${url}` +
274
+ (code ? ` (${code})` : '') +
275
+ `. Build requires internet access for font optimization.` +
276
+ ` If building offline, set TIMBER_SKIP_FONTS=1 to skip font downloading.`
277
+ );
278
+ }
279
+ if (!response.ok) {
280
+ throw new Error(`Failed to download font from ${url}: ${response.status}`);
281
+ }
282
+ return Buffer.from(await response.arrayBuffer());
283
+ });
257
284
  }
258
285
 
259
286
  /**
@@ -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. */