@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.
- package/dist/_chunks/{actions-pN8r5Vnh.js → actions-O_LsyCE4.js} +3 -3
- package/dist/_chunks/{actions-pN8r5Vnh.js.map → actions-O_LsyCE4.js.map} +1 -1
- package/dist/_chunks/{cache-api-2hT5kfsr.js → cache-api-B-lhk9p4.js} +2 -2
- package/dist/_chunks/{cache-api-2hT5kfsr.js.map → cache-api-B-lhk9p4.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-DvdvFwwE.js → cli-schema-sync-D3naS3eH.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-DvdvFwwE.js.map → cli-schema-sync-D3naS3eH.js.map} +1 -1
- package/dist/_chunks/{logger-kUT0QH0K.js → logger-AWfuX-KJ.js} +1 -23
- package/dist/_chunks/logger-AWfuX-KJ.js.map +1 -0
- package/dist/_chunks/{walkers-Bv63zfAC.js → walkers-BtTlKviE.js} +2 -2
- package/dist/_chunks/{walkers-Bv63zfAC.js.map → walkers-BtTlKviE.js.map} +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +227 -4
- package/dist/index.js.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/request-dep.d.ts +21 -0
- package/dist/plugins/request-dep.d.ts.map +1 -0
- package/dist/routing/index.js +2 -2
- package/dist/server/als-registry.d.ts +0 -26
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +6 -0
- package/dist/server/build-manifest.d.ts.map +1 -1
- package/dist/server/cookie-context.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +3 -3
- package/dist/server/internal.js.map +1 -1
- package/dist/server/request-context.d.ts +0 -15
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-dep.d.ts +15 -0
- package/dist/server/request-dep.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +6 -6
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/plugins/build-manifest.ts +10 -1
- package/src/plugins/request-dep.ts +458 -0
- package/src/server/als-registry.ts +0 -44
- package/src/server/build-manifest.ts +6 -0
- package/src/server/cookie-context.ts +1 -7
- package/src/server/request-context.ts +3 -53
- package/src/server/request-dep.ts +37 -0
- package/src/server/route-element-builder.ts +2 -6
- package/src/server/rsc-entry/helpers.ts +9 -26
- package/src/server/rsc-entry/rsc-payload.ts +4 -13
- package/src/server/slot-resolver.ts +52 -46
- 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
|
-
|
|
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
|
-
|
|
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
|
|
670
|
-
|
|
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
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
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:
|
|
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
|
-
|
|
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.
|
|
201
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
|
|
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:
|
|
749
|
+
isRequestDependent: slotRequestDep,
|
|
744
750
|
denied: accessBlocked,
|
|
745
751
|
});
|
|
746
752
|
}
|