@timber-js/app 0.2.0-alpha.171 → 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/index.js +5 -5
- 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 +1 -1
- 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/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
|
@@ -38,7 +38,7 @@ import { createElement } from 'react';
|
|
|
38
38
|
import { requestContextAls, type RequestContextStore } from './als-registry.js';
|
|
39
39
|
import { computeComponentCacheKey, splitSlotProps, tryCanonicalize } from './prebuilt/cache-key.js';
|
|
40
40
|
import {
|
|
41
|
-
|
|
41
|
+
isChildrenSlotOutletElement,
|
|
42
42
|
resolveSlotNames,
|
|
43
43
|
prepareSlotRender,
|
|
44
44
|
wrapInSlotsProvider,
|
|
@@ -196,7 +196,7 @@ function resolveKeyParams(store: {
|
|
|
196
196
|
* exactly when production would serve the cache path rather than the
|
|
197
197
|
* live fallback (dev/prod parity, codex P2 on PR #890): requires an ALS
|
|
198
198
|
* store, keyable params (slot-param divergence unkeys), and keyable data
|
|
199
|
-
* props (after the
|
|
199
|
+
* props (after the children slot outlet strip).
|
|
200
200
|
*/
|
|
201
201
|
function isSlotRenderKeyable(dataProps: Record<string, unknown>): boolean {
|
|
202
202
|
const store = requestContextAls.getStore();
|
|
@@ -204,7 +204,7 @@ function isSlotRenderKeyable(dataProps: Record<string, unknown>): boolean {
|
|
|
204
204
|
const keyParams = resolveKeyParams(store);
|
|
205
205
|
if (keyParams === null) return false;
|
|
206
206
|
let keyProps = dataProps;
|
|
207
|
-
if (
|
|
207
|
+
if (isChildrenSlotOutletElement(keyProps.children)) {
|
|
208
208
|
const { children: _children, ...rest } = keyProps;
|
|
209
209
|
keyProps = rest;
|
|
210
210
|
}
|
|
@@ -547,23 +547,23 @@ async function tryRevivePayload(
|
|
|
547
547
|
if (keyParams === null) return null;
|
|
548
548
|
|
|
549
549
|
// Strip `children` from the cache key ONLY when it is the framework-
|
|
550
|
-
// injected
|
|
551
|
-
//
|
|
552
|
-
// that is the same on every request, so it should not vary
|
|
553
|
-
// User-provided React element children
|
|
554
|
-
//
|
|
555
|
-
//
|
|
550
|
+
// injected children slot outlet (<SlotOutlet slot="children" />).
|
|
551
|
+
// Layout components receive this as children — a deterministic client
|
|
552
|
+
// reference that is the same on every request, so it should not vary
|
|
553
|
+
// the key. User-provided React element children are NOT stripped —
|
|
554
|
+
// they remain unkeyable so the component renders live, which is
|
|
555
|
+
// correct: the cache cannot distinguish <A> from <B>.
|
|
556
556
|
// See design/45-cache-lifetimes.md §Layout Propagation, TIM-1181.
|
|
557
557
|
// keyPropsOverride = slot component (TIM-1173): the wrapper already
|
|
558
558
|
// split out slot props; only data props participate in the key. The
|
|
559
|
-
//
|
|
559
|
+
// children outlet strip applies to EITHER source — a cached layout
|
|
560
560
|
// declaring only named slots (slots: ['modal']) still receives the
|
|
561
561
|
// framework-injected children outlet in its data props, and it must not
|
|
562
562
|
// unkey the render there any more than on the non-slot path (codex P2
|
|
563
563
|
// on PR #890).
|
|
564
564
|
const allProps = (props ?? {}) as Record<string, unknown>;
|
|
565
565
|
let keyProps = keyPropsOverride ?? allProps;
|
|
566
|
-
if (
|
|
566
|
+
if (isChildrenSlotOutletElement(keyProps.children)) {
|
|
567
567
|
const { children: _children, ...rest } = keyProps;
|
|
568
568
|
keyProps = rest;
|
|
569
569
|
}
|
|
@@ -14,7 +14,13 @@
|
|
|
14
14
|
* and design/11-platform.md §"AsyncLocalStorage".
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
requestContextAls,
|
|
19
|
+
taintScopeAls,
|
|
20
|
+
markAccessorTouched,
|
|
21
|
+
type RequestContextStore,
|
|
22
|
+
type TaintScope,
|
|
23
|
+
} from './als-registry.js';
|
|
18
24
|
import { _setGetSearchParamsFn } from '../search-params/define.js';
|
|
19
25
|
import { _setGetSegmentParamsFn } from '../segment-params/define.js';
|
|
20
26
|
import { consumeSeededCookies } from './cookie-context.js';
|
|
@@ -39,6 +45,7 @@ export function getHeaders(): ReadonlyHeaders {
|
|
|
39
45
|
'It can only be used in middleware, access checks, server components, and server actions.'
|
|
40
46
|
);
|
|
41
47
|
}
|
|
48
|
+
markAccessorTouched();
|
|
42
49
|
return store.headers;
|
|
43
50
|
}
|
|
44
51
|
|
|
@@ -51,6 +58,7 @@ export function getHeaders(): ReadonlyHeaders {
|
|
|
51
58
|
* @internal — not part of the public API. Use `getHeaders().get(name)` instead.
|
|
52
59
|
*/
|
|
53
60
|
export function getHeader(name: string): string | undefined {
|
|
61
|
+
// getHeaders() already calls markAccessorTouched()
|
|
54
62
|
const headers = getHeaders();
|
|
55
63
|
return headers.get(name) ?? undefined;
|
|
56
64
|
}
|
|
@@ -68,6 +76,7 @@ export function getSearchParams(): URLSearchParams {
|
|
|
68
76
|
'It can only be used in middleware, access checks, server components, and server actions.'
|
|
69
77
|
);
|
|
70
78
|
}
|
|
79
|
+
markAccessorTouched();
|
|
71
80
|
return store.searchParams;
|
|
72
81
|
}
|
|
73
82
|
|
|
@@ -217,6 +226,43 @@ export function getRequestSearchString(): string {
|
|
|
217
226
|
return store?.searchString ?? '';
|
|
218
227
|
}
|
|
219
228
|
|
|
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
|
+
store.taintResults.set(segmentKey, scope.touched);
|
|
255
|
+
}
|
|
256
|
+
return result;
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (store) {
|
|
259
|
+
if (!store.taintResults) store.taintResults = new Map();
|
|
260
|
+
store.taintResults.set(segmentKey, true);
|
|
261
|
+
}
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
220
266
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
221
267
|
|
|
222
268
|
/**
|
|
@@ -16,26 +16,13 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { createElement, Fragment } from 'react';
|
|
19
|
-
import { randomUUID } from 'node:crypto';
|
|
20
19
|
|
|
21
20
|
import { withSpan } from './tracing.js';
|
|
22
21
|
import type { RouteMatch } from './pipeline.js';
|
|
23
22
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
24
23
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
25
24
|
import type { Metadata } from './types.js';
|
|
26
|
-
import {
|
|
27
|
-
METADATA_ROUTE_CONVENTIONS,
|
|
28
|
-
getMetadataRouteAutoLink,
|
|
29
|
-
resolveServePathForFile,
|
|
30
|
-
} from './metadata-routes.js';
|
|
31
|
-
|
|
32
|
-
// In dev mode, use a per-startup nonce for metadata route cache busting
|
|
33
|
-
// instead of per-file content hashes (avoids rehashing on every request).
|
|
34
|
-
let _devNonce: string | undefined;
|
|
35
|
-
function getDevNonce(): string {
|
|
36
|
-
_devNonce ??= randomUUID().slice(0, 8);
|
|
37
|
-
return _devNonce;
|
|
38
|
-
}
|
|
25
|
+
import { collectMetadataRouteHeadElements } from './metadata-routes.js';
|
|
39
26
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
40
27
|
import { AccessGate } from './access-gate.js';
|
|
41
28
|
import { requestContextAls } from './als-registry.js';
|
|
@@ -46,37 +33,18 @@ import {
|
|
|
46
33
|
setDenyStatus,
|
|
47
34
|
} from './deny-boundary.js';
|
|
48
35
|
import type { DenyPageEntry } from './deny-boundary.js';
|
|
49
|
-
import {
|
|
36
|
+
import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
|
|
37
|
+
import { resolveSlotProps, type SlotSkipEntry } from './slot-resolver.js';
|
|
50
38
|
import { SegmentProvider } from '../client/segment-context.js';
|
|
51
39
|
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
40
|
+
import { SlotOutlet } from '../client/slot-outlet.js';
|
|
41
|
+
import { SlotsProvider } from '../client/slot-provider.js';
|
|
54
42
|
|
|
55
43
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
56
44
|
import type { InterceptionContext } from './pipeline.js';
|
|
57
|
-
import { shouldSkipSegment, computeSegmentKeys } from './state-tree-diff.js';
|
|
58
|
-
import
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Convert HeadElement descriptors to React elements for Float hoisting.
|
|
62
|
-
*
|
|
63
|
-
* React 19 Float hoists <title>, <meta>, and <link> rendered anywhere in
|
|
64
|
-
* the tree into <head> during SSR, and manages them in document.head
|
|
65
|
-
* during client-side rendering. This replaces the X-Timber-Head header
|
|
66
|
-
* side-channel and the imperative client DOM applier.
|
|
67
|
-
*/
|
|
68
|
-
export function headElementsToReact(elements: HeadElement[]): React.ReactElement {
|
|
69
|
-
const children: React.ReactElement[] = [];
|
|
70
|
-
for (let i = 0; i < elements.length; i++) {
|
|
71
|
-
const el = elements[i];
|
|
72
|
-
if (el.tag === 'title' && el.content !== undefined) {
|
|
73
|
-
children.push(createElement('title', { key: `t${i}` }, el.content));
|
|
74
|
-
} else if (el.attrs) {
|
|
75
|
-
children.push(createElement(el.tag, { key: `h${i}`, ...el.attrs }));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
return createElement(Fragment, null, ...children);
|
|
79
|
-
}
|
|
45
|
+
import { shouldSkipSegment, computeSegmentKeys, type ClientStateTree } from './state-tree-diff.js';
|
|
46
|
+
import { runInTaintScope } from './request-context.js';
|
|
47
|
+
import { headElementsToReact } from './metadata.js';
|
|
80
48
|
import { loadModule } from './safe-load.js';
|
|
81
49
|
|
|
82
50
|
/**
|
|
@@ -191,6 +159,12 @@ export interface RouteElementResult {
|
|
|
191
159
|
* See TIM-1045 (pages), TIM-1208 (layouts).
|
|
192
160
|
*/
|
|
193
161
|
shellSettled?: Promise<void>;
|
|
162
|
+
/**
|
|
163
|
+
* Slot metadata for the X-Timber-Segments header. Contains info about
|
|
164
|
+
* all parallel route slots encountered during element tree construction,
|
|
165
|
+
* enabling the client to populate its segment cache with slot entries.
|
|
166
|
+
*/
|
|
167
|
+
slotSkipInfo: SlotSkipEntry[];
|
|
194
168
|
}
|
|
195
169
|
|
|
196
170
|
// ─── Module Processing Helpers ─────────────────────────────────────────────
|
|
@@ -273,7 +247,7 @@ export async function buildRouteElement(
|
|
|
273
247
|
req: Request,
|
|
274
248
|
match: RouteMatch,
|
|
275
249
|
interception?: InterceptionContext,
|
|
276
|
-
clientStateTree?:
|
|
250
|
+
clientStateTree?: ClientStateTree | null,
|
|
277
251
|
metadataRouteHashes?: Record<string, string>
|
|
278
252
|
): Promise<RouteElementResult> {
|
|
279
253
|
const segments = match.segments;
|
|
@@ -408,79 +382,15 @@ export async function buildRouteElement(
|
|
|
408
382
|
const headElements = renderMetadataToElements(resolvedMetadata);
|
|
409
383
|
|
|
410
384
|
// Auto-link metadata route files (icon, apple-icon, manifest, opengraph-image).
|
|
411
|
-
// opengraph-image emits both og:image and twitter:image (no separate twitter-image convention).
|
|
412
|
-
// Skip OG auto-linking when the user already declared images in metadata.
|
|
413
385
|
// See design/16-metadata.md §"Auto-Linking"
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
? new URL(requestUrl.origin)
|
|
423
|
-
: resolvedMetadata.metadataBase;
|
|
424
|
-
|
|
425
|
-
for (let si = 0; si < segments.length; si++) {
|
|
426
|
-
const segment = segments[si];
|
|
427
|
-
if (!segment.metadataRoutes) continue;
|
|
428
|
-
// Skip auto-linking for denied segments — same gate as metadata().
|
|
429
|
-
if (si >= firstDeniedIndex) continue;
|
|
430
|
-
for (const baseName of Object.keys(segment.metadataRoutes)) {
|
|
431
|
-
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
432
|
-
if (!convention) continue;
|
|
433
|
-
// Non-nestable routes only auto-link from root
|
|
434
|
-
if (!convention.nestable && segment.urlPath !== '/') continue;
|
|
435
|
-
// Skip auto-linking entirely if user declared openGraph.images (covers both og + twitter)
|
|
436
|
-
if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
|
|
437
|
-
// Build the href using the actual request path (not the pattern with [param]).
|
|
438
|
-
// For nestable routes, the metadata route sits under the same resolved path
|
|
439
|
-
// as the page. For root-only routes, use '/'.
|
|
440
|
-
const resolvedPrefix = convention.nestable
|
|
441
|
-
? requestPathname === '/'
|
|
442
|
-
? ''
|
|
443
|
-
: requestPathname
|
|
444
|
-
: '';
|
|
445
|
-
const metaFile = segment.metadataRoutes[baseName];
|
|
446
|
-
const fileServePath = metaFile?.filePath
|
|
447
|
-
? resolveServePathForFile(baseName, metaFile.filePath)
|
|
448
|
-
: convention.serveExtension
|
|
449
|
-
? `${convention.servePath}.${convention.serveExtension}`
|
|
450
|
-
: convention.servePath;
|
|
451
|
-
let href = `${resolvedPrefix}/${fileServePath}`;
|
|
452
|
-
// Append cache-bust query param for image metadata routes
|
|
453
|
-
if (convention.type === 'opengraph-image') {
|
|
454
|
-
const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
|
|
455
|
-
const cacheBust = fileHash ?? getDevNonce();
|
|
456
|
-
href = `${href}?${cacheBust}`;
|
|
457
|
-
}
|
|
458
|
-
// Resolve to absolute URL for og:image/twitter:image (social crawlers need full URLs)
|
|
459
|
-
if (ogBase && convention.type === 'opengraph-image') {
|
|
460
|
-
href = new URL(href, ogBase).toString();
|
|
461
|
-
}
|
|
462
|
-
for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {
|
|
463
|
-
// Skip auto-linked twitter:image when user declared twitter.images
|
|
464
|
-
if (
|
|
465
|
-
hasUserTwitterImage &&
|
|
466
|
-
autoLink.tag === 'meta' &&
|
|
467
|
-
'name' in autoLink &&
|
|
468
|
-
autoLink.name === 'twitter:image'
|
|
469
|
-
)
|
|
470
|
-
continue;
|
|
471
|
-
if (autoLink.tag === 'link') {
|
|
472
|
-
const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
|
|
473
|
-
if (autoLink.type) attrs.type = autoLink.type;
|
|
474
|
-
headElements.push({ tag: 'link', attrs });
|
|
475
|
-
} else {
|
|
476
|
-
const attrs: Record<string, string> = { content: autoLink.content };
|
|
477
|
-
if (autoLink.property) attrs.property = autoLink.property;
|
|
478
|
-
if (autoLink.name) attrs.name = autoLink.name;
|
|
479
|
-
headElements.push({ tag: 'meta', attrs });
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
386
|
+
const autoLinkedElements = collectMetadataRouteHeadElements(
|
|
387
|
+
segments,
|
|
388
|
+
firstDeniedIndex,
|
|
389
|
+
resolvedMetadata,
|
|
390
|
+
new URL(req.url),
|
|
391
|
+
metadataRouteHashes
|
|
392
|
+
);
|
|
393
|
+
headElements.push(...autoLinkedElements);
|
|
484
394
|
|
|
485
395
|
// Convert HeadElement descriptors to React elements for Float hoisting.
|
|
486
396
|
// Placed at the page segment level so they're always re-rendered on
|
|
@@ -491,7 +401,7 @@ export async function buildRouteElement(
|
|
|
491
401
|
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
492
402
|
|
|
493
403
|
// Shell settlement tracking (TIM-1045 pages, TIM-1208 layouts).
|
|
494
|
-
//
|
|
404
|
+
// SlotsProvider is 'use client', so React Flight eagerly
|
|
495
405
|
// serializes both the layout and child content as independent subtrees —
|
|
496
406
|
// the page can settle before an outer layout. We need all deny-capable
|
|
497
407
|
// components to settle before committing the HTTP status.
|
|
@@ -637,6 +547,14 @@ export async function buildRouteElement(
|
|
|
637
547
|
|
|
638
548
|
let outermostSegmentProvider: React.ReactElement | null = null;
|
|
639
549
|
|
|
550
|
+
// Slot caching: the departing URL (X-Timber-URL header) and the
|
|
551
|
+
// destination URL (request URL) are compared to determine which
|
|
552
|
+
// slots' content has changed. Full URLs (with search) are passed
|
|
553
|
+
// so slot pages reading getSearchParams() are correctly re-rendered.
|
|
554
|
+
const departingUrl = req.headers.get('X-Timber-URL');
|
|
555
|
+
const destinationUrl = req.url;
|
|
556
|
+
const slotSkipInfo: SlotSkipEntry[] = [];
|
|
557
|
+
|
|
640
558
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
641
559
|
const segment = segments[i];
|
|
642
560
|
const isLeaf = i === segments.length - 1;
|
|
@@ -682,9 +600,15 @@ export async function buildRouteElement(
|
|
|
682
600
|
// This prevents leaking layout UI (sidebars, nav) on denied pages.
|
|
683
601
|
// See design/04-authorization.md §"Access Failure".
|
|
684
602
|
if (layoutComponent) {
|
|
685
|
-
// Resolve parallel slots for this layout
|
|
686
|
-
//
|
|
687
|
-
//
|
|
603
|
+
// Resolve parallel slots for this layout, wrapping each in
|
|
604
|
+
// SegmentOutlet for client-side caching. Unchanged slots get
|
|
605
|
+
// skip=true so the client keeps its cached content.
|
|
606
|
+
//
|
|
607
|
+
// Skip slot resolution for denied segments (TIM-1229): when a
|
|
608
|
+
// parent segment's AccessGate denies, the layout never renders,
|
|
609
|
+
// so slot props are never consumed. Resolving them is wasted work
|
|
610
|
+
// and could trigger side effects in slot access.ts functions.
|
|
611
|
+
const segmentId = segmentKeys[i];
|
|
688
612
|
const parentTreePath =
|
|
689
613
|
'/' +
|
|
690
614
|
segments
|
|
@@ -692,17 +616,21 @@ export async function buildRouteElement(
|
|
|
692
616
|
.map((s) => s.segmentName)
|
|
693
617
|
.filter(Boolean)
|
|
694
618
|
.join('/');
|
|
695
|
-
const slotProps
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
619
|
+
const slotProps =
|
|
620
|
+
i >= firstDeniedIndex
|
|
621
|
+
? {}
|
|
622
|
+
: await resolveSlotProps({
|
|
623
|
+
segment,
|
|
624
|
+
segmentId,
|
|
625
|
+
match,
|
|
626
|
+
h,
|
|
627
|
+
interception,
|
|
628
|
+
parentTreePath,
|
|
629
|
+
departingUrl,
|
|
630
|
+
destinationUrl,
|
|
631
|
+
clientStateTree: clientStateTree as ClientStateTree | null,
|
|
632
|
+
slotSkipInfo,
|
|
633
|
+
});
|
|
706
634
|
|
|
707
635
|
// '/'.split('/') → ['', ''] (length 2), but root should be [''] (length 1).
|
|
708
636
|
// The extra empty string makes useSelectedLayoutSegment skip the first URL segment.
|
|
@@ -710,16 +638,14 @@ export async function buildRouteElement(
|
|
|
710
638
|
const segmentPath = rawPath.length === 2 && rawPath[1] === '' ? [''] : rawPath;
|
|
711
639
|
const parallelRouteKeys = Object.keys(segment.slots ?? {});
|
|
712
640
|
|
|
713
|
-
const segmentId = segmentKeys[i];
|
|
714
|
-
|
|
715
641
|
// Build the layout element.
|
|
716
|
-
// Layouts receive <
|
|
717
|
-
// reference — instead of the varying
|
|
718
|
-
// content
|
|
719
|
-
//
|
|
720
|
-
//
|
|
642
|
+
// Layouts receive <SlotOutlet slot={LAYOUT_CHILDREN_SLOT} /> as
|
|
643
|
+
// children — a stable client reference — instead of the varying
|
|
644
|
+
// inner content. The actual child content is provided via
|
|
645
|
+
// SlotsProvider above the layout. Uses a reserved slot name to
|
|
646
|
+
// avoid collision with user-declared cache.component slots.
|
|
721
647
|
// See design/45-cache-lifetimes.md §Layout Propagation.
|
|
722
|
-
const childOutlet = h(
|
|
648
|
+
const childOutlet = h(SlotOutlet, { slot: LAYOUT_CHILDREN_SLOT });
|
|
723
649
|
let layoutElement: React.ReactElement;
|
|
724
650
|
if (isClientReference(layoutComponent)) {
|
|
725
651
|
layoutElement = h(layoutComponent, {
|
|
@@ -737,10 +663,13 @@ export async function buildRouteElement(
|
|
|
737
663
|
const layoutDenyPages = denyPageChains.get(i);
|
|
738
664
|
const hasLayoutDenyChain = !!layoutDenyPages && layoutDenyPages.length > 0;
|
|
739
665
|
const onLayoutSettled = hasLayoutDenyChain ? trackShellComponent() : undefined;
|
|
666
|
+
const taintKey = segmentId;
|
|
740
667
|
const TracedLayout = async (props: Record<string, unknown>) => {
|
|
741
668
|
try {
|
|
742
|
-
return await
|
|
743
|
-
(
|
|
669
|
+
return await runInTaintScope(taintKey, () =>
|
|
670
|
+
withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
|
|
671
|
+
(layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
|
|
672
|
+
)
|
|
744
673
|
);
|
|
745
674
|
} catch (error: unknown) {
|
|
746
675
|
if (error instanceof DenySignal && layoutDenyPages) {
|
|
@@ -750,8 +679,6 @@ export async function buildRouteElement(
|
|
|
750
679
|
return denyElement;
|
|
751
680
|
}
|
|
752
681
|
}
|
|
753
|
-
// Non-deny errors (RedirectSignal, runtime errors) propagate normally
|
|
754
|
-
// to onError — signalDetected / renderError handling covers those.
|
|
755
682
|
throw error;
|
|
756
683
|
} finally {
|
|
757
684
|
onLayoutSettled?.();
|
|
@@ -763,10 +690,10 @@ export async function buildRouteElement(
|
|
|
763
690
|
});
|
|
764
691
|
}
|
|
765
692
|
|
|
766
|
-
// Wrap the layout in
|
|
767
|
-
//
|
|
768
|
-
layoutElement = h(
|
|
769
|
-
|
|
693
|
+
// Wrap the layout in SlotsProvider so the SlotOutlet inside the
|
|
694
|
+
// layout's children can read the actual child content.
|
|
695
|
+
layoutElement = h(SlotsProvider, {
|
|
696
|
+
values: { [LAYOUT_CHILDREN_SLOT]: element },
|
|
770
697
|
children: layoutElement,
|
|
771
698
|
});
|
|
772
699
|
|
|
@@ -841,5 +768,6 @@ export async function buildRouteElement(
|
|
|
841
768
|
deferSuspenseFor,
|
|
842
769
|
skippedSegments,
|
|
843
770
|
shellSettled,
|
|
771
|
+
slotSkipInfo,
|
|
844
772
|
};
|
|
845
773
|
}
|
|
@@ -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
|
}
|