@timber-js/app 0.2.0-alpha.175 → 0.2.0-alpha.176

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 (51) hide show
  1. package/dist/_chunks/{actions-pN8r5Vnh.js → actions-O_LsyCE4.js} +3 -3
  2. package/dist/_chunks/{actions-pN8r5Vnh.js.map → actions-O_LsyCE4.js.map} +1 -1
  3. package/dist/_chunks/{cache-api-2hT5kfsr.js → cache-api-B-lhk9p4.js} +2 -2
  4. package/dist/_chunks/{cache-api-2hT5kfsr.js.map → cache-api-B-lhk9p4.js.map} +1 -1
  5. package/dist/_chunks/{cli-schema-sync-DvdvFwwE.js → cli-schema-sync-D3naS3eH.js} +2 -2
  6. package/dist/_chunks/{cli-schema-sync-DvdvFwwE.js.map → cli-schema-sync-D3naS3eH.js.map} +1 -1
  7. package/dist/_chunks/{logger-kUT0QH0K.js → logger-AWfuX-KJ.js} +1 -23
  8. package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
  9. package/dist/_chunks/{walkers-Bv63zfAC.js → walkers-BtTlKviE.js} +2 -2
  10. package/dist/_chunks/{walkers-Bv63zfAC.js.map → walkers-BtTlKviE.js.map} +1 -1
  11. package/dist/cache/index.js +1 -1
  12. package/dist/cli.js +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +227 -4
  15. package/dist/index.js.map +1 -1
  16. package/dist/plugins/build-manifest.d.ts.map +1 -1
  17. package/dist/plugins/request-dep.d.ts +21 -0
  18. package/dist/plugins/request-dep.d.ts.map +1 -0
  19. package/dist/routing/index.js +2 -2
  20. package/dist/server/als-registry.d.ts +0 -26
  21. package/dist/server/als-registry.d.ts.map +1 -1
  22. package/dist/server/build-manifest.d.ts +6 -0
  23. package/dist/server/build-manifest.d.ts.map +1 -1
  24. package/dist/server/cookie-context.d.ts.map +1 -1
  25. package/dist/server/index.js +2 -2
  26. package/dist/server/internal.js +3 -3
  27. package/dist/server/internal.js.map +1 -1
  28. package/dist/server/request-context.d.ts +0 -15
  29. package/dist/server/request-context.d.ts.map +1 -1
  30. package/dist/server/request-dep.d.ts +15 -0
  31. package/dist/server/request-dep.d.ts.map +1 -0
  32. package/dist/server/route-element-builder.d.ts.map +1 -1
  33. package/dist/server/rsc-entry/helpers.d.ts +6 -6
  34. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  35. package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
  36. package/dist/server/slot-resolver.d.ts +1 -1
  37. package/dist/server/slot-resolver.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/index.ts +2 -0
  40. package/src/plugins/build-manifest.ts +10 -1
  41. package/src/plugins/request-dep.ts +458 -0
  42. package/src/server/als-registry.ts +0 -44
  43. package/src/server/build-manifest.ts +6 -0
  44. package/src/server/cookie-context.ts +1 -7
  45. package/src/server/request-context.ts +3 -53
  46. package/src/server/request-dep.ts +37 -0
  47. package/src/server/route-element-builder.ts +2 -6
  48. package/src/server/rsc-entry/helpers.ts +9 -26
  49. package/src/server/rsc-entry/rsc-payload.ts +4 -13
  50. package/src/server/slot-resolver.ts +52 -46
  51. package/dist/_chunks/logger-kUT0QH0K.js.map +0 -1
@@ -140,18 +140,6 @@ 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;
155
143
  }
156
144
 
157
145
  /** A single outgoing cookie entry in the cookie jar. */
@@ -161,38 +149,6 @@ export interface CookieEntry {
161
149
  options: import('./cookie-context.js').CookieOptions;
162
150
  }
163
151
 
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
-
196
152
  // ─── Tracing ──────────────────────────────────────────────────────────────
197
153
  // Used by: tracing.ts (getTraceId(), getSpanId())
198
154
  // Design doc: design/17-logging.md
