@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.
@@ -349,19 +349,24 @@ var SegmentCache = class {
349
349
  * Only includes sync segments — async segments are excluded because the
350
350
  * server must always re-render them (they may depend on request context).
351
351
  *
352
+ * When mergeableFilter is provided, only segments whose paths are in the
353
+ * set are included. This ensures the server only skips segments that the
354
+ * client can actually merge (i.e., segments whose cached element tree
355
+ * contains an inner SegmentProvider the merger can splice into).
356
+ *
352
357
  * This is a performance optimization only, NOT a security boundary.
353
358
  * The server always runs all access.ts files regardless of the state tree.
354
359
  */
355
- serializeStateTree() {
360
+ serializeStateTree(mergeableFilter) {
356
361
  const segments = [];
357
- if (this.root) collectSyncSegments(this.root, segments);
362
+ if (this.root) collectSyncSegments(this.root, segments, mergeableFilter);
358
363
  return { segments };
359
364
  }
360
365
  };
361
366
  /** Recursively collect sync segment paths from the tree */
362
- function collectSyncSegments(node, out) {
363
- if (!node.isAsync) out.push(node.segment);
364
- for (const child of node.children.values()) collectSyncSegments(child, out);
367
+ function collectSyncSegments(node, out, mergeableFilter) {
368
+ if (!node.isAsync && (!mergeableFilter || mergeableFilter.has(node.segment))) out.push(node.segment);
369
+ for (const child of node.children.values()) collectSyncSegments(child, out, mergeableFilter);
365
370
  }
366
371
  /**
367
372
  * Build a SegmentNode tree from flat segment metadata.
@@ -524,6 +529,24 @@ var SegmentElementCache = class {
524
529
  get size() {
525
530
  return this.entries.size;
526
531
  }
532
+ /**
533
+ * Get the set of segment paths that are safe for the server to skip.
534
+ * Only segments with an inner SegmentProvider (hasMergeableChild) are
535
+ * included — the merger can only replace inner SegmentProviders, not
536
+ * pages embedded in layout output. Used to filter the state tree.
537
+ *
538
+ * Returns an empty set if the element cache is empty (no elements
539
+ * cached yet). This is the safe default — an empty set means no
540
+ * segments pass the filter, so the state tree is empty and the server
541
+ * does a full render. The element cache is populated lazily after the
542
+ * first SPA navigation (RSC-decoded elements from hydration are
543
+ * thenables that can't be walked until React resolves them).
544
+ */
545
+ getMergeablePaths() {
546
+ const paths = /* @__PURE__ */ new Set();
547
+ for (const [, entry] of this.entries) if (entry.hasMergeableChild) paths.add(entry.segmentPath);
548
+ return paths;
549
+ }
527
550
  };
528
551
  /**
529
552
  * Check if a React element is a SegmentProvider by looking for the
@@ -537,11 +560,15 @@ function isSegmentProvider(element) {
537
560
  }
538
561
  /**
539
562
  * Extract the segment path from a SegmentProvider element.
540
- * The `segments` prop is an array like ["", "dashboard", "settings"].
541
- * The path is reconstructed as "/" + segments.filter(Boolean).join("/").
563
+ *
564
+ * Uses the `segmentId` prop if available (set by the server for route groups
565
+ * to distinguish siblings that share the same urlPath). Falls back to
566
+ * reconstructing from the `segments` array prop.
542
567
  */
543
568
  function getSegmentPath(element) {
544
- const filtered = element.props.segments.filter(Boolean);
569
+ const props = element.props;
570
+ if (props.segmentId) return props.segmentId;
571
+ const filtered = props.segments.filter(Boolean);
545
572
  return filtered.length === 0 ? "/" : "/" + filtered.join("/");
546
573
  }
547
574
  /**
@@ -554,6 +581,7 @@ function getSegmentPath(element) {
554
581
  function extractSegments(element) {
555
582
  const segments = [];
556
583
  walkForSegments(element, segments);
584
+ for (let i = 0; i < segments.length; i++) segments[i].hasMergeableChild = i < segments.length - 1;
557
585
  return segments;
558
586
  }
559
587
  function walkForSegments(node, out) {
@@ -563,7 +591,8 @@ function walkForSegments(node, out) {
563
591
  if (isSegmentProvider(node)) {
564
592
  out.push({
565
593
  segmentPath: getSegmentPath(el),
566
- element: el
594
+ element: el,
595
+ hasMergeableChild: false
567
596
  });
568
597
  walkChildren(props.children, out);
569
598
  return;
@@ -628,7 +657,7 @@ function findSegmentProviderPath(node, targetPath) {
628
657
  */
629
658
  function replaceInnerSegment(cachedElement, newInnerContent, innerSegmentPath) {
630
659
  const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
631
- if (!path || path.length === 0) return cloneElement(cachedElement, {}, newInnerContent);
660
+ if (!path || path.length === 0) return cachedElement;
632
661
  let replacement = newInnerContent;
633
662
  for (let i = path.length - 1; i >= 0; i--) {
634
663
  const { element, childIndex } = path[i];
@@ -894,18 +923,30 @@ function createRouter(deps) {
894
923
  * In production, the entire callback runs inside a React transition with
895
924
  * useOptimistic for the pending URL. In tests, the payload is rendered directly.
896
925
  */
897
- async function renderViaTransition(pendingUrl, perform) {
926
+ async function renderViaTransition(url, perform) {
898
927
  if (deps.navigateTransition) {
899
928
  let headElements = null;
900
- await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
929
+ await deps.navigateTransition(url, async (wrapPayload) => {
901
930
  const result = await perform();
902
931
  headElements = result.headElements;
903
- return wrapPayload(mergeAndCachePayload(result.payload, result.skippedSegments));
932
+ const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
933
+ historyStack.push(url, {
934
+ payload: merged,
935
+ headElements: result.headElements,
936
+ params: result.params
937
+ });
938
+ return wrapPayload(merged);
904
939
  });
905
940
  return headElements;
906
941
  }
907
942
  const result = await perform();
908
- renderPayload(mergeAndCachePayload(result.payload, result.skippedSegments));
943
+ const merged = mergeAndCachePayload(result.payload, result.skippedSegments);
944
+ historyStack.push(url, {
945
+ payload: merged,
946
+ headElements: result.headElements,
947
+ params: result.params
948
+ });
949
+ renderPayload(merged);
909
950
  return result.headElements;
910
951
  }
911
952
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -931,7 +972,7 @@ function createRouter(deps) {
931
972
  skippedSegments: prefetched.skippedSegments ?? null
932
973
  } : void 0;
933
974
  if (result === void 0) {
934
- const stateTree = segmentCache.serializeStateTree();
975
+ const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
935
976
  const rawCurrentUrl = deps.getCurrentUrl();
936
977
  result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
937
978
  }
@@ -943,11 +984,6 @@ function createRouter(deps) {
943
984
  timber: true,
944
985
  scrollY: 0
945
986
  }, "", url);
946
- historyStack.push(url, {
947
- payload: result.payload,
948
- headElements: result.headElements,
949
- params: result.params
950
- });
951
987
  updateSegmentCache(result.segmentInfo);
952
988
  updateNavigationState(result.params, url);
953
989
  return result;
@@ -987,11 +1023,6 @@ function createRouter(deps) {
987
1023
  try {
988
1024
  applyHead(await renderViaTransition(currentUrl, async () => {
989
1025
  const result = await fetchRscPayload(currentUrl, deps);
990
- historyStack.push(currentUrl, {
991
- payload: result.payload,
992
- headElements: result.headElements,
993
- params: result.params
994
- });
995
1026
  updateSegmentCache(result.segmentInfo);
996
1027
  updateNavigationState(result.params, currentUrl);
997
1028
  return result;
@@ -1014,14 +1045,9 @@ function createRouter(deps) {
1014
1045
  setPending(true, url);
1015
1046
  try {
1016
1047
  applyHead(await renderViaTransition(url, async () => {
1017
- const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree());
1048
+ const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()));
1018
1049
  updateSegmentCache(result.segmentInfo);
1019
1050
  updateNavigationState(result.params, url);
1020
- historyStack.push(url, {
1021
- payload: result.payload,
1022
- headElements: result.headElements,
1023
- params: result.params
1024
- });
1025
1051
  return result;
1026
1052
  }));
1027
1053
  afterPaint(() => {
@@ -1040,7 +1066,7 @@ function createRouter(deps) {
1040
1066
  function prefetch(url) {
1041
1067
  if (prefetchCache.get(url) !== void 0) return;
1042
1068
  if (historyStack.has(url)) return;
1043
- fetchRscPayload(url, deps, segmentCache.serializeStateTree()).then((result) => {
1069
+ fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths())).then((result) => {
1044
1070
  prefetchCache.set(url, result);
1045
1071
  }, () => {});
1046
1072
  }
@@ -1331,7 +1357,7 @@ function useSegmentContext() {
1331
1357
  * Wraps each layout to provide segment position context.
1332
1358
  * Injected by rsc-entry.ts during element tree construction.
1333
1359
  */
1334
- function SegmentProvider({ segments, parallelRouteKeys, children }) {
1360
+ function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }) {
1335
1361
  const value = useMemo(() => ({
1336
1362
  segments,
1337
1363
  parallelRouteKeys