@timber-js/app 0.1.53 → 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.
@@ -1 +1 @@
1
- {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/client/router.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/client/router.ts"],"names":[],"mappings":"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"}
@@ -46,10 +46,15 @@ export declare class SegmentCache {
46
46
  * Only includes sync segments — async segments are excluded because the
47
47
  * server must always re-render them (they may depend on request context).
48
48
  *
49
+ * When mergeableFilter is provided, only segments whose paths are in the
50
+ * set are included. This ensures the server only skips segments that the
51
+ * client can actually merge (i.e., segments whose cached element tree
52
+ * contains an inner SegmentProvider the merger can splice into).
53
+ *
49
54
  * This is a performance optimization only, NOT a security boundary.
50
55
  * The server always runs all access.ts files regardless of the state tree.
51
56
  */
52
- serializeStateTree(): StateTree;
57
+ serializeStateTree(mergeableFilter?: Set<string>): StateTree;
53
58
  }
54
59
  /**
55
60
  * Segment metadata from the server, sent via X-Timber-Segments header.
@@ -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;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"}
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;;;;;;;;;;;;OAYG;IACH,kBAAkB,CAAC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,SAAS;CAO7D;AAkBD;;;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"}
@@ -20,6 +20,13 @@ export interface SegmentContextValue {
20
20
  export declare function useSegmentContext(): SegmentContextValue | null;
21
21
  interface SegmentProviderProps {
22
22
  segments: string[];
23
+ /**
24
+ * Unique identifier for this segment, used by the client-side segment
25
+ * merger for element caching. For route groups this includes the group
26
+ * name (e.g., "/(marketing)") since groups share their parent's urlPath.
27
+ * Falls back to the reconstructed path from `segments` if not provided.
28
+ */
29
+ segmentId?: string;
23
30
  parallelRouteKeys: string[];
24
31
  children: React.ReactNode;
25
32
  }
@@ -27,6 +34,6 @@ interface SegmentProviderProps {
27
34
  * Wraps each layout to provide segment position context.
28
35
  * Injected by rsc-entry.ts during element tree construction.
29
36
  */
30
- export declare function SegmentProvider({ segments, parallelRouteKeys, children }: SegmentProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SegmentContextValue | null>>;
37
+ export declare function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }: SegmentProviderProps): import("react").FunctionComponentElement<import("react").ProviderProps<SegmentContextValue | null>>;
31
38
  export {};
32
39
  //# sourceMappingURL=segment-context.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"segment-context.d.ts","sourceRoot":"","sources":["../../src/client/segment-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0FAA0F;IAC1F,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAMD,qFAAqF;AACrF,wBAAgB,iBAAiB,IAAI,mBAAmB,GAAG,IAAI,CAE9D;AAID,UAAU,oBAAoB;IAC5B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,QAAQ,EAAE,iBAAiB,EAAE,QAAQ,EAAE,EAAE,oBAAoB,uGAS9F"}
1
+ {"version":3,"file":"segment-context.d.ts","sourceRoot":"","sources":["../../src/client/segment-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAQH,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,0FAA0F;IAC1F,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAMD,qFAAqF;AACrF,wBAAgB,iBAAiB,IAAI,mBAAmB,GAAG,IAAI,CAE9D;AAID,UAAU,oBAAoB;IAC5B,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAAE,iBAAiB,EAAE,QAAQ,EAAE,EAAE,oBAAoB,uGASrH"}
@@ -30,6 +30,13 @@ export interface CachedSegmentEntry {
30
30
  segmentPath: string;
31
31
  /** The SegmentProvider element for this segment */
32
32
  element: ReactElement;
33
+ /**
34
+ * Whether this segment's cached element contains a nested SegmentProvider.
35
+ * Only segments with inner SegmentProviders are safe to skip — the merger
36
+ * can only replace inner SegmentProviders, not pages embedded in layout output.
37
+ * Used by the state tree serialization to exclude non-mergeable segments.
38
+ */
39
+ hasMergeableChild: boolean;
33
40
  }
34
41
  /**
35
42
  * Cache of React element subtrees per segment path.
@@ -42,6 +49,20 @@ export declare class SegmentElementCache {
42
49
  has(segmentPath: string): boolean;
43
50
  clear(): void;
44
51
  get size(): number;
52
+ /**
53
+ * Get the set of segment paths that are safe for the server to skip.
54
+ * Only segments with an inner SegmentProvider (hasMergeableChild) are
55
+ * included — the merger can only replace inner SegmentProviders, not
56
+ * pages embedded in layout output. Used to filter the state tree.
57
+ *
58
+ * Returns an empty set if the element cache is empty (no elements
59
+ * cached yet). This is the safe default — an empty set means no
60
+ * segments pass the filter, so the state tree is empty and the server
61
+ * does a full render. The element cache is populated lazily after the
62
+ * first SPA navigation (RSC-decoded elements from hydration are
63
+ * thenables that can't be walked until React resolves them).
64
+ */
65
+ getMergeablePaths(): Set<string>;
45
66
  }
