@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.
@@ -0,0 +1,80 @@
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
+ import type { SegmentInfo } from './segment-cache';
14
+ import type { HeadElement } from './head';
15
+ import type { RouterDeps } from './router';
16
+ /** Result of fetching an RSC payload — includes head elements and segment metadata. */
17
+ export interface FetchResult {
18
+ payload: unknown;
19
+ headElements: HeadElement[] | null;
20
+ /** Segment metadata from X-Timber-Segments header for populating the segment cache. */
21
+ segmentInfo: SegmentInfo[] | null;
22
+ /** Route params from X-Timber-Params header for populating useParams(). */
23
+ params: Record<string, string | string[]> | null;
24
+ /** Segment paths that were skipped by the server (for client-side merging). */
25
+ skippedSegments: string[] | null;
26
+ }
27
+ export declare const RSC_CONTENT_TYPE = "text/x-component";
28
+ export declare function buildRscHeaders(stateTree: {
29
+ segments: string[];
30
+ } | undefined, currentUrl?: string): Record<string, string>;
31
+ /**
32
+ * Extract head elements from the X-Timber-Head response header.
33
+ * Returns null if the header is missing or malformed.
34
+ */
35
+ export declare function extractHeadElements(response: Response): HeadElement[] | null;
36
+ /**
37
+ * Extract segment metadata from the X-Timber-Segments response header.
38
+ * Returns null if the header is missing or malformed.
39
+ *
40
+ * Format: JSON array of {path, isAsync} objects describing the rendered
41
+ * segment chain from root to leaf. Used to populate the client-side
42
+ * segment cache for state tree diffing on subsequent navigations.
43
+ */
44
+ export declare function extractSegmentInfo(response: Response): SegmentInfo[] | null;
45
+ /**
46
+ * Extract skipped segment paths from the X-Timber-Skipped-Segments header.
47
+ * Returns null if the header is missing or malformed.
48
+ *
49
+ * When the server skips sync layouts the client already has cached,
50
+ * it sends this header listing the skipped segment paths (outermost first).
51
+ * The client uses this to merge the partial payload with cached segments.
52
+ */
53
+ export declare function extractSkippedSegments(response: Response): string[] | null;
54
+ /**
55
+ * Extract route params from the X-Timber-Params response header.
56
+ * Returns null if the header is missing or malformed.
57
+ *
58
+ * Used to populate useParams() after client-side navigation.
59
+ */
60
+ export declare function extractParams(response: Response): Record<string, string | string[]> | null;
61
+ /**
62
+ * Thrown when an RSC payload response contains X-Timber-Redirect header.
63
+ * Caught in navigate() to trigger a soft router navigation to the redirect target.
64
+ */
65
+ export declare class RedirectError extends Error {
66
+ readonly redirectUrl: string;
67
+ constructor(url: string);
68
+ }
69
+ /**
70
+ * Fetch an RSC payload from the server. If a decodeRsc function is provided,
71
+ * the response is decoded into a React element tree via createFromFetch.
72
+ * Otherwise, the raw response text is returned (test mode).
73
+ *
74
+ * Also extracts head elements from the X-Timber-Head response header
75
+ * so the client can update document.title and <meta> tags after navigation.
76
+ */
77
+ export declare function fetchRscPayload(url: string, deps: RouterDeps, stateTree?: {
78
+ segments: string[];
79
+ }, currentUrl?: string): Promise<FetchResult>;
80
+ //# sourceMappingURL=rsc-fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rsc-fetch.d.ts","sourceRoot":"","sources":["../../src/client/rsc-fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAC1C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAI3C,uFAAuF;AACvF,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACnC,uFAAuF;IACvF,WAAW,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IAClC,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC;IACjD,+EAA+E;IAC/E,eAAe,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CAClC;AAID,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AA2BnD,wBAAgB,eAAe,CAC7B,SAAS,EAAE;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,SAAS,EAC7C,UAAU,CAAC,EAAE,MAAM,GAClB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAexB;AAID;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,EAAE,GAAG,IAAI,CAQ5E;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,QAAQ,EAAE,QAAQ,GAAG,WAAW,EAAE,GAAG,IAAI,CAQ3E;AAED;;;;;;;GAOG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,EAAE,GAAG,IAAI,CAS1E;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAQ1F;AAID;;;GAGG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACtC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;gBACjB,GAAG,EAAE,MAAM;CAIxB;AAID;;;;;;;GAOG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,UAAU,EAChB,SAAS,CAAC,EAAE;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,EAClC,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,WAAW,CAAC,CAuDtB"}
@@ -7,6 +7,8 @@ export interface PrefetchResult {
7
7
  segmentInfo?: SegmentInfo[] | null;
8
8
  /** Route params from X-Timber-Params header for populating useParams(). */
9
9
  params?: Record<string, string | string[]> | null;
10
+ /** Segment paths skipped by the server (for client-side merging). */
11
+ skippedSegments?: string[] | null;
10
12
  }
