@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
@@ -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
  }
@@ -14,6 +14,7 @@ import type { RouteMatch } from '../pipeline.js';
14
14
  import type { RedirectSignal } from '../primitives.js';
15
15
  import type { LayoutComponentEntry } from '../route-element-builder.js';
16
16
  import type { ManifestSegmentNode } from '../route-matcher.js';
17
+ import type { SlotSkipEntry } from '../slot-resolver.js';
17
18
 
18
19
  import {
19
20
  buildRedirectResponse,
@@ -21,6 +22,7 @@ import {
21
22
  createDebugChannelSink,
22
23
  RSC_CONTENT_TYPE,
23
24
  } from './helpers.js';
25
+ import { requestContextAls } from '../als-registry.js';
24
26
  import type { RenderSignals } from './rsc-stream.js';
25
27
 
26
28
  /**
@@ -40,7 +42,8 @@ export async function buildRscPayloadResponse(
40
42
  layoutComponents: LayoutComponentEntry[],
41
43
  match: RouteMatch,
42
44
  responseHeaders: Headers,
43
- skippedSegments?: string[]
45
+ skippedSegments?: string[],
46
+ slotSkipInfo?: SlotSkipEntry[]
44
47
  ): Promise<Response> {
45
48
  // Read the first chunk from the RSC stream before committing headers.
46
49
  // Race the first read against signal detection — if an async component
@@ -194,9 +197,19 @@ export async function buildRscPayloadResponse(
194
197
  // client rendering. No X-Timber-Head header needed. See TIM-1151.
195
198
 
196
199
  // Send segment metadata so the client can populate its segment cache
197
- // for state tree diffing on subsequent navigations.
200
+ // for state tree diffing on subsequent navigations. On the RSC payload
201
+ // path, taint results are available because the shell has rendered by
202
+ // the time we reach this point (first chunk already read from the stream).
198
203
  // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
199
- const segmentInfo = buildSegmentInfo(segments, layoutComponents);
204
+ const store = requestContextAls.getStore();
205
+ const segmentInfo = buildSegmentInfo(
206
+ segments,
207
+ layoutComponents,
208
+ slotSkipInfo,
209
+ store?.taintResults,
210
+ store?.orphanTaint,
211
+ skippedSegments
212
+ );
200
213
  responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
201
214
 
202
215
  // Send skipped segments so the client can merge the partial RSC payload
@@ -20,6 +20,7 @@ import type { RouteMatch } from '../pipeline.js';
20
20
  import { SsrStreamError } from '../primitives.js';
21
21
  import type { LayoutComponentEntry } from '../route-element-builder.js';
22
22
  import type { ManifestSegmentNode } from '../route-matcher.js';
23
+ import type { SlotSkipEntry } from '../slot-resolver.js';
23
24
  import type { NavContext } from '../ssr-bridge-types.js';
24
25
 
25
26
  import { htmlEscapeJsonString } from '../flight-scripts.js';
@@ -100,6 +101,7 @@ interface SsrRenderOptions {
100
101
  deferSuspenseFor: number;
101
102
  /** Tier 2 global-error.tsx file, if present in app/. */
102
103
  globalError?: GlobalErrorFile;
104
+ slotSkipInfo?: SlotSkipEntry[];
103
105
  }
104
106
 
