@timber-js/app 0.2.0-alpha.171 → 0.2.0-alpha.173
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 +53 -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 +321 -12
- 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
|
@@ -23,10 +23,20 @@ export interface SegmentNode {
|
|
|
23
23
|
segment: string;
|
|
24
24
|
/** The RSC flight payload for this segment (opaque to the cache) */
|
|
25
25
|
payload: unknown;
|
|
26
|
-
/**
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* Whether the segment/slot is request-dependent (calls getHeaders,
|
|
28
|
+
* getSearchParams, cookies, etc.). Request-dependent segments always
|
|
29
|
+
* re-render on navigation. For segments, this is still based on the
|
|
30
|
+
* AsyncFunction heuristic (to be replaced separately). For slots,
|
|
31
|
+
* this is taint-tracked via ALS.
|
|
32
|
+
*/
|
|
33
|
+
isRequestDependent: boolean;
|
|
28
34
|
/** Child segments keyed by segment path */
|
|
29
35
|
children: Map<string, SegmentNode>;
|
|
36
|
+
/** Parallel route slots keyed by slot path (e.g., "/@sidebar") */
|
|
37
|
+
slots?: Map<string, SegmentNode>;
|
|
38
|
+
/** Whether this slot's access.ts denied on its last render. */
|
|
39
|
+
denied?: boolean;
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
/**
|
|
@@ -35,6 +45,7 @@ export interface SegmentNode {
|
|
|
35
45
|
*/
|
|
36
46
|
export interface StateTree {
|
|
37
47
|
segments: string[];
|
|
48
|
+
slots?: string[];
|
|
38
49
|
}
|
|
39
50
|
|
|
40
51
|
// ─── Segment Cache ───────────────────────────────────────────────
|
|
@@ -79,10 +90,16 @@ export class SegmentCache {
|
|
|
79
90
|
*/
|
|
80
91
|
serializeStateTree(mergeableFilter?: Set<string>): StateTree {
|
|
81
92
|
const segments: string[] = [];
|
|
93
|
+
const slots: string[] = [];
|
|
82
94
|
if (this.root) {
|
|
83
95
|
collectSyncSegments(this.root, segments, mergeableFilter);
|
|
96
|
+
collectSyncSlots(this.root, slots);
|
|
97
|
+
}
|
|
98
|
+
const tree: StateTree = { segments };
|
|
99
|
+
if (slots.length > 0) {
|
|
100
|
+
tree.slots = slots;
|
|
84
101
|
}
|
|
85
|
-
return
|
|
102
|
+
return tree;
|
|
86
103
|
}
|
|
87
104
|
}
|
|
88
105
|
|
|
@@ -92,7 +109,7 @@ function collectSyncSegments(
|
|
|
92
109
|
out: string[],
|
|
93
110
|
mergeableFilter?: Set<string>
|
|
94
111
|
): void {
|
|
95
|
-
if (!node.
|
|
112
|
+
if (!node.isRequestDependent && (!mergeableFilter || mergeableFilter.has(node.segment))) {
|
|
96
113
|
out.push(node.segment);
|
|
97
114
|
}
|
|
98
115
|
for (const child of node.children.values()) {
|
|
@@ -100,6 +117,22 @@ function collectSyncSegments(
|
|
|
100
117
|
}
|
|
101
118
|
}
|
|
102
119
|
|
|
120
|
+
/** Recursively collect cacheable slot paths from the tree */
|
|
121
|
+
function collectSyncSlots(node: SegmentNode, out: string[]): void {
|
|
122
|
+
if (node.slots) {
|
|
123
|
+
for (const slot of node.slots.values()) {
|
|
124
|
+
// Exclude request-dependent slots (they must re-render every nav)
|
|
125
|
+
// and denied slots (their cached content is denial fallback, not real content)
|
|
126
|
+
if (!slot.isRequestDependent && !slot.denied) {
|
|
127
|
+
out.push(slot.segment);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
for (const child of node.children.values()) {
|
|
132
|
+
collectSyncSlots(child, out);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
103
136
|
// ─── Segment Tree Builder ────────────────────────────────────────
|
|
104
137
|
|
|
105
138
|
/**
|
|
@@ -110,7 +143,13 @@ export interface SegmentInfo {
|
|
|
110
143
|
path: string;
|
|
111
144
|
/** Outlet key — includes route group name when applicable (e.g., "/(marketing)"). */
|
|
112
145
|
segmentId?: string;
|
|
113
|
-
|
|
146
|
+
isRequestDependent: boolean;
|
|
147
|
+
/** True for parallel route slot entries. Slots are keyed by their slot path (e.g., "/@sidebar"). */
|
|
148
|
+
slot?: boolean;
|
|
149
|
+
/** Parent segment path for slot entries. Used to attach the slot to the correct SegmentNode. */
|
|
150
|
+
parentSegment?: string;
|
|
151
|
+
/** True when the slot's access.ts denied on this render. Denied slots are excluded from the state tree. */
|
|
152
|
+
denied?: boolean;
|
|
114
153
|
}
|
|
115
154
|
|
|
116
155
|
/**
|
|
@@ -128,21 +167,34 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
|
|
|
128
167
|
// Need at least a root segment to build a tree
|
|
129
168
|
if (segments.length === 0) return undefined;
|
|
130
169
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
|
|
170
|
+
// Separate slot entries from segment entries. Slots are attached to
|
|
171
|
+
// their parent segment node after the main chain is built.
|
|
172
|
+
const segmentEntries: SegmentInfo[] = [];
|
|
173
|
+
const slotEntries: SegmentInfo[] = [];
|
|
174
|
+
for (const info of segments) {
|
|
175
|
+
if (info.slot) {
|
|
176
|
+
slotEntries.push(info);
|
|
177
|
+
} else {
|
|
178
|
+
segmentEntries.push(info);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Build the main segment chain.
|
|
134
183
|
let root: SegmentNode | undefined;
|
|
135
184
|
let parent: SegmentNode | undefined;
|
|
185
|
+
const nodeById = new Map<string, SegmentNode>();
|
|
136
186
|
|
|
137
|
-
for (const info of
|
|
187
|
+
for (const info of segmentEntries) {
|
|
138
188
|
const id = info.segmentId ?? info.path;
|
|
139
189
|
const node: SegmentNode = {
|
|
140
190
|
segment: id,
|
|
141
191
|
payload: null,
|
|
142
|
-
|
|
192
|
+
isRequestDependent: info.isRequestDependent,
|
|
143
193
|
children: new Map(),
|
|
144
194
|
};
|
|
145
195
|
|
|
196
|
+
nodeById.set(id, node);
|
|
197
|
+
|
|
146
198
|
if (!root) {
|
|
147
199
|
root = node;
|
|
148
200
|
}
|
|
@@ -154,6 +206,27 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
|
|
|
154
206
|
parent = node;
|
|
155
207
|
}
|
|
156
208
|
|
|
209
|
+
// Attach slot entries to their parent segment nodes.
|
|
210
|
+
for (const slotInfo of slotEntries) {
|
|
211
|
+
const parentId = slotInfo.parentSegment;
|
|
212
|
+
const parentNode = parentId ? nodeById.get(parentId) : root;
|
|
213
|
+
if (!parentNode) continue;
|
|
214
|
+
|
|
215
|
+
const slotId = slotInfo.segmentId ?? slotInfo.path;
|
|
216
|
+
const slotNode: SegmentNode = {
|
|
217
|
+
segment: slotId,
|
|
218
|
+
payload: null,
|
|
219
|
+
isRequestDependent: slotInfo.isRequestDependent,
|
|
220
|
+
children: new Map(),
|
|
221
|
+
denied: slotInfo.denied,
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
if (!parentNode.slots) {
|
|
225
|
+
parentNode.slots = new Map();
|
|
226
|
+
}
|
|
227
|
+
parentNode.slots.set(slotId, slotNode);
|
|
228
|
+
}
|
|
229
|
+
|
|
157
230
|
return root;
|
|
158
231
|
}
|
|
159
232
|
|
|
@@ -32,12 +32,21 @@ export interface SegmentOutletProps {
|
|
|
32
32
|
* Unique identifier for this segment. For normal segments this is the
|
|
33
33
|
* urlPath (e.g., "/", "/dashboard"). For route groups this includes the
|
|
34
34
|
* group name (e.g., "/(marketing)") to distinguish siblings that share
|
|
35
|
-
* the same urlPath.
|
|
35
|
+
* the same urlPath. For slots this includes the slot name (e.g., "/@sidebar").
|
|
36
|
+
* Must match the segmentId used in state-tree-diff.ts.
|
|
36
37
|
*/
|
|
37
38
|
segmentPath: string;
|
|
38
39
|
|
|
39
40
|
/** The segment's React subtree (layout + inner content). */
|
|
40
41
|
children: ReactNode;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* When true, the outlet returns cached content instead of rendering
|
|
45
|
+
* new children. Used for skipped parallel route slots — the server
|
|
46
|
+
* sends an empty SegmentOutlet with skip=true, and the client keeps
|
|
47
|
+
* the previously cached slot content.
|
|
48
|
+
*/
|
|
49
|
+
skip?: boolean;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
/**
|
|
@@ -52,7 +61,7 @@ export interface SegmentOutletProps {
|
|
|
52
61
|
* when the same type appears at the same tree position. The ref persists
|
|
53
62
|
* across navigations because SegmentOutlet is reconciled, not remounted.
|
|
54
63
|
*/
|
|
55
|
-
export function SegmentOutlet({ segmentPath, children }: SegmentOutletProps) {
|
|
64
|
+
export function SegmentOutlet({ segmentPath, children, skip }: SegmentOutletProps) {
|
|
56
65
|
const updates = useContext(SegmentUpdateContext);
|
|
57
66
|
const contentRef = useRef<ReactNode>(null);
|
|
58
67
|
|
|
@@ -63,6 +72,19 @@ export function SegmentOutlet({ segmentPath, children }: SegmentOutletProps) {
|
|
|
63
72
|
return update;
|
|
64
73
|
}
|
|
65
74
|
|
|
75
|
+
// Skipped slot: keep cached content. The server sends skip=true for
|
|
76
|
+
// parallel route slots whose matched content hasn't changed. React
|
|
77
|
+
// reconciles this SegmentOutlet with the previous one at the same
|
|
78
|
+
// tree position, so the ref (cached content) is preserved.
|
|
79
|
+
if (skip) {
|
|
80
|
+
if (contentRef.current !== null) {
|
|
81
|
+
return contentRef.current;
|
|
82
|
+
}
|
|
83
|
+
// No cached content — the SegmentOutlet instance is fresh (not
|
|
84
|
+
// reconciled from a previous render). Fall through to render
|
|
85
|
+
// children (null). The slot will be fully rendered next navigation.
|
|
86
|
+
}
|
|
87
|
+
|
|
66
88
|
if (contentRef.current === null) {
|
|
67
89
|
contentRef.current = children;
|
|
68
90
|
return children;
|
|
@@ -8,18 +8,20 @@
|
|
|
8
8
|
* SlotsProvider carrying the live slot values; each outlet reads its
|
|
9
9
|
* value from this context by name.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
11
|
+
* Providers MERGE per-key with their parent: an inner SlotsProvider
|
|
12
|
+
* inherits the outer's values and overrides only the keys it declares.
|
|
13
|
+
* Two sibling instances each have their own provider and resolve
|
|
14
|
+
* independently. Layouts use a reserved slot name (LAYOUT_CHILDREN_SLOT)
|
|
15
|
+
* that cannot collide with user-declared slot names (TIM-1191).
|
|
15
16
|
*
|
|
16
|
-
* Generalizes the
|
|
17
|
-
* implicit `children` hole on layouts to explicitly declared,
|
|
18
|
-
* props on any cached component.
|
|
17
|
+
* Generalizes the original layout children pattern (TIM-1181) from a
|
|
18
|
+
* single implicit `children` hole on layouts to explicitly declared,
|
|
19
|
+
* named slot props on any cached component. Layouts use this same
|
|
20
|
+
* context with the reserved LAYOUT_CHILDREN_SLOT key (TIM-1191).
|
|
19
21
|
*
|
|
20
22
|
* SINGLETON GUARANTEE: globalThis + Symbol.for — the RSC client bundler
|
|
21
23
|
* can duplicate this module across chunks; globalThis guarantees a single
|
|
22
|
-
* context instance.
|
|
24
|
+
* context instance.
|
|
23
25
|
*
|
|
24
26
|
* See design/45-cache-lifetimes.md §Slot Components.
|
|
25
27
|
*/
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
* SlotsProvider — wraps a revived cached shell to supply live slot content
|
|
3
3
|
* to the SlotOutlet holes inside it (TIM-1173).
|
|
4
4
|
*
|
|
5
|
+
* Values MERGE with the nearest parent provider so that nesting a
|
|
6
|
+
* cache.component SlotsProvider inside a layout SlotsProvider preserves
|
|
7
|
+
* the layout's `children` slot while adding the component's own slots.
|
|
8
|
+
* Inner values override outer ones with the same name (spread order).
|
|
9
|
+
*
|
|
5
10
|
* Tree structure per cached-component instance:
|
|
6
11
|
* SlotsProvider(values={children: <Live />})
|
|
7
12
|
* └── revived shell
|
|
@@ -12,7 +17,7 @@
|
|
|
12
17
|
|
|
13
18
|
'use client';
|
|
14
19
|
|
|
15
|
-
import { createElement, type ReactNode } from 'react';
|
|
20
|
+
import { createElement, useContext, type ReactNode } from 'react';
|
|
16
21
|
import { SlotContext, type SlotValues } from './slot-context.js';
|
|
17
22
|
|
|
18
23
|
interface SlotsProviderProps {
|
|
@@ -21,5 +26,7 @@ interface SlotsProviderProps {
|
|
|
21
26
|
}
|
|
22
27
|
|
|
23
28
|
export function SlotsProvider({ values, children }: SlotsProviderProps) {
|
|
24
|
-
|
|
29
|
+
const parent = useContext(SlotContext);
|
|
30
|
+
const merged = parent ? { ...parent, ...values } : values;
|
|
31
|
+
return createElement(SlotContext.Provider, { value: merged }, children);
|
|
25
32
|
}
|
|
@@ -124,8 +124,35 @@ async function accessGateFallback(
|
|
|
124
124
|
* slot doesn't make architectural sense.
|
|
125
125
|
*/
|
|
126
126
|
export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactNode> {
|
|
127
|
-
const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } =
|
|
127
|
+
const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children, verdict } =
|
|
128
|
+
props;
|
|
128
129
|
|
|
130
|
+
// Fast path: replay pre-computed verdict from eager evaluation in resolveSlotProps.
|
|
131
|
+
if (verdict !== undefined) {
|
|
132
|
+
if (verdict === 'pass') return children;
|
|
133
|
+
if (verdict instanceof DenySignal) {
|
|
134
|
+
return (
|
|
135
|
+
buildDeniedFallback(DeniedComponent, slotName, verdict.data, createElement) ??
|
|
136
|
+
defaultFallback ??
|
|
137
|
+
null
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// RedirectSignal: treat as deny in production (same as existing behavior)
|
|
141
|
+
if (isDebug()) {
|
|
142
|
+
console.error(
|
|
143
|
+
'[timber] redirect() is not allowed in slot access.ts. ' +
|
|
144
|
+
'Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. ' +
|
|
145
|
+
"If you need to redirect, move the logic to the parent segment's access.ts."
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return (
|
|
149
|
+
buildDeniedFallback(DeniedComponent, slotName, undefined, createElement) ??
|
|
150
|
+
defaultFallback ??
|
|
151
|
+
null
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Fallback path: call accessFn during render (no pre-computed verdict).
|
|
129
156
|
try {
|
|
130
157
|
await accessFn();
|
|
131
158
|
} catch (error: unknown) {
|
|
@@ -140,6 +140,18 @@ export interface RequestContextStore {
|
|
|
140
140
|
* context for render-phase errors without module-level shared state.
|
|
141
141
|
*/
|
|
142
142
|
debugComponentsGetter?: () => DebugComponentEntry[];
|
|
143
|
+
/**
|
|
144
|
+
* Taint tracking results from render-time accessor scope tracking.
|
|
145
|
+
* Keyed by segment/slot ID, true = accessor was called during render.
|
|
146
|
+
* Populated by runInTaintScope() wrappers in TracedLayout and slot
|
|
147
|
+
* resolution; consumed by buildSegmentInfo() for X-Timber-Segments.
|
|
148
|
+
*/
|
|
149
|
+
taintResults?: Map<string, boolean>;
|
|
150
|
+
/**
|
|
151
|
+
* Set to true if an accessor is called outside any taint scope.
|
|
152
|
+
* When true, all segments are marked request-dependent (safe fallback).
|
|
153
|
+
*/
|
|
154
|
+
orphanTaint?: boolean;
|
|
143
155
|
}
|
|
144
156
|
|
|
145
157
|
/** A single outgoing cookie entry in the cookie jar. */
|
|
@@ -149,6 +161,38 @@ export interface CookieEntry {
|
|
|
149
161
|
options: import('./cookie-context.js').CookieOptions;
|
|
150
162
|
}
|
|
151
163
|
|
|
164
|
+
// ─── Taint Scope ─────────────────────────────────────────────────────────
|
|
165
|
+
// Per-component accessor taint tracking. Each scope runs inside its own
|
|
166
|
+
// ALS context so concurrent async components (Flight renders siblings in
|
|
167
|
+
// parallel) don't corrupt each other's touched flags.
|
|
168
|
+
|
|
169
|
+
export interface TaintScope {
|
|
170
|
+
touched: boolean;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const taintScopeAls = getOrCreateAls<TaintScope>(Symbol.for('timber:taint-scope-als'));
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Mark that a request accessor was called in the current scope.
|
|
177
|
+
* If a taint scope is active, marks that scope. If no scope is active
|
|
178
|
+
* but a request context exists, sets the orphan taint flag (all segments
|
|
179
|
+
* become request-dependent as a safety fallback).
|
|
180
|
+
*
|
|
181
|
+
* Lives here (not request-context.ts) to avoid circular deps with
|
|
182
|
+
* cookie-context.ts which also needs to call it.
|
|
183
|
+
*/
|
|
184
|
+
export function markAccessorTouched(): void {
|
|
185
|
+
const scope = taintScopeAls.getStore();
|
|
186
|
+
if (scope) {
|
|
187
|
+
scope.touched = true;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const store = requestContextAls.getStore();
|
|
191
|
+
if (store) {
|
|
192
|
+
store.orphanTaint = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
152
196
|
// ─── Tracing ──────────────────────────────────────────────────────────────
|
|
153
197
|
// Used by: tracing.ts (getTraceId(), getSpanId())
|
|
154
198
|
// Design doc: design/17-logging.md
|
|
@@ -12,7 +12,11 @@
|
|
|
12
12
|
* smuggling primitive that the encoding contract closes.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
requestContextAls,
|
|
17
|
+
markAccessorTouched,
|
|
18
|
+
type RequestContextStore,
|
|
19
|
+
} from './als-registry.js';
|
|
16
20
|
import { isDebug } from './debug.js';
|
|
17
21
|
import {
|
|
18
22
|
assertValidCookieName,
|
|
@@ -62,6 +66,8 @@ export function getCookieJar(): RequestCookies {
|
|
|
62
66
|
);
|
|
63
67
|
}
|
|
64
68
|
|
|
69
|
+
markAccessorTouched();
|
|
70
|
+
|
|
65
71
|
// Parse cookies lazily on first access
|
|
66
72
|
if (!store.parsedCookies) {
|
|
67
73
|
store.parsedCookies = parseCookieHeader(store.cookieHeader);
|
|
@@ -26,7 +26,7 @@ import { loadModule } from './safe-load.js';
|
|
|
26
26
|
import { isMdxFilePath } from './utils/mdx-file.js';
|
|
27
27
|
import { isDebug } from './debug.js';
|
|
28
28
|
import { resolveMetadata, renderMetadataToElements } from './metadata.js';
|
|
29
|
-
import { headElementsToReact } from './
|
|
29
|
+
import { headElementsToReact } from './metadata.js';
|
|
30
30
|
import { resolveStatusFile } from './status-code-resolver.js';
|
|
31
31
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
32
32
|
import type { RouteMatch } from './pipeline.js';
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
* See design/16-metadata.md §"Metadata Routes"
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import type { HeadElement } from './metadata.js';
|
|
15
|
+
import type { Metadata } from './types.js';
|
|
16
|
+
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
17
|
+
|
|
13
18
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
19
|
|
|
15
20
|
/** Classification of a metadata route file. */
|
|
@@ -334,3 +339,93 @@ export function getMetadataRouteAutoLink(type: MetadataRouteType, href: string):
|
|
|
334
339
|
return [];
|
|
335
340
|
}
|
|
336
341
|
}
|
|
342
|
+
|
|
343
|
+
// ─── Auto-Linking ──────────────────────────────────────────────────────────
|
|
344
|
+
|
|
345
|
+
// In dev mode, use a per-startup nonce for metadata route cache busting
|
|
346
|
+
// instead of per-file content hashes (avoids rehashing on every request).
|
|
347
|
+
let _devNonce: string | undefined;
|
|
348
|
+
function getDevNonce(): string {
|
|
349
|
+
_devNonce ??= randomUUID().slice(0, 8);
|
|
350
|
+
return _devNonce;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Collect auto-linked head elements from metadata route files in the segment chain.
|
|
355
|
+
*
|
|
356
|
+
* Walks each segment's metadataRoutes, resolves serve paths and URLs, and
|
|
357
|
+
* emits HeadElement descriptors for <link> and <meta> tags that React Float
|
|
358
|
+
* hoists into <head>.
|
|
359
|
+
*
|
|
360
|
+
* See design/16-metadata.md §"Auto-Linking"
|
|
361
|
+
*/
|
|
362
|
+
export function collectMetadataRouteHeadElements(
|
|
363
|
+
segments: ManifestSegmentNode[],
|
|
364
|
+
firstDeniedIndex: number,
|
|
365
|
+
resolvedMetadata: Metadata,
|
|
366
|
+
requestUrl: URL,
|
|
367
|
+
metadataRouteHashes?: Record<string, string>
|
|
368
|
+
): HeadElement[] {
|
|
369
|
+
const elements: HeadElement[] = [];
|
|
370
|
+
const hasUserOgImage = Boolean(resolvedMetadata.openGraph?.images);
|
|
371
|
+
const hasUserTwitterImage = Boolean(resolvedMetadata.twitter?.images);
|
|
372
|
+
const requestPathname = requestUrl.pathname;
|
|
373
|
+
// In dev mode, use the request origin so OG URLs resolve to localhost.
|
|
374
|
+
// In production, use metadataBase (the canonical domain).
|
|
375
|
+
const ogBase =
|
|
376
|
+
process.env.NODE_ENV !== 'production'
|
|
377
|
+
? new URL(requestUrl.origin)
|
|
378
|
+
: resolvedMetadata.metadataBase;
|
|
379
|
+
|
|
380
|
+
for (let si = 0; si < segments.length; si++) {
|
|
381
|
+
const segment = segments[si];
|
|
382
|
+
if (!segment.metadataRoutes) continue;
|
|
383
|
+
if (si >= firstDeniedIndex) continue;
|
|
384
|
+
for (const baseName of Object.keys(segment.metadataRoutes)) {
|
|
385
|
+
const convention = METADATA_ROUTE_CONVENTIONS[baseName];
|
|
386
|
+
if (!convention) continue;
|
|
387
|
+
if (!convention.nestable && segment.urlPath !== '/') continue;
|
|
388
|
+
if (convention.type === 'opengraph-image' && hasUserOgImage) continue;
|
|
389
|
+
const resolvedPrefix = convention.nestable
|
|
390
|
+
? requestPathname === '/'
|
|
391
|
+
? ''
|
|
392
|
+
: requestPathname
|
|
393
|
+
: '';
|
|
394
|
+
const metaFile = segment.metadataRoutes[baseName];
|
|
395
|
+
const fileServePath = metaFile?.filePath
|
|
396
|
+
? resolveServePathForFile(baseName, metaFile.filePath)
|
|
397
|
+
: convention.serveExtension
|
|
398
|
+
? `${convention.servePath}.${convention.serveExtension}`
|
|
399
|
+
: convention.servePath;
|
|
400
|
+
let href = `${resolvedPrefix}/${fileServePath}`;
|
|
401
|
+
if (convention.type === 'opengraph-image') {
|
|
402
|
+
const fileHash = metaFile?.filePath ? metadataRouteHashes?.[metaFile.filePath] : undefined;
|
|
403
|
+
const cacheBust = fileHash ?? getDevNonce();
|
|
404
|
+
href = `${href}?${cacheBust}`;
|
|
405
|
+
}
|
|
406
|
+
if (ogBase && convention.type === 'opengraph-image') {
|
|
407
|
+
href = new URL(href, ogBase).toString();
|
|
408
|
+
}
|
|
409
|
+
for (const autoLink of getMetadataRouteAutoLink(convention.type, href)) {
|
|
410
|
+
if (
|
|
411
|
+
hasUserTwitterImage &&
|
|
412
|
+
autoLink.tag === 'meta' &&
|
|
413
|
+
'name' in autoLink &&
|
|
414
|
+
autoLink.name === 'twitter:image'
|
|
415
|
+
)
|
|
416
|
+
continue;
|
|
417
|
+
if (autoLink.tag === 'link') {
|
|
418
|
+
const attrs: Record<string, string> = { rel: autoLink.rel, href: autoLink.href };
|
|
419
|
+
if (autoLink.type) attrs.type = autoLink.type;
|
|
420
|
+
elements.push({ tag: 'link', attrs });
|
|
421
|
+
} else {
|
|
422
|
+
const attrs: Record<string, string> = { content: autoLink.content };
|
|
423
|
+
if (autoLink.property) attrs.property = autoLink.property;
|
|
424
|
+
if (autoLink.name) attrs.name = autoLink.name;
|
|
425
|
+
elements.push({ tag: 'meta', attrs });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return elements;
|
|
431
|
+
}
|
package/src/server/metadata.ts
CHANGED
|
@@ -12,12 +12,33 @@
|
|
|
12
12
|
* See design/16-metadata.md
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { createElement, Fragment } from 'react';
|
|
15
16
|
import type { Metadata } from './types.js';
|
|
16
17
|
|
|
17
18
|
// Re-export renderMetadataToElements from the rendering module so existing
|
|
18
19
|
// consumers (route-element-builder, tests) can keep importing from here.
|
|
19
20
|
export { renderMetadataToElements } from './metadata-render.js';
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Convert HeadElement descriptors to React elements for Float hoisting.
|
|
24
|
+
*
|
|
25
|
+
* React 19 Float hoists <title>, <meta>, and <link> rendered anywhere in
|
|
26
|
+
* the tree into <head> during SSR, and manages them in document.head
|
|
27
|
+
* during client-side rendering.
|
|
28
|
+
*/
|
|
29
|
+
export function headElementsToReact(elements: HeadElement[]): React.ReactElement {
|
|
30
|
+
const children: React.ReactElement[] = [];
|
|
31
|
+
for (let i = 0; i < elements.length; i++) {
|
|
32
|
+
const el = elements[i];
|
|
33
|
+
if (el.tag === 'title' && el.content !== undefined) {
|
|
34
|
+
children.push(createElement('title', { key: `t${i}` }, el.content));
|
|
35
|
+
} else if (el.attrs) {
|
|
36
|
+
children.push(createElement(el.tag, { key: `h${i}`, ...el.attrs }));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return createElement(Fragment, null, ...children);
|
|
40
|
+
}
|
|
41
|
+
|
|
21
42
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
22
43
|
|
|
23
44
|
/** A single metadata entry from a layout or page module. */
|
|
@@ -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(
|