11
13
  /**
12
14
  * A node in the client-side segment tree. Each node represents a mounted
@@ -1 +1 @@
1
- {"version":3,"file":"segment-cache.d.ts","sourceRoot":"","sources":["../../src/client/segment-cache.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAI1C,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACnC,uFAAuF;IACvF,WAAW,CAAC,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACnC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC;CACnD;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,OAAO,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,OAAO,EAAE,OAAO,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;CACpC;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,IAAI,CAA0B;IAEtC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS;IAO7C,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI;IAM7C,KAAK,IAAI,IAAI;IAIb;;;;;;;OAOG;IACH,kBAAkB,IAAI,SAAS;CAOhC;AAcD;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,WAAW,GAAG,SAAS,CA+BjF;AASD;;;;;;;GAOG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAU;IACxC,OAAO,CAAC,OAAO,CAAoC;IAEnD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI;IAO9C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAU5C,0EAA0E;IAC1E,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;CAOjD"}
1
+ {"version":3,"file":"segment-cache.d.ts","sourceRoot":"","sources":["../../src/client/segment-cache.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAI1C,gFAAgF;AAChF,MAAM,WAAW,cAAc;IAC7B,OAAO,EAAE,OAAO,CAAC;IACjB,YAAY,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACnC,uFAAuF;IACvF,WAAW,CAAC,EAAE,WAAW,EAAE,GAAG,IAAI,CAAC;IACnC,2EAA2E;IAC3E,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAAC;IAClD,qEAAqE;IACrE,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;CACnC;AAED;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,OAAO,EAAE,OAAO,CAAC;IACjB,kFAAkF;IAClF,OAAO,EAAE,OAAO,CAAC;IACjB,2CAA2C;IAC3C,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;CACpC;AAED;;;GAGG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,EAAE,MAAM,EAAE,CAAC;CACpB;AAID;;;;GAIG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,IAAI,CAA0B;IAEtC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,WAAW,GAAG,SAAS;IAO7C,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,GAAG,IAAI;IAM7C,KAAK,IAAI,IAAI;IAIb;;;;;;;OAOG;IACH,kBAAkB,IAAI,SAAS;CAOhC;AAcD;;;GAGG;AACH,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,WAAW,GAAG,SAAS,CA+BjF;AASD;;;;;;;GAOG;AACH,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,MAAM,CAAU;IACxC,OAAO,CAAC,OAAO,CAAoC;IAEnD,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,GAAG,IAAI;IAO9C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAU5C,0EAA0E;IAC1E,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;CAOjD"}
@@ -0,0 +1,96 @@
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
+ import { type ReactElement, type ReactNode } from 'react';
24
+ /**
25
+ * A cached segment entry. Stores the full subtree rooted at a SegmentProvider
26
+ * and the path through the tree to the next SegmentProvider (or leaf).
27
+ */
28
+ export interface CachedSegmentEntry {
29
+ /** The segment's URL path (e.g., "/", "/dashboard") */
30
+ segmentPath: string;
31
+ /** The SegmentProvider element for this segment */
32
+ element: ReactElement;
33
+ }
34
+ /**
35
+ * Cache of React element subtrees per segment path.
36
+ * Updated after each navigation with the full decoded RSC element tree.
37
+ */
38
+ export declare class SegmentElementCache {
39
+ private entries;
40
+ get(segmentPath: string): CachedSegmentEntry | undefined;
41
+ set(segmentPath: string, entry: CachedSegmentEntry): void;
42
+ has(segmentPath: string): boolean;
43
+ clear(): void;
44
+ get size(): number;
45
+ }
46
+ /**
47
+ * Check if a React element is a SegmentProvider by looking for the
48
+ * `segments` prop (an array of path segments). This is the only
49
+ * component that receives this prop shape.
50
+ */
51
+ export declare function isSegmentProvider(element: unknown): element is ReactElement;
52
+ /**
53
+ * Extract the segment path from a SegmentProvider element.
54
+ * The `segments` prop is an array like ["", "dashboard", "settings"].
55
+ * The path is reconstructed as "/" + segments.filter(Boolean).join("/").
56
+ */
57
+ export declare function getSegmentPath(element: ReactElement): string;
58
+ /**
59
+ * Walk a React element tree and extract all SegmentProvider boundaries.
60
+ * Returns an ordered list of segment entries from outermost to innermost.
61
+ *
62
+ * This only finds SegmentProviders along the main children path — it does
63
+ * not descend into parallel routes/slots (those are separate subtrees).
64
+ */
65
+ export declare function extractSegments(element: unknown): CachedSegmentEntry[];
66
+ /**
67
+ * Cache all segment subtrees from a fully-rendered RSC element tree.
68
+ * Call this after every full RSC payload render (navigate, refresh, hydration).
69
+ */
70
+ export declare function cacheSegmentElements(element: unknown, cache: SegmentElementCache): void;
71
+ /**
72
+ * Replace a nested SegmentProvider within a cached element tree with
73
+ * new content. Uses cloneElement along the path to produce a new tree
74
+ * with preserved component identity at every level except the replaced node.
75
+ *
76
+ * @param cachedElement The cached SegmentProvider element for this segment
77
+ * @param newInnerContent The new React element to splice in at the inner segment position
78
+ * @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
79
+ * @returns New element tree with the inner segment replaced
80
+ */
81
+ export declare function replaceInnerSegment(cachedElement: ReactElement, newInnerContent: ReactNode, innerSegmentPath?: string): ReactElement;
82
+ /**
83
+ * Merge a partial RSC payload with cached segment elements.
84
+ *
85
+ * When the server skips segments, the partial payload starts from the
86
+ * first non-skipped segment. This function wraps it with cached elements
87
+ * for the skipped segments, producing a full tree that React can
88
+ * reconcile with the mounted tree (preserving layout state).
89
+ *
90
+ * @param partialPayload The RSC payload element (may be partial)
91
+ * @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
92
+ * @param cache The segment element cache
93
+ * @returns The merged full element tree, or the partial payload if merging isn't possible
94
+ */
95
+ export declare function mergeSegmentTree(partialPayload: unknown, skippedSegments: string[], cache: SegmentElementCache): unknown;
96
+ //# sourceMappingURL=segment-merger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"segment-merger.d.ts","sourceRoot":"","sources":["../../src/client/segment-merger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAgC,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIxF;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,OAAO,EAAE,YAAY,CAAC;CACvB;AAID;;;GAGG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,OAAO,CAAyC;IAExD,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIxD,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB,GAAG,IAAI;IAIzD,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAIjC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAID;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,YAAY,CAI3E;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAI5D;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,kBAAkB,EAAE,CAItE;AAsCD;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,mBAAmB,GACzB,IAAI,CAKN;AAqDD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,aAAa,EAAE,YAAY,EAC3B,eAAe,EAAE,SAAS,EAC1B,gBAAgB,CAAC,EAAE,MAAM,GACxB,YAAY,CA6Bd;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,MAAM,EAAE,EACzB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CA0BT"}
@@ -41,6 +41,13 @@ export interface RouteElementResult {
41
41
  segments: ManifestSegmentNode[];
42
42
  /** Max deferSuspenseFor hold window across all segments. */
43
43
  deferSuspenseFor: number;
44
+ /**
45
+ * Segment paths that were skipped because the client already has them cached.
46
+ * Ordered outermost to innermost. Empty when no segments were skipped.
47
+ * The client uses this to merge the partial payload with cached segments.
48
+ * See design/19-client-navigation.md §"X-Timber-State-Tree Header"
49
+ */
50
+ skippedSegments: string[];
44
51
  }