46
67
  /**
47
68
  * Check if a React element is a SegmentProvider by looking for the
@@ -51,8 +72,10 @@ export declare class SegmentElementCache {
51
72
  export declare function isSegmentProvider(element: unknown): element is ReactElement;
52
73
  /**
53
74
  * 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("/").
75
+ *
76
+ * Uses the `segmentId` prop if available (set by the server for route groups
77
+ * to distinguish siblings that share the same urlPath). Falls back to
78
+ * reconstructing from the `segments` array prop.
56
79
  */
57
80
  export declare function getSegmentPath(element: ReactElement): string;
58
81
  /**
@@ -1 +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"}
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;IACtB;;;;;OAKG;IACH,iBAAiB,EAAE,OAAO,CAAC;CAC5B;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;IAED;;;;;;;;;;;;OAYG;IACH,iBAAiB,IAAI,GAAG,CAAC,MAAM,CAAC;CASjC;AAID;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,YAAY,CAI3E;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAM5D;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,kBAAkB,EAAE,CAUtE;AAuCD;;;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,CAoCd;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,MAAM,EAAE,EACzB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CA0BT"}
@@ -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;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
+ {"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,CA2W7B"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.53",
3
+ "version": "0.1.54",
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,12 +545,12 @@ 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
- }
548
+ // NOTE: We do NOT cache segment elements from the initial RSC payload here.
549
+ // The decoded element from createFromReadableStream is a thenable/lazy
550
+ // element that React resolves during render — the segment walker can't
551
+ // traverse it. The element cache is populated lazily after the first SPA
552
+ // navigation (via mergeAndCachePayload in renderViaTransition), when
553
+ // the decoded payload is a fully resolved React element tree.
554
554
 
555
555
  // Note: __timber_params is read before hydrateRoot (see above) so that
556
556
  // NavigationProvider has correct values during hydration. If the hydration
@@ -222,16 +222,23 @@ export function createRouter(deps: RouterDeps): RouterInstance {
222
222
  * useOptimistic for the pending URL. In tests, the payload is rendered directly.
223
223
  */
