@timber-js/app 0.1.52 → 0.1.54

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.
@@ -37,6 +37,13 @@ export function useSegmentContext(): SegmentContextValue | null {
37
37
 
38
38
  interface SegmentProviderProps {
39
39
  segments: string[];
40
+ /**
41
+ * Unique identifier for this segment, used by the client-side segment
42
+ * merger for element caching. For route groups this includes the group
43
+ * name (e.g., "/(marketing)") since groups share their parent's urlPath.
44
+ * Falls back to the reconstructed path from `segments` if not provided.
45
+ */
46
+ segmentId?: string;
40
47
  parallelRouteKeys: string[];
41
48
  children: React.ReactNode;
42
49
  }
@@ -45,7 +52,7 @@ interface SegmentProviderProps {
45
52
  * Wraps each layout to provide segment position context.
46
53
  * Injected by rsc-entry.ts during element tree construction.
47
54
  */
48
- export function SegmentProvider({ segments, parallelRouteKeys, children }: SegmentProviderProps) {
55
+ export function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }: SegmentProviderProps) {
49
56
  const value = useMemo(
50
57
  () => ({ segments, parallelRouteKeys }),
51
58
  // segments and parallelRouteKeys are static per layout — they don't change
@@ -0,0 +1,345 @@
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(
190
+ element: unknown,
191
+ cache: SegmentElementCache
192
+ ): void {
193
+ const segments = extractSegments(element);
194
+ for (const entry of segments) {
195
+ cache.set(entry.segmentPath, entry);
196
+ }
197
+ }
198
+
199
+ // ─── Tree Merging ────────────────────────────────────────────────
200
+
201
+ /**
202
+ * Find a SegmentProvider nested in the children of a React element.
203
+ * Returns the path of elements from the given element down to the
204
+ * SegmentProvider, enabling reconstruction via cloneElement.
205
+ *
206
+ * The path is an array of [element, childIndex] pairs. childIndex is -1
207
+ * for single-child (non-array) props.children.
208
+ */
209
+ type TreePath = Array<{ element: ReactElement; childIndex: number }>;
210
+
211
+ function findSegmentProviderPath(
212
+ node: ReactElement,
213
+ targetPath?: string
214
+ ): TreePath | null {
215
+ const children = (node.props as { children?: ReactNode }).children;
216
+ if (children == null) return null;
217
+
218
+ if (Array.isArray(children)) {
219
+ for (let i = 0; i < children.length; i++) {
220
+ const child = children[i];
221
+ if (!isValidElement(child)) continue;
222
+
223
+ if (isSegmentProvider(child)) {
224
+ if (!targetPath || getSegmentPath(child) === targetPath) {
225
+ return [{ element: node, childIndex: i }];
226
+ }
227
+ }
228
+
229
+ const deeper = findSegmentProviderPath(child, targetPath);
230
+ if (deeper) {
231
+ return [{ element: node, childIndex: i }, ...deeper];
232
+ }
233
+ }
234
+ } else if (isValidElement(children)) {
235
+ if (isSegmentProvider(children)) {
236
+ if (!targetPath || getSegmentPath(children) === targetPath) {
237
+ return [{ element: node, childIndex: -1 }];
238
+ }
239
+ }
240
+
241
+ const deeper = findSegmentProviderPath(children, targetPath);
242
+ if (deeper) {
243
+ return [{ element: node, childIndex: -1 }, ...deeper];
244
+ }
245
+ }
246
+
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * Replace a nested SegmentProvider within a cached element tree with
252
+ * new content. Uses cloneElement along the path to produce a new tree
253
+ * with preserved component identity at every level except the replaced node.
254
+ *
255
+ * @param cachedElement The cached SegmentProvider element for this segment
256
+ * @param newInnerContent The new React element to splice in at the inner segment position
257
+ * @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
258
+ * @returns New element tree with the inner segment replaced
259
+ */
260
+ export function replaceInnerSegment(
261
+ cachedElement: ReactElement,
262
+ newInnerContent: ReactNode,
263
+ innerSegmentPath?: string
264
+ ): ReactElement {
265
+ const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
266
+
267
+ if (!path || path.length === 0) {
268
+ // No inner SegmentProvider found — this segment's cached element
269
+ // wraps a page directly (no child layout with a SegmentProvider).
270
+ // We CANNOT safely replace the page because it's embedded deep in
271
+ // the layout's server-rendered output tree and we don't know its
272
+ // position. Return the cached element unchanged as a safety fallback.
273
+ //
274
+ // The server should not skip segments without a child layout below
275
+ // them (enforced by hasRenderedLayoutBelow in buildRouteElement).
276
+ // If this codepath is reached, it indicates a server/client mismatch.
277
+ return cachedElement;
278
+ }
279
+
280
+ // Reconstruct bottom-up: replace the innermost element first, then
281
+ // clone each ancestor with the updated child.
282
+ let replacement: ReactNode = newInnerContent;
283
+
284
+ for (let i = path.length - 1; i >= 0; i--) {
285
+ const { element, childIndex } = path[i];
286
+
287
+ if (childIndex === -1) {
288
+ // Single child — replace it
289
+ replacement = cloneElement(element, {}, replacement);
290
+ } else {
291
+ // Array children — replace the specific index
292
+ const children = (element.props as { children: ReactNode[] }).children;
293
+ const newChildren = [...children];
294
+ newChildren[childIndex] = replacement;
295
+ replacement = cloneElement(element, {}, ...newChildren);
296
+ }
297
+ }
298
+
299
+ return replacement as ReactElement;
300
+ }
301
+
302
+ /**
303
+ * Merge a partial RSC payload with cached segment elements.
304
+ *
305
+ * When the server skips segments, the partial payload starts from the
306
+ * first non-skipped segment. This function wraps it with cached elements
307
+ * for the skipped segments, producing a full tree that React can
308
+ * reconcile with the mounted tree (preserving layout state).
309
+ *
310
+ * @param partialPayload The RSC payload element (may be partial)
311
+ * @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
312
+ * @param cache The segment element cache
313
+ * @returns The merged full element tree, or the partial payload if merging isn't possible
314
+ */
315
+ export function mergeSegmentTree(
316
+ partialPayload: unknown,
317
+ skippedSegments: string[],
318
+ cache: SegmentElementCache
319
+ ): unknown {
320
+ if (!isValidElement(partialPayload)) return partialPayload;
321
+ if (skippedSegments.length === 0) return partialPayload;
322
+
323
+ // Build from outermost to innermost: each skipped segment's cached
324
+ // element wraps the next, with the partial payload at the center.
325
+ let result: ReactNode = partialPayload;
326
+
327
+ // Process from innermost skipped segment to outermost
328
+ for (let i = skippedSegments.length - 1; i >= 0; i--) {
329
+ const segmentPath = skippedSegments[i];
330
+ const cached = cache.get(segmentPath);
331
+
332
+ if (!cached) {
333
+ // No cached element for this segment — can't merge.
334
+ // This shouldn't happen (server only skips segments the client
335
+ // has cached), but if it does, return the partial payload as-is.
336
+ return partialPayload;
337
+ }
338
+
339
+ // Replace the inner content of the cached segment with our current result.
340
+ // The inner content is either the next SegmentProvider or the page.
341
+ result = replaceInnerSegment(cached.element, result);
342
+ }
343
+
344
+ return result;
345
+ }
@@ -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)
@@ -315,6 +326,16 @@ export async function buildRouteElement(
315
326
  // client navigation), sync layouts the client already has are skipped.
316
327
  // Access.ts already ran for ALL segments in the pre-render loop above.
317
328
  // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
329
+ //
330
+ // hasRenderedLayoutBelow tracks whether a non-skipped layout has been
331
+ // seen below the current segment. A segment can ONLY be skipped if
332
+ // there is a rendered layout below it — the client merger can only
333
+ // replace inner SegmentProviders (client component boundaries), not
334
+ // page content embedded in a layout's server-rendered output.
335
+ // Without this guard, skipping the innermost layout causes the merger
336
+ // to drop the layout entirely and replace it with just the page.
337
+ let hasRenderedLayoutBelow = false;
338
+
318
339
  for (let i = segments.length - 1; i >= 0; i--) {
319
340
  const segment = segments[i];
320
341
  const isLeaf = i === segments.length - 1;
@@ -323,20 +344,38 @@ export async function buildRouteElement(
323
344
  // Check if this segment's layout can be skipped for partial rendering.
324
345
  // Skipped segments: no layout wrapping, no error boundaries, no slots,
325
346
  // no AccessGate in element tree (access already ran pre-render).
326
- const skip = shouldSkipSegment(
327
- segment.urlPath,
328
- layoutComponent,
329
- isLeaf,
330
- clientStateTree ?? null
331
- );
347
+ //
348
+ // Additional constraints beyond shouldSkipSegment:
349
+ // - Must have a rendered layout below (so the merger can find an
350
+ // inner SegmentProvider to splice the new content into)
351
+ // - Route groups are never skipped because sibling groups share the
352
+ // same urlPath (e.g., /(marketing) and /(app) both have "/"),
353
+ // which would cause the wrong cached layout to be reused
354
+ const skip =
355
+ shouldSkipSegment(
356
+ segment.urlPath,
357
+ layoutComponent,
358
+ isLeaf,
359
+ clientStateTree ?? null
360
+ ) &&
361
+ hasRenderedLayoutBelow &&
362
+ segment.segmentType !== 'group';
332
363
 
333
364
  if (skip) {
334
365
  // Skip this segment entirely — the client uses its cached version.
335
366
  // Access.ts already ran in the pre-render loop (security guarantee).
336
367
  // Metadata was already resolved above (head elements are correct).
368
+ // Record for X-Timber-Skipped-Segments header (outermost first, so prepend).
369
+ skippedSegments.unshift(segment.urlPath);
337
370
  continue;
338
371
  }
339
372
 
373
+ // This segment is rendered — mark that future (outer) segments have
374
+ // a rendered layout below them and can safely be skipped.
375
+ if (layoutComponent) {
376
+ hasRenderedLayoutBelow = true;
377
+ }
378
+
340
379
  // Wrap with error boundaries from this segment (inside layout).
341
380
  element = await wrapSegmentWithErrorBoundaries(segment, element, h);
342
381
 
@@ -394,8 +433,18 @@ export async function buildRouteElement(
394
433
  );
395
434
  };
396
435
 
436
+ // segmentId uniquely identifies this segment for client-side element
437
+ // caching. For route groups, urlPath is shared with the parent (both "/"),
438
+ // so we include the group name to distinguish them. Without this, the
439
+ // segment merger's element cache would conflate root and group elements.
440
+ const segmentId =
441
+ segment.segmentType === 'group'
442
+ ? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
443
+ : segment.urlPath;
444
+
397
445
  element = h(SegmentProvider, {
398
446
  segments: segmentPath,
447
+ segmentId,
399
448
  parallelRouteKeys,
400
449
  children: h(TracedLayout, {
401
450
  ...slotProps,
@@ -413,5 +462,6 @@ export async function buildRouteElement(
413
462
  layoutComponents,
414
463
  segments,
415
464
  deferSuspenseFor,
465
+ skippedSegments,
416
466
  };
417
467
  }
@@ -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
- _urlPath: string,
59
- _layoutComponent: ((...args: unknown[]) => unknown) | undefined,
60
- _isLeaf: boolean,
61
- _clientSegments: Set<string> | null
58
+ urlPath: string,
59
+ layoutComponent: ((...args: unknown[]) => unknown) | undefined,
60
+ isLeaf: boolean,
61
+ clientSegments: Set<string> | null
62
62
  ): boolean {
63
- // DISABLED: Server-side segment skipping is not safe without client-side
64
- // tree merging. When the server omits a layout from the RSC payload, the
65
- // client receives a tree with a different structure (no SegmentProvider,
66
- // no layout wrapper). React sees a different component tree shape and
67
- // re-mounts everything — destroying DOM state (input values, focus,
68
- // scroll position) in layouts that should have been preserved.
69
- //
70
- // The correct fix requires client-side tree merging: the router must
71
- // splice the partial RSC response into the existing cached tree, only
72
- // replacing changed segments. Until that's implemented, always render
73
- // the full tree. RSC naturally handles this efficiently — client
74
- // components are sent as references, not re-serialized.
75
- //
76
- // See design/19-client-navigation.md §"X-Timber-State-Tree Header"
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
  }