45
52
  /**
46
53
  * Wraps a DenySignal or RedirectSignal with the layout components loaded
@@ -1 +1 @@
1
- {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAKzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;CAC1B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AAID;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAgU7B"}
1
+ {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAO7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAKzD,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AAID;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAuU7B"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0EA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAwXD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BA5P3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA8PhD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA0EA;;;;;GAKG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,GAAG,IAAI,CAE/F;AAyXD,OAAO,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAIzE,OAAO,EAAE,gBAAgB,EAAE,MAAM,8BAA8B,CAAC;8BA7P3C,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA+PhD,wBAAiE"}
@@ -21,5 +21,5 @@ import type { RenderSignals } from './rsc-stream.js';
21
21
  * initial component tree, allowing onError to capture DenySignal/
22
22
  * RedirectSignal before we commit the response. See TIM-344.
23
23
  */
24
- export declare function buildRscPayloadResponse(req: Request, rscStream: ReadableStream<Uint8Array>, signals: RenderSignals, segments: ManifestSegmentNode[], layoutComponents: LayoutComponentEntry[], headElements: HeadElement[], match: RouteMatch, responseHeaders: Headers): Promise<Response>;
24
+ export declare function buildRscPayloadResponse(req: Request, rscStream: ReadableStream<Uint8Array>, signals: RenderSignals, segments: ManifestSegmentNode[], layoutComponents: LayoutComponentEntry[], headElements: HeadElement[], match: RouteMatch, responseHeaders: Headers, skippedSegments?: string[]): Promise<Response>;
25
25
  //# sourceMappingURL=rsc-payload.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAQrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,GACvB,OAAO,CAAC,QAAQ,CAAC,CAiFnB"}
