@timber-js/app 0.1.52 → 0.1.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.js +300 -48
- 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 +8 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts +8 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-merger.d.ts +119 -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 +72 -209
- package/src/client/rsc-fetch.ts +234 -0
- package/src/client/segment-cache.ts +16 -5
- package/src/client/segment-context.ts +8 -1
- package/src/client/segment-merger.ts +345 -0
- package/src/server/route-element-builder.ts +56 -6
- 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. */
|
|
@@ -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.
|
|
@@ -480,27 +485,218 @@ function useParams(_route) {
|
|
|
480
485
|
return getSsrData()?.params ?? currentParams;
|
|
481
486
|
}
|
|
482
487
|
//#endregion
|
|
483
|
-
//#region src/client/
|
|
488
|
+
//#region src/client/segment-merger.ts
|
|
484
489
|
/**
|
|
485
|
-
*
|
|
486
|
-
*
|
|
490
|
+
* Segment Merger — client-side tree merging for partial RSC payloads.
|
|
491
|
+
*
|
|
492
|
+
* When the server skips rendering sync layouts (because the client already
|
|
493
|
+
* has them cached), the RSC payload is missing outer segment wrappers.
|
|
494
|
+
* This module reconstructs the full element tree by splicing the partial
|
|
495
|
+
* payload into cached segment subtrees.
|
|
496
|
+
*
|
|
497
|
+
* The approach:
|
|
498
|
+
* 1. After each full RSC payload render, walk the decoded element tree
|
|
499
|
+
* and cache each segment's subtree (identified by SegmentProvider boundaries)
|
|
500
|
+
* 2. When a partial payload arrives, wrap it with cached segment elements
|
|
501
|
+
* using React.cloneElement to preserve component identity
|
|
502
|
+
*
|
|
503
|
+
* React.cloneElement preserves the element's `type` — React sees the same
|
|
504
|
+
* component at the same tree position and reconciles (preserving state)
|
|
505
|
+
* rather than remounting. This is how layout state survives navigations.
|
|
506
|
+
*
|
|
507
|
+
* Design docs: 19-client-navigation.md §"Navigation Reconciliation"
|
|
508
|
+
* Security: access.ts runs on the server regardless of skipping — this
|
|
509
|
+
* is a performance optimization only. See 13-security.md.
|
|
487
510
|
*/
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
511
|
+
/**
|
|
512
|
+
* Cache of React element subtrees per segment path.
|
|
513
|
+
* Updated after each navigation with the full decoded RSC element tree.
|
|
514
|
+
*/
|
|
515
|
+
var SegmentElementCache = class {
|
|
516
|
+
entries = /* @__PURE__ */ new Map();
|
|
517
|
+
get(segmentPath) {
|
|
518
|
+
return this.entries.get(segmentPath);
|
|
519
|
+
}
|
|
520
|
+
set(segmentPath, entry) {
|
|
521
|
+
this.entries.set(segmentPath, entry);
|
|
522
|
+
}
|
|
523
|
+
has(segmentPath) {
|
|
524
|
+
return this.entries.has(segmentPath);
|
|
525
|
+
}
|
|
526
|
+
clear() {
|
|
527
|
+
this.entries.clear();
|
|
528
|
+
}
|
|
529
|
+
get size() {
|
|
530
|
+
return this.entries.size;
|
|
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;
|
|
493
549
|
}
|
|
494
550
|
};
|
|
495
551
|
/**
|
|
496
|
-
* Check if
|
|
497
|
-
*
|
|
552
|
+
* Check if a React element is a SegmentProvider by looking for the
|
|
553
|
+
* `segments` prop (an array of path segments). This is the only
|
|
554
|
+
* component that receives this prop shape.
|
|
498
555
|
*/
|
|
499
|
-
function
|
|
500
|
-
if (
|
|
501
|
-
|
|
502
|
-
return
|
|
556
|
+
function isSegmentProvider(element) {
|
|
557
|
+
if (!isValidElement(element)) return false;
|
|
558
|
+
const props = element.props;
|
|
559
|
+
return Array.isArray(props.segments);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Extract the segment path from a SegmentProvider element.
|
|
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.
|
|
567
|
+
*/
|
|
568
|
+
function getSegmentPath(element) {
|
|
569
|
+
const props = element.props;
|
|
570
|
+
if (props.segmentId) return props.segmentId;
|
|
571
|
+
const filtered = props.segments.filter(Boolean);
|
|
572
|
+
return filtered.length === 0 ? "/" : "/" + filtered.join("/");
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Walk a React element tree and extract all SegmentProvider boundaries.
|
|
576
|
+
* Returns an ordered list of segment entries from outermost to innermost.
|
|
577
|
+
*
|
|
578
|
+
* This only finds SegmentProviders along the main children path — it does
|
|
579
|
+
* not descend into parallel routes/slots (those are separate subtrees).
|
|
580
|
+
*/
|
|
581
|
+
function extractSegments(element) {
|
|
582
|
+
const segments = [];
|
|
583
|
+
walkForSegments(element, segments);
|
|
584
|
+
for (let i = 0; i < segments.length; i++) segments[i].hasMergeableChild = i < segments.length - 1;
|
|
585
|
+
return segments;
|
|
586
|
+
}
|
|
587
|
+
function walkForSegments(node, out) {
|
|
588
|
+
if (!isValidElement(node)) return;
|
|
589
|
+
const el = node;
|
|
590
|
+
const props = el.props;
|
|
591
|
+
if (isSegmentProvider(node)) {
|
|
592
|
+
out.push({
|
|
593
|
+
segmentPath: getSegmentPath(el),
|
|
594
|
+
element: el,
|
|
595
|
+
hasMergeableChild: false
|
|
596
|
+
});
|
|
597
|
+
walkChildren(props.children, out);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
walkChildren(props.children, out);
|
|
601
|
+
}
|
|
602
|
+
function walkChildren(children, out) {
|
|
603
|
+
if (children == null) return;
|
|
604
|
+
if (Array.isArray(children)) for (const child of children) walkForSegments(child, out);
|
|
605
|
+
else walkForSegments(children, out);
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Cache all segment subtrees from a fully-rendered RSC element tree.
|
|
609
|
+
* Call this after every full RSC payload render (navigate, refresh, hydration).
|
|
610
|
+
*/
|
|
611
|
+
function cacheSegmentElements(element, cache) {
|
|
612
|
+
const segments = extractSegments(element);
|
|
613
|
+
for (const entry of segments) cache.set(entry.segmentPath, entry);
|
|
614
|
+
}
|
|
615
|
+
function findSegmentProviderPath(node, targetPath) {
|
|
616
|
+
const children = node.props.children;
|
|
617
|
+
if (children == null) return null;
|
|
618
|
+
if (Array.isArray(children)) for (let i = 0; i < children.length; i++) {
|
|
619
|
+
const child = children[i];
|
|
620
|
+
if (!isValidElement(child)) continue;
|
|
621
|
+
if (isSegmentProvider(child)) {
|
|
622
|
+
if (!targetPath || getSegmentPath(child) === targetPath) return [{
|
|
623
|
+
element: node,
|
|
624
|
+
childIndex: i
|
|
625
|
+
}];
|
|
626
|
+
}
|
|
627
|
+
const deeper = findSegmentProviderPath(child, targetPath);
|
|
628
|
+
if (deeper) return [{
|
|
629
|
+
element: node,
|
|
630
|
+
childIndex: i
|
|
631
|
+
}, ...deeper];
|
|
632
|
+
}
|
|
633
|
+
else if (isValidElement(children)) {
|
|
634
|
+
if (isSegmentProvider(children)) {
|
|
635
|
+
if (!targetPath || getSegmentPath(children) === targetPath) return [{
|
|
636
|
+
element: node,
|
|
637
|
+
childIndex: -1
|
|
638
|
+
}];
|
|
639
|
+
}
|
|
640
|
+
const deeper = findSegmentProviderPath(children, targetPath);
|
|
641
|
+
if (deeper) return [{
|
|
642
|
+
element: node,
|
|
643
|
+
childIndex: -1
|
|
644
|
+
}, ...deeper];
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Replace a nested SegmentProvider within a cached element tree with
|
|
650
|
+
* new content. Uses cloneElement along the path to produce a new tree
|
|
651
|
+
* with preserved component identity at every level except the replaced node.
|
|
652
|
+
*
|
|
653
|
+
* @param cachedElement The cached SegmentProvider element for this segment
|
|
654
|
+
* @param newInnerContent The new React element to splice in at the inner segment position
|
|
655
|
+
* @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
|
|
656
|
+
* @returns New element tree with the inner segment replaced
|
|
657
|
+
*/
|
|
658
|
+
function replaceInnerSegment(cachedElement, newInnerContent, innerSegmentPath) {
|
|
659
|
+
const path = findSegmentProviderPath(cachedElement, innerSegmentPath);
|
|
660
|
+
if (!path || path.length === 0) return cachedElement;
|
|
661
|
+
let replacement = newInnerContent;
|
|
662
|
+
for (let i = path.length - 1; i >= 0; i--) {
|
|
663
|
+
const { element, childIndex } = path[i];
|
|
664
|
+
if (childIndex === -1) replacement = cloneElement(element, {}, replacement);
|
|
665
|
+
else {
|
|
666
|
+
const newChildren = [...element.props.children];
|
|
667
|
+
newChildren[childIndex] = replacement;
|
|
668
|
+
replacement = cloneElement(element, {}, ...newChildren);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return replacement;
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Merge a partial RSC payload with cached segment elements.
|
|
675
|
+
*
|
|
676
|
+
* When the server skips segments, the partial payload starts from the
|
|
677
|
+
* first non-skipped segment. This function wraps it with cached elements
|
|
678
|
+
* for the skipped segments, producing a full tree that React can
|
|
679
|
+
* reconcile with the mounted tree (preserving layout state).
|
|
680
|
+
*
|
|
681
|
+
* @param partialPayload The RSC payload element (may be partial)
|
|
682
|
+
* @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
|
|
683
|
+
* @param cache The segment element cache
|
|
684
|
+
* @returns The merged full element tree, or the partial payload if merging isn't possible
|
|
685
|
+
*/
|
|
686
|
+
function mergeSegmentTree(partialPayload, skippedSegments, cache) {
|
|
687
|
+
if (!isValidElement(partialPayload)) return partialPayload;
|
|
688
|
+
if (skippedSegments.length === 0) return partialPayload;
|
|
689
|
+
let result = partialPayload;
|
|
690
|
+
for (let i = skippedSegments.length - 1; i >= 0; i--) {
|
|
691
|
+
const segmentPath = skippedSegments[i];
|
|
692
|
+
const cached = cache.get(segmentPath);
|
|
693
|
+
if (!cached) return partialPayload;
|
|
694
|
+
result = replaceInnerSegment(cached.element, result);
|
|
695
|
+
}
|
|
696
|
+
return result;
|
|
503
697
|
}
|
|
698
|
+
//#endregion
|
|
699
|
+
//#region src/client/rsc-fetch.ts
|
|
504
700
|
var RSC_CONTENT_TYPE = "text/x-component";
|
|
505
701
|
/**
|
|
506
702
|
* Generate a short random cache-busting ID (5 chars, a-z0-9).
|
|
@@ -557,6 +753,24 @@ function extractSegmentInfo(response) {
|
|
|
557
753
|
}
|
|
558
754
|
}
|
|
559
755
|
/**
|
|
756
|
+
* Extract skipped segment paths from the X-Timber-Skipped-Segments header.
|
|
757
|
+
* Returns null if the header is missing or malformed.
|
|
758
|
+
*
|
|
759
|
+
* When the server skips sync layouts the client already has cached,
|
|
760
|
+
* it sends this header listing the skipped segment paths (outermost first).
|
|
761
|
+
* The client uses this to merge the partial payload with cached segments.
|
|
762
|
+
*/
|
|
763
|
+
function extractSkippedSegments(response) {
|
|
764
|
+
const header = response.headers.get("X-Timber-Skipped-Segments");
|
|
765
|
+
if (!header) return null;
|
|
766
|
+
try {
|
|
767
|
+
const parsed = JSON.parse(header);
|
|
768
|
+
return Array.isArray(parsed) ? parsed : null;
|
|
769
|
+
} catch {
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
560
774
|
* Extract route params from the X-Timber-Params response header.
|
|
561
775
|
* Returns null if the header is missing or malformed.
|
|
562
776
|
*
|
|
@@ -572,6 +786,17 @@ function extractParams(response) {
|
|
|
572
786
|
}
|
|
573
787
|
}
|
|
574
788
|
/**
|
|
789
|
+
* Thrown when an RSC payload response contains X-Timber-Redirect header.
|
|
790
|
+
* Caught in navigate() to trigger a soft router navigation to the redirect target.
|
|
791
|
+
*/
|
|
792
|
+
var RedirectError = class extends Error {
|
|
793
|
+
redirectUrl;
|
|
794
|
+
constructor(url) {
|
|
795
|
+
super(`Server redirect to ${url}`);
|
|
796
|
+
this.redirectUrl = url;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
/**
|
|
575
800
|
* Fetch an RSC payload from the server. If a decodeRsc function is provided,
|
|
576
801
|
* the response is decoded into a React element tree via createFromFetch.
|
|
577
802
|
* Otherwise, the raw response text is returned (test mode).
|
|
@@ -590,12 +815,14 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
590
815
|
let headElements = null;
|
|
591
816
|
let segmentInfo = null;
|
|
592
817
|
let params = null;
|
|
818
|
+
let skippedSegments = null;
|
|
593
819
|
const wrappedPromise = fetchPromise.then((response) => {
|
|
594
820
|
const redirectLocation = response.headers.get("X-Timber-Redirect") || (response.status >= 300 && response.status < 400 ? response.headers.get("Location") : null);
|
|
595
821
|
if (redirectLocation) throw new RedirectError(redirectLocation);
|
|
596
822
|
headElements = extractHeadElements(response);
|
|
597
823
|
segmentInfo = extractSegmentInfo(response);
|
|
598
824
|
params = extractParams(response);
|
|
825
|
+
skippedSegments = extractSkippedSegments(response);
|
|
599
826
|
return response;
|
|
600
827
|
});
|
|
601
828
|
await wrappedPromise;
|
|
@@ -603,7 +830,8 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
603
830
|
payload: await deps.decodeRsc(wrappedPromise),
|
|
604
831
|
headElements,
|
|
605
832
|
segmentInfo,
|
|
606
|
-
params
|
|
833
|
+
params,
|
|
834
|
+
skippedSegments
|
|
607
835
|
};
|
|
608
836
|
}
|
|
609
837
|
const response = await deps.fetch(rscUrl, {
|
|
@@ -618,9 +846,21 @@ async function fetchRscPayload(url, deps, stateTree, currentUrl) {
|
|
|
618
846
|
payload: await response.text(),
|
|
619
847
|
headElements: extractHeadElements(response),
|
|
620
848
|
segmentInfo: extractSegmentInfo(response),
|
|
621
|
-
params: extractParams(response)
|
|
849
|
+
params: extractParams(response),
|
|
850
|
+
skippedSegments: extractSkippedSegments(response)
|
|
622
851
|
};
|
|
623
852
|
}
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/client/router.ts
|
|
855
|
+
/**
|
|
856
|
+
* Check if an error is an abort error (connection closed / fetch aborted).
|
|
857
|
+
* Browsers throw DOMException with name 'AbortError' when a fetch is aborted.
|
|
858
|
+
*/
|
|
859
|
+
function isAbortError(error) {
|
|
860
|
+
if (error instanceof DOMException && error.name === "AbortError") return true;
|
|
861
|
+
if (error instanceof Error && error.name === "AbortError") return true;
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
624
864
|
/**
|
|
625
865
|
* Create a router instance. In production, called once at app hydration
|
|
626
866
|
* with real browser APIs. In tests, called with mock dependencies.
|
|
@@ -629,6 +869,7 @@ function createRouter(deps) {
|
|
|
629
869
|
const segmentCache = new SegmentCache();
|
|
630
870
|
const prefetchCache = new PrefetchCache();
|
|
631
871
|
const historyStack = new HistoryStack();
|
|
872
|
+
const segmentElementCache = new SegmentElementCache();
|
|
632
873
|
let pending = false;
|
|
633
874
|
let pendingUrl = null;
|
|
634
875
|
const pendingListeners = /* @__PURE__ */ new Set();
|
|
@@ -650,6 +891,17 @@ function createRouter(deps) {
|
|
|
650
891
|
if (deps.renderRoot) deps.renderRoot(payload);
|
|
651
892
|
}
|
|
652
893
|
/**
|
|
894
|
+
* Merge a partial RSC payload with cached segment elements if segments
|
|
895
|
+
* were skipped, then cache segments from the (merged) payload.
|
|
896
|
+
* Returns the merged payload ready for rendering.
|
|
897
|
+
*/
|
|
898
|
+
function mergeAndCachePayload(payload, skippedSegments) {
|
|
899
|
+
let merged = payload;
|
|
900
|
+
if (skippedSegments && skippedSegments.length > 0) merged = mergeSegmentTree(payload, skippedSegments, segmentElementCache);
|
|
901
|
+
cacheSegmentElements(merged, segmentElementCache);
|
|
902
|
+
return merged;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
653
905
|
* Update navigation state (params + pathname) for the next render.
|
|
654
906
|
*
|
|
655
907
|
* Sets both the module-level fallback (for tests and SSR) and the
|
|
@@ -671,18 +923,30 @@ function createRouter(deps) {
|
|
|
671
923
|
* In production, the entire callback runs inside a React transition with
|
|
672
924
|
* useOptimistic for the pending URL. In tests, the payload is rendered directly.
|
|
673
925
|
*/
|
|
674
|
-
async function renderViaTransition(
|
|
926
|
+
async function renderViaTransition(url, perform) {
|
|
675
927
|
if (deps.navigateTransition) {
|
|
676
928
|
let headElements = null;
|
|
677
|
-
await deps.navigateTransition(
|
|
929
|
+
await deps.navigateTransition(url, async (wrapPayload) => {
|
|
678
930
|
const result = await perform();
|
|
679
931
|
headElements = result.headElements;
|
|
680
|
-
|
|
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);
|
|
681
939
|
});
|
|
682
940
|
return headElements;
|
|
683
941
|
}
|
|
684
942
|
const result = await perform();
|
|
685
|
-
|
|
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);
|
|
686
950
|
return result.headElements;
|
|
687
951
|
}
|
|
688
952
|
/** Apply head elements (title, meta tags) to the DOM if available. */
|
|
@@ -704,10 +968,11 @@ function createRouter(deps) {
|
|
|
704
968
|
payload: prefetched.payload,
|
|
705
969
|
headElements: prefetched.headElements,
|
|
706
970
|
segmentInfo: prefetched.segmentInfo ?? null,
|
|
707
|
-
params: prefetched.params ?? null
|
|
971
|
+
params: prefetched.params ?? null,
|
|
972
|
+
skippedSegments: prefetched.skippedSegments ?? null
|
|
708
973
|
} : void 0;
|
|
709
974
|
if (result === void 0) {
|
|
710
|
-
const stateTree = segmentCache.serializeStateTree();
|
|
975
|
+
const stateTree = segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths());
|
|
711
976
|
const rawCurrentUrl = deps.getCurrentUrl();
|
|
712
977
|
result = await fetchRscPayload(url, deps, stateTree, rawCurrentUrl.startsWith("http") ? new URL(rawCurrentUrl).pathname : new URL(rawCurrentUrl, "http://localhost").pathname);
|
|
713
978
|
}
|
|
@@ -719,11 +984,6 @@ function createRouter(deps) {
|
|
|
719
984
|
timber: true,
|
|
720
985
|
scrollY: 0
|
|
721
986
|
}, "", url);
|
|
722
|
-
historyStack.push(url, {
|
|
723
|
-
payload: result.payload,
|
|
724
|
-
headElements: result.headElements,
|
|
725
|
-
params: result.params
|
|
726
|
-
});
|
|
727
987
|
updateSegmentCache(result.segmentInfo);
|
|
728
988
|
updateNavigationState(result.params, url);
|
|
729
989
|
return result;
|
|
@@ -763,11 +1023,6 @@ function createRouter(deps) {
|
|
|
763
1023
|
try {
|
|
764
1024
|
applyHead(await renderViaTransition(currentUrl, async () => {
|
|
765
1025
|
const result = await fetchRscPayload(currentUrl, deps);
|
|
766
|
-
historyStack.push(currentUrl, {
|
|
767
|
-
payload: result.payload,
|
|
768
|
-
headElements: result.headElements,
|
|
769
|
-
params: result.params
|
|
770
|
-
});
|
|
771
1026
|
updateSegmentCache(result.segmentInfo);
|
|
772
1027
|
updateNavigationState(result.params, currentUrl);
|
|
773
1028
|
return result;
|
|
@@ -790,14 +1045,9 @@ function createRouter(deps) {
|
|
|
790
1045
|
setPending(true, url);
|
|
791
1046
|
try {
|
|
792
1047
|
applyHead(await renderViaTransition(url, async () => {
|
|
793
|
-
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree());
|
|
1048
|
+
const result = await fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths()));
|
|
794
1049
|
updateSegmentCache(result.segmentInfo);
|
|
795
1050
|
updateNavigationState(result.params, url);
|
|
796
|
-
historyStack.push(url, {
|
|
797
|
-
payload: result.payload,
|
|
798
|
-
headElements: result.headElements,
|
|
799
|
-
params: result.params
|
|
800
|
-
});
|
|
801
1051
|
return result;
|
|
802
1052
|
}));
|
|
803
1053
|
afterPaint(() => {
|
|
@@ -816,7 +1066,7 @@ function createRouter(deps) {
|
|
|
816
1066
|
function prefetch(url) {
|
|
817
1067
|
if (prefetchCache.get(url) !== void 0) return;
|
|
818
1068
|
if (historyStack.has(url)) return;
|
|
819
|
-
fetchRscPayload(url, deps, segmentCache.serializeStateTree()).then((result) => {
|
|
1069
|
+
fetchRscPayload(url, deps, segmentCache.serializeStateTree(segmentElementCache.getMergeablePaths())).then((result) => {
|
|
820
1070
|
prefetchCache.set(url, result);
|
|
821
1071
|
}, () => {});
|
|
822
1072
|
}
|
|
@@ -833,14 +1083,16 @@ function createRouter(deps) {
|
|
|
833
1083
|
prefetch,
|
|
834
1084
|
applyRevalidation(element, headElements) {
|
|
835
1085
|
const currentUrl = deps.getCurrentUrl();
|
|
1086
|
+
const merged = mergeAndCachePayload(element, null);
|
|
836
1087
|
historyStack.push(currentUrl, {
|
|
837
|
-
payload:
|
|
1088
|
+
payload: merged,
|
|
838
1089
|
headElements
|
|
839
1090
|
});
|
|
840
|
-
renderPayload(
|
|
1091
|
+
renderPayload(merged);
|
|
841
1092
|
applyHead(headElements);
|
|
842
1093
|
},
|
|
843
1094
|
initSegmentCache: (segments) => updateSegmentCache(segments),
|
|
1095
|
+
cacheElementTree: (element) => cacheSegmentElements(element, segmentElementCache),
|
|
844
1096
|
segmentCache,
|
|
845
1097
|
prefetchCache,
|
|
846
1098
|
historyStack
|
|
@@ -1105,7 +1357,7 @@ function useSegmentContext() {
|
|
|
1105
1357
|
* Wraps each layout to provide segment position context.
|
|
1106
1358
|
* Injected by rsc-entry.ts during element tree construction.
|
|
1107
1359
|
*/
|
|
1108
|
-
function SegmentProvider({ segments, parallelRouteKeys, children }) {
|
|
1360
|
+
function SegmentProvider({ segments, segmentId: _segmentId, parallelRouteKeys, children }) {
|
|
1109
1361
|
const value = useMemo(() => ({
|
|
1110
1362
|
segments,
|
|
1111
1363
|
parallelRouteKeys
|