@timber-js/app 0.1.52 → 0.1.53
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/client/index.js +248 -22
- package/dist/client/index.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +80 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -0
- package/dist/client/segment-cache.d.ts +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts +96 -0
- package/dist/client/segment-merger.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +1 -1
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +7 -0
- package/src/client/router.ts +48 -188
- package/src/client/rsc-fetch.ts +234 -0
- package/src/client/segment-cache.ts +2 -0
- package/src/client/segment-merger.ts +297 -0
- package/src/server/route-element-builder.ts +14 -0
- package/src/server/rsc-entry/index.ts +3 -2
- package/src/server/rsc-entry/rsc-payload.ts +8 -1
- package/src/server/state-tree-diff.ts +18 -19
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSC Fetch — handles fetching and parsing RSC Flight payloads.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from router.ts to keep both files under the 500-line limit.
|
|
5
|
+
* This module handles:
|
|
6
|
+
* - Cache-busting URL generation for RSC requests
|
|
7
|
+
* - Building RSC request headers (Accept, X-Timber-State-Tree)
|
|
8
|
+
* - Extracting metadata from RSC response headers
|
|
9
|
+
* - Fetching and decoding RSC payloads
|
|
10
|
+
*
|
|
11
|
+
* See design/19-client-navigation.md §"RSC Payload Handling"
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { SegmentInfo } from './segment-cache';
|
|
15
|
+
import type { HeadElement } from './head';
|
|
16
|
+
import type { RouterDeps } from './router';
|
|
17
|
+
|
|
18
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/** Result of fetching an RSC payload — includes head elements and segment metadata. */
|
|
21
|
+
export interface FetchResult {
|
|
22
|
+
payload: unknown;
|
|
23
|
+
headElements: HeadElement[] | null;
|
|
24
|
+
/** Segment metadata from X-Timber-Segments header for populating the segment cache. */
|
|
25
|
+
segmentInfo: SegmentInfo[] | null;
|
|
26
|
+
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
27
|
+
params: Record<string, string | string[]> | null;
|
|
28
|
+
/** Segment paths that were skipped by the server (for client-side merging). */
|
|
29
|
+
skippedSegments: string[] | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Constants ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export const RSC_CONTENT_TYPE = 'text/x-component';
|
|
35
|
+
|
|
36
|
+
// ─── URL Helpers ─────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
40
|
+
* Matches the format Next.js uses for _rsc params.
|
|
41
|
+
*/
|
|
42
|
+
function generateCacheBustId(): string {
|
|
43
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
44
|
+
let id = '';
|
|
45
|
+
for (let i = 0; i < 5; i++) {
|
|
46
|
+
id += chars[(Math.random() * 36) | 0];
|
|
47
|
+
}
|
|
48
|
+
return id;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Append a `_rsc=<id>` query parameter to the URL.
|
|
53
|
+
* Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
|
|
54
|
+
* for RSC navigation requests and signals that this is an RSC fetch.
|
|
55
|
+
*/
|
|
56
|
+
function appendRscParam(url: string): string {
|
|
57
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
58
|
+
return `${url}${separator}_rsc=${generateCacheBustId()}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function buildRscHeaders(
|
|
62
|
+
stateTree: { segments: string[] } | undefined,
|
|
63
|
+
currentUrl?: string
|
|
64
|
+
): Record<string, string> {
|
|
65
|
+
const headers: Record<string, string> = {
|
|
66
|
+
Accept: RSC_CONTENT_TYPE,
|
|
67
|
+
};
|
|
68
|
+
if (stateTree) {
|
|
69
|
+
headers['X-Timber-State-Tree'] = JSON.stringify(stateTree);
|
|
70
|
+
}
|
|
71
|
+
// Send current URL for intercepting route resolution.
|
|
72
|
+
// The server uses this to determine if an intercepting route should
|
|
73
|
+
// render instead of the actual target route (modal pattern).
|
|
74
|
+
// See design/07-routing.md §"Intercepting Routes"
|
|
75
|
+
if (currentUrl) {
|
|
76
|
+
headers['X-Timber-URL'] = currentUrl;
|
|
77
|
+
}
|
|
78
|
+
return headers;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Response Header Extraction ──────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extract head elements from the X-Timber-Head response header.
|
|
85
|
+
* Returns null if the header is missing or malformed.
|
|
86
|
+
*/
|
|
87
|
+
export function extractHeadElements(response: Response): HeadElement[] | null {
|
|
88
|
+
const header = response.headers.get('X-Timber-Head');
|
|
89
|
+
if (!header) return null;
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(decodeURIComponent(header));
|
|
92
|
+
} catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Extract segment metadata from the X-Timber-Segments response header.
|
|
99
|
+
* Returns null if the header is missing or malformed.
|
|
100
|
+
*
|
|
101
|
+
* Format: JSON array of {path, isAsync} objects describing the rendered
|
|
102
|
+
* segment chain from root to leaf. Used to populate the client-side
|
|
103
|
+
* segment cache for state tree diffing on subsequent navigations.
|
|
104
|
+
*/
|
|
105
|
+
export function extractSegmentInfo(response: Response): SegmentInfo[] | null {
|
|
106
|
+
const header = response.headers.get('X-Timber-Segments');
|
|
107
|
+
if (!header) return null;
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(header);
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract skipped segment paths from the X-Timber-Skipped-Segments header.
|
|
117
|
+
* Returns null if the header is missing or malformed.
|
|
118
|
+
*
|
|
119
|
+
* When the server skips sync layouts the client already has cached,
|
|
120
|
+
* it sends this header listing the skipped segment paths (outermost first).
|
|
121
|
+
* The client uses this to merge the partial payload with cached segments.
|
|
122
|
+
*/
|
|
123
|
+
export function extractSkippedSegments(response: Response): string[] | null {
|
|
124
|
+
const header = response.headers.get('X-Timber-Skipped-Segments');
|
|
125
|
+
if (!header) return null;
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(header);
|
|
128
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Extract route params from the X-Timber-Params response header.
|
|
136
|
+
* Returns null if the header is missing or malformed.
|
|
137
|
+
*
|
|
138
|
+
* Used to populate useParams() after client-side navigation.
|
|
139
|
+
*/
|
|
140
|
+
export function extractParams(response: Response): Record<string, string | string[]> | null {
|
|
141
|
+
const header = response.headers.get('X-Timber-Params');
|
|
142
|
+
if (!header) return null;
|
|
143
|
+
try {
|
|
144
|
+
return JSON.parse(header);
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Redirect Error ──────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
154
|
+
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
155
|
+
*/
|
|
156
|
+
export class RedirectError extends Error {
|
|
157
|
+
readonly redirectUrl: string;
|
|
158
|
+
constructor(url: string) {
|
|
159
|
+
super(`Server redirect to ${url}`);
|
|
160
|
+
this.redirectUrl = url;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ─── Fetch ───────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
168
|
+
* the response is decoded into a React element tree via createFromFetch.
|
|
169
|
+
* Otherwise, the raw response text is returned (test mode).
|
|
170
|
+
*
|
|
171
|
+
* Also extracts head elements from the X-Timber-Head response header
|
|
172
|
+
* so the client can update document.title and <meta> tags after navigation.
|
|
173
|
+
*/
|
|
174
|
+
export async function fetchRscPayload(
|
|
175
|
+
url: string,
|
|
176
|
+
deps: RouterDeps,
|
|
177
|
+
stateTree?: { segments: string[] },
|
|
178
|
+
currentUrl?: string
|
|
179
|
+
): Promise<FetchResult> {
|
|
180
|
+
const rscUrl = appendRscParam(url);
|
|
181
|
+
const headers = buildRscHeaders(stateTree, currentUrl);
|
|
182
|
+
if (deps.decodeRsc) {
|
|
183
|
+
// Production path: use createFromFetch for streaming RSC decoding.
|
|
184
|
+
// createFromFetch takes a Promise<Response> and progressively parses
|
|
185
|
+
// the RSC Flight stream as chunks arrive.
|
|
186
|
+
//
|
|
187
|
+
// Intercept the response to read X-Timber-Head before createFromFetch
|
|
188
|
+
// consumes the body. Reading headers does NOT consume the body stream.
|
|
189
|
+
const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
190
|
+
let headElements: HeadElement[] | null = null;
|
|
191
|
+
let segmentInfo: SegmentInfo[] | null = null;
|
|
192
|
+
let params: Record<string, string | string[]> | null = null;
|
|
193
|
+
let skippedSegments: string[] | null = null;
|
|
194
|
+
const wrappedPromise = fetchPromise.then((response) => {
|
|
195
|
+
// Detect server-side redirects. The server returns 204 + X-Timber-Redirect
|
|
196
|
+
// for RSC payload requests instead of a raw 302, because fetch with
|
|
197
|
+
// redirect: "manual" turns 302s into opaque redirects (status 0, null body)
|
|
198
|
+
// which crashes createFromFetch when it tries to read the body stream.
|
|
199
|
+
const redirectLocation =
|
|
200
|
+
response.headers.get('X-Timber-Redirect') ||
|
|
201
|
+
(response.status >= 300 && response.status < 400 ? response.headers.get('Location') : null);
|
|
202
|
+
if (redirectLocation) {
|
|
203
|
+
throw new RedirectError(redirectLocation);
|
|
204
|
+
}
|
|
205
|
+
headElements = extractHeadElements(response);
|
|
206
|
+
segmentInfo = extractSegmentInfo(response);
|
|
207
|
+
params = extractParams(response);
|
|
208
|
+
skippedSegments = extractSkippedSegments(response);
|
|
209
|
+
return response;
|
|
210
|
+
});
|
|
211
|
+
// Await so headElements/segmentInfo/params are populated before we return.
|
|
212
|
+
// Also await the decoded payload — createFromFetch returns a thenable
|
|
213
|
+
// that resolves to the React element tree.
|
|
214
|
+
await wrappedPromise;
|
|
215
|
+
const payload = await deps.decodeRsc(wrappedPromise);
|
|
216
|
+
return { payload, headElements, segmentInfo, params, skippedSegments };
|
|
217
|
+
}
|
|
218
|
+
// Test/fallback path: return raw text
|
|
219
|
+
const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
|
|
220
|
+
// Check for redirect in test path too
|
|
221
|
+
if (response.status >= 300 && response.status < 400) {
|
|
222
|
+
const location = response.headers.get('Location');
|
|
223
|
+
if (location) {
|
|
224
|
+
throw new RedirectError(location);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
payload: await response.text(),
|
|
229
|
+
headElements: extractHeadElements(response),
|
|
230
|
+
segmentInfo: extractSegmentInfo(response),
|
|
231
|
+
params: extractParams(response),
|
|
232
|
+
skippedSegments: extractSkippedSegments(response),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -13,6 +13,8 @@ export interface PrefetchResult {
|
|
|
13
13
|
segmentInfo?: SegmentInfo[] | null;
|
|
14
14
|
/** Route params from X-Timber-Params header for populating useParams(). */
|
|
15
15
|
params?: Record<string, string | string[]> | null;
|
|
16
|
+
/** Segment paths skipped by the server (for client-side merging). */
|
|
17
|
+
skippedSegments?: string[] | null;
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Segment Merger — client-side tree merging for partial RSC payloads.
|
|
3
|
+
*
|
|
4
|
+
* When the server skips rendering sync layouts (because the client already
|
|
5
|
+
* has them cached), the RSC payload is missing outer segment wrappers.
|
|
6
|
+
* This module reconstructs the full element tree by splicing the partial
|
|
7
|
+
* payload into cached segment subtrees.
|
|
8
|
+
*
|
|
9
|
+
* The approach:
|
|
10
|
+
* 1. After each full RSC payload render, walk the decoded element tree
|
|
11
|
+
* and cache each segment's subtree (identified by SegmentProvider boundaries)
|
|
12
|
+
* 2. When a partial payload arrives, wrap it with cached segment elements
|
|
13
|
+
* using React.cloneElement to preserve component identity
|
|
14
|
+
*
|
|
15
|
+
* React.cloneElement preserves the element's `type` — React sees the same
|
|
16
|
+
* component at the same tree position and reconciles (preserving state)
|
|
17
|
+
* rather than remounting. This is how layout state survives navigations.
|
|
18
|
+
*
|
|
19
|
+
* Design docs: 19-client-navigation.md §"Navigation Reconciliation"
|
|
20
|
+
* Security: access.ts runs on the server regardless of skipping — this
|
|
21
|
+
* is a performance optimization only. See 13-security.md.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
|
25
|
+
|
|
26
|
+
// ─── Types ───────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A cached segment entry. Stores the full subtree rooted at a SegmentProvider
|
|
30
|
+
* and the path through the tree to the next SegmentProvider (or leaf).
|
|
31
|
+
*/
|
|
32
|
+
export interface CachedSegmentEntry {
|
|
33
|
+
/** The segment's URL path (e.g., "/", "/dashboard") */
|
|
34
|
+
segmentPath: string;
|
|
35
|
+
/** The SegmentProvider element for this segment */
|
|
36
|
+
element: ReactElement;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ─── Segment Element Cache ───────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Cache of React element subtrees per segment path.
|
|
43
|
+
* Updated after each navigation with the full decoded RSC element tree.
|
|
44
|
+
*/
|
|
45
|
+
export class SegmentElementCache {
|
|
46
|
+
private entries = new Map<string, CachedSegmentEntry>();
|
|
47
|
+
|
|
48
|
+
get(segmentPath: string): CachedSegmentEntry | undefined {
|
|
49
|
+
return this.entries.get(segmentPath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
set(segmentPath: string, entry: CachedSegmentEntry): void {
|
|
53
|
+
this.entries.set(segmentPath, entry);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
has(segmentPath: string): boolean {
|
|
57
|
+
return this.entries.has(segmentPath);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clear(): void {
|
|
61
|
+
this.entries.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get size(): number {
|
|
65
|
+
return this.entries.size;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ─── SegmentProvider Detection ───────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if a React element is a SegmentProvider by looking for the
|
|
73
|
+
* `segments` prop (an array of path segments). This is the only
|
|
74
|
+
* component that receives this prop shape.
|
|
75
|
+
*/
|
|
76
|
+
export function isSegmentProvider(element: unknown): element is ReactElement {
|
|
77
|
+
if (!isValidElement(element)) return false;
|
|
78
|
+
const props = element.props as Record<string, unknown>;
|
|
79
|
+
return Array.isArray(props.segments);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Extract the segment path from a SegmentProvider element.
|
|
84
|
+
* The `segments` prop is an array like ["", "dashboard", "settings"].
|
|
85
|
+
* The path is reconstructed as "/" + segments.filter(Boolean).join("/").
|
|
86
|
+
*/
|
|
87
|
+
export function getSegmentPath(element: ReactElement): string {
|
|
88
|
+
const segments = (element.props as { segments: string[] }).segments;
|
|
89
|
+
const filtered = segments.filter(Boolean);
|
|
90
|
+
return filtered.length === 0 ? '/' : '/' + filtered.join('/');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Tree Walking ────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Walk a React element tree and extract all SegmentProvider boundaries.
|
|
97
|
+
* Returns an ordered list of segment entries from outermost to innermost.
|
|
98
|
+
*
|
|
99
|
+
* This only finds SegmentProviders along the main children path — it does
|
|
100
|
+
* not descend into parallel routes/slots (those are separate subtrees).
|
|
101
|
+
*/
|
|
102
|
+
export function extractSegments(element: unknown): CachedSegmentEntry[] {
|
|
103
|
+
const segments: CachedSegmentEntry[] = [];
|
|
104
|
+
walkForSegments(element, segments);
|
|
105
|
+
return segments;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function walkForSegments(node: unknown, out: CachedSegmentEntry[]): void {
|
|
109
|
+
if (!isValidElement(node)) return;
|
|
110
|
+
|
|
111
|
+
// Use a local binding to avoid TypeScript narrowing issues with
|
|
112
|
+
// isSegmentProvider's type predicate on the same variable.
|
|
113
|
+
const el: ReactElement = node as ReactElement;
|
|
114
|
+
const props = el.props as Record<string, unknown>;
|
|
115
|
+
|
|
116
|
+
if (isSegmentProvider(node)) {
|
|
117
|
+
out.push({
|
|
118
|
+
segmentPath: getSegmentPath(el),
|
|
119
|
+
element: el,
|
|
120
|
+
});
|
|
121
|
+
// Continue walking into children to find nested segments
|
|
122
|
+
walkChildren(props.children as ReactNode, out);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Not a SegmentProvider — walk children looking for one
|
|
127
|
+
walkChildren(props.children as ReactNode, out);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function walkChildren(children: ReactNode, out: CachedSegmentEntry[]): void {
|
|
131
|
+
if (children == null) return;
|
|
132
|
+
|
|
133
|
+
if (Array.isArray(children)) {
|
|
134
|
+
for (const child of children) {
|
|
135
|
+
walkForSegments(child, out);
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
walkForSegments(children, out);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── Cache Population ────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Cache all segment subtrees from a fully-rendered RSC element tree.
|
|
146
|
+
* Call this after every full RSC payload render (navigate, refresh, hydration).
|
|
147
|
+
*/
|
|
148
|
+
export function cacheSegmentElements(
|
|
149
|
+
element: unknown,
|
|
150
|
+
cache: SegmentElementCache
|
|
151
|
+
): void {
|
|
152
|
+
const segments = extractSegments(element);
|
|
153
|
+
for (const entry of segments) {
|
|
154
|
+
cache.set(entry.segmentPath, entry);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Tree Merging ────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Find a SegmentProvider nested in the children of a React element.
|
|
162
|
+
* Returns the path of elements from the given element down to the
|
|
163
|
+
* SegmentProvider, enabling reconstruction via cloneElement.
|
|
164
|
+
*
|
|
165
|
+
* The path is an array of [element, childIndex] pairs. childIndex is -1
|
|
166
|
+
* for single-child (non-array) props.children.
|
|
167
|
+
*/
|
|
168
|
+
type TreePath = Array<{ element: ReactElement; childIndex: number }>;
|
|
169
|
+
|
|
170
|
+
function findSegmentProviderPath(
|
|
171
|
+
node: ReactElement,
|
|
172
|
+
targetPath?: string
|
|
173
|
+
): TreePath | null {
|
|
174
|
+
const children = (node.props as { children?: ReactNode }).children;
|
|
175
|
+
if (children == null) return null;
|
|
176
|
+
|
|
177
|
+
if (Array.isArray(children)) {
|
|
178
|
+
for (let i = 0; i < children.length; i++) {
|
|
179
|
+
const child = children[i];
|
|
180
|
+
if (!isValidElement(child)) continue;
|
|
181
|
+
|
|
182
|
+
if (isSegmentProvider(child)) {
|
|
183
|
+
if (!targetPath || getSegmentPath(child) === targetPath) {
|
|
184
|
+
return [{ element: node, childIndex: i }];
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const deeper = findSegmentProviderPath(child, targetPath);
|
|
189
|
+
if (deeper) {
|
|
190
|
+
return [{ element: node, childIndex: i }, ...deeper];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
} else if (isValidElement(children)) {
|
|
194
|
+
if (isSegmentProvider(children)) {
|
|
195
|
+
if (!targetPath || getSegmentPath(children) === targetPath) {
|
|
196
|
+
return [{ element: node, childIndex: -1 }];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const deeper = findSegmentProviderPath(children, targetPath);
|
|
201
|
+
if (deeper) {
|
|
202
|
+
return [{ element: node, childIndex: -1 }, ...deeper];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace a nested SegmentProvider within a cached element tree with
|
|
211
|
+
* new content. Uses cloneElement along the path to produce a new tree
|
|
212
|
+
* with preserved component identity at every level except the replaced node.
|
|
213
|
+
*
|
|
214
|
+
* @param cachedElement The cached SegmentProvider element for this segment
|
|
215
|
+
* @param newInnerContent The new React element to splice in at the inner segment position
|
|
216
|
+
* @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
|
|
217
|
+
* @returns New element tree with the inner segment replaced
|
|
218
|
+
*/
|
|
219
|
+
export function replaceInnerSegment(
|
|
220
|
+
cachedElement: ReactElement,
|
|
221
|
+
newInnerContent: ReactNode,
|
|
222
|
+
innerSegmentPath?: string
|
|
223
|
+
): ReactElement {
|
|
224
|
+
const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
|
|
225
|
+
|
|
226
|
+
if (!path || path.length === 0) {
|
|
227
|
+
// No inner SegmentProvider found — the cached element's children
|
|
228
|
+
// IS the leaf content. Replace children directly.
|
|
229
|
+
return cloneElement(cachedElement, {}, newInnerContent);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Reconstruct bottom-up: replace the innermost element first, then
|
|
233
|
+
// clone each ancestor with the updated child.
|
|
234
|
+
let replacement: ReactNode = newInnerContent;
|
|
235
|
+
|
|
236
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
237
|
+
const { element, childIndex } = path[i];
|
|
238
|
+
|
|
239
|
+
if (childIndex === -1) {
|
|
240
|
+
// Single child — replace it
|
|
241
|
+
replacement = cloneElement(element, {}, replacement);
|
|
242
|
+
} else {
|
|
243
|
+
// Array children — replace the specific index
|
|
244
|
+
const children = (element.props as { children: ReactNode[] }).children;
|
|
245
|
+
const newChildren = [...children];
|
|
246
|
+
newChildren[childIndex] = replacement;
|
|
247
|
+
replacement = cloneElement(element, {}, ...newChildren);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return replacement as ReactElement;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Merge a partial RSC payload with cached segment elements.
|
|
256
|
+
*
|
|
257
|
+
* When the server skips segments, the partial payload starts from the
|
|
258
|
+
* first non-skipped segment. This function wraps it with cached elements
|
|
259
|
+
* for the skipped segments, producing a full tree that React can
|
|
260
|
+
* reconcile with the mounted tree (preserving layout state).
|
|
261
|
+
*
|
|
262
|
+
* @param partialPayload The RSC payload element (may be partial)
|
|
263
|
+
* @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
|
|
264
|
+
* @param cache The segment element cache
|
|
265
|
+
* @returns The merged full element tree, or the partial payload if merging isn't possible
|
|
266
|
+
*/
|
|
267
|
+
export function mergeSegmentTree(
|
|
268
|
+
partialPayload: unknown,
|
|
269
|
+
skippedSegments: string[],
|
|
270
|
+
cache: SegmentElementCache
|
|
271
|
+
): unknown {
|
|
272
|
+
if (!isValidElement(partialPayload)) return partialPayload;
|
|
273
|
+
if (skippedSegments.length === 0) return partialPayload;
|
|
274
|
+
|
|
275
|
+
// Build from outermost to innermost: each skipped segment's cached
|
|
276
|
+
// element wraps the next, with the partial payload at the center.
|
|
277
|
+
let result: ReactNode = partialPayload;
|
|
278
|
+
|
|
279
|
+
// Process from innermost skipped segment to outermost
|
|
280
|
+
for (let i = skippedSegments.length - 1; i >= 0; i--) {
|
|
281
|
+
const segmentPath = skippedSegments[i];
|
|
282
|
+
const cached = cache.get(segmentPath);
|
|
283
|
+
|
|
284
|
+
if (!cached) {
|
|
285
|
+
// No cached element for this segment — can't merge.
|
|
286
|
+
// This shouldn't happen (server only skips segments the client
|
|
287
|
+
// has cached), but if it does, return the partial payload as-is.
|
|
288
|
+
return partialPayload;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Replace the inner content of the cached segment with our current result.
|
|
292
|
+
// The inner content is either the next SegmentProvider or the page.
|
|
293
|
+
result = replaceInnerSegment(cached.element, result);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
@@ -61,6 +61,13 @@ export interface RouteElementResult {
|
|
|
61
61
|
segments: ManifestSegmentNode[];
|
|
62
62
|
/** Max deferSuspenseFor hold window across all segments. */
|
|
63
63
|
deferSuspenseFor: number;
|
|
64
|
+
/**
|
|
65
|
+
* Segment paths that were skipped because the client already has them cached.
|
|
66
|
+
* Ordered outermost to innermost. Empty when no segments were skipped.
|
|
67
|
+
* The client uses this to merge the partial payload with cached segments.
|
|
68
|
+
* See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
69
|
+
*/
|
|
70
|
+
skippedSegments: string[];
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
/**
|
|
@@ -305,6 +312,10 @@ export async function buildRouteElement(
|
|
|
305
312
|
layoutComponents.map(({ component, segment }) => [segment, component])
|
|
306
313
|
);
|
|
307
314
|
|
|
315
|
+
// Track which segments were skipped for the X-Timber-Skipped-Segments header.
|
|
316
|
+
// The client uses this to merge the partial payload with its cached segments.
|
|
317
|
+
const skippedSegments: string[] = [];
|
|
318
|
+
|
|
308
319
|
// Wrap from innermost (leaf) to outermost (root), processing every
|
|
309
320
|
// segment in the chain. Each segment may contribute:
|
|
310
321
|
// 1. Error boundaries (status files + error.tsx)
|
|
@@ -334,6 +345,8 @@ export async function buildRouteElement(
|
|
|
334
345
|
// Skip this segment entirely — the client uses its cached version.
|
|
335
346
|
// Access.ts already ran in the pre-render loop (security guarantee).
|
|
336
347
|
// Metadata was already resolved above (head elements are correct).
|
|
348
|
+
// Record for X-Timber-Skipped-Segments header (outermost first, so prepend).
|
|
349
|
+
skippedSegments.unshift(segment.urlPath);
|
|
337
350
|
continue;
|
|
338
351
|
}
|
|
339
352
|
|
|
@@ -413,5 +426,6 @@ export async function buildRouteElement(
|
|
|
413
426
|
layoutComponents,
|
|
414
427
|
segments,
|
|
415
428
|
deferSuspenseFor,
|
|
429
|
+
skippedSegments,
|
|
416
430
|
};
|
|
417
431
|
}
|
|
@@ -330,7 +330,7 @@ async function renderRoute(
|
|
|
330
330
|
throw error;
|
|
331
331
|
}
|
|
332
332
|
|
|
333
|
-
const { element, headElements, layoutComponents, deferSuspenseFor } = routeResult;
|
|
333
|
+
const { element, headElements, layoutComponents, deferSuspenseFor, skippedSegments } = routeResult;
|
|
334
334
|
|
|
335
335
|
// Build head HTML for injection into the SSR output.
|
|
336
336
|
// Collects CSS, fonts, and modulepreload from the build manifest for matched segments.
|
|
@@ -434,7 +434,8 @@ async function renderRoute(
|
|
|
434
434
|
layoutComponents,
|
|
435
435
|
headElements,
|
|
436
436
|
match,
|
|
437
|
-
responseHeaders
|
|
437
|
+
responseHeaders,
|
|
438
|
+
skippedSegments
|
|
438
439
|
);
|
|
439
440
|
}
|
|
440
441
|
|
|
@@ -41,7 +41,8 @@ export async function buildRscPayloadResponse(
|
|
|
41
41
|
layoutComponents: LayoutComponentEntry[],
|
|
42
42
|
headElements: HeadElement[],
|
|
43
43
|
match: RouteMatch,
|
|
44
|
-
responseHeaders: Headers
|
|
44
|
+
responseHeaders: Headers,
|
|
45
|
+
skippedSegments?: string[]
|
|
45
46
|
): Promise<Response> {
|
|
46
47
|
// Read the first chunk from the RSC stream before committing headers.
|
|
47
48
|
const reader = rscStream.getReader();
|
|
@@ -113,6 +114,12 @@ export async function buildRscPayloadResponse(
|
|
|
113
114
|
const segmentInfo = buildSegmentInfo(segments, layoutComponents);
|
|
114
115
|
responseHeaders.set('X-Timber-Segments', JSON.stringify(segmentInfo));
|
|
115
116
|
|
|
117
|
+
// Send skipped segments so the client can merge the partial RSC payload
|
|
118
|
+
// with its cached segment elements. See design/19-client-navigation.md.
|
|
119
|
+
if (skippedSegments && skippedSegments.length > 0) {
|
|
120
|
+
responseHeaders.set('X-Timber-Skipped-Segments', JSON.stringify(skippedSegments));
|
|
121
|
+
}
|
|
122
|
+
|
|
116
123
|
// Send route params so the client can populate useParams() after
|
|
117
124
|
// SPA navigation. Without this, useParams() returns {}.
|
|
118
125
|
if (Object.keys(match.params).length > 0) {
|
|
@@ -55,24 +55,23 @@ export function parseClientStateTree(req: Request): Set<string> | null {
|
|
|
55
55
|
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
56
56
|
*/
|
|
57
57
|
export function shouldSkipSegment(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
urlPath: string,
|
|
59
|
+
layoutComponent: ((...args: unknown[]) => unknown) | undefined,
|
|
60
|
+
isLeaf: boolean,
|
|
61
|
+
clientSegments: Set<string> | null
|
|
62
62
|
): boolean {
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
//
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
return false;
|
|
63
|
+
// No state tree → full render (initial load, refresh, etc.)
|
|
64
|
+
if (!clientSegments) return false;
|
|
65
|
+
|
|
66
|
+
// Leaf segments (pages) are never skipped
|
|
67
|
+
if (isLeaf) return false;
|
|
68
|
+
|
|
69
|
+
// No layout → nothing to skip
|
|
70
|
+
if (!layoutComponent) return false;
|
|
71
|
+
|
|
72
|
+
// Async layouts always re-render (they may depend on request context)
|
|
73
|
+
if (layoutComponent.constructor?.name === 'AsyncFunction') return false;
|
|
74
|
+
|
|
75
|
+
// Skip if the client already has this segment cached
|
|
76
|
+
return clientSegments.has(urlPath);
|
|
78
77
|
}
|