224
224
  async function renderViaTransition(
225
- pendingUrl: string,
225
+ url: string,
226
226
  perform: () => Promise<FetchResult>
227
227
  ): Promise<HeadElement[] | null> {
228
228
  if (deps.navigateTransition) {
229
229
  let headElements: HeadElement[] | null = null;
230
- await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
230
+ await deps.navigateTransition(url, async (wrapPayload) => {
231
231
  const result = await perform();
232
232
  headElements = result.headElements;
233
233
  // Merge partial payload with cached segments before wrapping
234
234
  const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
235
+ // Store the MERGED payload in history — not the partial pre-merge tree.
236
+ // This ensures handlePopState replays the complete tree on back/forward.
237
+ historyStack.push(url, {
238
+ payload: merged,
239
+ headElements: result.headElements,
240
+ params: result.params,
241
+ });
235
242
  return wrapPayload(merged);
236
243
  });
237
244
  return headElements;
@@ -240,6 +247,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
240
247
  const result = await perform();
241
248
  // Merge partial payload with cached segments before rendering
242
249
  const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
250
+ // Store merged payload in history
251
+ historyStack.push(url, {
252
+ payload: merged,
253
+ headElements: result.headElements,
254
+ params: result.params,
255
+ });
243
256
  renderPayload(merged);
244
257
  return result.headElements;
245
258
  }
@@ -284,7 +297,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
284
297
  if (result === undefined) {
285
298
  // Fetch RSC payload with state tree for partial rendering.
286
299
  // Send current URL for intercepting route resolution (modal pattern).
287
- const stateTree = segmentCache.serializeStateTree();
300
+ const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
288
301
  const rawCurrentUrl = deps.getCurrentUrl();
289
302
  const currentUrl = rawCurrentUrl.startsWith('http')
290
303
  ? new URL(rawCurrentUrl).pathname
@@ -299,12 +312,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
299
312
  deps.pushState({ timber: true, scrollY: 0 }, '', url);
300
313
  }
301
314
 
302
- // Store the payload in the history stack
303
- historyStack.push(url, {
304
- payload: result.payload,
305
- headElements: result.headElements,
306
- params: result.params,
307
- });
315
+ // NOTE: History push is deferred — the merged payload (after segment
316
+ // merging in renderViaTransition) is stored by the caller, not here.
317
+ // Storing result.payload here would record the partial (pre-merge)
318
+ // RSC tree, causing handlePopState to replay an incomplete tree.
308
319
 
309
320
  // Update the segment cache with the new route's segment tree.
310
321
  updateSegmentCache(result.segmentInfo);
@@ -375,11 +386,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
375
386
  const headElements = await renderViaTransition(currentUrl, async () => {
376
387
  // No state tree sent — server renders the complete RSC payload
377
388
  const result = await fetchRscPayload(currentUrl, deps);
378
- historyStack.push(currentUrl, {
379
- payload: result.payload,
380
- headElements: result.headElements,
381
- params: result.params,
382
- });
389
+ // History push handled by renderViaTransition (stores merged payload)
383
390
  updateSegmentCache(result.segmentInfo);
384
391
  updateNavigationState(result.params, currentUrl);
385
392
  return result;
@@ -414,15 +421,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
414
421
  setPending(true, url);
415
422
  try {
416
423
  const headElements = await renderViaTransition(url, async () => {
417
- const stateTree = segmentCache.serializeStateTree();
424
+ const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
418
425
  const result = await fetchRscPayload(url, deps, stateTree);
419
426
  updateSegmentCache(result.segmentInfo);
420
427
  updateNavigationState(result.params, url);
421
- historyStack.push(url, {
422
- payload: result.payload,
423
- headElements: result.headElements,
424
- params: result.params,
425
- });
428
+ // History push handled by renderViaTransition (stores merged payload)
426
429
  return result;
427
430
  });
428
431
 
@@ -447,7 +450,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
447
450
  if (historyStack.has(url)) return;
448
451
 
449
452
  // Fire-and-forget fetch
450
- const stateTree = segmentCache.serializeStateTree();
453
+ const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
451
454
  void fetchRscPayload(url, deps, stateTree).then(
452
455
  (result) => {
453
456
  prefetchCache.set(url, result);
@@ -72,25 +72,34 @@ export class SegmentCache {
72
72
  * Only includes sync segments — async segments are excluded because the
73
73
  * server must always re-render them (they may depend on request context).
74
74
  *
75
+ * When mergeableFilter is provided, only segments whose paths are in the
76
+ * set are included. This ensures the server only skips segments that the
77
+ * client can actually merge (i.e., segments whose cached element tree
78
+ * contains an inner SegmentProvider the merger can splice into).
79
+ *
75
80
  * This is a performance optimization only, NOT a security boundary.
76
81
  * The server always runs all access.ts files regardless of the state tree.
77
82
  */
78
- serializeStateTree(): StateTree {
83
+ serializeStateTree(mergeableFilter?: Set<string>): StateTree {
79
84
  const segments: string[] = [];
80
85
  if (this.root) {
81
- collectSyncSegments(this.root, segments);
86
+ collectSyncSegments(this.root, segments, mergeableFilter);
82
87
  }
83
88
  return { segments };
84
89
  }
85
90
  }
86
91
 
87
92
  /** Recursively collect sync segment paths from the tree */
88
- function collectSyncSegments(node: SegmentNode, out: string[]): void {
89
- if (!node.isAsync) {
93
+ function collectSyncSegments(
94
+ node: SegmentNode,
95
+ out: string[],
96
+ mergeableFilter?: Set<string>
97
+ ): void {
98
+ if (!node.isAsync && (!mergeableFilter || mergeableFilter.has(node.segment))) {
90
99
  out.push(node.segment);
91
100
  }
92
101
  for (const child of node.children.values()) {
93
- collectSyncSegments(child, out);
102
+ collectSyncSegments(child, out, mergeableFilter);
94
103
  }
95
104
  }
96
105
 
@@ -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
@@ -34,6 +34,13 @@ export interface CachedSegmentEntry {
34
34
  segmentPath: string;
35
35
  /** The SegmentProvider element for this segment */
36
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;
37
44
  }
38
45
 
39
46
  // ─── Segment Element Cache ───────────────────────────────────────
@@ -64,6 +71,29 @@ export class SegmentElementCache {
64
71
  get size(): number {
65
72
  return this.entries.size;
66
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
+ }
67
97
  }
68
98
 
69
99
  // ─── SegmentProvider Detection ───────────────────────────────────
@@ -81,12 +111,16 @@ export function isSegmentProvider(element: unknown): element is ReactElement {
81
111
 
82
112
  /**
83
113
  * 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("/").
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.
86
118
  */
87
119
  export function getSegmentPath(element: ReactElement): string {
88
- const segments = (element.props as { segments: string[] }).segments;
89
- const filtered = segments.filter(Boolean);
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);
90
124
  return filtered.length === 0 ? '/' : '/' + filtered.join('/');
91
125
  }
92
126
 
@@ -102,6 +136,12 @@ export function getSegmentPath(element: ReactElement): string {
102
136
  export function extractSegments(element: unknown): CachedSegmentEntry[] {
103
137
  const segments: CachedSegmentEntry[] = [];
104
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
+ }
105
145
  return segments;
106
146
  }
107
147
 
@@ -117,6 +157,7 @@ function walkForSegments(node: unknown, out: CachedSegmentEntry[]): void {
117
157
  out.push({
118
158
  segmentPath: getSegmentPath(el),
119
159
  element: el,
160
+ hasMergeableChild: false, // computed after collection in extractSegments
120
161
  });
121
162
  // Continue walking into children to find nested segments
122
163
  walkChildren(props.children as ReactNode, out);
@@ -224,9 +265,16 @@ export function replaceInnerSegment(
224
265
  const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
225
266
 
226
267
  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);
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;
230
278
  }
231
279
 
232
280
  // Reconstruct bottom-up: replace the innermost element first, then
@@ -326,6 +326,16 @@ export async function buildRouteElement(
326
326
  // client navigation), sync layouts the client already has are skipped.
327
327
  // Access.ts already ran for ALL segments in the pre-render loop above.
328
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
+
329
339
  for (let i = segments.length - 1; i >= 0; i--) {
330
340
  const segment = segments[i];
331
341
  const isLeaf = i === segments.length - 1;
@@ -334,12 +344,22 @@ export async function buildRouteElement(
334
344
  // Check if this segment's layout can be skipped for partial rendering.
335
345
  // Skipped segments: no layout wrapping, no error boundaries, no slots,
336
346
  // no AccessGate in element tree (access already ran pre-render).
337
- const skip = shouldSkipSegment(
338
- segment.urlPath,
339
- layoutComponent,
340
- isLeaf,
341
- clientStateTree ?? null
342
- );
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';
343
363
 
344
364
  if (skip) {
345
365
  // Skip this segment entirely — the client uses its cached version.
@@ -350,6 +370,12 @@ export async function buildRouteElement(
350
370
  continue;
351
371
  }
352
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
+
353
379
  // Wrap with error boundaries from this segment (inside layout).
354
380
  element = await wrapSegmentWithErrorBoundaries(segment, element, h);
355
381
 
@@ -407,8 +433,18 @@ export async function buildRouteElement(
407
433
  );
408
434
  };
409
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
+
410
445
  element = h(SegmentProvider, {
411
446
  segments: segmentPath,
447
+ segmentId,
412
448
  parallelRouteKeys,
413
449
  children: h(TracedLayout, {
414
450
  ...slotProps,