@@ -41,6 +41,12 @@ export interface BuildManifest {
41
41
  * In dev mode this is empty — a startup nonce is used instead.
42
42
  */
43
43
  metadataRouteHashes?: Record<string, string>;
44
+ /**
45
+ * Static request-dependency map from the Vite plugin's module-graph analysis.
46
+ * Map from segment entry file path to whether it's request-dependent.
47
+ * Used by buildSegmentInfo() for X-Timber-Segments in production.
48
+ */
49
+ requestDep?: Record<string, boolean>;
44
50
  }
45
51
 
46
52
  /** Empty build manifest used in dev mode. */
@@ -12,11 +12,7 @@
12
12
  * smuggling primitive that the encoding contract closes.
13
13
  */
14
14
 
15
- import {
16
- requestContextAls,
17
- markAccessorTouched,
18
- type RequestContextStore,
19
- } from './als-registry.js';
15
+ import { requestContextAls, type RequestContextStore } from './als-registry.js';
20
16
  import { isDebug } from './debug.js';
21
17
  import {
22
18
  assertValidCookieName,
@@ -66,8 +62,6 @@ export function getCookieJar(): RequestCookies {
66
62
  );
67
63
  }
68
64
 
69
- markAccessorTouched();
70
-
71
65
  // Parse cookies lazily on first access
72
66
  if (!store.parsedCookies) {
73
67
  store.parsedCookies = parseCookieHeader(store.cookieHeader);
@@ -14,13 +14,7 @@
14
14
  * and design/11-platform.md §"AsyncLocalStorage".
15
15
  */
16
16
 
17
- import {
18
- requestContextAls,
19
- taintScopeAls,
20
- markAccessorTouched,
21
- type RequestContextStore,
22
- type TaintScope,
23
- } from './als-registry.js';
17
+ import { requestContextAls, type RequestContextStore } from './als-registry.js';
24
18
  import { _setGetSearchParamsFn } from '../search-params/define.js';
25
19
  import { _setGetSegmentParamsFn } from '../segment-params/define.js';
26
20
  import { consumeSeededCookies } from './cookie-context.js';
@@ -45,7 +39,7 @@ export function getHeaders(): ReadonlyHeaders {
45
39
  'It can only be used in middleware, access checks, server components, and server actions.'
46
40
  );
47
41
  }
48
- markAccessorTouched();
42
+
49
43
  return store.headers;
50
44
  }
51
45
 
@@ -58,7 +52,6 @@ export function getHeaders(): ReadonlyHeaders {
58
52
  * @internal — not part of the public API. Use `getHeaders().get(name)` instead.
59
53
  */
60
54
  export function getHeader(name: string): string | undefined {
61
- // getHeaders() already calls markAccessorTouched()
62
55
  const headers = getHeaders();
63
56
  return headers.get(name) ?? undefined;
64
57
  }
@@ -76,7 +69,7 @@ export function getSearchParams(): URLSearchParams {
76
69
  'It can only be used in middleware, access checks, server components, and server actions.'
77
70
  );
78
71
  }
79
- markAccessorTouched();
72
+
80
73
  return store.searchParams;
81
74
  }
82
75
 
@@ -226,49 +219,6 @@ export function getRequestSearchString(): string {
226
219
  return store?.searchString ?? '';
227
220
  }
228
221
 
229
- // ─── Request Accessor Taint Tracking ─────────────────────────────────────
230
- // Used by segment/slot skip decisions to determine whether a component
231
- // actually reads per-request data, replacing the AsyncFunction heuristic.
232
-
233
- /**
234
- * Run a callback inside a taint tracking scope. Returns whether any
235
- * request accessor (getHeaders, getSearchParams, cookies) was called
236
- * during the callback's execution.
237
- *
238
- * Uses a dedicated ALS so concurrent async components (Flight renders
239
- * siblings in parallel) each get their own isolated scope. The shared
240
- * push/pop stack model was unsound under concurrency.
241
- *
242
- * Results are stored in the request context's taintResults map, keyed
243
- * by segment/slot ID, for buildSegmentInfo() to consume.
244
- *
245
- * @internal — framework use only (route-element-builder.ts, slot-resolver.ts)
246
- */
247
- export async function runInTaintScope<T>(segmentKey: string, fn: () => T | Promise<T>): Promise<T> {
248
- const store = requestContextAls.getStore();
249
- const scope: TaintScope = { touched: false };
250
- try {
251
- const result = await taintScopeAls.run(scope, fn);
252
- if (store) {
253
- if (!store.taintResults) store.taintResults = new Map();
254
- // OR-merge: once a key is tainted, it stays tainted. A second scope
255
- // for the same key (e.g. default.tsx fallback after a denied slot page)
256
- // must not overwrite an earlier true with false.
257
- store.taintResults.set(
258
- segmentKey,
259
- scope.touched || (store.taintResults.get(segmentKey) ?? false)
260
- );
261
- }
262
- return result;
263
- } catch (error) {
264
- if (store) {
265
- if (!store.taintResults) store.taintResults = new Map();
266
- store.taintResults.set(segmentKey, true);
267
- }
268
- throw error;
269
- }
270
- }
271
-
272
222
  // ─── Types ────────────────────────────────────────────────────────────────