1
+ {"version":3,"file":"rsc-payload.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/rsc-payload.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC;AAEvD,OAAO,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AAC3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC;AAQrE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAErD;;;;;;;;GAQG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,cAAc,CAAC,UAAU,CAAC,EACrC,OAAO,EAAE,aAAa,EACtB,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,YAAY,EAAE,WAAW,EAAE,EAC3B,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,eAAe,CAAC,EAAE,MAAM,EAAE,GACzB,OAAO,CAAC,QAAQ,CAAC,CAuFnB"}
@@ -39,5 +39,5 @@ export declare function parseClientStateTree(req: Request): Set<string> | null;
39
39
  * @param isLeaf - Whether this is the leaf segment (page segment)
40
40
  * @param clientSegments - Set of paths from X-Timber-State-Tree, or null
41
41
  */
42
- export declare function shouldSkipSegment(_urlPath: string, _layoutComponent: ((...args: unknown[]) => unknown) | undefined, _isLeaf: boolean, _clientSegments: Set<string> | null): boolean;
42
+ export declare function shouldSkipSegment(urlPath: string, layoutComponent: ((...args: unknown[]) => unknown) | undefined, isLeaf: boolean, clientSegments: Set<string> | null): boolean;
43
43
  //# sourceMappingURL=state-tree-diff.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,QAAQ,EAAE,MAAM,EAChB,gBAAgB,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC/D,OAAO,EAAE,OAAO,EAChB,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GAClC,OAAO,CAgBT"}
1
+ {"version":3,"file":"state-tree-diff.d.ts","sourceRoot":"","sources":["../../src/server/state-tree-diff.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CAarE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,MAAM,EACf,eAAe,EAAE,CAAC,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC,GAAG,SAAS,EAC9D,MAAM,EAAE,OAAO,EACf,cAAc,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACjC,OAAO,CAeT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -545,6 +545,13 @@ function bootstrap(runtimeConfig: typeof config): void {
545
545
  delete (self as unknown as Record<string, unknown>).__timber_segments;
546
546
  }
547
547
 
548
+ // Cache segment elements from the initial RSC payload for client-side
549
+ // tree merging. This ensures the first client navigation can use partial
550
+ // payloads — the merger needs cached elements for skipped segments.
551
+ if (initialElement) {
552
+ router.cacheElementTree(initialElement);
553
+ }
554
+
548
555
  // Note: __timber_params is read before hydrateRoot (see above) so that
549
556
  // NavigationProvider has correct values during hydration. If the hydration
550
557
  // path was skipped (no RSC payload), populate the fallback here.
@@ -7,6 +7,13 @@ import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
8
  import { setCurrentParams } from './use-params.js';
9
9
  import { setNavigationState } from './navigation-context.js';
10
+ import {
11
+ SegmentElementCache,
12
+ cacheSegmentElements,
13
+ mergeSegmentTree,
14
+ } from './segment-merger.js';
15
+ import { fetchRscPayload, RedirectError } from './rsc-fetch.js';
16
+ import type { FetchResult } from './rsc-fetch.js';
10
17
 
11
18
  // ─── Types ───────────────────────────────────────────────────────
12
19
 
@@ -71,16 +78,6 @@ export interface RouterDeps {
71
78
  ) => Promise<void>;
72
79
  }
