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