273
223
 
274
224
  /**
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Static request-dependency lookup.
3
+ *
4
+ * Queries the Vite plugin's precomputed module-graph analysis to determine
5
+ * whether a segment's entry file (layout/page/default) is request-dependent.
6
+ *
7
+ * In dev: the plugin registers a resolver function via Symbol.for bridge
8
+ * that lazily walks the RSC module graph.
9
+ * In prod: reads from the build manifest's requestDep map (set during build
10
+ * and serialized into the manifest init script by the adapter).
11
+ *
12
+ * Returns true (conservative) when no data is available.
13
+ */
14
+
15
+ const BRIDGE_KEY = Symbol.for('timber:request-dep');
16
+
17
+ type RequestDepResolver = (filePath: string) => boolean;
18
+
19
+ export function isStaticRequestDependent(filePath: string): boolean {
20
+ // Dev path: Vite plugin registers a resolver function
21
+ const resolver = (globalThis as Record<symbol, unknown>)[BRIDGE_KEY] as
22
+ | RequestDepResolver
23
+ | undefined;
24
+ if (resolver) return resolver(filePath);
25
+
26
+ // Prod path: read from the build manifest (loaded by adapter's init script)
27
+ const manifest = (
28
+ globalThis as unknown as {
29
+ __TIMBER_BUILD_MANIFEST__?: { requestDep?: Record<string, boolean> };
30
+ }
31
+ ).__TIMBER_BUILD_MANIFEST__;
32
+ if (manifest?.requestDep && filePath in manifest.requestDep) {
33
+ return manifest.requestDep[filePath];
34
+ }
35
+
36
+ return true; // Unknown → conservative
37
+ }
@@ -43,7 +43,6 @@ import { SlotsProvider } from '../client/slot-provider.js';
43
43
  import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
44
44
  import type { InterceptionContext } from './pipeline.js';
45
45
  import { shouldSkipSegment, computeSegmentKeys, type ClientStateTree } from './state-tree-diff.js';
46
- import { runInTaintScope } from './request-context.js';
47
46
  import { headElementsToReact } from './metadata.js';
48
47
  import { loadModule } from './safe-load.js';
49
48
 
