@timber-js/app 0.2.0-alpha.124 → 0.2.0-alpha.125
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/{build-output-helper-DYOWsPjZ.js → build-output-helper-K7oUCRqU.js} +1 -1
- package/dist/_chunks/{build-output-helper-DYOWsPjZ.js.map → build-output-helper-K7oUCRqU.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DaERBEGV.js → navigation-root-CeNTCxzS.js} +5 -5
- package/dist/_chunks/navigation-root-CeNTCxzS.js.map +1 -0
- package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
- package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +4 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/index.js +3 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +127 -269
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +19 -17
- package/dist/client/link-pending-store.d.ts.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +4 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +10 -7
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +23 -37
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +21 -0
- package/dist/client/segment-update-context.d.ts.map +1 -0
- package/dist/index.js +1 -1
- package/dist/server/internal.js +57 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +12 -5
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry/hydrate.ts +7 -1
- package/src/client/browser-entry/index.ts +3 -1
- package/src/client/browser-entry/post-hydration.ts +0 -13
- package/src/client/browser-entry/router-init.ts +50 -24
- package/src/client/link-pending-store.ts +22 -17
- package/src/client/link.tsx +12 -6
- package/src/client/navigation-root.tsx +40 -18
- package/src/client/router.ts +109 -97
- package/src/client/rsc-fetch.ts +85 -6
- package/src/client/segment-cache.ts +5 -2
- package/src/client/segment-outlet.tsx +40 -49
- package/src/client/segment-update-context.ts +42 -0
- package/src/server/route-element-builder.ts +69 -2
- package/src/server/rsc-entry/helpers.ts +18 -14
- package/src/server/state-tree-diff.ts +26 -22
- package/dist/_chunks/navigation-root-DaERBEGV.js.map +0 -1
- package/dist/client/segment-merger.d.ts +0 -119
- package/dist/client/segment-merger.d.ts.map +0 -1
- package/src/client/segment-merger.ts +0 -339
|
@@ -1,339 +0,0 @@
|
|
|
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
|
-
* Whether this segment's cached element contains a nested SegmentProvider.
|
|
39
|
-
* Only segments with inner SegmentProviders are safe to skip — the merger
|
|
40
|
-
* can only replace inner SegmentProviders, not pages embedded in layout output.
|
|
41
|
-
* Used by the state tree serialization to exclude non-mergeable segments.
|
|
42
|
-
*/
|
|
43
|
-
hasMergeableChild: boolean;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ─── Segment Element Cache ───────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Cache of React element subtrees per segment path.
|
|
50
|
-
* Updated after each navigation with the full decoded RSC element tree.
|
|
51
|
-
*/
|
|
52
|
-
export class SegmentElementCache {
|
|
53
|
-
private entries = new Map<string, CachedSegmentEntry>();
|
|
54
|
-
|
|
55
|
-
get(segmentPath: string): CachedSegmentEntry | undefined {
|
|
56
|
-
return this.entries.get(segmentPath);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
set(segmentPath: string, entry: CachedSegmentEntry): void {
|
|
60
|
-
this.entries.set(segmentPath, entry);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
has(segmentPath: string): boolean {
|
|
64
|
-
return this.entries.has(segmentPath);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
clear(): void {
|
|
68
|
-
this.entries.clear();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
get size(): number {
|
|
72
|
-
return this.entries.size;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Get the set of segment paths that are safe for the server to skip.
|
|
77
|
-
* Only segments with an inner SegmentProvider (hasMergeableChild) are
|
|
78
|
-
* included — the merger can only replace inner SegmentProviders, not
|
|
79
|
-
* pages embedded in layout output. Used to filter the state tree.
|
|
80
|
-
*
|
|
81
|
-
* Returns an empty set if the element cache is empty (no elements
|
|
82
|
-
* cached yet). This is the safe default — an empty set means no
|
|
83
|
-
* segments pass the filter, so the state tree is empty and the server
|
|
84
|
-
* does a full render. The element cache is populated lazily after the
|
|
85
|
-
* first SPA navigation (RSC-decoded elements from hydration are
|
|
86
|
-
* thenables that can't be walked until React resolves them).
|
|
87
|
-
*/
|
|
88
|
-
getMergeablePaths(): Set<string> {
|
|
89
|
-
const paths = new Set<string>();
|
|
90
|
-
for (const [, entry] of this.entries) {
|
|
91
|
-
if (entry.hasMergeableChild) {
|
|
92
|
-
paths.add(entry.segmentPath);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return paths;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// ─── SegmentProvider Detection ───────────────────────────────────
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Check if a React element is a SegmentProvider by looking for the
|
|
103
|
-
* `segments` prop (an array of path segments). This is the only
|
|
104
|
-
* component that receives this prop shape.
|
|
105
|
-
*/
|
|
106
|
-
export function isSegmentProvider(element: unknown): element is ReactElement {
|
|
107
|
-
if (!isValidElement(element)) return false;
|
|
108
|
-
const props = element.props as Record<string, unknown>;
|
|
109
|
-
return Array.isArray(props.segments);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Extract the segment path from a SegmentProvider element.
|
|
114
|
-
*
|
|
115
|
-
* Uses the `segmentId` prop if available (set by the server for route groups
|
|
116
|
-
* to distinguish siblings that share the same urlPath). Falls back to
|
|
117
|
-
* reconstructing from the `segments` array prop.
|
|
118
|
-
*/
|
|
119
|
-
export function getSegmentPath(element: ReactElement): string {
|
|
120
|
-
const props = element.props as { segments: string[]; segmentId?: string };
|
|
121
|
-
// segmentId is the authoritative key — includes group name for route groups
|
|
122
|
-
if (props.segmentId) return props.segmentId;
|
|
123
|
-
const filtered = props.segments.filter(Boolean);
|
|
124
|
-
return filtered.length === 0 ? '/' : '/' + filtered.join('/');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// ─── Tree Walking ────────────────────────────────────────────────
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Walk a React element tree and extract all SegmentProvider boundaries.
|
|
131
|
-
* Returns an ordered list of segment entries from outermost to innermost.
|
|
132
|
-
*
|
|
133
|
-
* This only finds SegmentProviders along the main children path — it does
|
|
134
|
-
* not descend into parallel routes/slots (those are separate subtrees).
|
|
135
|
-
*/
|
|
136
|
-
export function extractSegments(element: unknown): CachedSegmentEntry[] {
|
|
137
|
-
const segments: CachedSegmentEntry[] = [];
|
|
138
|
-
walkForSegments(element, segments);
|
|
139
|
-
// Compute hasMergeableChild: a segment is mergeable if there's another
|
|
140
|
-
// SegmentProvider nested below it. The segments list is ordered outermost
|
|
141
|
-
// to innermost, so each segment's child is the next entry.
|
|
142
|
-
for (let i = 0; i < segments.length; i++) {
|
|
143
|
-
segments[i].hasMergeableChild = i < segments.length - 1;
|
|
144
|
-
}
|
|
145
|
-
return segments;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function walkForSegments(node: unknown, out: CachedSegmentEntry[]): void {
|
|
149
|
-
if (!isValidElement(node)) return;
|
|
150
|
-
|
|
151
|
-
// Use a local binding to avoid TypeScript narrowing issues with
|
|
152
|
-
// isSegmentProvider's type predicate on the same variable.
|
|
153
|
-
const el: ReactElement = node as ReactElement;
|
|
154
|
-
const props = el.props as Record<string, unknown>;
|
|
155
|
-
|
|
156
|
-
if (isSegmentProvider(node)) {
|
|
157
|
-
out.push({
|
|
158
|
-
segmentPath: getSegmentPath(el),
|
|
159
|
-
element: el,
|
|
160
|
-
hasMergeableChild: false, // computed after collection in extractSegments
|
|
161
|
-
});
|
|
162
|
-
// Continue walking into children to find nested segments
|
|
163
|
-
walkChildren(props.children as ReactNode, out);
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Not a SegmentProvider — walk children looking for one
|
|
168
|
-
walkChildren(props.children as ReactNode, out);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function walkChildren(children: ReactNode, out: CachedSegmentEntry[]): void {
|
|
172
|
-
if (children == null) return;
|
|
173
|
-
|
|
174
|
-
if (Array.isArray(children)) {
|
|
175
|
-
for (const child of children) {
|
|
176
|
-
walkForSegments(child, out);
|
|
177
|
-
}
|
|
178
|
-
} else {
|
|
179
|
-
walkForSegments(children, out);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// ─── Cache Population ────────────────────────────────────────────
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Cache all segment subtrees from a fully-rendered RSC element tree.
|
|
187
|
-
* Call this after every full RSC payload render (navigate, refresh, hydration).
|
|
188
|
-
*/
|
|
189
|
-
export function cacheSegmentElements(element: unknown, cache: SegmentElementCache): void {
|
|
190
|
-
const segments = extractSegments(element);
|
|
191
|
-
for (const entry of segments) {
|
|
192
|
-
cache.set(entry.segmentPath, entry);
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// ─── Tree Merging ────────────────────────────────────────────────
|
|
197
|
-
|
|
198
|
-
/**
|
|
199
|
-
* Find a SegmentProvider nested in the children of a React element.
|
|
200
|
-
* Returns the path of elements from the given element down to the
|
|
201
|
-
* SegmentProvider, enabling reconstruction via cloneElement.
|
|
202
|
-
*
|
|
203
|
-
* The path is an array of [element, childIndex] pairs. childIndex is -1
|
|
204
|
-
* for single-child (non-array) props.children.
|
|
205
|
-
*/
|
|
206
|
-
type TreePath = Array<{ element: ReactElement; childIndex: number }>;
|
|
207
|
-
|
|
208
|
-
function findSegmentProviderPath(node: ReactElement, targetPath?: string): TreePath | null {
|
|
209
|
-
const children = (node.props as { children?: ReactNode }).children;
|
|
210
|
-
if (children == null) return null;
|
|
211
|
-
|
|
212
|
-
if (Array.isArray(children)) {
|
|
213
|
-
for (let i = 0; i < children.length; i++) {
|
|
214
|
-
const child = children[i];
|
|
215
|
-
if (!isValidElement(child)) continue;
|
|
216
|
-
|
|
217
|
-
if (isSegmentProvider(child)) {
|
|
218
|
-
if (!targetPath || getSegmentPath(child) === targetPath) {
|
|
219
|
-
return [{ element: node, childIndex: i }];
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
const deeper = findSegmentProviderPath(child, targetPath);
|
|
224
|
-
if (deeper) {
|
|
225
|
-
return [{ element: node, childIndex: i }, ...deeper];
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
} else if (isValidElement(children)) {
|
|
229
|
-
if (isSegmentProvider(children)) {
|
|
230
|
-
if (!targetPath || getSegmentPath(children) === targetPath) {
|
|
231
|
-
return [{ element: node, childIndex: -1 }];
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const deeper = findSegmentProviderPath(children, targetPath);
|
|
236
|
-
if (deeper) {
|
|
237
|
-
return [{ element: node, childIndex: -1 }, ...deeper];
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Replace a nested SegmentProvider within a cached element tree with
|
|
246
|
-
* new content. Uses cloneElement along the path to produce a new tree
|
|
247
|
-
* with preserved component identity at every level except the replaced node.
|
|
248
|
-
*
|
|
249
|
-
* @param cachedElement The cached SegmentProvider element for this segment
|
|
250
|
-
* @param newInnerContent The new React element to splice in at the inner segment position
|
|
251
|
-
* @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
|
|
252
|
-
* @returns New element tree with the inner segment replaced
|
|
253
|
-
*/
|
|
254
|
-
export function replaceInnerSegment(
|
|
255
|
-
cachedElement: ReactElement,
|
|
256
|
-
newInnerContent: ReactNode,
|
|
257
|
-
innerSegmentPath?: string
|
|
258
|
-
): ReactElement {
|
|
259
|
-
const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
|
|
260
|
-
|
|
261
|
-
if (!path || path.length === 0) {
|
|
262
|
-
// No inner SegmentProvider found — this segment's cached element
|
|
263
|
-
// wraps a page directly (no child layout with a SegmentProvider).
|
|
264
|
-
// We CANNOT safely replace the page because it's embedded deep in
|
|
265
|
-
// the layout's server-rendered output tree and we don't know its
|
|
266
|
-
// position. Return the cached element unchanged as a safety fallback.
|
|
267
|
-
//
|
|
268
|
-
// The server should not skip segments without a child layout below
|
|
269
|
-
// them (enforced by hasRenderedLayoutBelow in buildRouteElement).
|
|
270
|
-
// If this codepath is reached, it indicates a server/client mismatch.
|
|
271
|
-
return cachedElement;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// Reconstruct bottom-up: replace the innermost element first, then
|
|
275
|
-
// clone each ancestor with the updated child.
|
|
276
|
-
let replacement: ReactNode = newInnerContent;
|
|
277
|
-
|
|
278
|
-
for (let i = path.length - 1; i >= 0; i--) {
|
|
279
|
-
const { element, childIndex } = path[i];
|
|
280
|
-
|
|
281
|
-
if (childIndex === -1) {
|
|
282
|
-
// Single child — replace it
|
|
283
|
-
replacement = cloneElement(element, {}, replacement);
|
|
284
|
-
} else {
|
|
285
|
-
// Array children — replace the specific index
|
|
286
|
-
const children = (element.props as { children: ReactNode[] }).children;
|
|
287
|
-
const newChildren = [...children];
|
|
288
|
-
newChildren[childIndex] = replacement;
|
|
289
|
-
replacement = cloneElement(element, {}, ...newChildren);
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return replacement as ReactElement;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Merge a partial RSC payload with cached segment elements.
|
|
298
|
-
*
|
|
299
|
-
* When the server skips segments, the partial payload starts from the
|
|
300
|
-
* first non-skipped segment. This function wraps it with cached elements
|
|
301
|
-
* for the skipped segments, producing a full tree that React can
|
|
302
|
-
* reconcile with the mounted tree (preserving layout state).
|
|
303
|
-
*
|
|
304
|
-
* @param partialPayload The RSC payload element (may be partial)
|
|
305
|
-
* @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
|
|
306
|
-
* @param cache The segment element cache
|
|
307
|
-
* @returns The merged full element tree, or the partial payload if merging isn't possible
|
|
308
|
-
*/
|
|
309
|
-
export function mergeSegmentTree(
|
|
310
|
-
partialPayload: unknown,
|
|
311
|
-
skippedSegments: string[],
|
|
312
|
-
cache: SegmentElementCache
|
|
313
|
-
): unknown {
|
|
314
|
-
if (!isValidElement(partialPayload)) return partialPayload;
|
|
315
|
-
if (skippedSegments.length === 0) return partialPayload;
|
|
316
|
-
|
|
317
|
-
// Build from outermost to innermost: each skipped segment's cached
|
|
318
|
-
// element wraps the next, with the partial payload at the center.
|
|
319
|
-
let result: ReactNode = partialPayload;
|
|
320
|
-
|
|
321
|
-
// Process from innermost skipped segment to outermost
|
|
322
|
-
for (let i = skippedSegments.length - 1; i >= 0; i--) {
|
|
323
|
-
const segmentPath = skippedSegments[i];
|
|
324
|
-
const cached = cache.get(segmentPath);
|
|
325
|
-
|
|
326
|
-
if (!cached) {
|
|
327
|
-
// No cached element for this segment — can't merge.
|
|
328
|
-
// This shouldn't happen (server only skips segments the client
|
|
329
|
-
// has cached), but if it does, return the partial payload as-is.
|
|
330
|
-
return partialPayload;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
// Replace the inner content of the cached segment with our current result.
|
|
334
|
-
// The inner content is either the next SegmentProvider or the page.
|
|
335
|
-
result = replaceInnerSegment(cached.element, result);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return result;
|
|
339
|
-
}
|