@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.
- package/dist/client/index.js +248 -22
- package/dist/client/index.js.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +80 -0
- package/dist/client/rsc-fetch.d.ts.map +1 -0
- package/dist/client/segment-cache.d.ts +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts +96 -0
- package/dist/client/segment-merger.d.ts.map +1 -0
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +1 -1
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +7 -0
- package/src/client/router.ts +48 -188
- package/src/client/rsc-fetch.ts +234 -0
- package/src/client/segment-cache.ts +2 -0
- package/src/client/segment-merger.ts +297 -0
- package/src/server/route-element-builder.ts +14 -0
- package/src/server/rsc-entry/index.ts +3 -2
- package/src/server/rsc-entry/rsc-payload.ts +8 -1
- package/src/server/state-tree-diff.ts +18 -19
package/dist/client/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { a as _setCurrentParams, c as cachedSearchParams, i as _setCachedSearch,
|
|
|
3
3
|
import { n as useQueryStates, t as bindUseQueryStates } from "../_chunks/use-query-states-DAhgj8Gx.js";
|
|
4
4
|
import { t as useCookie } from "../_chunks/use-cookie-dDbpCTx-.js";
|
|
5
5
|
import { TimberErrorBoundary } from "./error-boundary.js";
|
|
6
|
-
import React, { createContext, createElement, useActionState as useActionState$1, useContext, useEffect, useMemo, useRef, useSyncExternalStore, useTransition } from "react";
|
|
6
|
+
import React, { cloneElement, createContext, createElement, isValidElement, useActionState as useActionState$1, useContext, useEffect, useMemo, useRef, useSyncExternalStore, useTransition } from "react";
|
|
7
7
|
import { jsx } from "react/jsx-runtime";
|
|
8
8
|
//#region src/client/link-navigate-interceptor.tsx
|
|
9
9
|
/** Symbol used to store the onNavigate callback on anchor elements. */
|
|
@@ -480,27 +480,194 @@ function useParams(_route) {
|
|
|
480
480
|
return getSsrData()?.params ?? currentParams;
|
|
481
481
|
}
|
|
482
482
|
//#endregion
|
|
483
|
-
//#region src/client/
|
|
483
|
+
//#region src/client/segment-merger.ts
|
|
484
484
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
485
|
+
* Segment Merger — client-side tree merging for partial RSC payloads.
|
|
486
|
+
*
|
|
487
|
+
* When the server skips rendering sync layouts (because the client already
|
|
488
|
+
* has them cached), the RSC payload is missing outer segment wrappers.
|
|
489
|
+
* This module reconstructs the full element tree by splicing the partial
|
|
490
|
+
* payload into cached segment subtrees.
|
|
491
|
+
*
|
|
492
|
+
* The approach:
|
|
493
|
+
* 1. After each full RSC payload render, walk the decoded element tree
|
|
494
|
+
* and cache each segment's subtree (identified by SegmentProvider boundaries)
|
|
495
|
+
* 2. When a partial payload arrives, wrap it with cached segment elements
|
|
496
|
+
* using React.cloneElement to preserve component identity
|
|
497
|
+
*
|
|
498
|
+
* React.cloneElement preserves the element's `type` — React sees the same
|
|
499
|
+
* component at the same tree position and reconciles (preserving state)
|
|
500
|
+
* rather than remounting. This is how layout state survives navigations.
|
|
501
|
+
*
|
|
502
|
+
* Design docs: 19-client-navigation.md §"Navigation Reconciliation"
|
|
503
|
+
* Security: access.ts runs on the server regardless of skipping — this
|
|
504
|
+
* is a performance optimization only. See 13-security.md.
|
|
487
505
|
*/
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Cache of React element subtrees per segment path.
|
|
508
|
+
* Updated after each navigation with the full decoded RSC element tree.
|
|
509
|
+
*/
|
|
510
|
+
var SegmentElementCache = class {
|
|
511
|
+
entries = /* @__PURE__ */ new Map();
|
|
512
|
+
get(segmentPath) {
|
|
513
|
+
return this.entries.get(segmentPath);
|
|
514
|
+
}
|
|
515
|
+
set(segmentPath, entry) {
|
|
516
|
+
this.entries.set(segmentPath, entry);
|
|
517
|
+
}
|
|
518
|
+
has(segmentPath) {
|
|
519
|
+
return this.entries.has(segmentPath);
|
|
520
|
+
}
|
|
521
|
+
clear() {
|
|
522
|
+
this.entries.clear();
|
|
523
|
+
}
|
|
524
|
+
get size() {
|
|
525
|
+
return this.entries.size;
|
|
493
526
|
}
|
|
494
527
|
};
|
|
495
528
|
/**
|
|
496
|
-
* Check if
|
|
497
|
-
*
|
|
529
|
+
* Check if a React element is a SegmentProvider by looking for the
|
|
530
|
+
* `segments` prop (an array of path segments). This is the only
|
|
531
|
+
* component that receives this prop shape.
|
|
498
532
|
*/
|
|
499
|
-
function
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
return
|
|
533
|
+
function isSegmentProvider(element) {
|
|
534
|
+
if (!isValidElement(element)) return false;
|
|
535
|
+
const props = element.props;
|
|
536
|
+
return Array.isArray(props.segments);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 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("/").
|
|
542
|
+
*/
|
|
543
|
+
function getSegmentPath(element) {
|
|
544
|
+
const filtered = element.props.segments.filter(Boolean);
|
|
545
|
+
return filtered.length === 0 ? "/" : "/" + filtered.join("/");
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Walk a React element tree and extract all SegmentProvider boundaries.
|
|
549
|
+
* Returns an ordered list of segment entries from outermost to innermost.
|
|
550
|
+
*
|
|
551
|
+
* This only finds SegmentProviders along the main children path — it does
|
|
552
|
+
* not descend into parallel routes/slots (those are separate subtrees).
|
|
553
|
+
*/
|
|
554
|
+
function extractSegments(element) {
|
|
555
|
+
const segments = [];
|
|
556
|
+
walkForSegments(element, segments);
|
|
557
|
+
return segments;
|
|
558
|
+
}
|
|
559
|
+
function walkForSegments(node, out) {
|
|
560
|
+
if (!isValidElement(node)) return;
|
|
561
|
+
const el = node;
|
|
562
|
+
const props = el.props;
|
|
563
|
+
if (isSegmentProvider(node)) {
|
|
564
|
+
out.push({
|
|
565
|
+
segmentPath: getSegmentPath(el),
|
|
566
|
+
element: el
|
|
567
|
+
});
|
|
568
|
+
walkChildren(props.children, out);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
walkChildren(props.children, out);
|
|
572
|
+
}
|
|
573
|
+
function walkChildren(children, out) {
|
|
574
|
+
if (children == null) return;
|
|
575
|
+
if (Array.isArray(children)) for (const child of children) walkForSegments(child, out);
|
|
576
|
+
else walkForSegments(children, out);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Cache all segment subtrees from a fully-rendered RSC element tree.
|
|
580
|
+
* Call this after every full RSC payload render (navigate, refresh, hydration).
|
|
581
|
+
*/
|
|
582
|
+
function cacheSegmentElements(element, cache) {
|
|
583
|
+
const segments = extractSegments(element);
|
|
584
|
+
for (const entry of segments) cache.set(entry.segmentPath, entry);
|
|
585
|
+
}
|
|
586
|
+
function findSegmentProviderPath(node, targetPath) {
|
|
587
|
+
const children = node.props.children;
|
|
588
|
+
if (children == null) return null;
|
|
589
|
+
if (Array.isArray(children)) for (let i = 0; i < children.length; i++) {
|
|
590
|
+
const child = children[i];
|
|
591
|
+
if (!isValidElement(child)) continue;
|
|
592
|
+
if (isSegmentProvider(child)) {
|
|
593
|
+
if (!targetPath || getSegmentPath(child) === targetPath) return [{
|
|
594
|
+
element: node,
|
|
595
|
+
childIndex: i
|
|
596
|
+
}];
|
|
597
|
+
}
|
|
598
|
+
const deeper = findSegmentProviderPath(child, targetPath);
|
|
599
|
+
if (deeper) return [{
|
|
600
|
+
element: node,
|
|
601
|
+
childIndex: i
|
|
602
|
+
}, ...deeper];
|
|
603
|
+
}
|
|
604
|
+
else if (isValidElement(children)) {
|
|
605
|
+
if (isSegmentProvider(children)) {
|
|
606
|
+
if (!targetPath || getSegmentPath(children) === targetPath) return [{
|
|
607
|
+
element: node,
|
|
608
|
+
childIndex: -1
|
|
609
|
+
}];
|
|
610
|
+
}
|
|
611
|
+
const deeper = findSegmentProviderPath(children, targetPath);
|
|
612
|
+
if (deeper) return [{
|
|
613
|
+
element: node,
|
|
614
|
+
childIndex: -1
|
|
615
|
+
}, ...deeper];
|
|
616
|
+
}
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Replace a nested SegmentProvider within a cached element tree with
|
|
621
|
+
* new content. Uses cloneElement along the path to produce a new tree
|
|
622
|
+
* with preserved component identity at every level except the replaced node.
|
|
623
|
+
*
|
|
624
|
+
* @param cachedElement The cached SegmentProvider element for this segment
|
|
625
|
+
* @param newInnerContent The new React element to splice in at the inner segment position
|
|
626
|
+
* @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
|
|
627
|
+
* @returns New element tree with the inner segment replaced
|
|
628
|
+
*/
|
|
629
|
+
function replaceInnerSegment(cachedElement, newInnerContent, innerSegmentPath) {
|
|
630
|
+
const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
|
|
631
|
+
if (!path || path.length === 0) return cloneElement(cachedElement, {}, newInnerContent);
|
|
632
|
+
let replacement = newInnerContent;
|
|
633
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
634
|
+
const { element, childIndex } = path[i];
|
|
635
|
+
if (childIndex === -1) replacement = cloneElement(element, {}, replacement);
|
|
636
|
+
else {
|
|
637
|
+
const newChildren = [...element.props.children];
|
|
638
|
+
newChildren[childIndex] = replacement;
|
|
639
|
+
replacement = cloneElement(element, {}, ...newChildren);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return replacement;
|
|
503
643
|
}
|
|
644
|
+
/**
|
|
645
|
+
* Merge a partial RSC payload with cached segment elements.
|
|
646
|
+
*
|
|
647
|
+
* When the server skips segments, the partial payload starts from the
|
|
648
|
+
* first non-skipped segment. This function wraps it with cached elements
|
|
649
|
+
* for the skipped segments, producing a full tree that React can
|
|
650
|
+
* reconcile with the mounted tree (preserving layout state).
|
|
651
|
+
*
|
|
652
|
+
* @param partialPayload The RSC payload element (may be partial)
|
|
653
|
+
* @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
|
|
654
|
+
* @param cache The segment element cache
|
|
655
|
+
* @returns The merged full element tree, or the partial payload if merging isn't possible
|
|
656
|
+
*/
|
|
657
|
+
function mergeSegmentTree(partialPayload, skippedSegments, cache) {
|
|
658
|
+
if (!isValidElement(partialPayload)) return partialPayload;
|
|
659
|
+
if (skippedSegments.length === 0) return partialPayload;
|
|
660
|
+
let result = partialPayload;
|
|
661
|
+
for (let i = skippedSegments.length - 1; i >= 0; i--) {
|
|
662
|
+
const segmentPath = skippedSegments[i];
|
|
663
|
+
const cached = cache.get(segmentPath);
|
|
664
|
+
if (!cached) return partialPayload;
|
|
665
|
+
result = replaceInnerSegment(cached.element, result);
|
|
666
|
+
}
|
|
667
|
+
return result;
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
//#region src/client/rsc-fetch.ts
|
|
504
671
|
var RSC_CONTENT_TYPE = "text/x-component";
|
|
505
672
|
/**
|
|
506
673
|
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
@@ -557,6 +724,24 @@ function extractSegmentInfo(response) {
|
|
|
557
724
|
}
|
|
558
725
|
}
|
|
559
726
|
/**
|
|
727
|
+
* Extract skipped segment paths from the X-Timber-Skipped-Segments header.
|
|
728
|
+
* Returns null if the header is missing or malformed.
|
|
729
|
+
*
|
|
730
|
+
* When the server skips sync layouts the client already has cached,
|
|
731
|
+
* it sends this header listing the skipped segment paths (outermost first).
|
|
732
|
+
* The client uses this to merge the partial payload with cached segments.
|
|
733
|
+
*/
|
|
734
|
+
function extractSkippedSegments(response) {
|
|
735
|
+
const header = response.headers.get("X-Timber-Skipped-Segments");
|
|
736
|
+
if (!header) return null;
|
|
737
|
+
try {
|
|
738
|
+
const parsed = JSON.parse(header);
|
|
739
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
740
|
+
} catch {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
/**
|
|
560
745
|
* Extract route params from the X-Timber-Params response header.
|
|
561
746
|
* Returns null if the header is missing or malformed.
|
|
562
747
|
*
|
|
@@ -572,6 +757,17 @@ function extractParams(response) {
|
|
|
572
757
|
}
|
|
573
758
|
}
|
|
574
759
|
/**
|
|
760
|
+
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
761
|
+
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
762
|
+
*/
|
|
763
|
+
var RedirectError = class extends Error {
|
|
764
|
+
redirectUrl;
|
|
765
|
+
constructor(url) {
|
|
766
|
+
super(`Server redirect to ${url}`);
|
|
767
|
+
this.redirectUrl = url;
|
|
768
|
+
}
|
|
769
|
+
};
|
|
770
|
+
/**
|
|
575
771
|
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
576
772
|
* the response is decoded into a React element tree via createFromFetch.
|
|
577
773
|
* Otherwise, the raw response text is returned (test mode).
|
|
@@ -590,12 +786,14 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
590
786
|
let headElements = null;
|
|
591
787
|
let segmentInfo = null;
|
|
592
788
|
let params = null;
|
|
789
|
+
let skippedSegments = null;
|
|
593
790
|
const wrappedPromise = fetchPromise.then((response) => {
|
|
594
791
|
const redirectLocation = response.headers.get("X-Timber-Redirect") || (response.status >= 300 && response.status < 400 ? response.headers.get("Location") : null);
|
|
595
792
|
if (redirectLocation) throw new RedirectError(redirectLocation);
|
|
596
793
|
headElements = extractHeadElements(response);
|
|
597
794
|
segmentInfo = extractSegmentInfo(response);
|
|
598
795
|
params = extractParams(response);
|
|
796
|
+
skippedSegments = extractSkippedSegments(response);
|
|
599
797
|
return response;
|
|
600
798
|
});
|
|
601
799
|
await wrappedPromise;
|
|
@@ -603,7 +801,8 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
603
801
|
payload: await deps.decodeRsc(wrappedPromise),
|
|
604
802
|
headElements,
|
|
605
803
|
segmentInfo,
|
|
606
|
-
params
|
|
804
|
+
params,
|
|
805
|
+
skippedSegments
|
|
607
806
|
};
|
|
608
807
|
}
|
|
609
808
|
const response = await deps.fetch(rscUrl, {
|
|
@@ -618,9 +817,21 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
618
817
|
payload: await response.text(),
|
|
619
818
|
headElements: extractHeadElements(response),
|
|
620
819
|
segmentInfo: extractSegmentInfo(response),
|
|
621
|
-
params: extractParams(response)
|
|
820
|
+
params: extractParams(response),
|
|
821
|
+
skippedSegments: extractSkippedSegments(response)
|
|
622
822
|
};
|
|
623
823
|
}
|
|
824
|
+
//#endregion
|
|
825
|
+
//#region src/client/router.ts
|
|
826
|
+
/**
|
|
827
|
+
* Check if an error is an abort error (connection closed / fetch aborted).
|
|
828
|
+
* Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
|
|
829
|
+
*/
|
|
830
|
+
function isAbortError(error) {
|
|
831
|
+
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
832
|
+
if (error instanceof Error && error.name === "AbortError") return true;
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
624
835
|
/**
|
|
625
836
|
* Create a router instance. In production, called once at app hydration
|
|
626
837
|
* with real browser APIs. In tests, called with mock dependencies.
|
|
@@ -629,6 +840,7 @@ function createRouter(deps) {
|
|
|
629
840
|
const segmentCache = new SegmentCache();
|
|
630
841
|
const prefetchCache = new PrefetchCache();
|
|
631
842
|
const historyStack = new HistoryStack();
|
|
843
|
+
const segmentElementCache = new SegmentElementCache();
|
|
632
844
|
let pending = false;
|
|
633
845
|
let pendingUrl = null;
|
|
634
846
|
const pendingListeners = /* @__PURE__ */ new Set();
|
|
@@ -650,6 +862,17 @@ function createRouter(deps) {
|
|
|
650
862
|
if (deps.renderRoot) deps.renderRoot(payload);
|
|
651
863
|
}
|
|
652
864
|
/**
|
|
865
|
+
* Merge a partial RSC payload with cached segment elements if segments
|
|
866
|
+
* were skipped, then cache segments from the (merged) payload.
|
|
867
|
+
* Returns the merged payload ready for rendering.
|
|
868
|
+
*/
|
|
869
|
+
function mergeAndCachePayload(payload, skippedSegments) {
|
|
870
|
+
let merged = payload;
|
|
871
|
+
if (skippedSegments && skippedSegments.length > 0) merged = mergeSegmentTree(payload, skippedSegments, segmentElementCache);
|
|
872
|
+
cacheSegmentElements(merged, segmentElementCache);
|
|
873
|
+
return merged;
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
653
876
|
* Update navigation state (params + pathname) for the next render.
|
|
654
877
|
*
|
|
655
878
|
* Sets both the module-level fallback (for tests and SSR) and the
|
|
@@ -677,12 +900,12 @@ function createRouter(deps) {
|
|
|
677
900
|
await deps.navigateTransition(pendingUrl, async (wrapPayload) => {
|
|
678
901
|
const result = await perform();
|
|
679
902
|
headElements = result.headElements;
|
|
680
|
-
return wrapPayload(result.payload);
|
|
903
|
+
return wrapPayload(mergeAndCachePayload(result.payload, result.skippedSegments));
|
|
681
904
|
});
|
|
682
905
|
return headElements;
|
|
683
906
|
}
|
|
684
907
|
const result = await perform();
|
|
685
|
-
renderPayload(result.payload);
|
|
908
|
+
renderPayload(mergeAndCachePayload(result.payload, result.skippedSegments));
|
|
686
909
|
return result.headElements;
|
|
687
910
|
}
|
|
688
911
|
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
@@ -704,7 +927,8 @@ function createRouter(deps) {
|
|
|
704
927
|
payload: prefetched.payload,
|
|
705
928
|
headElements: prefetched.headElements,
|
|
706
929
|
segmentInfo: prefetched.segmentInfo ?? null,
|
|
707
|
-
params: prefetched.params ?? null
|
|
930
|
+
params: prefetched.params ?? null,
|
|
931
|
+
skippedSegments: prefetched.skippedSegments ?? null
|
|
708
932
|
} : void 0;
|
|
709
933
|
if (result === void 0) {
|
|
710
934
|
const stateTree = segmentCache.serializeStateTree();
|
|
@@ -833,14 +1057,16 @@ function createRouter(deps) {
|
|
|
833
1057
|
prefetch,
|
|
834
1058
|
applyRevalidation(element, headElements) {
|
|
835
1059
|
const currentUrl = deps.getCurrentUrl();
|
|
1060
|
+
const merged = mergeAndCachePayload(element, null);
|
|
836
1061
|
historyStack.push(currentUrl, {
|
|
837
|
-
payload:
|
|
1062
|
+
payload: merged,
|
|
838
1063
|
headElements
|
|
839
1064
|
});
|
|
840
|
-
renderPayload(
|
|
1065
|
+
renderPayload(merged);
|
|
841
1066
|
applyHead(headElements);
|
|
842
1067
|
},
|
|
843
1068
|
initSegmentCache: (segments) => updateSegmentCache(segments),
|
|
1069
|
+
cacheElementTree: (element) => cacheSegmentElements(element, segmentElementCache),
|
|
844
1070
|
segmentCache,
|
|
845
1071
|
prefetchCache,
|
|
846
1072
|
historyStack
|