@@ -663,13 +662,10 @@ export async function buildRouteElement(
663
662
  const layoutDenyPages = denyPageChains.get(i);
664
663
  const hasLayoutDenyChain = !!layoutDenyPages && layoutDenyPages.length > 0;
665
664
  const onLayoutSettled = hasLayoutDenyChain ? trackShellComponent() : undefined;
666
- const taintKey = segmentId;
667
665
  const TracedLayout = async (props: Record<string, unknown>) => {
668
666
  try {
669
- return await runInTaintScope(taintKey, () =>
670
- withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
671
- (layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
672
- )
667
+ return await withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
668
+ (layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
673
669
  );
674
670
  } catch (error: unknown) {
675
671
  if (error instanceof DenySignal && layoutDenyPages) {
@@ -9,6 +9,7 @@ import { swallow } from '../logger.js';
9
9
  import { computeSegmentKeys } from '../state-tree-diff.js';
10
10
  import type { SlotSkipEntry } from '../slot-resolver.js';
11
11
  import { isClientReference } from '../route-element-builder.js';
12
+ import { isStaticRequestDependent } from '../request-dep.js';
12
13
 
13
14
  /** RSC content type for client navigation payload requests. */
14
15
  export const RSC_CONTENT_TYPE = 'text/x-component';
@@ -171,11 +172,11 @@ export function parseDebugRows(text: string): DebugComponentEntry[] {
171
172
  * Describes the rendered segment chain with request-dependency status,
172
173
  * enabling the client to populate its segment cache for state tree diffing.
173
174
  *
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).
175
+ * Request-dependency is determined by static module-graph analysis: the
176
+ * Vite plugin walks each segment's import graph and marks it as
177
+ * request-dependent if any reachable module calls a request accessor.
178
+ * This replaces the previous runtime ALS-based taint tracking which
179
+ * couldn't attribute accessor calls in Flight-deferred child components.
179
180
  *
180
181
  * See design/07-routing.md §"Server Diffing Rules".
181
182
  */
@@ -195,8 +196,6 @@ export function buildSegmentInfo(
195
196
  segment: ManifestSegmentNode;
196
197
  }>,
197
198
  slotSkipInfo?: SlotSkipEntry[],
198
- taintResults?: Map<string, boolean>,
199
- orphanTaint?: boolean,
200
199
  skippedSegmentKeys?: string[]
201
200
  ): SegmentInfoEntry[] {
202
201
  const layoutBySegment = new Map(
@@ -216,22 +215,12 @@ export function buildSegmentInfo(
216
215
  const segmentId = segmentKeys[i];
217
216
  let isRequestDependent: boolean;
218
217
  if (isClientReference(component)) {
219
- // Client components never execute on the server — they can't
220
- // call getHeaders()/cookies()/getSearchParams(). Always cacheable.
221
218
  isRequestDependent = false;
222
- } else if (orphanTaint) {
223
- isRequestDependent = true;
224
- } else if (taintResults?.has(segmentId)) {
225
- isRequestDependent = taintResults.get(segmentId)!;
226
219
  } 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
220
  isRequestDependent = false;
221
+ } else if (segment.layout) {
222
+ isRequestDependent = isStaticRequestDependent(segment.layout.filePath);
232
223
  } else {
233
- // No taint data (SSR path or segment not yet rendered).
234
- // Default to true — safe fallback that degrades to current behavior.
235
224
  isRequestDependent = true;
236
225
  }
237
226
 
@@ -247,15 +236,9 @@ export function buildSegmentInfo(
247
236
 
248
237
  if (slotSkipInfo) {
249
238
  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
239
  const entry: SegmentInfoEntry = {
257
240
  path: slot.slotKey,
258
- isRequestDependent: slotRequestDependent,
241
+ isRequestDependent: slot.isRequestDependent,
259
242
  slot: true,
260
243
  parentSegment: slot.parentSegmentId,
261
244
  };
@@ -22,7 +22,7 @@ import {
22
22
  createDebugChannelSink,
23
23
  RSC_CONTENT_TYPE,
24
24
  } from './helpers.js';
25
- import { requestContextAls } from '../als-registry.js';
25
+
26
26
  import type { RenderSignals } from './rsc-stream.js';
27
27
 
28
28
  /**
@@ -197,19 +197,10 @@ export async function buildRscPayloadResponse(
197
197
  // client rendering. No X-Timber-Head header needed. See TIM-1151.
198
198
 
199
199
  // Send segment metadata so the client can populate its segment cache
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).
200
+ // for state tree diffing on subsequent navigations. Request-dependency
201
+ // is determined by static module-graph analysis (not runtime taint).
203
202
  // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
204
- const store = requestContextAls.getStore();
205
- const segmentInfo = buildSegmentInfo(
206
- segments,
207
- layoutComponents,
208
- slotSkipInfo,
209
- store?.taintResults,
210
- store?.orphanTaint,
211
- skippedSegments
212
- );
203
+ const segmentInfo = buildSegmentInfo(segments, layoutComponents, slotSkipInfo, skippedSegments);
213
204
  responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
214
205
 
215
206
  // Send skipped segments so the client can merge the partial RSC payload
@@ -31,7 +31,7 @@ import { coerceSlotParams } from './param-coercion.js';
31
31
  import { matchUrlParts } from './tree-match.js';
32
32
  import { SegmentOutlet } from '../client/segment-outlet.js';
33
33
  import { computeSlotKey, shouldSkipSlot, type ClientStateTree } from './state-tree-diff.js';
34
- import { runInTaintScope } from './request-context.js';
34
+ import { isStaticRequestDependent } from './request-dep.js';
35
35
 
36
36
  type CreateElementFn = (...args: unknown[]) => React.ReactElement;
37
37
 
@@ -52,26 +52,14 @@ async function loadComponent(loader: {
52
52
  /**
53
53
  * Load and render the default.tsx fallback for a slot node.
54
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.
59
55
  */
60
56
  async function renderDefaultFallback(
61
57
  slotNode: ManifestSegmentNode,
62
- h: CreateElementFn,
63
- taintKey?: string
58
+ h: CreateElementFn
64
59
  ): Promise<React.ReactElement | null> {
65
60
  if (!slotNode.default) return null;
66
61
  const DefaultComp = await loadComponent(slotNode.default);
67
62
  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
- }
75
63
  return h(DefaultComp, {});
76
64
  }
77
65
 
@@ -96,8 +84,7 @@ export async function resolveSlotElement(
96
84
  h: CreateElementFn,
97
85
  interception?: InterceptionContext,
98
86
  parentTreePath?: string,
99
- accessVerdicts?: SlotAccessVerdict[],
100
- taintKey?: string
87
+ accessVerdicts?: SlotAccessVerdict[]
101
88
  ): Promise<React.ReactElement | null> {
102
89
  // When interception is active, try to match intercepting children in this
103
90
  // slot against the target pathname. If an intercepting child matches, render
@@ -132,7 +119,7 @@ export async function resolveSlotElement(
132
119
  // degrade to default.tsx or null — not crash the page. This matches
133
120
  // Next.js behavior. See design/02-rendering-pipeline.md
134
121
  // §"Slot Access Failure = Graceful Degradation"
135
- const denyFallback = await renderDefaultFallback(slotNode, h, taintKey);
122
+ const denyFallback = await renderDefaultFallback(slotNode, h);
136
123
 
137
124
  // Build the slot page element.
138
125
  // Client references ('use client' pages) must NOT be called as functions —
@@ -148,11 +135,9 @@ export async function resolveSlotElement(
148
135
  if (isClientReference(SlotPage)) {
149
136
  element = h(SlotPage, {});
150
137
  } else {
151
- const slotTaintKey = taintKey;
152
138
  const SafeSlotPage = async (props: Record<string, unknown>) => {
153
- const run = async () => (SlotPage as (props: Record<string, unknown>) => unknown)(props);
154
139
  try {
155
- return slotTaintKey ? await runInTaintScope(slotTaintKey, run) : await run();
140
+ return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
156
141
  } catch (error) {
157
142
  // RedirectSignal must propagate — the pipeline handles redirects
158
143
  // at the top level. Swallowing it here would silently return
@@ -180,13 +165,7 @@ export async function resolveSlotElement(
180
165
  // intermediate slot segments (everything between slot root and leaf).
181
166
  // Process innermost-first, same order as route-element-builder.ts
182
167
  // handles main segments. The slot root (index 0) is handled below.
183
- element = await wrapWithIntermediateSegments(
184
- slotMatch.chain,
185
- element,
186
- h,
187
- accessVerdicts,
188
- taintKey
189
- );
168
+ element = await wrapWithIntermediateSegments(slotMatch.chain, element, h, accessVerdicts);
190
169
 
191
170
  // Wrap with slot root's layout — INSIDE the access gate, so the layout
192
171
  // server component never executes when access.ts denies. See TIM-1074.
@@ -199,7 +178,7 @@ export async function resolveSlotElement(
199
178
  // On denial: denied.tsx → default.tsx → null (graceful degradation),
200
179
  // rendered WITHOUT the denied slot's own layout.
201
180
  // See design/04-authorization.md §"Slot-Level Auth".
202
- element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts, taintKey);
181
+ element = await wrapWithAccessGate(slotNode, element, h, accessVerdicts);
203
182
 
204
183
  // Wrap with slot root's error boundaries (outermost)
205
184
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, h);
@@ -225,8 +204,7 @@ export async function resolveSlotElement(
225
204
  // Per design/02-rendering-pipeline.md: "No access check for default.tsx."
226
205
  // Access verdicts from the eager evaluation are not applied here.
227
206
  // 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);
207
+ return renderDefaultFallback(slotNode, h);
230
208
  }
231
209
 
232
210
  // ─── Element Wrapping Helpers ───────────────────────────────────────────────
@@ -251,14 +229,13 @@ async function wrapWithIntermediateSegments(
251
229
  chain: ManifestSegmentNode[],
252
230
  element: React.ReactElement,
253
231
  h: CreateElementFn,
254
- accessVerdicts?: SlotAccessVerdict[],
255
- taintKey?: string
232
+ accessVerdicts?: SlotAccessVerdict[]
256
233
  ): Promise<React.ReactElement> {
257
234
  for (let i = chain.length - 1; i > 0; i--) {
258
235
  const seg = chain[i];
259
236
  element = await wrapSegmentWithErrorBoundaries(seg, element, h);
260
237
  element = await wrapWithLayout(seg, element, h);
261
- element = await wrapWithAccessGate(seg, element, h, accessVerdicts, taintKey);
238
+ element = await wrapWithAccessGate(seg, element, h, accessVerdicts);
262
239
  }
263
240
  return element;
264
241
  }
@@ -285,25 +262,21 @@ async function wrapWithAccessGate(
285
262
  slotNode: ManifestSegmentNode,
286
263
  element: React.ReactElement,
287
264
  h: CreateElementFn,
288
- accessVerdicts?: SlotAccessVerdict[],
289
- taintKey?: string
265
+ accessVerdicts?: SlotAccessVerdict[]
290
266
  ): Promise<React.ReactElement> {
291
267
  if (!slotNode.access) return element;
292
268
 
293
269
  const accessFn = await loadComponent(slotNode.access);
294
270
  if (!accessFn) return element;
295
271
 
296
- // Pass the component (not pre-built element) so SlotAccessGate can
297
- // forward DenySignal.data as dangerouslyPassData dynamically. See TIM-488.
298
272
  let DeniedComponent: ((...args: unknown[]) => unknown) | null = null;
299
273
  if (slotNode.denied) {
300
274
  DeniedComponent = (await loadComponent(slotNode.denied)) ?? null;
301
275
  }
302
276
 
303
- // Extract slot name from the directory name (strip @ prefix)
304
277
  const slotName = slotNode.segmentName?.replace(/^@/, '') ?? '';
305
278
 
306
- const defaultFallback = await renderDefaultFallback(slotNode, h, taintKey);
279
+ const defaultFallback = await renderDefaultFallback(slotNode, h);
307
280
 
308
281
  // Look up pre-computed verdict from eager evaluation. Only replay
309
282
  // denial/redirect verdicts — 'pass' verdicts must NOT be replayed
@@ -726,21 +699,54 @@ export async function resolveSlotProps({
726
699
  h,
727
700
  interception,
728
701
  parentTreePath,
729
- chainVerdicts,
730
- slotKey
702
+ chainVerdicts
731
703
  );
732
704
  slotProps[slotName] = h(SegmentOutlet, {
733
705
  segmentPath: slotKey,
734
706
  children: resolvedElement,
735
707
  });
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.
708
+ // Static analysis: check if any file in the slot's RENDERED tree
709
+ // is request-dependent. Only check layouts when destMatch exists —
710
+ // unmatched slots render default.tsx directly without layouts.
711
+ let slotRequestDep = false;
712
+ const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
713
+ const hasRenderedPage = destMatch && destLeaf?.page;
714
+ if (hasRenderedPage) {
715
+ // Check slot root layout (rendered by wrapWithLayout)
716
+ if (
717
+ slotManifest.layout?.filePath &&
718
+ isStaticRequestDependent(slotManifest.layout.filePath)
719
+ ) {
720
+ slotRequestDep = true;
721
+ }
722
+ // Check intermediate slot layouts
723
+ if (!slotRequestDep) {
724
+ for (const seg of destMatch.chain) {
725
+ if (seg.layout?.filePath && isStaticRequestDependent(seg.layout.filePath)) {
726
+ slotRequestDep = true;
727
+ break;
728
+ }
729
+ }
730
+ }
731
+ // Check leaf page
732
+ if (!slotRequestDep) {
733
+ if (destLeaf?.page?.filePath && isStaticRequestDependent(destLeaf.page.filePath)) {
734
+ slotRequestDep = true;
735
+ }
736
+ }
737
+ } else {
738
+ // No match — only default.tsx is rendered (no layouts)
739
+ const defaultFile = slotManifest.default?.filePath;
740
+ if (!defaultFile) {
741
+ slotRequestDep = true; // No entry file → conservative
742
+ } else {
743
+ slotRequestDep = isStaticRequestDependent(defaultFile);
744
+ }
745
+ }
740
746
  slotSkipInfo.push({
741
747
  slotKey,
742
748
  parentSegmentId: segmentId,
743
- isRequestDependent: true,
749
+ isRequestDependent: slotRequestDep,
744
750
  denied: accessBlocked,
745
751
  });
746
752
  }