@rangojs/router 0.28.0 → 0.30.0
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/bin/rango.js +1 -1
- package/dist/types/browser/action-fence.d.ts +2 -1
- package/dist/types/browser/event-controller.d.ts +6 -1
- package/dist/types/browser/partial-update.d.ts +11 -0
- package/dist/types/browser/react/use-action.d.ts +10 -4
- package/dist/types/browser/react/use-handle.d.ts +20 -16
- package/dist/types/browser/react/use-link-status.d.ts +5 -2
- package/dist/types/browser/react/use-route-state.d.ts +45 -0
- package/dist/types/browser/segment-reconciler.d.ts +26 -0
- package/dist/types/browser/types.d.ts +30 -8
- package/dist/types/browser/wire-protocol.d.ts +1 -0
- package/dist/types/cache/tag-invalidation.d.ts +0 -10
- package/dist/types/router/error-handling.d.ts +7 -1
- package/dist/types/router/match-context.d.ts +2 -0
- package/dist/types/router/match-middleware/background-revalidation.d.ts +3 -2
- package/dist/types/router/navigation-snapshot.d.ts +7 -0
- package/dist/types/router/segment-resolution/helpers.d.ts +19 -3
- package/dist/types/router/segment-resolution/resolution-pass.d.ts +2 -0
- package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +2 -0
- package/dist/types/router/types.d.ts +1 -1
- package/dist/types/server/request-context.d.ts +30 -1
- package/dist/types/testing/render-route.d.ts +43 -8
- package/dist/types/testing/serve-shell-request.d.ts +8 -1
- package/dist/types/types/error-types.d.ts +27 -8
- package/dist/types/wire-params.d.ts +5 -0
- package/dist/vite/index.js +62 -67
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +6 -8
- package/skills/hooks/SKILL.md +1 -1
- package/skills/hooks/data.md +8 -1
- package/skills/hooks/handle-and-actions.md +40 -2
- package/skills/hooks/navigation.md +121 -15
- package/skills/hooks/state.md +6 -4
- package/skills/hooks/urls.md +17 -5
- package/skills/intercept/SKILL.md +6 -0
- package/skills/observability/SKILL.md +30 -0
- package/skills/server-actions/SKILL.md +8 -1
- package/skills/testing/client-components.md +25 -18
- package/skills/testing/serve-shell-request.md +7 -7
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/action-fence.ts +2 -1
- package/src/browser/event-controller.ts +78 -19
- package/src/browser/navigation-bridge.ts +45 -13
- package/src/browser/navigation-client.ts +2 -0
- package/src/browser/network-error-handler.ts +4 -0
- package/src/browser/partial-update.ts +33 -6
- package/src/browser/react/use-action.ts +27 -50
- package/src/browser/react/use-handle.ts +27 -18
- package/src/browser/react/use-link-status.ts +48 -60
- package/src/browser/react/use-navigation.ts +76 -51
- package/src/browser/react/use-params.ts +6 -35
- package/src/browser/react/use-pathname.ts +3 -32
- package/src/browser/react/use-route-state.ts +141 -0
- package/src/browser/react/use-search-params.ts +7 -40
- package/src/browser/segment-reconciler.ts +43 -0
- package/src/browser/server-action-bridge.ts +35 -8
- package/src/browser/types.ts +31 -8
- package/src/browser/wire-protocol.ts +2 -0
- package/src/cache/cache-runtime.ts +28 -31
- package/src/cache/document-cache.ts +34 -37
- package/src/cache/read-through-swr.ts +20 -18
- package/src/cache/tag-invalidation.ts +3 -18
- package/src/router/error-handling.ts +18 -6
- package/src/router/intercept-resolution.ts +9 -4
- package/src/router/match-api.ts +1 -0
- package/src/router/match-context.ts +3 -0
- package/src/router/match-middleware/background-revalidation.ts +5 -4
- package/src/router/match-middleware/cache-lookup.ts +5 -3
- package/src/router/navigation-snapshot.ts +10 -0
- package/src/router/segment-resolution/helpers.ts +49 -13
- package/src/router/segment-resolution/loader-cache.ts +4 -3
- package/src/router/segment-resolution/resolution-pass.ts +2 -0
- package/src/router/segment-resolution/revalidation.ts +57 -28
- package/src/router/segment-resolution/streamed-handler-boundary.ts +3 -0
- package/src/router/types.ts +1 -1
- package/src/router.ts +7 -4
- package/src/rsc/request-stages.ts +4 -2
- package/src/rsc/shell-capture.ts +63 -58
- package/src/server/request-context.ts +49 -0
- package/src/testing/dispatch.ts +3 -0
- package/src/testing/render-route.tsx +204 -16
- package/src/testing/serve-shell-request.ts +19 -10
- package/src/types/error-types.ts +27 -8
- package/src/wire-params.ts +5 -0
|
@@ -92,6 +92,11 @@ export interface ActionEntry {
|
|
|
92
92
|
startedAt: number;
|
|
93
93
|
/** Whether action processing is complete (may still be streaming) */
|
|
94
94
|
completed?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Set when a later call started (see startAction): "id" hides this call
|
|
97
|
+
* from every reader, "name" from readers that match by export name.
|
|
98
|
+
*/
|
|
99
|
+
supersededBy?: "id" | "name";
|
|
95
100
|
}
|
|
96
101
|
|
|
97
102
|
/**
|
|
@@ -104,7 +109,7 @@ export interface DerivedNavigationState {
|
|
|
104
109
|
isStreaming: boolean;
|
|
105
110
|
/** Whether a navigation is active (fetching or streaming, before commit) */
|
|
106
111
|
isNavigating: boolean;
|
|
107
|
-
/**
|
|
112
|
+
/** The location of the last page flushed to React (useNavigation keeps the page on screen) */
|
|
108
113
|
location: NavigationLocation;
|
|
109
114
|
/** URL being navigated to (null if idle) */
|
|
110
115
|
pendingUrl: string | null;
|
|
@@ -537,6 +542,28 @@ export function createEventController(
|
|
|
537
542
|
let unsettledNavigation: NavigationEntry | null = null;
|
|
538
543
|
|
|
539
544
|
const inflightActions = new Map<string, ActionEntry>();
|
|
545
|
+
// Each action id's last settled call, which useAction reports until the
|
|
546
|
+
// action's next call starts (startAction deletes it). A call moves here
|
|
547
|
+
// when doSettle's cleanup takes it out of inflightActions. A trimmed
|
|
548
|
+
// snapshot, so no abort controller outlives the call. abortAllActions leaves
|
|
549
|
+
// settled calls: they persist through navigations until the action's next
|
|
550
|
+
// call.
|
|
551
|
+
type TrackedCall = Pick<
|
|
552
|
+
ActionEntry,
|
|
553
|
+
| "actionId"
|
|
554
|
+
| "phase"
|
|
555
|
+
| "payload"
|
|
556
|
+
| "result"
|
|
557
|
+
| "error"
|
|
558
|
+
| "revalidationError"
|
|
559
|
+
| "supersededBy"
|
|
560
|
+
>;
|
|
561
|
+
const settledActions = new Map<string, TrackedCall>();
|
|
562
|
+
|
|
563
|
+
function* trackedCalls(): Generator<TrackedCall> {
|
|
564
|
+
yield* inflightActions.values();
|
|
565
|
+
yield* settledActions.values();
|
|
566
|
+
}
|
|
540
567
|
|
|
541
568
|
let location: NavigationLocation =
|
|
542
569
|
config?.initialLocation ??
|
|
@@ -708,19 +735,24 @@ export function createEventController(
|
|
|
708
735
|
}
|
|
709
736
|
|
|
710
737
|
function getActionState(actionId: string): TrackedActionState {
|
|
711
|
-
//
|
|
712
|
-
//
|
|
713
|
-
if (inflightActions.size === 0)
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
738
|
+
// No call tracked: the shared idle snapshot (the same reference use-action
|
|
739
|
+
// falls back to).
|
|
740
|
+
if (inflightActions.size === 0 && settledActions.size === 0) {
|
|
741
|
+
return DEFAULT_ACTION_STATE;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// The latest call this reader matches, in flight or settled, or none once
|
|
745
|
+
// a later call was aborted: an earlier call is never reported again.
|
|
746
|
+
const byName = !actionId.includes("#");
|
|
747
|
+
let entry: TrackedCall | undefined;
|
|
748
|
+
for (const a of trackedCalls()) {
|
|
749
|
+
if (
|
|
750
|
+
matchesActionId(actionId, a.actionId) &&
|
|
751
|
+
(byName ? !a.supersededBy : a.supersededBy !== "id")
|
|
752
|
+
) {
|
|
753
|
+
entry = a;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
724
756
|
|
|
725
757
|
if (!entry) {
|
|
726
758
|
return { ...DEFAULT_ACTION_STATE };
|
|
@@ -899,9 +931,9 @@ export function createEventController(
|
|
|
899
931
|
// after that point is strictly ordered behind the prior action's server
|
|
900
932
|
// execution, and there is no skipped render or order uncertainty for
|
|
901
933
|
// consolidation to repair. Completed entries still linger in the map for
|
|
902
|
-
// the 100ms doSettle window
|
|
903
|
-
//
|
|
904
|
-
//
|
|
934
|
+
// the 100ms doSettle window and, on a slow connection, while the Flight
|
|
935
|
+
// stream drains its EOF after complete(). Counting them latched
|
|
936
|
+
// hadAnyConcurrentActions for back-to-back sequential actions,
|
|
905
937
|
// which made the LAST action classify as consolidation-needed; the
|
|
906
938
|
// consolidation refetch omits every concurrently-revalidated segment id
|
|
907
939
|
// from _rsc_segments, so the server re-ran gated loaders as "new-segment"
|
|
@@ -913,6 +945,22 @@ export function createEventController(
|
|
|
913
945
|
hadAnyConcurrentActions = true;
|
|
914
946
|
}
|
|
915
947
|
|
|
948
|
+
// useAction reports the latest call. Every earlier call that a reader of
|
|
949
|
+
// this one also matches, in flight or settled, is hidden from that reader
|
|
950
|
+
// for good, so it never shows again, also once this call is aborted: a
|
|
951
|
+
// call of the same action from every reader, a call of another action
|
|
952
|
+
// with the same export name from readers that match by name
|
|
953
|
+
// (matchesActionId). So at most one call matches a reader (getActionState).
|
|
954
|
+
const hash = actionId.lastIndexOf("#");
|
|
955
|
+
const name = hash === -1 ? null : actionId.slice(hash + 1);
|
|
956
|
+
settledActions.delete(actionId);
|
|
957
|
+
for (const a of trackedCalls()) {
|
|
958
|
+
if (a.actionId === actionId) a.supersededBy = "id";
|
|
959
|
+
else if (name !== null && matchesActionId(name, a.actionId)) {
|
|
960
|
+
a.supersededBy ??= "name";
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
|
|
916
964
|
const entry: ActionEntry = {
|
|
917
965
|
id,
|
|
918
966
|
actionId,
|
|
@@ -956,9 +1004,20 @@ export function createEventController(
|
|
|
956
1004
|
if (settled) return;
|
|
957
1005
|
settled = true;
|
|
958
1006
|
|
|
959
|
-
// Cleanup after brief delay (allow useAction to read result)
|
|
960
1007
|
setTimeout(() => {
|
|
961
|
-
|
|
1008
|
+
// The action's last settled call until its next call starts, unless
|
|
1009
|
+
// that call already started.
|
|
1010
|
+
if (inflightActions.delete(id) && entry.supersededBy !== "id") {
|
|
1011
|
+
settledActions.set(actionId, {
|
|
1012
|
+
actionId: entry.actionId,
|
|
1013
|
+
phase: entry.phase,
|
|
1014
|
+
payload: entry.payload,
|
|
1015
|
+
result: entry.result,
|
|
1016
|
+
error: entry.error,
|
|
1017
|
+
revalidationError: entry.revalidationError,
|
|
1018
|
+
supersededBy: entry.supersededBy,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
962
1021
|
// Free this cohort's arbitration once its last action has settled, so
|
|
963
1022
|
// a long-running action elsewhere cannot pin keys from a drained entry.
|
|
964
1023
|
releaseCohort();
|
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
} from "./navigation-store.js";
|
|
46
46
|
import type { EventController } from "./event-controller.js";
|
|
47
47
|
import { isInterceptOnlyCache } from "./intercept-utils.js";
|
|
48
|
+
import { actionBoundaryAt, idsAboveBoundary } from "./segment-reconciler.js";
|
|
48
49
|
import {
|
|
49
50
|
toNetworkError,
|
|
50
51
|
emitNetworkError,
|
|
@@ -206,7 +207,10 @@ export function createNavigationBridge(
|
|
|
206
207
|
error: ServerRedirect,
|
|
207
208
|
url: string,
|
|
208
209
|
hops: number,
|
|
209
|
-
options:
|
|
210
|
+
options: Pick<
|
|
211
|
+
NavigateOptionsInternal,
|
|
212
|
+
"replace" | "transition" | "_redirectFrom"
|
|
213
|
+
>,
|
|
210
214
|
): Promise<void> {
|
|
211
215
|
const redirectUrl = validateRedirectOrigin(
|
|
212
216
|
error.url,
|
|
@@ -228,6 +232,7 @@ export function createNavigationBridge(
|
|
|
228
232
|
...options,
|
|
229
233
|
_skipCache: true,
|
|
230
234
|
_redirectHops: hops + 1,
|
|
235
|
+
_redirectFrom: options._redirectFrom ?? url,
|
|
231
236
|
});
|
|
232
237
|
}
|
|
233
238
|
|
|
@@ -414,8 +419,11 @@ export function createNavigationBridge(
|
|
|
414
419
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
415
420
|
// 4. redirect-with-state - force re-render so hooks read fresh state
|
|
416
421
|
// 5. stale cache - server action invalidated it, need fresh data with loading state
|
|
422
|
+
// 6. an error boundary in the list - a link renders the page again; its
|
|
423
|
+
// ids would tell the server the client holds what the error replaced
|
|
417
424
|
const hasUsableCache =
|
|
418
425
|
isRestorable(cachedSegments) &&
|
|
426
|
+
!cachedSegments.some((s) => s.type === "error") &&
|
|
419
427
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
420
428
|
!isLeavingIntercept &&
|
|
421
429
|
!cached?.stale &&
|
|
@@ -538,7 +546,11 @@ export function createNavigationBridge(
|
|
|
538
546
|
error,
|
|
539
547
|
url,
|
|
540
548
|
options?._redirectHops ?? 0,
|
|
541
|
-
{
|
|
549
|
+
{
|
|
550
|
+
replace: options?.replace,
|
|
551
|
+
transition: options?.transition,
|
|
552
|
+
_redirectFrom: options?._redirectFrom,
|
|
553
|
+
},
|
|
542
554
|
);
|
|
543
555
|
}
|
|
544
556
|
|
|
@@ -569,9 +581,19 @@ export function createNavigationBridge(
|
|
|
569
581
|
},
|
|
570
582
|
|
|
571
583
|
/**
|
|
572
|
-
* Refresh current route
|
|
584
|
+
* Refresh current route. A cross-tab refresh holds the action error
|
|
585
|
+
* boundary the page shows (actionBoundaryAt), as the follow-up does: it
|
|
586
|
+
* is this tab's own outcome, not a read another tab's write changed. A
|
|
587
|
+
* refresh the app asks for renders it again.
|
|
573
588
|
*/
|
|
574
|
-
async refresh(): Promise<void> {
|
|
589
|
+
async refresh(crossTab?: boolean): Promise<void> {
|
|
590
|
+
const shown =
|
|
591
|
+
(crossTab &&
|
|
592
|
+
!store.getInterceptSourceUrl() &&
|
|
593
|
+
store.getCachedSegments(store.getHistoryKey())?.segments) ||
|
|
594
|
+
[];
|
|
595
|
+
const at = actionBoundaryAt(shown);
|
|
596
|
+
if (at === 0) return;
|
|
575
597
|
eventController.abortNavigation();
|
|
576
598
|
|
|
577
599
|
const tx = createNavigationTransaction(
|
|
@@ -589,7 +611,7 @@ export function createNavigationBridge(
|
|
|
589
611
|
false,
|
|
590
612
|
tx.handle.signal,
|
|
591
613
|
tx.with({ url: window.location.href, replace: true, scroll: false }),
|
|
592
|
-
{ type: "navigate", refresh: true },
|
|
614
|
+
{ type: "navigate", refresh: true, boundary: shown[at]?.id },
|
|
593
615
|
);
|
|
594
616
|
} catch (error) {
|
|
595
617
|
// Aborted or superseded: bail without rendering a boundary (see navigate()).
|
|
@@ -717,9 +739,20 @@ export function createNavigationBridge(
|
|
|
717
739
|
}),
|
|
718
740
|
});
|
|
719
741
|
|
|
742
|
+
// An action error boundary the entry shows (actionBoundaryAt) is
|
|
743
|
+
// held: the page holds the ids above it, so a link renders it again,
|
|
744
|
+
// and the revalidation below renders above it. Never under an
|
|
745
|
+
// intercept, as the follow-up (server-action-bridge.ts).
|
|
746
|
+
const boundaryAt = isIntercept ? -1 : actionBoundaryAt(cachedSegments);
|
|
747
|
+
const boundary = cachedSegments[boundaryAt]?.id;
|
|
748
|
+
const heldIds = idsAboveBoundary(
|
|
749
|
+
cachedSegments.map((s) => s.id),
|
|
750
|
+
boundary,
|
|
751
|
+
);
|
|
752
|
+
|
|
720
753
|
// Update store to point to this history entry
|
|
721
754
|
store.setHistoryKey(historyKey);
|
|
722
|
-
store.setSegmentIds(
|
|
755
|
+
store.setSegmentIds(heldIds);
|
|
723
756
|
store.setCurrentUrl(url);
|
|
724
757
|
store.setPath(new URL(url).pathname);
|
|
725
758
|
|
|
@@ -798,8 +831,9 @@ export function createNavigationBridge(
|
|
|
798
831
|
onUpdate({ ...popstateUpdate, inTransition: true });
|
|
799
832
|
});
|
|
800
833
|
}
|
|
801
|
-
// SWR: If stale, trigger background revalidation
|
|
802
|
-
|
|
834
|
+
// SWR: If stale, trigger background revalidation. Nothing is
|
|
835
|
+
// above a boundary at the page's first segment.
|
|
836
|
+
if (isStale && boundaryAt !== 0) {
|
|
803
837
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
804
838
|
// Background revalidation - don't await, just fire and forget.
|
|
805
839
|
// When the entry's handles are incomplete (a deferred Meta was still
|
|
@@ -811,9 +845,7 @@ export function createNavigationBridge(
|
|
|
811
845
|
// would stay the pre-resolution carry. handlesPending is set only for
|
|
812
846
|
// the deferred-Meta-aborted case, so action/cross-tab SWR keeps the
|
|
813
847
|
// cheap diff path.
|
|
814
|
-
const segmentIds = cached?.handlesPending
|
|
815
|
-
? []
|
|
816
|
-
: cachedSegments.map((s) => s.id);
|
|
848
|
+
const segmentIds = cached?.handlesPending ? [] : heldIds;
|
|
817
849
|
|
|
818
850
|
const tx = createNavigationTransaction(
|
|
819
851
|
store,
|
|
@@ -835,7 +867,7 @@ export function createNavigationBridge(
|
|
|
835
867
|
interceptSourceUrl,
|
|
836
868
|
cacheOnly: true,
|
|
837
869
|
}),
|
|
838
|
-
{ type: "stale-revalidation", interceptSourceUrl },
|
|
870
|
+
{ type: "stale-revalidation", interceptSourceUrl, boundary },
|
|
839
871
|
)
|
|
840
872
|
.catch((error) => {
|
|
841
873
|
if (isBackgroundSuppressible(error)) return;
|
|
@@ -990,7 +1022,7 @@ export function createNavigationBridge(
|
|
|
990
1022
|
|
|
991
1023
|
// Register cross-tab refresh callback with the store
|
|
992
1024
|
store.setCrossTabRefreshCallback(() => {
|
|
993
|
-
this.refresh();
|
|
1025
|
+
this.refresh(true);
|
|
994
1026
|
});
|
|
995
1027
|
|
|
996
1028
|
window.addEventListener("popstate", handlePopstate);
|
|
@@ -82,6 +82,7 @@ export function createNavigationClient(
|
|
|
82
82
|
rewriteTarget,
|
|
83
83
|
signal,
|
|
84
84
|
staleRevalidation,
|
|
85
|
+
boundary,
|
|
85
86
|
interceptSourceUrl,
|
|
86
87
|
version,
|
|
87
88
|
routerId,
|
|
@@ -114,6 +115,7 @@ export function createNavigationClient(
|
|
|
114
115
|
segmentIds,
|
|
115
116
|
emptySegments: "send",
|
|
116
117
|
stale: staleRevalidation,
|
|
118
|
+
boundary,
|
|
117
119
|
version,
|
|
118
120
|
routerId,
|
|
119
121
|
rewriteTarget,
|
|
@@ -28,6 +28,9 @@ export function toNetworkError(
|
|
|
28
28
|
* Render an error into the segment tree via the onUpdate subscriber so the
|
|
29
29
|
* nearest error boundary catches it. Wrapped in startTransition; RenderErrorThrower
|
|
30
30
|
* throws during render (async rejections do not reach boundaries on their own).
|
|
31
|
+
* Flagged inTransition like every tree handed over in a transition, so route
|
|
32
|
+
* state that arrives after it (the failed navigation's idle) waits for its
|
|
33
|
+
* commit (NavigationProvider's "anchor").
|
|
31
34
|
*/
|
|
32
35
|
function emitErrorToBoundary(
|
|
33
36
|
onUpdate: UpdateSubscriber,
|
|
@@ -43,6 +46,7 @@ function emitErrorToBoundary(
|
|
|
43
46
|
segments: [],
|
|
44
47
|
isError: true,
|
|
45
48
|
},
|
|
49
|
+
inTransition: true,
|
|
46
50
|
});
|
|
47
51
|
});
|
|
48
52
|
}
|
|
@@ -14,7 +14,7 @@ export const addTransitionType: ((type: string) => void) | undefined =
|
|
|
14
14
|
"addTransitionType" in React ? (React as any).addTransitionType : undefined;
|
|
15
15
|
|
|
16
16
|
import type { RenderSegmentsOptions } from "../segment-system.js";
|
|
17
|
-
import { reconcileSegments } from "./segment-reconciler.js";
|
|
17
|
+
import { idsAboveBoundary, reconcileSegments } from "./segment-reconciler.js";
|
|
18
18
|
import type { ReconcileActor } from "./segment-reconciler.js";
|
|
19
19
|
import { Defer } from "./defer.js";
|
|
20
20
|
import {
|
|
@@ -236,15 +236,28 @@ export type UpdateMode =
|
|
|
236
236
|
* transition({ when }) sees kind "revalidate", not "replace".
|
|
237
237
|
*/
|
|
238
238
|
refresh?: boolean;
|
|
239
|
+
/** The cross-tab refresh's held boundary (see `action`). */
|
|
240
|
+
boundary?: string;
|
|
241
|
+
}
|
|
242
|
+
| { type: "leave-intercept"; interceptSourceUrl?: string; boundary?: never }
|
|
243
|
+
| {
|
|
244
|
+
type: "stale-revalidation";
|
|
245
|
+
interceptSourceUrl?: string;
|
|
246
|
+
/** Back/Forward's held boundary (see `action`). */
|
|
247
|
+
boundary?: string;
|
|
239
248
|
}
|
|
240
|
-
| { type: "leave-intercept"; interceptSourceUrl?: string }
|
|
241
|
-
| { type: "stale-revalidation"; interceptSourceUrl?: string }
|
|
242
249
|
| {
|
|
243
250
|
type: "action";
|
|
244
251
|
interceptSourceUrl?: string;
|
|
245
252
|
actionId?: string;
|
|
246
253
|
/** The action's FormData and result, for transition({ when }) `action`. */
|
|
247
254
|
action?: TransitionWhenActionInput;
|
|
255
|
+
/**
|
|
256
|
+
* An action's error boundary the page shows (actionBoundaryAt): the
|
|
257
|
+
* server renders above it, its cached segment stays, and the page
|
|
258
|
+
* holds only the ids above it.
|
|
259
|
+
*/
|
|
260
|
+
boundary?: string;
|
|
248
261
|
};
|
|
249
262
|
|
|
250
263
|
/**
|
|
@@ -569,6 +582,7 @@ export function createPartialUpdater(
|
|
|
569
582
|
const historyKeyAtStart = store.getHistoryKey();
|
|
570
583
|
|
|
571
584
|
const interceptSourceUrl = mode.interceptSourceUrl;
|
|
585
|
+
const boundary = mode.boundary;
|
|
572
586
|
|
|
573
587
|
let segments: string[];
|
|
574
588
|
if (mode.type === "leave-intercept") {
|
|
@@ -690,6 +704,7 @@ export function createPartialUpdater(
|
|
|
690
704
|
: store.getHistoryEntryMemory()?.rewriteTarget,
|
|
691
705
|
staleRevalidation:
|
|
692
706
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
707
|
+
boundary,
|
|
693
708
|
version: getVersion(),
|
|
694
709
|
routerId: store.getRouterId?.(),
|
|
695
710
|
});
|
|
@@ -874,14 +889,14 @@ export function createPartialUpdater(
|
|
|
874
889
|
// in place of each missing value, and this navigation fills them from
|
|
875
890
|
// one fill request once it has committed the tree.
|
|
876
891
|
let defers: Holes["defers"] | undefined;
|
|
877
|
-
let heldIds = matchedIds;
|
|
892
|
+
let heldIds = idsAboveBoundary(matchedIds, boundary);
|
|
878
893
|
const placeholders = serverSegments.filter(isPlaceholder);
|
|
879
894
|
if (placeholders.length > 0) {
|
|
880
895
|
defers = armHoles(placeholders);
|
|
881
896
|
// A placeholder is not held: every later request from this page
|
|
882
897
|
// leaves its id out, so the server renders it.
|
|
883
898
|
const deferredIds = new Set(placeholders.map((s) => s.id));
|
|
884
|
-
heldIds =
|
|
899
|
+
heldIds = heldIds.filter((id) => !deferredIds.has(id));
|
|
885
900
|
}
|
|
886
901
|
|
|
887
902
|
const reconcile = () =>
|
|
@@ -895,7 +910,10 @@ export function createPartialUpdater(
|
|
|
895
910
|
});
|
|
896
911
|
let reconciled = reconcile();
|
|
897
912
|
|
|
898
|
-
|
|
913
|
+
// Not the ids at or below a held boundary: its loaders may never have
|
|
914
|
+
// reached the client (the route failed on its GET), and nothing renders
|
|
915
|
+
// them under the error.
|
|
916
|
+
const missing = missingIds(heldIds, reconciled.segments);
|
|
899
917
|
|
|
900
918
|
if (missing.length > 0) {
|
|
901
919
|
const missingCount = missing.length;
|
|
@@ -1119,6 +1137,15 @@ export function createPartialUpdater(
|
|
|
1119
1137
|
}),
|
|
1120
1138
|
...(serverLocationState && { serverState: serverLocationState }),
|
|
1121
1139
|
};
|
|
1140
|
+
// A refetch that held an action's boundary commits only while the page
|
|
1141
|
+
// on screen still shows it: a later commit there (an action's success)
|
|
1142
|
+
// replaced it, and this response would put the error back.
|
|
1143
|
+
if (
|
|
1144
|
+
boundary &&
|
|
1145
|
+
!entryOnScreen()?.includes(cachedSegs.find((s) => s.id === boundary)!)
|
|
1146
|
+
) {
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1122
1149
|
const cachedHandleData = readCachedHandleData?.();
|
|
1123
1150
|
const { scroll: navScroll } = tx.commit(
|
|
1124
1151
|
heldIds,
|
|
@@ -1,13 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
useContext,
|
|
5
|
-
useState,
|
|
6
|
-
useEffect,
|
|
7
|
-
useRef,
|
|
8
|
-
useOptimistic,
|
|
9
|
-
startTransition,
|
|
10
|
-
} from "react";
|
|
3
|
+
import { useContext, useState, useEffect, useRef } from "react";
|
|
11
4
|
import { NavigationStoreContext } from "./context.js";
|
|
12
5
|
import { shallowEqual } from "./shallow-equal.js";
|
|
13
6
|
import type { TrackedActionState } from "../types.js";
|
|
@@ -78,19 +71,25 @@ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
|
|
|
78
71
|
* tracks the state of individual server action invocations.
|
|
79
72
|
*
|
|
80
73
|
* Uses the event controller for reactive state management.
|
|
81
|
-
* State is derived from the
|
|
74
|
+
* State is derived from the calls the controller tracks: those in flight,
|
|
75
|
+
* and each action's last settled call.
|
|
82
76
|
*
|
|
83
77
|
* Features:
|
|
84
78
|
* - Tracks action lifecycle: idle → loading → streaming → idle
|
|
85
|
-
* -
|
|
86
|
-
* - If
|
|
79
|
+
* - Keeps the settled call's state until the action is called again.
|
|
80
|
+
* - If the same action fires several times, reports the latest call to
|
|
81
|
+
* start. An earlier call that settles later changes nothing it reports;
|
|
82
|
+
* a latest call that was aborted reports idle with nothing. One call at a
|
|
83
|
+
* time, not one per argument: a reader filtering on `payload` cannot track
|
|
84
|
+
* an earlier call once a later one starts.
|
|
87
85
|
* - Supports selector pattern like useNavigation
|
|
88
86
|
*
|
|
89
87
|
* Matching behavior:
|
|
90
88
|
* - **Function reference**: Uses full $$id for exact matching. This is precise
|
|
91
89
|
* and distinguishes between actions with the same name in different files.
|
|
92
90
|
* - **String**: Matches by suffix (action name after #). This is convenient
|
|
93
|
-
* but may be ambiguous if multiple files export the same action name
|
|
91
|
+
* but may be ambiguous if multiple files export the same action name: it
|
|
92
|
+
* follows the latest call of any action with that export name.
|
|
94
93
|
*
|
|
95
94
|
* @param action - Either a server action function or a string action name.
|
|
96
95
|
* - **Function**: Must be directly imported in the client component.
|
|
@@ -138,8 +137,10 @@ export function useAction<T>(
|
|
|
138
137
|
? getActionId(action)
|
|
139
138
|
: "";
|
|
140
139
|
|
|
141
|
-
//
|
|
142
|
-
|
|
140
|
+
// Set urgently from notifyAction's task, also while React holds a page: a
|
|
141
|
+
// transition of its own would take the held page's transition types (see
|
|
142
|
+
// useNavigation).
|
|
143
|
+
const [value, setValue] = useState<T | TrackedActionState>(() => {
|
|
143
144
|
if (!ctx) {
|
|
144
145
|
return selector ? selector(DEFAULT_ACTION_STATE) : DEFAULT_ACTION_STATE;
|
|
145
146
|
}
|
|
@@ -149,11 +150,8 @@ export function useAction<T>(
|
|
|
149
150
|
: ctx.eventController.getActionState(actionId);
|
|
150
151
|
return selector ? selector(state) : state;
|
|
151
152
|
});
|
|
152
|
-
const prevSelected = useRef(
|
|
153
|
-
prevSelected.current =
|
|
154
|
-
const [optimisticState, setOptimisticState] = useOptimistic<
|
|
155
|
-
T | TrackedActionState
|
|
156
|
-
>(null!);
|
|
153
|
+
const prevSelected = useRef(value);
|
|
154
|
+
prevSelected.current = value;
|
|
157
155
|
|
|
158
156
|
// Ref keeps the latest selector for subscription callbacks without
|
|
159
157
|
// re-subscribing on every render. Selector changes themselves are not
|
|
@@ -166,41 +164,20 @@ export function useAction<T>(
|
|
|
166
164
|
useEffect(() => {
|
|
167
165
|
if (!ctx) return;
|
|
168
166
|
|
|
167
|
+
const sync = (state: TrackedActionState) => {
|
|
168
|
+
const selected = selectorRef.current ? selectorRef.current(state) : state;
|
|
169
|
+
if (!shallowEqual(selected, prevSelected.current)) {
|
|
170
|
+
prevSelected.current = selected;
|
|
171
|
+
setValue(selected);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
169
174
|
// Sync current state for the (possibly new) actionId so that switching
|
|
170
175
|
// actions on an idle page doesn't leave stale data from the old action.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
? selectorRef.current(currentState)
|
|
174
|
-
: currentState;
|
|
175
|
-
if (!shallowEqual(currentSelected, prevSelected.current)) {
|
|
176
|
-
prevSelected.current = currentSelected;
|
|
177
|
-
setBaseState(currentSelected);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Subscribe to action-specific updates
|
|
181
|
-
const unsubscribe = ctx.eventController.subscribeToAction(
|
|
182
|
-
actionId,
|
|
183
|
-
(state) => {
|
|
184
|
-
const selectedState = selectorRef.current
|
|
185
|
-
? selectorRef.current(state)
|
|
186
|
-
: state;
|
|
187
|
-
|
|
188
|
-
if (!shallowEqual(selectedState, prevSelected.current)) {
|
|
189
|
-
prevSelected.current = selectedState;
|
|
190
|
-
setBaseState(selectedState);
|
|
191
|
-
startTransition(() => {
|
|
192
|
-
setOptimisticState(selectedState);
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
},
|
|
196
|
-
);
|
|
197
|
-
|
|
198
|
-
return () => {
|
|
199
|
-
unsubscribe();
|
|
200
|
-
};
|
|
176
|
+
sync(ctx.eventController.getActionState(actionId));
|
|
177
|
+
return ctx.eventController.subscribeToAction(actionId, sync);
|
|
201
178
|
}, [actionId]);
|
|
202
179
|
|
|
203
|
-
return
|
|
180
|
+
return value;
|
|
204
181
|
}
|
|
205
182
|
|
|
206
183
|
export type { TrackedActionState };
|
|
@@ -6,6 +6,7 @@ import { collectHandleData } from "../../handle.js";
|
|
|
6
6
|
import type { HandleData } from "../types.js";
|
|
7
7
|
import { NavigationStoreContext } from "./context.js";
|
|
8
8
|
import { shallowEqual } from "./shallow-equal.js";
|
|
9
|
+
import { useCommitted } from "./use-route-state.js";
|
|
9
10
|
import type { RouteStateSignal } from "../event-controller.js";
|
|
10
11
|
|
|
11
12
|
/**
|
|
@@ -53,7 +54,7 @@ export function useHandle<T, A, S>(
|
|
|
53
54
|
return selectorRef.current ? selectorRef.current(collected) : collected;
|
|
54
55
|
};
|
|
55
56
|
|
|
56
|
-
const [value, setValue] = useCommitAnchoredState(collect);
|
|
57
|
+
const [value, setValue] = useCommitAnchoredState(collect, true);
|
|
57
58
|
|
|
58
59
|
useEffect(() => {
|
|
59
60
|
if (!ctx) return;
|
|
@@ -68,25 +69,30 @@ export function useHandle<T, A, S>(
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
|
-
* Route state the controller's handle notifications drive
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* priority, it renders in the page's lane, and the page's effects read it.
|
|
77
|
-
* A page in a transition (signal "anchor") does not: until it commits
|
|
72
|
+
* Route state the controller's handle notifications drive commits with the
|
|
73
|
+
* page it belongs to. NavigationProvider flushes the controller inside every
|
|
74
|
+
* payload update, with signal "anchor" for a page in a transition and
|
|
75
|
+
* "release" for an urgent one, so the page's own change is set in the page's
|
|
76
|
+
* lane and the page's effects read it. From the anchor until the page commits
|
|
78
77
|
* (releaseRouteState() from the provider's layout effect, signal "release"),
|
|
79
|
-
* a
|
|
80
|
-
* page's own commit
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
78
|
+
* a later change waits and is applied in the commit's task, right after the
|
|
79
|
+
* page's own commit: set in a lane of its own, it would commit over the page
|
|
80
|
+
* being left (#1165).
|
|
81
|
+
*
|
|
82
|
+
* `waitForRelease`, set by useHandle only: the anchor's own change waits too,
|
|
83
|
+
* so a handle value lands right after its page's commit, never in its lane. A
|
|
84
|
+
* handle value can carry a promise that suspends, and in the page's lane one
|
|
85
|
+
* that suspends in a revealed <Suspense> would hold the page. Nothing
|
|
86
|
+
* useSegments reads can suspend, so it leaves it off and its path and
|
|
87
|
+
* location commit with their page.
|
|
88
|
+
*
|
|
89
|
+
* A value shallow-equal to the last one sets nothing, so a reader renders only
|
|
90
|
+
* when its value moves. A release is compared with what React committed, as in
|
|
91
|
+
* useRouteState.
|
|
87
92
|
*/
|
|
88
93
|
export function useCommitAnchoredState<T>(
|
|
89
94
|
init: () => T,
|
|
95
|
+
waitForRelease?: boolean,
|
|
90
96
|
): [T, (value: T, signal?: RouteStateSignal) => void] {
|
|
91
97
|
// An app that enables the React Compiler compiles the router too; its memo
|
|
92
98
|
// cache gains nothing here and cost 107 B gzip in the router chunk.
|
|
@@ -95,15 +101,18 @@ export function useCommitAnchoredState<T>(
|
|
|
95
101
|
const ref = useRef<{ last: T; held?: boolean; waiting?: boolean }>({
|
|
96
102
|
last: value,
|
|
97
103
|
});
|
|
104
|
+
const committed = useCommitted(value);
|
|
98
105
|
const [set] = useState(() => (next: T, signal?: RouteStateSignal): void => {
|
|
99
106
|
const r = ref.current;
|
|
100
107
|
if (signal) r.waiting = signal === "anchor";
|
|
101
108
|
if (!shallowEqual(next, r.last)) {
|
|
102
109
|
r.last = next;
|
|
103
110
|
r.held = true;
|
|
111
|
+
} else if (signal === "release" && r.last !== committed.current) {
|
|
112
|
+
r.held = true;
|
|
104
113
|
}
|
|
105
|
-
|
|
106
|
-
|
|
114
|
+
if (!r.held || (r.waiting && (waitForRelease || signal !== "anchor")))
|
|
115
|
+
return;
|
|
107
116
|
r.held = false;
|
|
108
117
|
setValue(() => r.last);
|
|
109
118
|
});
|