105
107
  /**
@@ -168,7 +170,7 @@ export async function renderSsrResponse(opts: SsrRenderOptions): Promise<Respons
168
170
  // Skipped when client JS is disabled — no client JS to consume it.
169
171
  const segmentScript = clientJsDisabled
170
172
  ? ''
171
- : `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents)))}</script>`;
173
+ : `<script>self.__timber_segments=${htmlEscapeJsonString(JSON.stringify(buildSegmentInfo(segments, layoutComponents, opts.slotSkipInfo)))}</script>`;
172
174
 
173
175
  // Embed route params in HTML so useSegmentParams() works on initial hydration.
174
176
  // Without this, useSegmentParams() returns {} until the first client navigation.
@@ -29,6 +29,9 @@ import type { ManifestSegmentNode } from './route-matcher.js';
29
29
  import { setSlotParams } from './request-context.js';
30
30
  import { coerceSlotParams } from './param-coercion.js';
31
31
  import { matchUrlParts } from './tree-match.js';
32
+ import { SegmentOutlet } from '../client/segment-outlet.js';
33
+ import { computeSlotKey, shouldSkipSlot, type ClientStateTree } from './state-tree-diff.js';
34
+ import { runInTaintScope } from './request-context.js';
32
35
 
33
36
  type CreateElementFn = (...args: unknown[]) => React.ReactElement;
34
37
 
@@ -80,7 +83,9 @@ export async function resolveSlotElement(
80
83
  match: RouteMatch,
81
84
  h: CreateElementFn,
82
85
  interception?: InterceptionContext,
83
- parentTreePath?: string
86
+ parentTreePath?: string,
87
+ accessVerdicts?: SlotAccessVerdict[],
88
+ taintKey?: string
84
89
  ): Promise<React.ReactElement | null> {
85
90
  // When interception is active, try to match intercepting children in this
86
91
  // slot against the target pathname. If an intercepting child matches, render
@@ -131,9 +136,11 @@ export async function resolveSlotElement(
131
136
  if (isClientReference(SlotPage)) {
132
137
  element = h(SlotPage, {});
133
138
  } else {
139
+ const slotTaintKey = taintKey;
134
140
  const SafeSlotPage = async (props: Record<string, unknown>) => {
141
+ const run = async () => (SlotPage as (props: Record<string, unknown>) => unknown)(props);
135
142
  try {
136
- return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
143
+ return slotTaintKey ? await runInTaintScope(slotTaintKey, run) : await run();
137
144
  } catch (error) {
138
145
  // RedirectSignal must propagate — the pipeline handles redirects
139
146
  // at the top level. Swallowing it here would silently return
@@ -161,7 +168,7 @@ export async function resolveSlotElement(
161
168
  // intermediate slot segments (everything between slot root and leaf).
162
169
  // Process innermost-first, same order as route-element-builder.ts
163
170
  // handles main segments. The slot root (index 0) is handled below.
164
- element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
171
+ element = await wrapWithIntermediateSegments(slotMatch.chain, element, h, accessVerdicts);
165
172
 
166
173
  // Wrap with slot root's layout — INSIDE the access gate, so the layout
167
174
  // server component never executes when access.ts denies. See TIM-1074.
@@ -174,7 +181,7 @@ export async function resolveSlotElement(
174
181
  // On denial: denied.tsx → default.tsx → null (graceful degradation),
175
182
  // rendered WITHOUT the denied slot's own layout.
176
183
  // See design/04-authorization.md §"Slot-Level Auth".
177
- element = await wrapWithAccessGate(slotNode, element, h);
184
+ element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts);
178
185
 
179
186
  // Wrap with slot root's error boundaries (outermost)
180
187
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
@@ -196,7 +203,9 @@ export async function resolveSlotElement(
196
203
  }
197
204
  }
198
205
 
199
- // No matching page — render default.tsx fallback
206
+ // No matching page — render default.tsx fallback.
207
+ // Per design/02-rendering-pipeline.md: "No access check for default.tsx."
208
+ // Access verdicts from the eager evaluation are not applied here.
200
209
  return renderDefaultFallback(slotNode, h);
201
210
  }
202
211
 
@@ -221,13 +230,14 @@ export async function resolveSlotElement(
221
230
  async function wrapWithIntermediateSegments(
222
231
  chain: ManifestSegmentNode[],
223
232
  element: React.ReactElement,
224
- h: CreateElementFn
233
+ h: CreateElementFn,
234
+ accessVerdicts?: SlotAccessVerdict[]
225
235
  ): Promise<React.ReactElement> {
226
236
  for (let i = chain.length - 1; i > 0; i--) {
227
237
  const seg = chain[i];
228
238
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
229
239
  element = await wrapWithLayout(seg, element, h);
230
- element = await wrapWithAccessGate(seg, element, h);
240
+ element = await wrapWithAccessGate(seg, element, h, accessVerdicts);
231
241
  }
232
242
  return element;
233
243
  }
@@ -253,7 +263,8 @@ async function wrapWithLayout(
253
263
  async function wrapWithAccessGate(
254
264
  slotNode: ManifestSegmentNode,
255
265
  element: React.ReactElement,
256
- h: CreateElementFn
266
+ h: CreateElementFn,
267
+ accessVerdicts?: SlotAccessVerdict[]
257
268
  ): Promise<React.ReactElement> {
258
269
  if (!slotNode.access) return element;
259
270
 
@@ -272,6 +283,16 @@ async function wrapWithAccessGate(
272
283
 
273
284
  const defaultFallback = await renderDefaultFallback(slotNode, h);
274
285
 
286
+ // Look up pre-computed verdict from eager evaluation. Only replay
287
+ // denial/redirect verdicts — 'pass' verdicts must NOT be replayed
288
+ // because the access function may warm React.cache (e.g., requireUser())
289
+ // for layout/page dedup, and replaying 'pass' skips the cache-warming
290
+ // call. 'error' verdicts are also excluded — the gate must re-run
291
+ // accessFn so the error reaches the slot's error boundary.
292
+ const preVerdict = accessVerdicts?.find(
293
+ (v) => v.node === slotNode && v.verdict !== 'pass' && v.verdict !== 'error'
294
+ );
295
+
275
296
  return h(SlotAccessGate, {
276
297
  accessFn,
277
298
  DeniedComponent,
@@ -279,6 +300,7 @@ async function wrapWithAccessGate(
279
300
  createElement: h,
280
301
  defaultFallback,
281
302
  children: element,
303
+ verdict: preVerdict?.verdict,
282
304
  });
283
305
  }
284
306
 
@@ -439,3 +461,272 @@ function findInterceptingMatch(
439
461
 
440
462
  return null;
441
463
  }
464
+
465
+ // ─── Slot Access Evaluation ─────────────────────────────────────────────────
466
+
467
+ /** Result of eagerly evaluating one segment's access.ts. */
468
+ export interface SlotAccessVerdict {
469
+ node: ManifestSegmentNode;
470
+ /**
471
+ * 'pass' — access allowed.
472
+ * DenySignal/RedirectSignal — access denied, replayed in SlotAccessGate.
473
+ * 'error' — unknown error. Forces full render. No verdict passed to
474
+ * SlotAccessGate, so it calls accessFn during render and the error
475
+ * reaches the slot's error boundary.
476
+ */
477
+ verdict: 'pass' | 'error' | DenySignal | RedirectSignal;
478
+ }
479
+
480
+ /**
481
+ * Eagerly evaluate the access chain for a slot (root + intermediate segments).
482
+ *
483
+ * Runs each access.ts top-down (outermost first). If any denies, the chain
484
+ * stops (shallowest failure wins, matching segment access semantics).
485
+ *
486
+ * Verdicts are stored for replay in SlotAccessGate / wrapWithIntermediateSegments
487
+ * so access.ts is called exactly once per request.
488
+ */
489
+ async function evaluateSlotAccessChain(
490
+ slotRoot: ManifestSegmentNode,
491
+ chain: ManifestSegmentNode[]
492
+ ): Promise<SlotAccessVerdict[]> {
493
+ // Collect all nodes with access.ts: slot root + intermediate chain segments
494
+ const nodesWithAccess: ManifestSegmentNode[] = [];
495
+ if (slotRoot.access) nodesWithAccess.push(slotRoot);
496
+ for (let i = 1; i < chain.length; i++) {
497
+ if (chain[i].access) nodesWithAccess.push(chain[i]);
498
+ }
499
+
500
+ if (nodesWithAccess.length === 0) return [];
501
+
502
+ const results: SlotAccessVerdict[] = [];
503
+ for (const node of nodesWithAccess) {
504
+ const accessFn = await loadComponent(node.access!);
505
+ if (!accessFn) {
506
+ results.push({ node, verdict: 'pass' });
507
+ continue;
508
+ }
509
+ try {
510
+ await accessFn();
511
+ results.push({ node, verdict: 'pass' });
512
+ } catch (e) {
513
+ if (e instanceof DenySignal) {
514
+ results.push({ node, verdict: e });
515
+ break; // shallowest failure wins
516
+ }
517
+ if (e instanceof RedirectSignal) {
518
+ results.push({ node, verdict: e });
519
+ break;
520
+ }
521
+ // Unknown error — record as 'error' to force full render (blocks
522
+ // canSkip via accessBlocked). No verdict is passed to SlotAccessGate
523
+ // (wrapWithAccessGate filters 'error' out), so the gate calls
524
+ // accessFn during render and the error reaches the error boundary.
525
+ results.push({ node, verdict: 'error' });
526
+ break;
527
+ }
528
+ }
529
+ return results;
530
+ }
531
+
532
+ // ─── Slot Caching ──────────────────────────────────────────────────────────
533
+
534
+ export interface SlotSkipEntry {
535
+ slotKey: string;
536
+ parentSegmentId: string;
537
+ /**
538
+ * Whether the slot's components called request accessors (getHeaders,
539
+ * getSearchParams, cookies) during their last render. Replaces the
540
+ * unreliable AsyncFunction heuristic — taint-tracked via ALS.
541
+ */
542
+ isRequestDependent: boolean;
543
+ /** Whether the slot's access.ts denied on this render. */
544
+ denied: boolean;
545
+ }
546
+
547
+ interface ResolveSlotPropsArgs {
548
+ segment: ManifestSegmentNode;
549
+ segmentId: string;
550
+ match: RouteMatch;
551
+ h: CreateElementFn;
552
+ interception?: InterceptionContext;
553
+ parentTreePath: string;
554
+ departingUrl: string | null;
555
+ destinationUrl: string;
556
+ clientStateTree: ClientStateTree | null;
557
+ slotSkipInfo: SlotSkipEntry[];
558
+ }
559
+
560
+ /**
561
+ * Resolve all parallel route slots for a layout, wrapping each in a
562
+ * SegmentOutlet for client-side caching. Slots whose matched content
563
+ * hasn't changed between the departing and destination URLs are rendered
564
+ * with `skip=true` so the client keeps its cached content.
565
+ */
566
+ export async function resolveSlotProps({
567
+ segment,
568
+ segmentId,
569
+ match,
570
+ h,
571
+ interception,
572
+ parentTreePath,
573
+ departingUrl,
574
+ destinationUrl,
575
+ clientStateTree,
576
+ slotSkipInfo,
577
+ }: ResolveSlotPropsArgs): Promise<Record<string, unknown>> {
578
+ const slotProps: Record<string, unknown> = {};
579
+ const slotEntries = Object.entries(segment.slots ?? {});
580
+ if (slotEntries.length === 0) return slotProps;
581
+
582
+ // Parse URLs to extract pathnames for slot skip comparison.
583
+ // The departing URL (X-Timber-URL) is an untrusted request header —
584
+ // catch parse failures and fall back to no-cache (full render).
585
+ const destParsed = new URL(destinationUrl, 'http://localhost');
586
+ let depParsed: URL | null = null;
587
+ if (departingUrl) {
588
+ try {
589
+ depParsed = new URL(departingUrl, 'http://localhost');
590
+ } catch {
591
+ // Malformed departing URL — disable slot skipping for this request
592
+ }
593
+ }
594
+ const destinationPathname = destParsed.pathname;
595
+ const departingPathname = depParsed?.pathname ?? null;
596
+
597
+ // Compute URL parts for slot skip comparison.
598
+ // Include the owning segment (segIdx + 1) to match findSlotMatch,
599
+ // which slices at parentIndex + 1. Filter out the root segment
600
+ // (segmentName === '') — it maps to '/' and doesn't consume a URL part.
601
+ const segIdx = match.segments.indexOf(segment);
602
+ const parentSegments =
603
+ segIdx >= 0 ? match.segments.slice(0, segIdx + 1).filter((s) => s.segmentName !== '') : [];
604
+ const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
605
+ const parentConsumedParts = extractUrlParts(parentSegments, rawParams);
606
+ const sliceAt = parentConsumedParts.length;
607
+
608
+ function splitPathname(pathname: string): string[] {
609
+ return pathname === '/' ? [] : pathname.slice(1).split('/');
610
+ }
611
+
612
+ const destinationAll = splitPathname(destinationPathname);
613
+ const departingAll = departingPathname ? splitPathname(departingPathname) : null;
614
+ const destinationParts = destinationAll.slice(sliceAt);
615
+ const departingParts = departingAll ? departingAll.slice(sliceAt) : null;
616
+ const clientSlots = clientStateTree?.slots ?? null;
617
+
618
+ // Check if parent segment's URL parts changed (e.g., /users/1 → /users/2).
619
+ let parentParamsChanged = false;
620
+ if (departingAll) {
621
+ const depParent = departingAll.slice(0, sliceAt);
622
+ const destParent = destinationAll.slice(0, sliceAt);
623
+ parentParamsChanged =
624
+ depParent.length !== destParent.length || depParent.some((p, i) => p !== destParent[i]);
625
+ }
626
+
627
+ for (const [slotName, slotNode] of slotEntries) {
628
+ const slotManifest = slotNode as ManifestSegmentNode;
629
+ const slotKey = computeSlotKey(segmentId, `@${slotName}`);
630
+
631
+ // Match the slot's sub-tree against the destination URL parts.
632
+ // Used for both the skip decision and eager access evaluation.
633
+ const destMatch = matchUrlParts(slotManifest, destinationParts);
634
+
635
+ // Seed slot params BEFORE eager access evaluation so that access.ts
636
+ // files calling getSegmentParams() see the slot's own coerced params,
637
+ // not the main route's. This is the same seeding that resolveSlotElement
638
+ // does, but we need it here for the eager path.
639
+ if (destMatch && parentTreePath && Object.keys(destMatch.params).length > 0) {
640
+ const slotSuffix = destMatch.chain
641
+ .map((s) => s.segmentName)
642
+ .filter(Boolean)
643
+ .join('/');
644
+ const prefix = parentTreePath === '/' ? '' : parentTreePath;
645
+ const fullSlotPath = `${prefix}/${slotSuffix}`;
646
+ const coerced = coerceSlotParams(destMatch.chain, destMatch.params);
647
+ setSlotParams(fullSlotPath, coerced);
648
+ }
649
+
650
+ // Check non-access skip conditions first. If any of these fail,
651
+ // the slot can't be skipped regardless of access — no need to run
652
+ // the eager access evaluation (which would double-run access.ts
653
+ // since the in-tree gate also calls it during render).
654
+ const hasInterceptingChildren = slotManifest.children.some(
655
+ (c) => c.segmentType === 'intercepting'
656
+ );
657
+ const isSkipCandidate =
658
+ destMatch !== null &&
659
+ !interception &&
660
+ !hasInterceptingChildren &&
661
+ !parentParamsChanged &&
662
+ departingParts !== null &&
663
+ shouldSkipSlot({
664
+ slotKey,
665
+ clientSlots,
666
+ slotNode: slotManifest,
667
+ departingUrlParts: departingParts,
668
+ destinationUrlParts: destinationParts,
669
+ });
670
+
671
+ // Eagerly evaluate the slot's access chain only for skip candidates.
672
+ // This serves two purposes:
673
+ // 1. Satisfies security principle #3 (auth always runs) for skipped slots
674
+ // 2. Determines whether access denied (denied slots must not be skipped)
675
+ //
676
+ // Non-skip-candidate slots skip eager evaluation — their access.ts
677
+ // runs during render via SlotAccessGate (normal path). Per design doc:
678
+ // "No access check for default.tsx" (destMatch null = unmatched slot).
679
+ const chainVerdicts = isSkipCandidate
680
+ ? await evaluateSlotAccessChain(slotManifest, destMatch!.chain)
681
+ : [];
682
+ const accessBlocked = chainVerdicts.some((v) => v.verdict !== 'pass');
683
+ const canSkip = isSkipCandidate && !accessBlocked;
684
+
685
+ if (canSkip) {
686
+ // Access already ran eagerly (all verdicts 'pass') — principle #3 satisfied.
687
+ // No gate wrapper needed.
688
+ slotProps[slotName] = h(SegmentOutlet, {
689
+ segmentPath: slotKey,
690
+ skip: true,
691
+ children: null,
692
+ });
693
+ // Skipped slots don't render, so they're not request-dependent.
694
+ slotSkipInfo.push({
695
+ slotKey,
696
+ parentSegmentId: segmentId,
697
+ isRequestDependent: false,
698
+ denied: false,
699
+ });
700
+ } else {
701
+ const resolvedElement = await resolveSlotElement(
702
+ slotManifest,
703
+ match,
704
+ h,
705
+ interception,
706
+ parentTreePath,
707
+ chainVerdicts,
708
+ slotKey
709
+ );
710
+ slotProps[slotName] = h(SegmentOutlet, {
711
+ segmentPath: slotKey,
712
+ children: resolvedElement,
713
+ });
714
+ // Conservative default: fully-rendered slots are marked as
715
+ // request-dependent. Element building doesn't execute server
716
+ // components (that happens during renderToReadableStream), so
717
+ // we can't observe whether the page/layout calls getHeaders()
718
+ // or cookies(). Marking as request-dependent means the client
719
+ // won't report the slot as cacheable, so it's always re-rendered.
720
+ // TODO(LOCAL-1127): Implement render-time taint tracking to
721
+ // allow skipping slots that don't actually read request context.
722
+ slotSkipInfo.push({
723
+ slotKey,
724
+ parentSegmentId: segmentId,
725
+ isRequestDependent: true,
726
+ denied: accessBlocked,
727
+ });
728
+ }
729
+ }
730
+
731
+ return slotProps;
732
+ }