@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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* as a shell with a stable <SlotOutlet slot={name} /> client-reference
|
|
6
6
|
* hole per declared slot prop; live slot content composes in per instance
|
|
7
7
|
* at request time via SlotsProvider/SlotContext. This generalizes the
|
|
8
|
-
* TIM-1181 layout mechanism
|
|
9
|
-
*
|
|
8
|
+
* TIM-1181 layout mechanism to explicitly declared, named slots on any
|
|
9
|
+
* cached component. Layouts use the same mechanism with a `children` slot.
|
|
10
10
|
*
|
|
11
11
|
* Both the production wrapper (cache path) and the dev wrapper (no cache,
|
|
12
12
|
* same tree shape for dev/prod parity) build their render from these
|
|
@@ -16,27 +16,50 @@
|
|
|
16
16
|
import { createElement } from 'react';
|
|
17
17
|
|
|
18
18
|
import type { SlotValues } from '../../client/slot-context.js';
|
|
19
|
-
import { ChildSegmentOutlet } from '../../client/child-segment-outlet.js';
|
|
20
19
|
import { SlotOutlet } from '../../client/slot-outlet.js';
|
|
21
20
|
import { SlotsProvider } from '../../client/slot-provider.js';
|
|
22
21
|
import { splitSlotProps } from './cache-key.js';
|
|
23
22
|
import type { PrebuiltComponentOptions } from '../prebuilt-runtime.js';
|
|
24
23
|
|
|
25
24
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
25
|
+
* Framework-internal slot name for layout children. Uses a prefix that
|
|
26
|
+
* cannot collide with user-declared slot names (which are React prop
|
|
27
|
+
* names). Layout children flow through SlotsProvider/SlotOutlet with
|
|
28
|
+
* this key instead of the bare "children" key, so a cached component
|
|
29
|
+
* with `slots: ['children']` inside a layout never shadows the layout's
|
|
30
|
+
* own children delivery.
|
|
31
|
+
*
|
|
32
|
+
* Defined here (a server module) rather than in `client/slot-context.ts`
|
|
33
|
+
* (`'use client'`) because RSC's client-reference proxying would replace
|
|
34
|
+
* the string value with a proxy object in the server environment. The
|
|
35
|
+
* client-side SlotOutlet receives this value via props, so it doesn't
|
|
36
|
+
* need the constant. See TIM-1191.
|
|
31
37
|
*/
|
|
32
|
-
export
|
|
38
|
+
export const LAYOUT_CHILDREN_SLOT = '\0timber:children';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Is this value the framework-injected layout children slot outlet
|
|
42
|
+
* (`<SlotOutlet slot={LAYOUT_CHILDREN_SLOT} />`)? Layout components
|
|
43
|
+
* receive this as their `children` prop — a stable client reference
|
|
44
|
+
* whose cache key is stripped so layout `cache.component` hits work
|
|
45
|
+
* (design/45 §Layout Propagation).
|
|
46
|
+
*
|
|
47
|
+
* Uses a framework-reserved slot name (LAYOUT_CHILDREN_SLOT) that
|
|
48
|
+
* cannot collide with user-declared slot names, preventing context
|
|
49
|
+
* shadowing when a cached component with its own SlotsProvider is
|
|
50
|
+
* nested inside a layout. See TIM-1191.
|
|
51
|
+
*
|
|
52
|
+
* Total: the probes run against a user-controlled value, and a Proxy
|
|
53
|
+
* trap can throw — that must classify as "not the outlet" (the
|
|
54
|
+
* canonicalizer then rejects the value as unkeyable → live render), not
|
|
55
|
+
* abort the request (codex P2 on PR #890).
|
|
56
|
+
*/
|
|
57
|
+
export function isChildrenSlotOutletElement(value: unknown): boolean {
|
|
33
58
|
try {
|
|
34
|
-
return
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
(value as { type: unknown }).type === ChildSegmentOutlet
|
|
39
|
-
);
|
|
59
|
+
if (value == null || typeof value !== 'object') return false;
|
|
60
|
+
if (!('type' in value)) return false;
|
|
61
|
+
const el = value as { type: unknown; props?: { slot?: unknown } };
|
|
62
|
+
return el.type === SlotOutlet && el.props?.slot === LAYOUT_CHILDREN_SLOT;
|
|
40
63
|
} catch {
|
|
41
64
|
return false;
|
|
42
65
|
}
|
|
@@ -161,7 +184,7 @@ export function wrapInSlotsProvider(
|
|
|
161
184
|
): React.ReactElement {
|
|
162
185
|
// children passed as a prop, not a JSX child — the shell is `unknown`
|
|
163
186
|
// to the type system. Same pattern as route-element-builder's `h` alias
|
|
164
|
-
// for
|
|
187
|
+
// for SlotsProvider.
|
|
165
188
|
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
166
189
|
return h(SlotsProvider, { values: slotValues as SlotValues, children: shell });
|
|
167
190
|
}
|
|
@@ -46,7 +46,7 @@ import { createElement } from 'react';
|
|
|
46
46
|
|
|
47
47
|
import { requestContextAls } from './als-registry.js';
|
|
48
48
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
49
|
-
import {
|
|
49
|
+
import { SlotOutlet } from '../client/slot-outlet.js';
|
|
50
50
|
import { coerceSegmentParams } from './param-coercion.js';
|
|
51
51
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
52
52
|
import {
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
type NestedWrapperRender,
|
|
57
57
|
} from './prebuilt/capture-state.js';
|
|
58
58
|
import { computeComponentCacheKey } from './prebuilt/cache-key.js';
|
|
59
|
+
import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
|
|
59
60
|
import { BuildTimeContextError, createBuildTimeStore } from './prebuilt/synthetic-store.js';
|
|
60
61
|
import {
|
|
61
62
|
getPrebuiltRegistration,
|
|
@@ -155,7 +156,7 @@ interface CandidateRoute {
|
|
|
155
156
|
layoutsToLoad: ManifestFile[];
|
|
156
157
|
/** Component IDs attributed to this route's page + layout chain. */
|
|
157
158
|
componentIds: string[];
|
|
158
|
-
/** Component IDs from layout files (receive
|
|
159
|
+
/** Component IDs from layout files (receive SlotOutlet as children). */
|
|
159
160
|
layoutComponentIds: Set<string>;
|
|
160
161
|
}
|
|
161
162
|
|
|
@@ -356,11 +357,11 @@ async function renderEntry(
|
|
|
356
357
|
// synthetic store (segmentParams.get() inside the component).
|
|
357
358
|
// Tag resolution also runs inside this scope so function-form tags
|
|
358
359
|
// that read segment params see the synthetic store (codex P2 on PR #858).
|
|
359
|
-
// Layout captures get <
|
|
360
|
-
// cached payload has a context-driven hole (design/45, TIM-1181).
|
|
360
|
+
// Layout captures get <SlotOutlet slot="children" /> as children so
|
|
361
|
+
// the cached payload has a context-driven hole (design/45, TIM-1181).
|
|
361
362
|
const renderProps =
|
|
362
363
|
isLayout && !('children' in props)
|
|
363
|
-
? { ...props, children: createElement(
|
|
364
|
+
? { ...props, children: createElement(SlotOutlet, { slot: LAYOUT_CHILDREN_SLOT }) }
|
|
364
365
|
: props;
|
|
365
366
|
bytes = await requestContextAls.run(store, async () => {
|
|
366
367
|
const stream = renderToReadableStream(
|
|
@@ -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
|
}
|