@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19
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/README.md +46 -8
- package/dist/bin/rango.js +105 -18
- package/dist/vite/index.js +227 -93
- package/package.json +15 -14
- package/skills/hooks/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +94 -1
- package/skills/middleware/SKILL.md +81 -0
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +187 -17
- package/skills/route/SKILL.md +42 -1
- package/skills/router-setup/SKILL.md +77 -0
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +38 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +25 -27
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +0 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +46 -13
- package/src/browser/navigation-client.ts +32 -61
- package/src/browser/navigation-store.ts +1 -31
- package/src/browser/navigation-transaction.ts +46 -207
- package/src/browser/partial-update.ts +102 -150
- package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
- package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
- package/src/browser/react/Link.tsx +28 -23
- package/src/browser/react/NavigationProvider.tsx +9 -1
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +1 -1
- package/src/browser/react/location-state.ts +2 -0
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/use-action.ts +9 -1
- package/src/browser/react/use-handle.ts +3 -25
- package/src/browser/react/use-params.ts +2 -4
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +7 -60
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +29 -23
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +115 -96
- package/src/browser/types.ts +1 -31
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +5 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +45 -3
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +132 -96
- package/src/cache/cache-scope.ts +71 -73
- package/src/cache/cf/cf-cache-store.ts +9 -4
- package/src/cache/document-cache.ts +72 -47
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/memory-segment-store.ts +18 -7
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +101 -112
- package/src/cache/taint.ts +26 -0
- package/src/client.tsx +53 -30
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +33 -1
- package/src/index.ts +27 -0
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +4 -3
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +94 -15
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +1 -0
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +61 -7
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +69 -4
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/middleware-types.ts +7 -0
- package/src/router/middleware.ts +93 -8
- package/src/router/pattern-matching.ts +41 -5
- package/src/router/prerender-match.ts +34 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +34 -0
- package/src/router/router-options.ts +200 -0
- package/src/router/segment-resolution/fresh.ts +123 -30
- package/src/router/segment-resolution/helpers.ts +19 -0
- package/src/router/segment-resolution/loader-cache.ts +37 -146
- package/src/router/segment-resolution/revalidation.ts +358 -94
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/types.ts +7 -1
- package/src/router.ts +155 -11
- package/src/rsc/handler-context.ts +11 -0
- package/src/rsc/handler.ts +380 -88
- package/src/rsc/helpers.ts +25 -16
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +232 -19
- package/src/rsc/response-route-handler.ts +37 -26
- package/src/rsc/rsc-rendering.ts +12 -5
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +134 -58
- package/src/rsc/types.ts +8 -0
- package/src/search-params.ts +22 -10
- package/src/server/context.ts +53 -5
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +66 -9
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +90 -9
- package/src/ssr/index.tsx +63 -27
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +1 -6
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +5 -0
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +9 -0
- package/src/types/handler-context.ts +35 -13
- package/src/types/loader-types.ts +7 -0
- package/src/types/route-entry.ts +28 -0
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +27 -2
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +12 -4
- package/src/vite/discovery/bundle-postprocess.ts +12 -7
- package/src/vite/discovery/discover-routers.ts +30 -18
- package/src/vite/discovery/prerender-collection.ts +24 -27
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/rango.ts +3 -3
- package/src/vite/router-discovery.ts +99 -36
- package/src/vite/utils/prerender-utils.ts +21 -0
- package/src/vite/utils/shared-utils.ts +3 -1
- package/src/browser/request-controller.ts +0 -164
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
|
@@ -158,8 +158,8 @@ export interface ActionHandle extends Disposable {
|
|
|
158
158
|
readonly settled: boolean;
|
|
159
159
|
/** Check if any concurrent actions were started */
|
|
160
160
|
hadConcurrentActions: boolean;
|
|
161
|
-
/** Get
|
|
162
|
-
|
|
161
|
+
/** Get raw set of segments revalidated by concurrent actions */
|
|
162
|
+
getRevalidatedSegments(): Set<string>;
|
|
163
163
|
/** Clear consolidation tracking */
|
|
164
164
|
clearConsolidation(): void;
|
|
165
165
|
}
|
|
@@ -210,6 +210,8 @@ export interface EventController {
|
|
|
210
210
|
// Direct state access for advanced use
|
|
211
211
|
getCurrentNavigation(): NavigationEntry | null;
|
|
212
212
|
getInflightActions(): Map<string, ActionEntry>;
|
|
213
|
+
/** Whether any concurrent actions have occurred (shared across all handles) */
|
|
214
|
+
hadAnyConcurrentActions(): boolean;
|
|
213
215
|
}
|
|
214
216
|
|
|
215
217
|
// ============================================================================
|
|
@@ -388,8 +390,8 @@ export function createEventController(
|
|
|
388
390
|
state,
|
|
389
391
|
isStreaming,
|
|
390
392
|
location,
|
|
391
|
-
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
|
|
392
|
-
// Background revalidations don't expose a pending URL
|
|
393
|
+
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
|
|
394
|
+
// Background revalidations (skipLoadingState) don't expose a pending URL.
|
|
393
395
|
pendingUrl:
|
|
394
396
|
currentNavigation?.phase === "fetching" &&
|
|
395
397
|
!currentNavigation.options?.skipLoadingState
|
|
@@ -482,6 +484,7 @@ export function createEventController(
|
|
|
482
484
|
|
|
483
485
|
startStreaming(): StreamingToken {
|
|
484
486
|
let ended = false;
|
|
487
|
+
entry.phase = "streaming";
|
|
485
488
|
activeStreamCount++;
|
|
486
489
|
notify();
|
|
487
490
|
|
|
@@ -669,24 +672,8 @@ export function createEventController(
|
|
|
669
672
|
// If streaming is in progress, tryFinalize() will be called when streaming ends
|
|
670
673
|
},
|
|
671
674
|
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
// We don't need to wait for streaming to complete since we're refetching anyway
|
|
675
|
-
// Count actions that are still fetching (waiting for server response)
|
|
676
|
-
const stillFetchingCount = [...inflightActions.values()].filter(
|
|
677
|
-
(a) => a.phase === "fetching",
|
|
678
|
-
).length;
|
|
679
|
-
|
|
680
|
-
if (stillFetchingCount > 0) {
|
|
681
|
-
return null; // Some actions still waiting for server response
|
|
682
|
-
}
|
|
683
|
-
if (!hadAnyConcurrentActions) {
|
|
684
|
-
return null; // No concurrent actions occurred
|
|
685
|
-
}
|
|
686
|
-
if (concurrentRevalidatedSegments.size === 0) {
|
|
687
|
-
return null; // No segments to consolidate
|
|
688
|
-
}
|
|
689
|
-
return Array.from(concurrentRevalidatedSegments);
|
|
675
|
+
getRevalidatedSegments(): Set<string> {
|
|
676
|
+
return concurrentRevalidatedSegments;
|
|
690
677
|
},
|
|
691
678
|
|
|
692
679
|
clearConsolidation() {
|
|
@@ -721,16 +708,26 @@ export function createEventController(
|
|
|
721
708
|
}
|
|
722
709
|
|
|
723
710
|
function abortAllActions() {
|
|
724
|
-
for (const entry of inflightActions
|
|
711
|
+
for (const [id, entry] of inflightActions) {
|
|
712
|
+
// Preserve settling entries — they have already been handled by
|
|
713
|
+
// fail()/complete() and will self-cleanup via the settlement timeout.
|
|
714
|
+
// Clearing them here would prevent debounced notifications from
|
|
715
|
+
// delivering the error/result state to subscribers.
|
|
716
|
+
if (entry.phase === "settling") continue;
|
|
725
717
|
entry.abort.abort();
|
|
718
|
+
inflightActions.delete(id);
|
|
726
719
|
}
|
|
727
|
-
inflightActions.clear();
|
|
728
720
|
hadAnyConcurrentActions = false;
|
|
729
721
|
concurrentRevalidatedSegments.clear();
|
|
730
722
|
notify();
|
|
731
|
-
// Notify all action listeners
|
|
732
|
-
|
|
733
|
-
|
|
723
|
+
// Notify all action listeners directly by subscription ID.
|
|
724
|
+
// actionListeners keys are subscription IDs (possibly short names like
|
|
725
|
+
// "addToCart"), not full entry actionIds. Passing them to notifyAction
|
|
726
|
+
// would fail the suffix matcher — instead, notify each subscriber with
|
|
727
|
+
// its own state.
|
|
728
|
+
for (const [subscriptionId, listeners] of actionListeners) {
|
|
729
|
+
const state = getActionState(subscriptionId);
|
|
730
|
+
listeners.forEach((listener) => listener(state));
|
|
734
731
|
}
|
|
735
732
|
}
|
|
736
733
|
|
|
@@ -860,6 +857,7 @@ export function createEventController(
|
|
|
860
857
|
// Direct access
|
|
861
858
|
getCurrentNavigation: () => currentNavigation,
|
|
862
859
|
getInflightActions: () => inflightActions,
|
|
860
|
+
hadAnyConcurrentActions: () => hadAnyConcurrentActions,
|
|
863
861
|
};
|
|
864
862
|
}
|
|
865
863
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isLocationStateEntry,
|
|
3
|
+
resolveLocationStateEntries,
|
|
4
|
+
} from "./react/location-state-shared.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Check if state is from typed LocationStateEntry[] (has __rsc_ls_ keys)
|
|
8
|
+
*/
|
|
9
|
+
function isTypedLocationState(
|
|
10
|
+
state: unknown,
|
|
11
|
+
): state is Record<string, unknown> {
|
|
12
|
+
if (state === null || typeof state !== "object") return false;
|
|
13
|
+
return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve navigation state - handles both LocationStateEntry[] and plain formats
|
|
18
|
+
*/
|
|
19
|
+
export function resolveNavigationState(state: unknown): unknown {
|
|
20
|
+
if (
|
|
21
|
+
Array.isArray(state) &&
|
|
22
|
+
state.length > 0 &&
|
|
23
|
+
isLocationStateEntry(state[0])
|
|
24
|
+
) {
|
|
25
|
+
return resolveLocationStateEntries(state);
|
|
26
|
+
}
|
|
27
|
+
return state;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build history state object from user state
|
|
32
|
+
* - Typed state: spread directly into history.state
|
|
33
|
+
* - Plain state: store in history.state.state
|
|
34
|
+
*/
|
|
35
|
+
export function buildHistoryState(
|
|
36
|
+
userState: unknown,
|
|
37
|
+
routerState?: { intercept?: boolean; sourceUrl?: string },
|
|
38
|
+
serverState?: Record<string, unknown>,
|
|
39
|
+
): Record<string, unknown> | null {
|
|
40
|
+
const result: Record<string, unknown> = {};
|
|
41
|
+
|
|
42
|
+
if (routerState?.intercept) {
|
|
43
|
+
result.intercept = true;
|
|
44
|
+
if (routerState.sourceUrl) {
|
|
45
|
+
result.sourceUrl = routerState.sourceUrl;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (userState !== undefined) {
|
|
50
|
+
if (isTypedLocationState(userState)) {
|
|
51
|
+
Object.assign(result, userState);
|
|
52
|
+
} else {
|
|
53
|
+
result.state = userState;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (serverState) {
|
|
58
|
+
Object.assign(result, serverState);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Merge server-set location state into the current history entry.
|
|
66
|
+
* Replaces the current history state and dispatches notification event
|
|
67
|
+
* so useLocationState hooks re-read from history.state.
|
|
68
|
+
*/
|
|
69
|
+
export function mergeLocationState(
|
|
70
|
+
locationState: Record<string, unknown>,
|
|
71
|
+
): void {
|
|
72
|
+
const merged = {
|
|
73
|
+
...window.history.state,
|
|
74
|
+
...locationState,
|
|
75
|
+
};
|
|
76
|
+
window.history.replaceState(merged, "", window.location.href);
|
|
77
|
+
if (Object.keys(locationState).some((k) => k.startsWith("__rsc_ls_"))) {
|
|
78
|
+
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -44,7 +44,7 @@ export function hasActiveIntercept(slots?: Record<string, SlotState>): boolean {
|
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
46
|
* Check if cached segments contain any intercept segments.
|
|
47
|
-
* Intercept caches shouldn't be used for
|
|
47
|
+
* Intercept caches shouldn't be used for cached SWR rendering since
|
|
48
48
|
* whether interception happens depends on the current page context.
|
|
49
49
|
*/
|
|
50
50
|
export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
|
|
@@ -131,10 +131,7 @@ export function setupLinkInterception(
|
|
|
131
131
|
|
|
132
132
|
document.addEventListener("click", handleClick);
|
|
133
133
|
|
|
134
|
-
console.log("[Browser] Link interception enabled");
|
|
135
|
-
|
|
136
134
|
return () => {
|
|
137
135
|
document.removeEventListener("click", handleClick);
|
|
138
|
-
console.log("[Browser] Link interception disabled");
|
|
139
136
|
};
|
|
140
137
|
}
|
|
@@ -91,6 +91,11 @@ export function insertMissingDiffSegments(
|
|
|
91
91
|
): void {
|
|
92
92
|
if (!diff || diff.length === 0) return;
|
|
93
93
|
|
|
94
|
+
// Track how many siblings have been inserted per parent so each new
|
|
95
|
+
// sibling goes after the last one rather than always at parentIndex + 1
|
|
96
|
+
// (which would reverse the server order).
|
|
97
|
+
const insertedPerParent = new Map<string, number>();
|
|
98
|
+
|
|
94
99
|
diff.forEach((diffId: string) => {
|
|
95
100
|
if (!matchedIdSet.has(diffId)) {
|
|
96
101
|
const fromServer = newSegmentMap.get(diffId);
|
|
@@ -104,8 +109,10 @@ export function insertMissingDiffSegments(
|
|
|
104
109
|
(s) => s.id === parentLayoutId,
|
|
105
110
|
);
|
|
106
111
|
if (parentIndex !== -1) {
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
const alreadyInserted = insertedPerParent.get(parentLayoutId) ?? 0;
|
|
113
|
+
const insertAt = parentIndex + 1 + alreadyInserted;
|
|
114
|
+
allSegments.splice(insertAt, 0, fromServer);
|
|
115
|
+
insertedPerParent.set(parentLayoutId, alreadyInserted + 1);
|
|
109
116
|
debugLog(
|
|
110
117
|
`[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`,
|
|
111
118
|
);
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "./network-error-handler.js";
|
|
29
29
|
import { debugLog } from "./logging.js";
|
|
30
30
|
import { ServerRedirect } from "../errors.js";
|
|
31
|
+
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
31
32
|
|
|
32
33
|
// Polyfill Symbol.dispose for Safari and older browsers
|
|
33
34
|
if (typeof Symbol.dispose === "undefined") {
|
|
@@ -81,7 +82,7 @@ export function createNavigationBridge(
|
|
|
81
82
|
return {
|
|
82
83
|
/**
|
|
83
84
|
* Navigate to a URL
|
|
84
|
-
* Uses
|
|
85
|
+
* Uses cached segments for SWR revalidation when available
|
|
85
86
|
*/
|
|
86
87
|
async navigate(
|
|
87
88
|
url: string,
|
|
@@ -93,10 +94,30 @@ export function createNavigationBridge(
|
|
|
93
94
|
? resolveNavigationState(options.state)
|
|
94
95
|
: undefined;
|
|
95
96
|
|
|
97
|
+
// Cross-origin URLs are not handled by SPA navigation.
|
|
98
|
+
// Fall back to a full browser navigation for http/https only.
|
|
99
|
+
let targetUrl: URL;
|
|
100
|
+
try {
|
|
101
|
+
targetUrl = new URL(url, window.location.origin);
|
|
102
|
+
} catch {
|
|
103
|
+
console.warn(`[rango] navigate() ignored: malformed URL "${url}"`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (targetUrl.origin !== window.location.origin) {
|
|
107
|
+
if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") {
|
|
108
|
+
console.error(
|
|
109
|
+
`[rango] navigate() blocked: unsupported scheme "${targetUrl.protocol}"`,
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
window.location.href = targetUrl.href;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
// Only abort pending requests when navigating to a different route
|
|
97
118
|
// Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
|
|
98
119
|
const currentPath = new URL(window.location.href).pathname;
|
|
99
|
-
const targetPath =
|
|
120
|
+
const targetPath = targetUrl.pathname;
|
|
100
121
|
if (currentPath !== targetPath) {
|
|
101
122
|
eventController.abortNavigation();
|
|
102
123
|
}
|
|
@@ -155,7 +176,7 @@ export function createNavigationBridge(
|
|
|
155
176
|
const interceptHistoryKey = generateHistoryKey(url, { intercept: true });
|
|
156
177
|
const hasInterceptCache = store.hasHistoryCache(interceptHistoryKey);
|
|
157
178
|
|
|
158
|
-
// Skip
|
|
179
|
+
// Skip cached SWR for:
|
|
159
180
|
// 1. intercept caches - interception depends on source page context
|
|
160
181
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
161
182
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
@@ -191,19 +212,14 @@ export function createNavigationBridge(
|
|
|
191
212
|
scroll: options?.scroll,
|
|
192
213
|
state: resolvedState,
|
|
193
214
|
}),
|
|
194
|
-
// Pass cached segments (merged with current page's fresh segments for shared IDs)
|
|
195
|
-
// so the segment map is consistent with what we tell the server we have.
|
|
196
|
-
// Server decides what needs revalidation based on route matching and custom functions.
|
|
197
|
-
// No need for staleRevalidation flag - we're sending the freshest segments we have.
|
|
198
|
-
// Also pass cached handle data for restoring breadcrumbs when server returns empty diff.
|
|
199
|
-
// When leaving intercept, pass the flag so fetchPartialUpdate knows to filter segments.
|
|
200
215
|
hasUsableCache
|
|
201
216
|
? {
|
|
217
|
+
type: "navigate" as const,
|
|
202
218
|
targetCacheSegments: cachedSegments,
|
|
203
219
|
targetCacheHandleData: cachedHandleData,
|
|
204
220
|
}
|
|
205
221
|
: isLeavingIntercept
|
|
206
|
-
? {
|
|
222
|
+
? { type: "leave-intercept" as const }
|
|
207
223
|
: undefined,
|
|
208
224
|
);
|
|
209
225
|
} catch (error) {
|
|
@@ -211,7 +227,14 @@ export function createNavigationBridge(
|
|
|
211
227
|
// `using` cleanup resets loading state. Re-navigate to the redirect
|
|
212
228
|
// target carrying the server-set state into history.pushState.
|
|
213
229
|
if (error instanceof ServerRedirect) {
|
|
214
|
-
|
|
230
|
+
const redirectUrl = validateRedirectOrigin(
|
|
231
|
+
error.url,
|
|
232
|
+
window.location.origin,
|
|
233
|
+
);
|
|
234
|
+
if (!redirectUrl) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
return this.navigate(redirectUrl, {
|
|
215
238
|
state: error.state,
|
|
216
239
|
replace: options?.replace,
|
|
217
240
|
_skipCache: true,
|
|
@@ -408,7 +431,7 @@ export function createNavigationBridge(
|
|
|
408
431
|
interceptSourceUrl,
|
|
409
432
|
cacheOnly: true,
|
|
410
433
|
}),
|
|
411
|
-
{
|
|
434
|
+
{ type: "stale-revalidation", interceptSourceUrl },
|
|
412
435
|
)
|
|
413
436
|
.catch((error) => {
|
|
414
437
|
if (isBackgroundSuppressible(error)) return;
|
|
@@ -444,7 +467,14 @@ export function createNavigationBridge(
|
|
|
444
467
|
undefined,
|
|
445
468
|
false,
|
|
446
469
|
tx.handle.signal,
|
|
447
|
-
tx.with({
|
|
470
|
+
tx.with({
|
|
471
|
+
url,
|
|
472
|
+
replace: true,
|
|
473
|
+
scroll: false,
|
|
474
|
+
intercept: isIntercept,
|
|
475
|
+
interceptSourceUrl,
|
|
476
|
+
}),
|
|
477
|
+
isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
|
|
448
478
|
);
|
|
449
479
|
// Restore scroll position after fetch completes
|
|
450
480
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -498,6 +528,9 @@ export function createNavigationBridge(
|
|
|
498
528
|
"[Browser] Page restored from bfcache, resetting navigation state",
|
|
499
529
|
);
|
|
500
530
|
eventController.abortNavigation();
|
|
531
|
+
// pagehide flips scrollRestoration to "auto" for bfcache compat;
|
|
532
|
+
// restore "manual" so the router controls scroll on SPA navigations.
|
|
533
|
+
window.history.scrollRestoration = "manual";
|
|
501
534
|
}
|
|
502
535
|
};
|
|
503
536
|
|
|
@@ -12,6 +12,11 @@ import {
|
|
|
12
12
|
startBrowserTransaction,
|
|
13
13
|
} from "./logging.js";
|
|
14
14
|
import { getRangoState } from "./rango-state.js";
|
|
15
|
+
import {
|
|
16
|
+
extractRscHeaderUrl,
|
|
17
|
+
emptyResponse,
|
|
18
|
+
teeWithCompletion,
|
|
19
|
+
} from "./response-adapter.js";
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -111,25 +116,18 @@ export function createNavigationClient(
|
|
|
111
116
|
signal,
|
|
112
117
|
}).then((response) => {
|
|
113
118
|
// Check for version mismatch - server wants us to reload
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
throw new Error(
|
|
121
|
-
`X-RSC-Reload blocked: origin mismatch (${target.origin})`,
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
} catch (e) {
|
|
125
|
-
console.error("[rango]", e);
|
|
126
|
-
return response;
|
|
127
|
-
}
|
|
119
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
120
|
+
if (reload === "blocked") {
|
|
121
|
+
resolveStreamComplete();
|
|
122
|
+
return emptyResponse();
|
|
123
|
+
}
|
|
124
|
+
if (reload) {
|
|
128
125
|
if (tx) {
|
|
129
|
-
browserDebugLog(tx, "version mismatch, reloading", {
|
|
126
|
+
browserDebugLog(tx, "version mismatch, reloading", {
|
|
127
|
+
reloadUrl: reload.url,
|
|
128
|
+
});
|
|
130
129
|
}
|
|
131
|
-
window.location.href =
|
|
132
|
-
// Return a never-resolving promise to prevent further processing
|
|
130
|
+
window.location.href = reload.url;
|
|
133
131
|
return new Promise<Response>(() => {});
|
|
134
132
|
}
|
|
135
133
|
|
|
@@ -137,56 +135,29 @@ export function createNavigationClient(
|
|
|
137
135
|
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
138
136
|
// to a URL rendering full HTML). Throw ServerRedirect so the
|
|
139
137
|
// navigation bridge catches it and re-navigates with _skipCache.
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
if (tx) {
|
|
143
|
-
browserDebugLog(tx, "server redirect", { redirectUrl });
|
|
144
|
-
}
|
|
138
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
139
|
+
if (redirect === "blocked") {
|
|
145
140
|
resolveStreamComplete();
|
|
146
|
-
|
|
141
|
+
return emptyResponse();
|
|
147
142
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
143
|
+
if (redirect) {
|
|
144
|
+
if (tx) {
|
|
145
|
+
browserDebugLog(tx, "server redirect", {
|
|
146
|
+
redirectUrl: redirect.url,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
151
149
|
resolveStreamComplete();
|
|
152
|
-
|
|
150
|
+
throw new ServerRedirect(redirect.url, undefined);
|
|
153
151
|
}
|
|
154
152
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
(async () => {
|
|
160
|
-
const reader = trackingStream.getReader();
|
|
161
|
-
|
|
162
|
-
// Cancel tracking if navigation is aborted
|
|
163
|
-
const onAbort = reader.cancel.bind(reader);
|
|
164
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
while (true) {
|
|
168
|
-
const { done } = await reader.read();
|
|
169
|
-
if (done) break;
|
|
170
|
-
}
|
|
171
|
-
} finally {
|
|
172
|
-
signal?.removeEventListener("abort", onAbort);
|
|
173
|
-
reader.releaseLock();
|
|
174
|
-
if (tx) {
|
|
175
|
-
browserDebugLog(tx, "stream complete");
|
|
176
|
-
}
|
|
153
|
+
return teeWithCompletion(
|
|
154
|
+
response,
|
|
155
|
+
() => {
|
|
156
|
+
if (tx) browserDebugLog(tx, "stream complete");
|
|
177
157
|
resolveStreamComplete();
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
resolveStreamComplete();
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
// Return response with the RSC stream
|
|
185
|
-
return new Response(rscStream, {
|
|
186
|
-
headers: response.headers,
|
|
187
|
-
status: response.status,
|
|
188
|
-
statusText: response.statusText,
|
|
189
|
-
});
|
|
158
|
+
},
|
|
159
|
+
signal,
|
|
160
|
+
);
|
|
190
161
|
});
|
|
191
162
|
|
|
192
163
|
try {
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
ActionStateListener,
|
|
13
13
|
HandleData,
|
|
14
14
|
} from "./types.js";
|
|
15
|
-
import { clearPrefetchCache } from "./prefetch
|
|
15
|
+
import { clearPrefetchCache } from "./prefetch/cache.js";
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Default action state (idle with no payload)
|
|
@@ -338,7 +338,6 @@ export function createNavigationStore(
|
|
|
338
338
|
* Clear the history cache and broadcast to other tabs
|
|
339
339
|
*/
|
|
340
340
|
function clearCacheAndBroadcast(): void {
|
|
341
|
-
console.log("[Browser] Clearing cache and broadcasting to other tabs");
|
|
342
341
|
clearCacheInternal();
|
|
343
342
|
broadcastInvalidation();
|
|
344
343
|
}
|
|
@@ -347,9 +346,6 @@ export function createNavigationStore(
|
|
|
347
346
|
* Mark cache as stale and broadcast to other tabs
|
|
348
347
|
*/
|
|
349
348
|
function markStaleAndBroadcast(): void {
|
|
350
|
-
console.log(
|
|
351
|
-
"[Browser] Marking cache as stale and broadcasting to other tabs",
|
|
352
|
-
);
|
|
353
349
|
markCacheAsStaleInternal();
|
|
354
350
|
broadcastInvalidation();
|
|
355
351
|
}
|
|
@@ -372,14 +368,6 @@ export function createNavigationStore(
|
|
|
372
368
|
path: currentPath,
|
|
373
369
|
segmentIds: currentSegmentIds,
|
|
374
370
|
});
|
|
375
|
-
console.log(
|
|
376
|
-
"[Browser] Broadcast sent for path:",
|
|
377
|
-
currentPath,
|
|
378
|
-
"segments:",
|
|
379
|
-
currentSegmentIds.join(", "),
|
|
380
|
-
);
|
|
381
|
-
} else {
|
|
382
|
-
console.warn("[Browser] No BroadcastChannel available");
|
|
383
371
|
}
|
|
384
372
|
}
|
|
385
373
|
|
|
@@ -404,34 +392,21 @@ export function createNavigationStore(
|
|
|
404
392
|
return;
|
|
405
393
|
}
|
|
406
394
|
|
|
407
|
-
console.log(
|
|
408
|
-
"[Browser] Cache marked stale by another tab, shared segments:",
|
|
409
|
-
mutatedSegmentIds
|
|
410
|
-
.filter((id) => currentSegmentIds.includes(id))
|
|
411
|
-
.join(", "),
|
|
412
|
-
);
|
|
413
395
|
markCacheAsStaleInternal();
|
|
414
396
|
|
|
415
397
|
// Auto-refresh if enabled and callback is registered
|
|
416
398
|
if (crossTabAutoRefresh && crossTabRefreshCallback) {
|
|
417
399
|
// If idle, refresh immediately. If loading, wait for idle then refresh.
|
|
418
400
|
if (navState.state === "idle") {
|
|
419
|
-
console.log("[Browser] Cross-tab refresh triggered (idle)");
|
|
420
401
|
crossTabRefreshCallback();
|
|
421
402
|
} else if (!pendingCrossTabRefresh) {
|
|
422
403
|
// Only queue one refresh, ignore subsequent events while loading
|
|
423
404
|
pendingCrossTabRefresh = true;
|
|
424
|
-
console.log(
|
|
425
|
-
"[Browser] Navigation in progress, deferring cross-tab refresh",
|
|
426
|
-
);
|
|
427
405
|
// Subscribe to state changes, refresh when idle
|
|
428
406
|
const listener: StateListener = () => {
|
|
429
407
|
if (navState.state === "idle") {
|
|
430
408
|
stateListeners.delete(listener);
|
|
431
409
|
pendingCrossTabRefresh = false;
|
|
432
|
-
console.log(
|
|
433
|
-
"[Browser] Cross-tab refresh triggered (deferred)",
|
|
434
|
-
);
|
|
435
410
|
crossTabRefreshCallback?.();
|
|
436
411
|
}
|
|
437
412
|
};
|
|
@@ -656,11 +631,6 @@ export function createNavigationStore(
|
|
|
656
631
|
*/
|
|
657
632
|
markCacheAsStale(): void {
|
|
658
633
|
markCacheAsStaleInternal();
|
|
659
|
-
console.log(
|
|
660
|
-
"[Browser] Marked",
|
|
661
|
-
historyCache.length,
|
|
662
|
-
"cache entries as stale",
|
|
663
|
-
);
|
|
664
634
|
},
|
|
665
635
|
|
|
666
636
|
/**
|