@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
@@ -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
 
@@ -49,14 +52,26 @@ async function loadComponent(loader: {
49
52
  /**
50
53
  * Load and render the default.tsx fallback for a slot node.
51
54
  * Returns null if the slot has no default.tsx or it has no default export.
55
+ *
56
+ * When taintKey is provided, wraps the component in a taint scope so
57
+ * accessor calls (getHeaders, cookies, etc.) inside the default.tsx
58
+ * are attributed to this slot instead of poisoning orphanTaint.
52
59
  */
53
60
  async function renderDefaultFallback(
54
61
  slotNode: ManifestSegmentNode,
55
- h: CreateElementFn
62
+ h: CreateElementFn,
63
+ taintKey?: string
56
64
  ): Promise<React.ReactElement | null> {
57
65
  if (!slotNode.default) return null;
58
66
  const DefaultComp = await loadComponent(slotNode.default);
59
67
  if (!DefaultComp) return null;
68
+ if (taintKey && !isClientReference(DefaultComp)) {
69
+ const TaintedDefault = async (props: Record<string, unknown>) =>
70
+ runInTaintScope(taintKey, () =>
71
+ (DefaultComp as (props: Record<string, unknown>) => unknown)(props)
72
+ );
73
+ return h(TaintedDefault, {});
74
+ }
60
75
  return h(DefaultComp, {});
61
76
  }
62
77
 
@@ -80,7 +95,9 @@ export async function resolveSlotElement(
80
95
  match: RouteMatch,
81
96
  h: CreateElementFn,
82
97
  interception?: InterceptionContext,
83
- parentTreePath?: string
98
+ parentTreePath?: string,
99
+ accessVerdicts?: SlotAccessVerdict[],
100
+ taintKey?: string
84
101
  ): Promise<React.ReactElement | null> {
85
102
  // When interception is active, try to match intercepting children in this
86
103
  // slot against the target pathname. If an intercepting child matches, render
@@ -115,7 +132,7 @@ export async function resolveSlotElement(
115
132
  // degrade to default.tsx or null — not crash the page. This matches
116
133
  // Next.js behavior. See design/02-rendering-pipeline.md
117
134
  // §"Slot Access Failure = Graceful Degradation"
118
- const denyFallback = await renderDefaultFallback(slotNode, h);
135
+ const denyFallback = await renderDefaultFallback(slotNode, h, taintKey);
119
136
 
120
137
  // Build the slot page element.
121
138
  // Client references ('use client' pages) must NOT be called as functions —
@@ -131,9 +148,11 @@ export async function resolveSlotElement(
131
148
  if (isClientReference(SlotPage)) {
132
149
  element = h(SlotPage, {});
133
150
  } else {
151
+ const slotTaintKey = taintKey;
134
152
  const SafeSlotPage = async (props: Record<string, unknown>) => {
153
+ const run = async () => (SlotPage as (props: Record<string, unknown>) => unknown)(props);
135
154
  try {
136
- return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
155
+ return slotTaintKey ? await runInTaintScope(slotTaintKey, run) : await run();
137
156
  } catch (error) {
138
157
  // RedirectSignal must propagate — the pipeline handles redirects
139
158
  // at the top level. Swallowing it here would silently return
@@ -161,7 +180,13 @@ export async function resolveSlotElement(
161
180
  // intermediate slot segments (everything between slot root and leaf).
162
181
  // Process innermost-first, same order as route-element-builder.ts
163
182
  // handles main segments. The slot root (index 0) is handled below.
164
- element = await wrapWithIntermediateSegments(slotMatch.chain, element, h);
183
+ element = await wrapWithIntermediateSegments(
184
+ slotMatch.chain,
185
+ element,
186
+ h,
187
+ accessVerdicts,
188
+ taintKey
189
+ );
165
190
 
166
191
  // Wrap with slot root's layout — INSIDE the access gate, so the layout
167
192
  // server component never executes when access.ts denies. See TIM-1074.
@@ -174,7 +199,7 @@ export async function resolveSlotElement(
174
199
  // On denial: denied.tsx → default.tsx → null (graceful degradation),
175
200
  // rendered WITHOUT the denied slot's own layout.
176
201
  // See design/04-authorization.md §"Slot-Level Auth".
177
- element = await wrapWithAccessGate(slotNode, element, h);
202
+ element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts, taintKey);
178
203
 
179
204
  // Wrap with slot root's error boundaries (outermost)
180
205
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
@@ -196,8 +221,12 @@ export async function resolveSlotElement(
196
221
  }
197
222
  }
198
223
 
199
- // No matching page — render default.tsx fallback
200
- return renderDefaultFallback(slotNode, h);
224
+ // No matching page — render default.tsx fallback.
225
+ // Per design/02-rendering-pipeline.md: "No access check for default.tsx."
226
+ // Access verdicts from the eager evaluation are not applied here.
227
+ // Pass taintKey so accessor calls inside default.tsx are scoped to this
228
+ // slot instead of poisoning orphanTaint for the entire request.
229
+ return renderDefaultFallback(slotNode, h, taintKey);
201
230
  }
202
231
 
203
232
  // ─── Element Wrapping Helpers ───────────────────────────────────────────────
@@ -221,13 +250,15 @@ export async function resolveSlotElement(
221
250
  async function wrapWithIntermediateSegments(
222
251
  chain: ManifestSegmentNode[],
223
252
  element: React.ReactElement,
224
- h: CreateElementFn
253
+ h: CreateElementFn,
254
+ accessVerdicts?: SlotAccessVerdict[],
255
+ taintKey?: string
225
256
  ): Promise<React.ReactElement> {
226
257
  for (let i = chain.length - 1; i > 0; i--) {
227
258
  const seg = chain[i];
228
259
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
229
260
  element = await wrapWithLayout(seg, element, h);
230
- element = await wrapWithAccessGate(seg, element, h);
261
+ element = await wrapWithAccessGate(seg, element, h, accessVerdicts, taintKey);
231
262
  }
232
263
  return element;
233
264
  }
@@ -253,7 +284,9 @@ async function wrapWithLayout(
253
284
  async function wrapWithAccessGate(
254
285
  slotNode: ManifestSegmentNode,
255
286
  element: React.ReactElement,
256
- h: CreateElementFn
287
+ h: CreateElementFn,
288
+ accessVerdicts?: SlotAccessVerdict[],
289
+ taintKey?: string
257
290
  ): Promise<React.ReactElement> {
258
291
  if (!slotNode.access) return element;
259
292
 
@@ -270,7 +303,17 @@ async function wrapWithAccessGate(
270
303
  // Extract slot name from the directory name (strip @ prefix)
271
304
  const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
272
305
 
273
- const defaultFallback = await renderDefaultFallback(slotNode, h);
306
+ const defaultFallback = await renderDefaultFallback(slotNode, h, taintKey);
307
+
308
+ // Look up pre-computed verdict from eager evaluation. Only replay
309
+ // denial/redirect verdicts — 'pass' verdicts must NOT be replayed
310
+ // because the access function may warm React.cache (e.g., requireUser())
311
+ // for layout/page dedup, and replaying 'pass' skips the cache-warming
312
+ // call. 'error' verdicts are also excluded — the gate must re-run
313
+ // accessFn so the error reaches the slot's error boundary.
314
+ const preVerdict = accessVerdicts?.find(
315
+ (v) => v.node === slotNode && v.verdict !== 'pass' && v.verdict !== 'error'
316
+ );
274
317
 
275
318
  return h(SlotAccessGate, {
276
319
  accessFn,
@@ -279,6 +322,7 @@ async function wrapWithAccessGate(
279
322
  createElement: h,
280
323
  defaultFallback,
281
324
  children: element,
325
+ verdict: preVerdict?.verdict,
282
326
  });
283
327
  }
284
328
 
@@ -439,3 +483,268 @@ function findInterceptingMatch(
439
483
 
440
484
  return null;
441
485
  }
486
+
487
+ // ─── Slot Access Evaluation ─────────────────────────────────────────────────
488
+
489
+ /** Result of eagerly evaluating one segment's access.ts. */
490
+ export interface SlotAccessVerdict {
491
+ node: ManifestSegmentNode;
492
+ /**
493
+ * 'pass' — access allowed.
494
+ * DenySignal/RedirectSignal — access denied, replayed in SlotAccessGate.
495
+ * 'error' — unknown error. Forces full render. No verdict passed to
496
+ * SlotAccessGate, so it calls accessFn during render and the error
497
+ * reaches the slot's error boundary.
498
+ */
499
+ verdict: 'pass' | 'error' | DenySignal | RedirectSignal;
500
+ }
501
+
502
+ /**
503
+ * Eagerly evaluate the access chain for a slot (root + intermediate segments).
504
+ *
505
+ * Runs each access.ts top-down (outermost first). If any denies, the chain
506
+ * stops (shallowest failure wins, matching segment access semantics).
507
+ *
508
+ * Verdicts are stored for replay in SlotAccessGate / wrapWithIntermediateSegments
509
+ * so access.ts is called exactly once per request.
510
+ */
511
+ async function evaluateSlotAccessChain(
512
+ slotRoot: ManifestSegmentNode,
513
+ chain: ManifestSegmentNode[]
514
+ ): Promise<SlotAccessVerdict[]> {
515
+ // Collect all nodes with access.ts: slot root + intermediate chain segments
516
+ const nodesWithAccess: ManifestSegmentNode[] = [];
517
+ if (slotRoot.access) nodesWithAccess.push(slotRoot);
518
+ for (let i = 1; i < chain.length; i++) {
519
+ if (chain[i].access) nodesWithAccess.push(chain[i]);
520
+ }
521
+
522
+ if (nodesWithAccess.length === 0) return [];
523
+
524
+ const results: SlotAccessVerdict[] = [];
525
+ for (const node of nodesWithAccess) {
526
+ const accessFn = await loadComponent(node.access!);
527
+ if (!accessFn) {
528
+ results.push({ node, verdict: 'pass' });
529
+ continue;
530
+ }
531
+ try {
532
+ await accessFn();
533
+ results.push({ node, verdict: 'pass' });
534
+ } catch (e) {
535
+ if (e instanceof DenySignal) {
536
+ results.push({ node, verdict: e });
537
+ break; // shallowest failure wins
538
+ }
539
+ if (e instanceof RedirectSignal) {
540
+ results.push({ node, verdict: e });
541
+ break;
542
+ }
543
+ // Unknown error — record as 'error' to force full render (blocks
544
+ // canSkip via accessBlocked). No verdict is passed to SlotAccessGate
545
+ // (wrapWithAccessGate filters 'error' out), so the gate calls
546
+ // accessFn during render and the error reaches the error boundary.
547
+ results.push({ node, verdict: 'error' });
548
+ break;
549
+ }
550
+ }
551
+ return results;
552
+ }
553
+
554
+ // ─── Slot Caching ──────────────────────────────────────────────────────────
555
+
556
+ export interface SlotSkipEntry {
557
+ slotKey: string;
558
+ parentSegmentId: string;
559
+ /**
560
+ * Whether the slot's components called request accessors (getHeaders,
561
+ * getSearchParams, cookies) during their last render. Replaces the
562
+ * unreliable AsyncFunction heuristic — taint-tracked via ALS.
563
+ */
564
+ isRequestDependent: boolean;
565
+ /** Whether the slot's access.ts denied on this render. */
566
+ denied: boolean;
567
+ }
568
+
569
+ interface ResolveSlotPropsArgs {
570
+ segment: ManifestSegmentNode;
571
+ segmentId: string;
572
+ match: RouteMatch;
573
+ h: CreateElementFn;
574
+ interception?: InterceptionContext;
575
+ parentTreePath: string;
576
+ departingUrl: string | null;
577
+ destinationUrl: string;
578
+ clientStateTree: ClientStateTree | null;
579
+ slotSkipInfo: SlotSkipEntry[];
580
+ }
581
+
582
+ /**
583
+ * Resolve all parallel route slots for a layout, wrapping each in a
584
+ * SegmentOutlet for client-side caching. Slots whose matched content
585
+ * hasn't changed between the departing and destination URLs are rendered
586
+ * with `skip=true` so the client keeps its cached content.
587
+ */
588
+ export async function resolveSlotProps({
589
+ segment,
590
+ segmentId,
591
+ match,
592
+ h,
593
+ interception,
594
+ parentTreePath,
595
+ departingUrl,
596
+ destinationUrl,
597
+ clientStateTree,
598
+ slotSkipInfo,
599
+ }: ResolveSlotPropsArgs): Promise<Record<string, unknown>> {
600
+ const slotProps: Record<string, unknown> = {};
601
+ const slotEntries = Object.entries(segment.slots ?? {});
602
+ if (slotEntries.length === 0) return slotProps;
603
+
604
+ // Parse URLs to extract pathnames for slot skip comparison.
605
+ // The departing URL (X-Timber-URL) is an untrusted request header —
606
+ // catch parse failures and fall back to no-cache (full render).
607
+ const destParsed = new URL(destinationUrl, 'http://localhost');
608
+ let depParsed: URL | null = null;
609
+ if (departingUrl) {
610
+ try {
611
+ depParsed = new URL(departingUrl, 'http://localhost');
612
+ } catch {
613
+ // Malformed departing URL — disable slot skipping for this request
614
+ }
615
+ }
616
+ const destinationPathname = destParsed.pathname;
617
+ const departingPathname = depParsed?.pathname ?? null;
618
+
619
+ // Compute URL parts for slot skip comparison.
620
+ // Include the owning segment (segIdx + 1) to match findSlotMatch,
621
+ // which slices at parentIndex + 1. Filter out the root segment
622
+ // (segmentName === '') — it maps to '/' and doesn't consume a URL part.
623
+ const segIdx = match.segments.indexOf(segment);
624
+ const parentSegments =
625
+ segIdx >= 0 ? match.segments.slice(0, segIdx + 1).filter((s) => s.segmentName !== '') : [];
626
+ const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
627
+ const parentConsumedParts = extractUrlParts(parentSegments, rawParams);
628
+ const sliceAt = parentConsumedParts.length;
629
+
630
+ function splitPathname(pathname: string): string[] {
631
+ return pathname === '/' ? [] : pathname.slice(1).split('/');
632
+ }
633
+
634
+ const destinationAll = splitPathname(destinationPathname);
635
+ const departingAll = departingPathname ? splitPathname(departingPathname) : null;
636
+ const destinationParts = destinationAll.slice(sliceAt);
637
+ const departingParts = departingAll ? departingAll.slice(sliceAt) : null;
638
+ const clientSlots = clientStateTree?.slots ?? null;
639
+
640
+ // Check if parent segment's URL parts changed (e.g., /users/1 → /users/2).
641
+ let parentParamsChanged = false;
642
+ if (departingAll) {
643
+ const depParent = departingAll.slice(0, sliceAt);
644
+ const destParent = destinationAll.slice(0, sliceAt);
645
+ parentParamsChanged =
646
+ depParent.length !== destParent.length || depParent.some((p, i) => p !== destParent[i]);
647
+ }
648
+
649
+ for (const [slotName, slotNode] of slotEntries) {
650
+ const slotManifest = slotNode as ManifestSegmentNode;
651
+ const slotKey = computeSlotKey(segmentId, `@${slotName}`);
652
+
653
+ // Match the slot's sub-tree against the destination URL parts.
654
+ // Used for both the skip decision and eager access evaluation.
655
+ const destMatch = matchUrlParts(slotManifest, destinationParts);
656
+
657
+ // Seed slot params BEFORE eager access evaluation so that access.ts
658
+ // files calling getSegmentParams() see the slot's own coerced params,
659
+ // not the main route's. This is the same seeding that resolveSlotElement
660
+ // does, but we need it here for the eager path.
661
+ if (destMatch && parentTreePath && Object.keys(destMatch.params).length > 0) {
662
+ const slotSuffix = destMatch.chain
663
+ .map((s) => s.segmentName)
664
+ .filter(Boolean)
665
+ .join('/');
666
+ const prefix = parentTreePath === '/' ? '' : parentTreePath;
667
+ const fullSlotPath = `${prefix}/${slotSuffix}`;
668
+ const coerced = coerceSlotParams(destMatch.chain, destMatch.params);
669
+ setSlotParams(fullSlotPath, coerced);
670
+ }
671
+
672
+ // Check non-access skip conditions first. If any of these fail,
673
+ // the slot can't be skipped regardless of access — no need to run
674
+ // the eager access evaluation (which would double-run access.ts
675
+ // since the in-tree gate also calls it during render).
676
+ const hasInterceptingChildren = slotManifest.children.some(
677
+ (c) => c.segmentType === 'intercepting'
678
+ );
679
+ const isSkipCandidate =
680
+ destMatch !== null &&
681
+ !interception &&
682
+ !hasInterceptingChildren &&
683
+ !parentParamsChanged &&
684
+ departingParts !== null &&
685
+ shouldSkipSlot({
686
+ slotKey,
687
+ clientSlots,
688
+ slotNode: slotManifest,
689
+ departingUrlParts: departingParts,
690
+ destinationUrlParts: destinationParts,
691
+ });
692
+
693
+ // Eagerly evaluate the slot's access chain only for skip candidates.
694
+ // This serves two purposes:
695
+ // 1. Satisfies security principle #3 (auth always runs) for skipped slots
696
+ // 2. Determines whether access denied (denied slots must not be skipped)
697
+ //
698
+ // Non-skip-candidate slots skip eager evaluation — their access.ts
699
+ // runs during render via SlotAccessGate (normal path). Per design doc:
700
+ // "No access check for default.tsx" (destMatch null = unmatched slot).
701
+ const chainVerdicts = isSkipCandidate
702
+ ? await evaluateSlotAccessChain(slotManifest, destMatch!.chain)
703
+ : [];
704
+ const accessBlocked = chainVerdicts.some((v) => v.verdict !== 'pass');
705
+ const canSkip = isSkipCandidate && !accessBlocked;
706
+
707
+ if (canSkip) {
708
+ // Access already ran eagerly (all verdicts 'pass') — principle #3 satisfied.
709
+ // No gate wrapper needed.
710
+ slotProps[slotName] = h(SegmentOutlet, {
711
+ segmentPath: slotKey,
712
+ skip: true,
713
+ children: null,
714
+ });
715
+ // Skipped slots don't render, so they're not request-dependent.
716
+ slotSkipInfo.push({
717
+ slotKey,
718
+ parentSegmentId: segmentId,
719
+ isRequestDependent: false,
720
+ denied: false,
721
+ });
722
+ } else {
723
+ const resolvedElement = await resolveSlotElement(
724
+ slotManifest,
725
+ match,
726
+ h,
727
+ interception,
728
+ parentTreePath,
729
+ chainVerdicts,
730
+ slotKey
731
+ );
732
+ slotProps[slotName] = h(SegmentOutlet, {
733
+ segmentPath: slotKey,
734
+ children: resolvedElement,
735
+ });
736
+ // Conservative pre-render default: true. Overridden by taint
737
+ // results in buildSegmentInfo() after renderToReadableStream
738
+ // completes — runInTaintScope records whether the slot page
739
+ // actually called getHeaders/cookies/getSearchParams.
740
+ slotSkipInfo.push({
741
+ slotKey,
742
+ parentSegmentId: segmentId,
743
+ isRequestDependent: true,
744
+ denied: accessBlocked,
745
+ });
746
+ }
747
+ }
748
+
749
+ return slotProps;
750
+ }
@@ -15,6 +15,8 @@
15
15
  */
16
16
 
17
17
  import { swallow } from './logger.js';
18
+ import { matchUrlParts } from './tree-match.js';
19
+ import type { ManifestSegmentNode } from './route-matcher.js';
18
20
 
19
21
  // ─── Segment Key Computation ─────────────────────────────────────
20
22
 
@@ -59,27 +61,48 @@ export function computeSegmentKeys(segments: SegmentKeyInput[]): string[] {
59
61
  return keys;
60
62
  }
61
63
 
64
+ /**
65
+ * Compute a unique key for a parallel route slot.
66
+ * Format: `{parentSegmentId}/@{slotName}`, e.g. `/@sidebar` or `/dashboard/@modal`.
67
+ */
68
+ export function computeSlotKey(parentSegmentId: string, slotName: string): string {
69
+ const name = slotName.startsWith('@') ? slotName : `@${slotName}`;
70
+ const prefix = parentSegmentId === '/' ? '' : parentSegmentId;
71
+ return `${prefix}/${name}`;
72
+ }
73
+
62
74
  // ─── State Tree Parsing ──────────────────────────────────────────
63
75
 
76
+ /** Parsed client state tree with segment paths and optional slot paths. */
77
+ export interface ClientStateTree extends Set<string> {
78
+ slots?: Set<string> | null;
79
+ }
80
+
64
81
  /**
65
82
  * Parse the X-Timber-State-Tree header from a request.
66
83
  *
67
- * Returns a Set of segment paths the client has cached, or null if
84
+ * Returns a Set of segment paths the client has cached (with an
85
+ * additional `slots` property for cached slot paths), or null if
68
86
  * the header is missing, malformed, or empty. Parsing happens before
69
87
  * renderToReadableStream — not inside the React render pass.
70
88
  *
71
- * @returns Set of sync segment paths, or null if no valid state tree
89
+ * @returns ClientStateTree with segments and slots, or null if no valid state tree
72
90
  */
73
- export function parseClientStateTree(req: Request): Set<string> | null {
91
+ export function parseClientStateTree(req: Request): ClientStateTree | null {
74
92
  const header = req.headers.get('X-Timber-State-Tree');
75
93
  if (!header) return null;
76
94
 
77
95
  try {
78
- const parsed = JSON.parse(header) as { segments?: unknown };
96
+ const parsed = JSON.parse(header) as { segments?: unknown; slots?: unknown };
79
97
  if (!Array.isArray(parsed.segments) || parsed.segments.length === 0) {
80
98
  return null;
81
99
  }
82
- return new Set(parsed.segments as string[]);
100
+ const result = new Set(parsed.segments as string[]) as ClientStateTree;
101
+ result.slots =
102
+ Array.isArray(parsed.slots) && parsed.slots.length > 0
103
+ ? new Set(parsed.slots as string[])
104
+ : null;
105
+ return result;
83
106
  } catch (err) {
84
107
  swallow(err, 'malformed X-Timber-State-Tree header');
85
108
  return null;
@@ -120,10 +143,83 @@ export function shouldSkipSegment(
120
143
  if (!layoutComponent) return false;
121
144
  if (isLeaf) return false;
122
145
 
123
- // Async layouts always re-render — they may depend on per-request data
124
- // (cookies, headers, database queries) that changes between navigations.
125
- // constructor.name check: AsyncFunction.name === 'AsyncFunction'
146
+ // Pre-render guard: async layouts may depend on per-request data.
147
+ // Taint tracking (runInTaintScope) handles the post-render client-side
148
+ // caching decision via X-Timber-Segments. This server-side check remains
149
+ // conservative because taint results aren't available at element-build time.
126
150
  if (layoutComponent.constructor.name === 'AsyncFunction') return false;
127
151
 
128
152
  return true;
129
153
  }
154
+
155
+ // ─── Slot Skip Decision ─────────────────────────────────────────
156
+
157
+ interface ShouldSkipSlotArgs {
158
+ slotKey: string;
159
+ clientSlots: Set<string> | null;
160
+ slotNode: ManifestSegmentNode;
161
+ departingUrlParts: string[];
162
+ destinationUrlParts: string[];
163
+ }
164
+
165
+ /**
166
+ * Determine whether a parallel route slot can be skipped.
167
+ *
168
+ * A slot is skipped when ALL of:
169
+ * 1. The client has this slot cached (slotKey is in clientSlots)
170
+ * 2. The slot's matched page is the SAME for both the departing and
171
+ * destination URLs (same page file + same extracted params)
172
+ *
173
+ * The comparison uses matchUrlParts to find what each URL would match
174
+ * in the slot's sub-tree, then compares the matched page file and params.
175
+ *
176
+ * This is a performance optimization only, NOT a security boundary.
177
+ * Slot access.ts always runs via SlotAccessGate regardless.
178
+ */
179
+ export function shouldSkipSlot({
180
+ slotKey,
181
+ clientSlots,
182
+ slotNode,
183
+ departingUrlParts,
184
+ destinationUrlParts,
185
+ }: ShouldSkipSlotArgs): boolean {
186
+ if (!clientSlots) return false;
187
+ if (!clientSlots.has(slotKey)) return false;
188
+
189
+ const departingMatch = matchUrlParts(slotNode, departingUrlParts);
190
+ const destinationMatch = matchUrlParts(slotNode, destinationUrlParts);
191
+
192
+ // Both null (no match) — slot shows default.tsx in both cases
193
+ if (!departingMatch && !destinationMatch) return true;
194
+ // One null, one not — match changed
195
+ if (!departingMatch || !destinationMatch) return false;
196
+
197
+ const departingLeaf = departingMatch.chain[departingMatch.chain.length - 1];
198
+ const destinationLeaf = destinationMatch.chain[destinationMatch.chain.length - 1];
199
+
200
+ // Both must have a page, and the page file must be the same
201
+ if (!departingLeaf.page || !destinationLeaf.page) {
202
+ return !departingLeaf.page && !destinationLeaf.page;
203
+ }
204
+ if (departingLeaf.page.filePath !== destinationLeaf.page.filePath) return false;
205
+
206
+ // Compare extracted params — if any differ, the slot content may change
207
+ return paramsEqual(departingMatch.params, destinationMatch.params);
208
+ }
209
+
210
+ function paramsEqual(
211
+ a: Record<string, string | string[]>,
212
+ b: Record<string, string | string[]>
213
+ ): boolean {
214
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
215
+ for (const key of keys) {
216
+ const va = a[key];
217
+ const vb = b[key];
218
+ if (Array.isArray(va) && Array.isArray(vb)) {
219
+ if (va.length !== vb.length || va.some((v, i) => v !== vb[i])) return false;
220
+ } else if (va !== vb) {
221
+ return false;
222
+ }
223
+ }
224
+ return true;
225
+ }
@@ -180,6 +180,16 @@ export interface SlotAccessGateProps {
180
180
  createElement: CreateElement;
181
181
  defaultFallback: ReactNode;
182
182
  children: ReactNode;
183
+ /**
184
+ * Pre-computed verdict from eager access evaluation. When provided,
185
+ * SlotAccessGate replays it synchronously instead of re-calling accessFn.
186
+ * 'pass' → render children. DenySignal → graceful degradation.
187
+ * undefined → call accessFn during render (backward compat, error re-run).
188
+ */
189
+ verdict?:
190
+ | 'pass'
191
+ | import('./primitives.js').DenySignal
192
+ | import('./primitives.js').RedirectSignal;
183
193
  }
184
194
 
185
195
  /**