73
80
 
74
- /** Result of fetching an RSC payload — includes head elements and segment metadata. */
75
- interface FetchResult {
76
- payload: unknown;
77
- headElements: HeadElement[] | null;
78
- /** Segment metadata from X-Timber-Segments header for populating the segment cache. */
79
- segmentInfo: SegmentInfo[] | null;
80
- /** Route params from X-Timber-Params header for populating useParams(). */
81
- params: Record<string, string | string[]> | null;
82
- }
83
-
84
81
  export interface RouterInstance {
85
82
  /** Navigate to a new URL (forward navigation) */
86
83
  navigate(url: string, options?: NavigationOptions): Promise<void>;
@@ -107,6 +104,12 @@ export interface RouterInstance {
107
104
  * Called on initial hydration with segment info embedded in the HTML.
108
105
  */
109
106
  initSegmentCache(segments: SegmentInfo[]): void;
107
+ /**
108
+ * Cache segment elements from a decoded RSC element tree.
109
+ * Called on initial hydration to populate the element cache so the
110
+ * first client navigation can use partial payloads.
111
+ */
112
+ cacheElementTree(element: unknown): void;
110
113
  /** The segment cache (exposed for tests and <Link> prefetch) */
111
114
  segmentCache: SegmentCache;
112
115
  /** The prefetch cache (exposed for tests and <Link> prefetch) */
@@ -115,18 +118,6 @@ export interface RouterInstance {
115
118
  historyStack: HistoryStack;
116
119
  }
117
120
 
118
- /**
119
- * Thrown when an RSC payload response contains X-Timber-Redirect header.
120
- * Caught in navigate() to trigger a soft router navigation to the redirect target.
121
- */
122
- class RedirectError extends Error {
123
- readonly redirectUrl: string;
124
- constructor(url: string) {
125
- super(`Server redirect to ${url}`);
126
- this.redirectUrl = url;
127
- }
128
- }
129
-
130
121
  /**
131
122
  * Check if an error is an abort error (connection closed / fetch aborted).
132
123
  * Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
@@ -137,168 +128,6 @@ function isAbortError(error: unknown): boolean {
137
128
  return false;
138
129
  }
139
130
 
140
- // ─── RSC Fetch ───────────────────────────────────────────────────
141
-
142
- const RSC_CONTENT_TYPE = 'text/x-component';
143
-
144
- /**
145
- * Generate a short random cache-busting ID (5 chars, a-z0-9).
146
- * Matches the format Next.js uses for _rsc params.
147
- */
148
- function generateCacheBustId(): string {
149
- const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
150
- let id = '';
151
- for (let i = 0; i < 5; i++) {
152
- id += chars[(Math.random() * 36) | 0];
153
- }
154
- return id;
155
- }
156
-
157
- /**
158
- * Append a `_rsc=<id>` query parameter to the URL.
159
- * Follows Next.js's pattern — prevents CDN/browser from serving cached HTML
160
- * for RSC navigation requests and signals that this is an RSC fetch.
161
- */
162
- function appendRscParam(url: string): string {
163
- const separator = url.includes('?') ? '&' : '?';
164
- return `${url}${separator}_rsc=${generateCacheBustId()}`;
165
- }
166
-
167
- function buildRscHeaders(
168
- stateTree: { segments: string[] } | undefined,
169
- currentUrl?: string
170
- ): Record<string, string> {
171
- const headers: Record<string, string> = {
172
- Accept: RSC_CONTENT_TYPE,
173
- };
174
- if (stateTree) {
175
- headers['X-Timber-State-Tree'] = JSON.stringify(stateTree);
176
- }
177
- // Send current URL for intercepting route resolution.
178
- // The server uses this to determine if an intercepting route should
179
- // render instead of the actual target route (modal pattern).
180
- // See design/07-routing.md §"Intercepting Routes"
181
- if (currentUrl) {
182
- headers['X-Timber-URL'] = currentUrl;
183
- }
184
- return headers;
185
- }
186
-
187
- /**
188
- * Extract head elements from the X-Timber-Head response header.
189
- * Returns null if the header is missing or malformed.
190
- */
191
- function extractHeadElements(response: Response): HeadElement[] | null {
192
- const header = response.headers.get('X-Timber-Head');
193
- if (!header) return null;
194
- try {
195
- return JSON.parse(decodeURIComponent(header));
196
- } catch {
197
- return null;
198
- }
199
- }
200
-
201
- /**
202
- * Extract segment metadata from the X-Timber-Segments response header.
203
- * Returns null if the header is missing or malformed.
204
- *
205
- * Format: JSON array of {path, isAsync} objects describing the rendered
206
- * segment chain from root to leaf. Used to populate the client-side
207
- * segment cache for state tree diffing on subsequent navigations.
208
- */
209
- function extractSegmentInfo(response: Response): SegmentInfo[] | null {
210
- const header = response.headers.get('X-Timber-Segments');
211
- if (!header) return null;
212
- try {
213
- return JSON.parse(header);
214
- } catch {
215
- return null;
216
- }
217
- }
218
-
219
- /**
220
- * Extract route params from the X-Timber-Params response header.
221
- * Returns null if the header is missing or malformed.
222
- *
223
- * Used to populate useParams() after client-side navigation.
224
- */
225
- function extractParams(response: Response): Record<string, string | string[]> | null {
226
- const header = response.headers.get('X-Timber-Params');
227
- if (!header) return null;
228
- try {
229
- return JSON.parse(header);
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
- /**
236
- * Fetch an RSC payload from the server. If a decodeRsc function is provided,
237
- * the response is decoded into a React element tree via createFromFetch.
238
- * Otherwise, the raw response text is returned (test mode).
239
- *
240
- * Also extracts head elements from the X-Timber-Head response header
241
- * so the client can update document.title and <meta> tags after navigation.
242
- */
243
- async function fetchRscPayload(
244
- url: string,
245
- deps: RouterDeps,
246
- stateTree?: { segments: string[] },
247
- currentUrl?: string
248
- ): Promise<FetchResult> {
249
- const rscUrl = appendRscParam(url);
250
- const headers = buildRscHeaders(stateTree, currentUrl);
251
- if (deps.decodeRsc) {
252
- // Production path: use createFromFetch for streaming RSC decoding.
253
- // createFromFetch takes a Promise<Response> and progressively parses
254
- // the RSC Flight stream as chunks arrive.
255
- //
256
- // Intercept the response to read X-Timber-Head before createFromFetch
257
- // consumes the body. Reading headers does NOT consume the body stream.
258
- const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
259
- let headElements: HeadElement[] | null = null;
260
- let segmentInfo: SegmentInfo[] | null = null;
261
- let params: Record<string, string | string[]> | null = null;
262
- const wrappedPromise = fetchPromise.then((response) => {
263
- // Detect server-side redirects. The server returns 204 + X-Timber-Redirect
264
- // for RSC payload requests instead of a raw 302, because fetch with
265
- // redirect: "manual" turns 302s into opaque redirects (status 0, null body)
266
- // which crashes createFromFetch when it tries to read the body stream.
267
- const redirectLocation =
268
- response.headers.get('X-Timber-Redirect') ||
269
- (response.status >= 300 && response.status < 400 ? response.headers.get('Location') : null);
270
- if (redirectLocation) {
271
- throw new RedirectError(redirectLocation);
272
- }
273
- headElements = extractHeadElements(response);
274
- segmentInfo = extractSegmentInfo(response);
275
- params = extractParams(response);
276
- return response;
277
- });
278
- // Await so headElements/segmentInfo/params are populated before we return.
279
- // Also await the decoded payload — createFromFetch returns a thenable
280
- // that resolves to the React element tree.
281
- await wrappedPromise;
282
- const payload = await deps.decodeRsc(wrappedPromise);
283
- return { payload, headElements, segmentInfo, params };
284
- }
285
- // Test/fallback path: return raw text
286
- const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
287
- // Check for redirect in test path too
288
- if (response.status >= 300 && response.status < 400) {
289
- const location = response.headers.get('Location');
290
- if (location) {
291
- throw new RedirectError(location);
292
- }
293
- }
294
- return {
295
- payload: await response.text(),
296
- headElements: extractHeadElements(response),
297
- segmentInfo: extractSegmentInfo(response),
298
- params: extractParams(response),
299
- };
300
- }
301
-
302
131
  // ─── Router Factory ──────────────────────────────────────────────
303
132
 
304
133
  /**
@@ -309,6 +138,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
309
138
  const segmentCache = new SegmentCache();
310
139
  const prefetchCache = new PrefetchCache();
311
140
  const historyStack = new HistoryStack();
141
+ const segmentElementCache = new SegmentElementCache();
312
142
 
313
143
  let pending = false;
314
144
  let pendingUrl: string | null = null;
@@ -343,6 +173,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
343
173
  }
344
174
  }
345
175
 
176
+ /**
177
+ * Merge a partial RSC payload with cached segment elements if segments
178
+ * were skipped, then cache segments from the (merged) payload.
179
+ * Returns the merged payload ready for rendering.
180
+ */
181
+ function mergeAndCachePayload(
182
+ payload: unknown,
183
+ skippedSegments: string[] | null | undefined
184
+ ): unknown {
185
+ let merged = payload;
186
+
187
+ // If segments were skipped, merge the partial payload with cached segments
188
+ if (skippedSegments && skippedSegments.length > 0) {
189
+ merged = mergeSegmentTree(payload, skippedSegments, segmentElementCache);
190
+ }
191
+
192
+ // Cache segment elements from the (merged) payload for future merges
193
+ cacheSegmentElements(merged, segmentElementCache);
194
+
195
+ return merged;
196
+ }
197
+
346
198
  /**
347
199
  * Update navigation state (params + pathname) for the next render.
348
200
  *
@@ -378,13 +230,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
378
230
  await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
379
231
  const result = await perform();
380
232
  headElements = result.headElements;
381
- return wrapPayload(result.payload);
233
+ // Merge partial payload with cached segments before wrapping
234
+ const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
235
+ return wrapPayload(merged);
382
236
  });
383
237
  return headElements;
384
238
  }
385
239
  // Fallback: no transition (tests, no React tree)
386
240
  const result = await perform();
387
- renderPayload(result.payload);
241
+ // Merge partial payload with cached segments before rendering
242
+ const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
243
+ renderPayload(merged);
388
244
  return result.headElements;
389
245
  }
390
246
 
@@ -421,6 +277,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
421
277
  headElements: prefetched.headElements,
422
278
  segmentInfo: prefetched.segmentInfo ?? null,
423
279
  params: prefetched.params ?? null,
280
+ skippedSegments: prefetched.skippedSegments ?? null,
424
281
  }
425
282
  : undefined;
426
283
 
@@ -616,15 +473,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
616
473
  // Render the piggybacked element tree from a server action response.
617
474
  // Updates the current history entry with the fresh payload and applies
618
475
  // head elements — same as refresh() but without a server fetch.
476
+ // Cache segment elements for future partial merges.
619
477
  const currentUrl = deps.getCurrentUrl();
478
+ const merged = mergeAndCachePayload(element, null);
620
479
  historyStack.push(currentUrl, {
621
- payload: element,
480
+ payload: merged,
622
481
  headElements,
623
482
  });
624
- renderPayload(element);
483
+ renderPayload(merged);
625
484
  applyHead(headElements);
626
485
  },
627
486
  initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
487
+ cacheElementTree: (element: unknown) => cacheSegmentElements(element, segmentElementCache),
628
488
  segmentCache,
629
489
  prefetchCache,
630
490
  historyStack,