@rangojs/router 0.0.0-experimental.259 → 0.0.0-experimental.26
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 +294 -28
- package/dist/bin/rango.js +355 -47
- package/dist/vite/index.js +1658 -1239
- package/package.json +3 -3
- package/skills/cache-guide/SKILL.md +9 -5
- package/skills/caching/SKILL.md +4 -4
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +40 -29
- package/skills/host-router/SKILL.md +218 -0
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +229 -15
- package/skills/middleware/SKILL.md +109 -30
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +189 -19
- package/skills/rango/SKILL.md +1 -2
- package/skills/response-routes/SKILL.md +3 -3
- package/skills/route/SKILL.md +44 -3
- package/skills/router-setup/SKILL.md +80 -3
- package/skills/theme/SKILL.md +5 -4
- package/skills/typesafety/SKILL.md +59 -16
- package/skills/use-cache/SKILL.md +16 -2
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +56 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +29 -48
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +19 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +66 -443
- package/src/browser/navigation-client.ts +34 -62
- package/src/browser/navigation-store.ts +4 -33
- package/src/browser/navigation-transaction.ts +295 -0
- package/src/browser/partial-update.ts +103 -151
- package/src/browser/prefetch/cache.ts +67 -0
- package/src/browser/prefetch/fetch.ts +137 -0
- package/src/browser/prefetch/observer.ts +65 -0
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/prefetch/queue.ts +88 -0
- package/src/browser/rango-state.ts +112 -0
- package/src/browser/react/Link.tsx +154 -44
- package/src/browser/react/NavigationProvider.tsx +32 -0
- package/src/browser/react/context.ts +6 -0
- package/src/browser/react/filter-segment-order.ts +11 -0
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +29 -11
- package/src/browser/react/location-state.ts +6 -4
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/shallow-equal.ts +27 -0
- package/src/browser/react/use-action.ts +23 -45
- package/src/browser/react/use-client-cache.ts +5 -3
- package/src/browser/react/use-handle.ts +21 -64
- package/src/browser/react/use-navigation.ts +7 -32
- package/src/browser/react/use-params.ts +5 -34
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +3 -6
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +75 -114
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +46 -22
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +458 -405
- package/src/browser/types.ts +21 -35
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +38 -13
- package/src/build/generate-route-types.ts +4 -0
- package/src/build/index.ts +1 -0
- package/src/build/route-trie.ts +19 -3
- 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 +170 -18
- 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 +136 -123
- package/src/cache/cache-scope.ts +76 -83
- package/src/cache/cf/cf-cache-store.ts +12 -7
- package/src/cache/document-cache.ts +93 -69
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/index.ts +0 -15
- package/src/cache/memory-segment-store.ts +43 -69
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +140 -117
- package/src/cache/taint.ts +30 -3
- package/src/cache/types.ts +1 -115
- package/src/client.rsc.tsx +0 -1
- package/src/client.tsx +53 -76
- 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/index.ts +0 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +53 -10
- package/src/index.ts +73 -43
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +60 -18
- 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/index.ts +0 -3
- 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 +96 -17
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +6 -11
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +62 -54
- 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 +78 -10
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/match-result.ts +0 -9
- package/src/router/metrics.ts +233 -13
- package/src/router/middleware-types.ts +34 -39
- package/src/router/middleware.ts +290 -130
- package/src/router/pattern-matching.ts +61 -10
- package/src/router/prerender-match.ts +36 -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 +158 -40
- package/src/router/router-options.ts +223 -1
- package/src/router/router-registry.ts +5 -2
- package/src/router/segment-resolution/fresh.ts +165 -242
- package/src/router/segment-resolution/helpers.ts +263 -0
- package/src/router/segment-resolution/loader-cache.ts +102 -98
- package/src/router/segment-resolution/revalidation.ts +394 -272
- package/src/router/segment-resolution/static-store.ts +2 -2
- package/src/router/segment-resolution.ts +1 -3
- 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/trie-matching.ts +20 -2
- package/src/router/types.ts +7 -1
- package/src/router.ts +203 -18
- package/src/rsc/handler-context.ts +13 -2
- package/src/rsc/handler.ts +489 -438
- package/src/rsc/helpers.ts +125 -5
- package/src/rsc/index.ts +0 -20
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/manifest-init.ts +3 -2
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +245 -19
- package/src/rsc/response-route-handler.ts +347 -0
- package/src/rsc/rsc-rendering.ts +47 -43
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +166 -66
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +20 -2
- package/src/search-params.ts +38 -23
- package/src/server/context.ts +61 -7
- package/src/server/cookie-store.ts +190 -0
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +84 -12
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +275 -49
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +67 -28
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +4 -18
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +6 -1
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +22 -0
- package/src/types/handler-context.ts +103 -16
- package/src/types/index.ts +1 -1
- package/src/types/loader-types.ts +9 -6
- package/src/types/route-config.ts +17 -26
- package/src/types/route-entry.ts +28 -0
- package/src/types/segments.ts +0 -5
- 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 +29 -7
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +27 -9
- package/src/vite/discovery/bundle-postprocess.ts +32 -52
- package/src/vite/discovery/discover-routers.ts +52 -26
- package/src/vite/discovery/prerender-collection.ts +58 -41
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/state.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/index.ts +10 -51
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/expose-internal-ids.ts +4 -3
- package/src/vite/plugins/refresh-cmd.ts +65 -0
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/plugins/version-plugin.ts +188 -18
- package/src/vite/rango.ts +61 -36
- package/src/vite/router-discovery.ts +173 -100
- package/src/vite/utils/prerender-utils.ts +81 -0
- package/src/vite/utils/shared-utils.ts +19 -9
- package/skills/testing/SKILL.md +0 -226
- package/src/browser/lru-cache.ts +0 -61
- package/src/browser/react/prefetch.ts +0 -27
- package/src/browser/request-controller.ts +0 -164
- package/src/cache/memory-store.ts +0 -253
- package/src/href-context.ts +0 -33
- package/src/route-definition/route-function.ts +0 -119
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/{CLAUDE.md → AGENTS.md} +0 -0
|
@@ -1,95 +1,25 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
NavigationBridge,
|
|
3
3
|
NavigationBridgeConfig,
|
|
4
|
-
NavigateOptions,
|
|
5
4
|
NavigateOptionsInternal,
|
|
6
|
-
NavigationStore,
|
|
7
5
|
ResolvedSegment,
|
|
8
6
|
} from "./types.js";
|
|
9
7
|
import * as React from "react";
|
|
10
8
|
import { startTransition } from "react";
|
|
11
9
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "./
|
|
10
|
+
createNavigationTransaction,
|
|
11
|
+
resolveNavigationState,
|
|
12
|
+
} from "./navigation-transaction.js";
|
|
15
13
|
|
|
16
14
|
// addTransitionType is only available in React experimental
|
|
17
15
|
const addTransitionType: ((type: string) => void) | undefined =
|
|
18
16
|
"addTransitionType" in React ? (React as any).addTransitionType : undefined;
|
|
19
17
|
|
|
20
|
-
/**
|
|
21
|
-
* Check if state is from typed LocationStateEntry[] (has __rsc_ls_ keys)
|
|
22
|
-
*/
|
|
23
|
-
function isTypedLocationState(
|
|
24
|
-
state: unknown,
|
|
25
|
-
): state is Record<string, unknown> {
|
|
26
|
-
if (state === null || typeof state !== "object") return false;
|
|
27
|
-
return Object.keys(state).some((key) => key.startsWith("__rsc_ls_"));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Resolve navigation state - handles both LocationStateEntry[] and legacy formats
|
|
32
|
-
*/
|
|
33
|
-
function resolveNavigationState(state: unknown): unknown {
|
|
34
|
-
// Check if it's an array of LocationStateEntry
|
|
35
|
-
if (
|
|
36
|
-
Array.isArray(state) &&
|
|
37
|
-
state.length > 0 &&
|
|
38
|
-
isLocationStateEntry(state[0])
|
|
39
|
-
) {
|
|
40
|
-
return resolveLocationStateEntries(state);
|
|
41
|
-
}
|
|
42
|
-
// Return as-is for legacy formats
|
|
43
|
-
return state;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Build history state object from user state
|
|
48
|
-
* - Typed state: spread directly into history.state
|
|
49
|
-
* - Legacy state: store in history.state.state
|
|
50
|
-
*/
|
|
51
|
-
function buildHistoryState(
|
|
52
|
-
userState: unknown,
|
|
53
|
-
routerState?: { intercept?: boolean; sourceUrl?: string },
|
|
54
|
-
serverState?: Record<string, unknown>,
|
|
55
|
-
): Record<string, unknown> | null {
|
|
56
|
-
const result: Record<string, unknown> = {};
|
|
57
|
-
|
|
58
|
-
// Add router internal state
|
|
59
|
-
if (routerState?.intercept) {
|
|
60
|
-
result.intercept = true;
|
|
61
|
-
if (routerState.sourceUrl) {
|
|
62
|
-
result.sourceUrl = routerState.sourceUrl;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Add user state
|
|
67
|
-
if (userState !== undefined) {
|
|
68
|
-
if (isTypedLocationState(userState)) {
|
|
69
|
-
// Typed state: spread directly
|
|
70
|
-
Object.assign(result, userState);
|
|
71
|
-
} else {
|
|
72
|
-
// Legacy state: store in .state
|
|
73
|
-
result.state = userState;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Merge server-set location state (from ctx.setLocationState on non-redirect responses)
|
|
78
|
-
if (serverState) {
|
|
79
|
-
Object.assign(result, serverState);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return Object.keys(result).length > 0 ? result : null;
|
|
83
|
-
}
|
|
84
18
|
import { setupLinkInterception } from "./link-interceptor.js";
|
|
85
19
|
import { createPartialUpdater } from "./partial-update.js";
|
|
86
20
|
import { generateHistoryKey } from "./navigation-store.js";
|
|
87
|
-
import {
|
|
88
|
-
|
|
89
|
-
handleNavigationEnd,
|
|
90
|
-
ensureHistoryKey,
|
|
91
|
-
} from "./scroll-restoration.js";
|
|
92
|
-
import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
21
|
+
import { handleNavigationEnd } from "./scroll-restoration.js";
|
|
22
|
+
import type { EventController } from "./event-controller.js";
|
|
93
23
|
import { isInterceptOnlyCache } from "./intercept-utils.js";
|
|
94
24
|
import {
|
|
95
25
|
toNetworkError,
|
|
@@ -98,359 +28,18 @@ import {
|
|
|
98
28
|
} from "./network-error-handler.js";
|
|
99
29
|
import { debugLog } from "./logging.js";
|
|
100
30
|
import { ServerRedirect } from "../errors.js";
|
|
31
|
+
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
101
32
|
|
|
102
33
|
// Polyfill Symbol.dispose for Safari and older browsers
|
|
103
34
|
if (typeof Symbol.dispose === "undefined") {
|
|
104
35
|
(Symbol as any).dispose = Symbol("Symbol.dispose");
|
|
105
36
|
}
|
|
106
37
|
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
interface CommitOptions {
|
|
111
|
-
url: string;
|
|
112
|
-
segmentIds: string[];
|
|
113
|
-
segments: ResolvedSegment[];
|
|
114
|
-
replace?: boolean;
|
|
115
|
-
scroll?: boolean;
|
|
116
|
-
/** User-provided state to store in history.state */
|
|
117
|
-
state?: unknown;
|
|
118
|
-
/** If true, only update store without changing URL/history (for server actions) */
|
|
119
|
-
storeOnly?: boolean;
|
|
120
|
-
/** If true, this is an intercept route - store in history.state for popstate handling */
|
|
121
|
-
intercept?: boolean;
|
|
122
|
-
/** Source URL where the intercept was triggered from (stored in history.state) */
|
|
123
|
-
interceptSourceUrl?: string;
|
|
124
|
-
/** If true, only update cache without touching store or history (for background stale revalidation) */
|
|
125
|
-
cacheOnly?: boolean;
|
|
126
|
-
/** Server-set location state to merge into history.pushState */
|
|
127
|
-
serverState?: Record<string, unknown>;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Options that can override the pre-configured commit settings
|
|
132
|
-
*/
|
|
133
|
-
interface BoundCommitOverrides {
|
|
134
|
-
/** Override scroll behavior (e.g., disable for intercepts) */
|
|
135
|
-
scroll?: boolean;
|
|
136
|
-
/** Override replace behavior (e.g., force replace for intercepts) */
|
|
137
|
-
replace?: boolean;
|
|
138
|
-
/** Override user-provided state */
|
|
139
|
-
state?: unknown;
|
|
140
|
-
/** Mark this as an intercept route */
|
|
141
|
-
intercept?: boolean;
|
|
142
|
-
/** Source URL where intercept was triggered from */
|
|
143
|
-
interceptSourceUrl?: string;
|
|
144
|
-
/** If true, only update cache (for stale revalidation) */
|
|
145
|
-
cacheOnly?: boolean;
|
|
146
|
-
/** Server-set location state to merge into history.pushState */
|
|
147
|
-
serverState?: Record<string, unknown>;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Token for tracking an active stream - call end() when stream completes
|
|
152
|
-
*/
|
|
153
|
-
export interface StreamingToken {
|
|
154
|
-
end(): void;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Bound transaction with pre-configured commit options (without segmentIds/segments)
|
|
159
|
-
*/
|
|
160
|
-
export interface BoundTransaction {
|
|
161
|
-
readonly currentUrl: string;
|
|
162
|
-
/** Start streaming and get a token to end it when the stream completes */
|
|
163
|
-
startStreaming(): StreamingToken;
|
|
164
|
-
commit(
|
|
165
|
-
segmentIds: string[],
|
|
166
|
-
segments: ResolvedSegment[],
|
|
167
|
-
overrides?: BoundCommitOverrides,
|
|
168
|
-
): void;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Navigation transaction for managing state during navigation
|
|
173
|
-
* Uses the event controller handle for lifecycle management
|
|
174
|
-
*/
|
|
175
|
-
interface NavigationTransaction extends Disposable {
|
|
176
|
-
/** Optimistically commit from cache - instant render before revalidation */
|
|
177
|
-
optimisticCommit(options: CommitOptions): void;
|
|
178
|
-
/** Final commit with server data (or reconciliation after optimistic) */
|
|
179
|
-
commit(options: CommitOptions): void;
|
|
180
|
-
with(
|
|
181
|
-
options: Omit<CommitOptions, "segmentIds" | "segments">,
|
|
182
|
-
): BoundTransaction;
|
|
183
|
-
/** The navigation handle from the event controller */
|
|
184
|
-
handle: NavigationHandle;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Creates a navigation transaction that coordinates with the event controller.
|
|
189
|
-
* Handles loading state transitions and cleanup on completion/abort.
|
|
190
|
-
*
|
|
191
|
-
* Supports optimistic navigation: render from cache immediately,
|
|
192
|
-
* then revalidate in background and reconcile if data changed.
|
|
193
|
-
*/
|
|
194
|
-
function createNavigationTransaction(
|
|
195
|
-
store: NavigationStore,
|
|
196
|
-
eventController: EventController,
|
|
197
|
-
url: string,
|
|
198
|
-
options?: NavigateOptions & { skipLoadingState?: boolean },
|
|
199
|
-
): NavigationTransaction {
|
|
200
|
-
let committed = false;
|
|
201
|
-
let optimisticallyCommitted = false;
|
|
202
|
-
let earlyStatePushed = false;
|
|
203
|
-
const currentUrl = window.location.href;
|
|
204
|
-
|
|
205
|
-
// Start navigation in event controller (this sets loading state)
|
|
206
|
-
const handle = eventController.startNavigation(url, options);
|
|
207
|
-
|
|
208
|
-
// If state is provided, push it to history immediately so loading UI can access it
|
|
209
|
-
// This enables "optimistic state" - showing product names in skeletons etc.
|
|
210
|
-
if (options?.state !== undefined && !options?.replace) {
|
|
211
|
-
const earlyHistoryState = buildHistoryState(options.state);
|
|
212
|
-
window.history.pushState(earlyHistoryState, "", url);
|
|
213
|
-
earlyStatePushed = true;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Optimistically commit from cache - renders immediately before revalidation
|
|
218
|
-
* Sets optimisticallyCommitted flag so final commit() knows to reconcile
|
|
219
|
-
*/
|
|
220
|
-
function optimisticCommit(opts: CommitOptions): void {
|
|
221
|
-
optimisticallyCommitted = true;
|
|
222
|
-
|
|
223
|
-
const { url, segmentIds, segments, replace, scroll } = opts;
|
|
224
|
-
const parsedUrl = new URL(url, window.location.origin);
|
|
225
|
-
|
|
226
|
-
// Save current scroll position before navigating
|
|
227
|
-
handleNavigationStart();
|
|
228
|
-
|
|
229
|
-
// Update segment state
|
|
230
|
-
store.setSegmentIds(segmentIds);
|
|
231
|
-
store.setCurrentUrl(url);
|
|
232
|
-
store.setPath(parsedUrl.pathname);
|
|
233
|
-
|
|
234
|
-
// Generate history key from URL
|
|
235
|
-
const historyKey = generateHistoryKey(url);
|
|
236
|
-
store.setHistoryKey(historyKey);
|
|
237
|
-
|
|
238
|
-
// Cache segments with current handleData (will be overwritten by fresh data on final commit)
|
|
239
|
-
const currentHandleData = eventController.getHandleState().data;
|
|
240
|
-
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
241
|
-
|
|
242
|
-
// Build history state with user state if provided
|
|
243
|
-
const historyState = buildHistoryState(opts.state);
|
|
244
|
-
|
|
245
|
-
// Update browser URL
|
|
246
|
-
// Use replaceState if we already pushed early (for optimistic state access)
|
|
247
|
-
if (replace || earlyStatePushed) {
|
|
248
|
-
window.history.replaceState(historyState, "", url);
|
|
249
|
-
} else {
|
|
250
|
-
window.history.pushState(historyState, "", url);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Ensure new history entry has a scroll restoration key
|
|
254
|
-
ensureHistoryKey();
|
|
255
|
-
|
|
256
|
-
// Complete the navigation in event controller (sets idle state)
|
|
257
|
-
handle.complete(parsedUrl);
|
|
258
|
-
|
|
259
|
-
// Handle scroll after navigation
|
|
260
|
-
handleNavigationEnd({ scroll });
|
|
261
|
-
|
|
262
|
-
debugLog("[Browser] Optimistic commit from cache, historyKey:", historyKey);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/**
|
|
266
|
-
* Commit the navigation - updates store and URL atomically
|
|
267
|
-
* If optimisticCommit was called, this becomes a reconciliation
|
|
268
|
-
*/
|
|
269
|
-
function commit(opts: CommitOptions): void {
|
|
270
|
-
committed = true;
|
|
271
|
-
|
|
272
|
-
// If optimistic commit already done, adjust options for reconciliation
|
|
273
|
-
const isReconciliation = optimisticallyCommitted;
|
|
274
|
-
const {
|
|
275
|
-
url,
|
|
276
|
-
segmentIds,
|
|
277
|
-
segments,
|
|
278
|
-
storeOnly,
|
|
279
|
-
intercept,
|
|
280
|
-
interceptSourceUrl,
|
|
281
|
-
cacheOnly,
|
|
282
|
-
serverState,
|
|
283
|
-
} = opts;
|
|
284
|
-
// For reconciliation: always replace (URL already pushed), no scroll
|
|
285
|
-
const replace = isReconciliation ? true : opts.replace;
|
|
286
|
-
const scroll = isReconciliation ? false : opts.scroll;
|
|
287
|
-
|
|
288
|
-
const parsedUrl = new URL(url, window.location.origin);
|
|
289
|
-
|
|
290
|
-
// Generate history key from URL (with intercept suffix for separate caching)
|
|
291
|
-
const historyKey = generateHistoryKey(url, { intercept });
|
|
292
|
-
|
|
293
|
-
// For cache-only commits (stale revalidation), only update cache and return
|
|
294
|
-
// Don't touch store state or history - user may have navigated elsewhere
|
|
295
|
-
if (cacheOnly) {
|
|
296
|
-
const currentHandleData = eventController.getHandleState().data;
|
|
297
|
-
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
298
|
-
debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Save current scroll position before navigating (only for non-reconciliation)
|
|
303
|
-
if (!isReconciliation) {
|
|
304
|
-
handleNavigationStart();
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Update segment state atomically
|
|
308
|
-
store.setSegmentIds(segmentIds);
|
|
309
|
-
store.setCurrentUrl(url);
|
|
310
|
-
store.setPath(parsedUrl.pathname);
|
|
311
|
-
|
|
312
|
-
store.setHistoryKey(historyKey);
|
|
313
|
-
|
|
314
|
-
// Cache segments with current handleData for this history entry (fresh data overwrites optimistic)
|
|
315
|
-
const currentHandleData = eventController.getHandleState().data;
|
|
316
|
-
store.cacheSegmentsForHistory(historyKey, segments, currentHandleData);
|
|
317
|
-
|
|
318
|
-
// For server actions, skip URL/history updates but still complete navigation
|
|
319
|
-
if (storeOnly) {
|
|
320
|
-
debugLog("[Browser] Store updated (action)");
|
|
321
|
-
// Complete navigation to clear loading state
|
|
322
|
-
handle.complete(parsedUrl);
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Build history state - include user state, intercept info, and server-set state
|
|
327
|
-
const historyState = buildHistoryState(
|
|
328
|
-
opts.state,
|
|
329
|
-
{ intercept, sourceUrl: interceptSourceUrl },
|
|
330
|
-
serverState,
|
|
331
|
-
);
|
|
332
|
-
|
|
333
|
-
// Update browser URL (skip if reconciliation - already done in optimisticCommit)
|
|
334
|
-
if (!isReconciliation) {
|
|
335
|
-
// Use replaceState if we already pushed early (for optimistic state access) or replace requested
|
|
336
|
-
if (replace || earlyStatePushed) {
|
|
337
|
-
window.history.replaceState(historyState, "", url);
|
|
338
|
-
} else {
|
|
339
|
-
window.history.pushState(historyState, "", url);
|
|
340
|
-
}
|
|
341
|
-
// Ensure new history entry has a scroll restoration key
|
|
342
|
-
ensureHistoryKey();
|
|
343
|
-
|
|
344
|
-
// Notify location state hooks when history state includes typed entries.
|
|
345
|
-
// Needed for same-page redirects where components don't remount and
|
|
346
|
-
// useState initializers don't re-run, even though history.state was updated.
|
|
347
|
-
if (
|
|
348
|
-
historyState &&
|
|
349
|
-
Object.keys(historyState).some((k) => k.startsWith("__rsc_ls_"))
|
|
350
|
-
) {
|
|
351
|
-
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Complete the navigation in event controller (sets idle state, updates location)
|
|
356
|
-
handle.complete(parsedUrl);
|
|
357
|
-
|
|
358
|
-
// Handle scroll after navigation (skip if reconciliation)
|
|
359
|
-
if (!isReconciliation) {
|
|
360
|
-
handleNavigationEnd({ scroll });
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (isReconciliation) {
|
|
364
|
-
debugLog("[Browser] Reconciliation commit, historyKey:", historyKey);
|
|
365
|
-
} else {
|
|
366
|
-
debugLog(
|
|
367
|
-
"[Browser] Navigation committed, historyKey:",
|
|
368
|
-
historyKey,
|
|
369
|
-
intercept ? "(intercept)" : "",
|
|
370
|
-
);
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return {
|
|
375
|
-
handle,
|
|
376
|
-
optimisticCommit,
|
|
377
|
-
commit,
|
|
378
|
-
|
|
379
|
-
/**
|
|
380
|
-
* Create a bound transaction with pre-configured URL options
|
|
381
|
-
* segmentIds and segments provided at commit time (after they're resolved)
|
|
382
|
-
*/
|
|
383
|
-
with(
|
|
384
|
-
opts: Omit<CommitOptions, "segmentIds" | "segments">,
|
|
385
|
-
): BoundTransaction {
|
|
386
|
-
return {
|
|
387
|
-
get currentUrl() {
|
|
388
|
-
return currentUrl;
|
|
389
|
-
},
|
|
390
|
-
startStreaming() {
|
|
391
|
-
return handle.startStreaming();
|
|
392
|
-
},
|
|
393
|
-
commit: (
|
|
394
|
-
segmentIds: string[],
|
|
395
|
-
segments: ResolvedSegment[],
|
|
396
|
-
overrides?: BoundCommitOverrides,
|
|
397
|
-
) => {
|
|
398
|
-
// Allow overrides to disable scroll (e.g., for intercepts)
|
|
399
|
-
const finalScroll =
|
|
400
|
-
overrides?.scroll !== undefined ? overrides.scroll : opts.scroll;
|
|
401
|
-
// Allow overrides to force replace (e.g., for intercepts)
|
|
402
|
-
const finalReplace =
|
|
403
|
-
overrides?.replace !== undefined ? overrides.replace : opts.replace;
|
|
404
|
-
// Intercept info: overrides take precedence, fallback to opts
|
|
405
|
-
const intercept =
|
|
406
|
-
overrides?.intercept !== undefined
|
|
407
|
-
? overrides.intercept
|
|
408
|
-
: opts.intercept;
|
|
409
|
-
const interceptSourceUrl =
|
|
410
|
-
overrides?.interceptSourceUrl !== undefined
|
|
411
|
-
? overrides.interceptSourceUrl
|
|
412
|
-
: opts.interceptSourceUrl;
|
|
413
|
-
// Cache-only mode: overrides take precedence, fallback to opts
|
|
414
|
-
const cacheOnly =
|
|
415
|
-
overrides?.cacheOnly !== undefined
|
|
416
|
-
? overrides.cacheOnly
|
|
417
|
-
: opts.cacheOnly;
|
|
418
|
-
// User state: overrides take precedence, fallback to opts
|
|
419
|
-
const state =
|
|
420
|
-
overrides?.state !== undefined ? overrides.state : opts.state;
|
|
421
|
-
// Server-set location state: only from overrides (set by partial-update)
|
|
422
|
-
const serverState = overrides?.serverState;
|
|
423
|
-
commit({
|
|
424
|
-
...opts,
|
|
425
|
-
segmentIds,
|
|
426
|
-
segments,
|
|
427
|
-
scroll: finalScroll,
|
|
428
|
-
replace: finalReplace,
|
|
429
|
-
state,
|
|
430
|
-
intercept,
|
|
431
|
-
interceptSourceUrl,
|
|
432
|
-
cacheOnly,
|
|
433
|
-
serverState,
|
|
434
|
-
});
|
|
435
|
-
},
|
|
436
|
-
};
|
|
437
|
-
},
|
|
438
|
-
|
|
439
|
-
[Symbol.dispose]() {
|
|
440
|
-
// If aborted, another navigation took over - don't touch state
|
|
441
|
-
if (handle.signal.aborted) return;
|
|
442
|
-
|
|
443
|
-
// If not committed (and not optimistically committed), the handle's dispose
|
|
444
|
-
// will reset state to idle via the event controller
|
|
445
|
-
if (!committed && !optimisticallyCommitted) {
|
|
446
|
-
handle[Symbol.dispose]();
|
|
447
|
-
// The NavigationHandle's [Symbol.dispose] handles this
|
|
448
|
-
}
|
|
449
|
-
},
|
|
450
|
-
};
|
|
38
|
+
/** Get IDs of non-loader segments (layouts, routes, parallels). */
|
|
39
|
+
function getNonLoaderSegmentIds(segments: ResolvedSegment[]): string[] {
|
|
40
|
+
return segments.filter((s) => s.type !== "loader").map((s) => s.id);
|
|
451
41
|
}
|
|
452
42
|
|
|
453
|
-
// Export for use by server-action-bridge
|
|
454
43
|
export { createNavigationTransaction };
|
|
455
44
|
|
|
456
45
|
/**
|
|
@@ -493,7 +82,7 @@ export function createNavigationBridge(
|
|
|
493
82
|
return {
|
|
494
83
|
/**
|
|
495
84
|
* Navigate to a URL
|
|
496
|
-
* Uses
|
|
85
|
+
* Uses cached segments for SWR revalidation when available
|
|
497
86
|
*/
|
|
498
87
|
async navigate(
|
|
499
88
|
url: string,
|
|
@@ -505,10 +94,30 @@ export function createNavigationBridge(
|
|
|
505
94
|
? resolveNavigationState(options.state)
|
|
506
95
|
: undefined;
|
|
507
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
|
+
|
|
508
117
|
// Only abort pending requests when navigating to a different route
|
|
509
118
|
// Same-route navigation (e.g., /todos -> /todos) should not cancel in-flight actions
|
|
510
119
|
const currentPath = new URL(window.location.href).pathname;
|
|
511
|
-
const targetPath =
|
|
120
|
+
const targetPath = targetUrl.pathname;
|
|
512
121
|
if (currentPath !== targetPath) {
|
|
513
122
|
eventController.abortNavigation();
|
|
514
123
|
}
|
|
@@ -567,7 +176,7 @@ export function createNavigationBridge(
|
|
|
567
176
|
const interceptHistoryKey = generateHistoryKey(url, { intercept: true });
|
|
568
177
|
const hasInterceptCache = store.hasHistoryCache(interceptHistoryKey);
|
|
569
178
|
|
|
570
|
-
// Skip
|
|
179
|
+
// Skip cached SWR for:
|
|
571
180
|
// 1. intercept caches - interception depends on source page context
|
|
572
181
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
573
182
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
@@ -580,7 +189,7 @@ export function createNavigationBridge(
|
|
|
580
189
|
!isLeavingIntercept &&
|
|
581
190
|
!options?._skipCache;
|
|
582
191
|
|
|
583
|
-
|
|
192
|
+
const tx = createNavigationTransaction(store, eventController, url, {
|
|
584
193
|
...options,
|
|
585
194
|
state: resolvedState,
|
|
586
195
|
skipLoadingState: hasUsableCache,
|
|
@@ -591,9 +200,7 @@ export function createNavigationBridge(
|
|
|
591
200
|
await fetchPartialUpdate(
|
|
592
201
|
url,
|
|
593
202
|
hasUsableCache
|
|
594
|
-
? cachedSegments!
|
|
595
|
-
.filter((s) => s.type !== "loader")
|
|
596
|
-
.map((s) => s.id)
|
|
203
|
+
? getNonLoaderSegmentIds(cachedSegments!)
|
|
597
204
|
: options?._skipCache
|
|
598
205
|
? [] // Action redirect: send no segments so server renders everything fresh
|
|
599
206
|
: undefined,
|
|
@@ -605,27 +212,29 @@ export function createNavigationBridge(
|
|
|
605
212
|
scroll: options?.scroll,
|
|
606
213
|
state: resolvedState,
|
|
607
214
|
}),
|
|
608
|
-
// Pass cached segments (merged with current page's fresh segments for shared IDs)
|
|
609
|
-
// so the segment map is consistent with what we tell the server we have.
|
|
610
|
-
// Server decides what needs revalidation based on route matching and custom functions.
|
|
611
|
-
// No need for staleRevalidation flag - we're sending the freshest segments we have.
|
|
612
|
-
// Also pass cached handle data for restoring breadcrumbs when server returns empty diff.
|
|
613
|
-
// When leaving intercept, pass the flag so fetchPartialUpdate knows to filter segments.
|
|
614
215
|
hasUsableCache
|
|
615
216
|
? {
|
|
217
|
+
type: "navigate" as const,
|
|
616
218
|
targetCacheSegments: cachedSegments,
|
|
617
219
|
targetCacheHandleData: cachedHandleData,
|
|
618
220
|
}
|
|
619
221
|
: isLeavingIntercept
|
|
620
|
-
? {
|
|
222
|
+
? { type: "leave-intercept" as const }
|
|
621
223
|
: undefined,
|
|
622
224
|
);
|
|
623
225
|
} catch (error) {
|
|
624
226
|
// Server-side redirect with location state: the current transaction's
|
|
625
|
-
//
|
|
227
|
+
// cleanup resets loading state. Re-navigate to the redirect
|
|
626
228
|
// target carrying the server-set state into history.pushState.
|
|
627
229
|
if (error instanceof ServerRedirect) {
|
|
628
|
-
|
|
230
|
+
const redirectUrl = validateRedirectOrigin(
|
|
231
|
+
error.url,
|
|
232
|
+
window.location.origin,
|
|
233
|
+
);
|
|
234
|
+
if (!redirectUrl) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
return this.navigate(redirectUrl, {
|
|
629
238
|
state: error.state,
|
|
630
239
|
replace: options?.replace,
|
|
631
240
|
_skipCache: true,
|
|
@@ -651,6 +260,8 @@ export function createNavigationBridge(
|
|
|
651
260
|
}
|
|
652
261
|
|
|
653
262
|
throw error;
|
|
263
|
+
} finally {
|
|
264
|
+
tx[Symbol.dispose]();
|
|
654
265
|
}
|
|
655
266
|
},
|
|
656
267
|
|
|
@@ -660,7 +271,7 @@ export function createNavigationBridge(
|
|
|
660
271
|
async refresh(): Promise<void> {
|
|
661
272
|
eventController.abortNavigation();
|
|
662
273
|
|
|
663
|
-
|
|
274
|
+
const tx = createNavigationTransaction(
|
|
664
275
|
store,
|
|
665
276
|
eventController,
|
|
666
277
|
window.location.href,
|
|
@@ -690,6 +301,8 @@ export function createNavigationBridge(
|
|
|
690
301
|
return;
|
|
691
302
|
}
|
|
692
303
|
throw error;
|
|
304
|
+
} finally {
|
|
305
|
+
tx[Symbol.dispose]();
|
|
693
306
|
}
|
|
694
307
|
},
|
|
695
308
|
|
|
@@ -800,9 +413,7 @@ export function createNavigationBridge(
|
|
|
800
413
|
if (isStale) {
|
|
801
414
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
802
415
|
// Background revalidation - don't await, just fire and forget
|
|
803
|
-
const segmentIds = cachedSegments
|
|
804
|
-
.filter((s) => s.type !== "loader")
|
|
805
|
-
.map((s) => s.id);
|
|
416
|
+
const segmentIds = getNonLoaderSegmentIds(cachedSegments);
|
|
806
417
|
|
|
807
418
|
const tx = createNavigationTransaction(
|
|
808
419
|
store,
|
|
@@ -824,7 +435,7 @@ export function createNavigationBridge(
|
|
|
824
435
|
interceptSourceUrl,
|
|
825
436
|
cacheOnly: true,
|
|
826
437
|
}),
|
|
827
|
-
{
|
|
438
|
+
{ type: "stale-revalidation", interceptSourceUrl },
|
|
828
439
|
)
|
|
829
440
|
.catch((error) => {
|
|
830
441
|
if (isBackgroundSuppressible(error)) return;
|
|
@@ -850,7 +461,7 @@ export function createNavigationBridge(
|
|
|
850
461
|
}
|
|
851
462
|
|
|
852
463
|
// Fetch if not cached
|
|
853
|
-
|
|
464
|
+
const tx = createNavigationTransaction(store, eventController, url, {
|
|
854
465
|
replace: true,
|
|
855
466
|
});
|
|
856
467
|
|
|
@@ -860,7 +471,14 @@ export function createNavigationBridge(
|
|
|
860
471
|
undefined,
|
|
861
472
|
false,
|
|
862
473
|
tx.handle.signal,
|
|
863
|
-
tx.with({
|
|
474
|
+
tx.with({
|
|
475
|
+
url,
|
|
476
|
+
replace: true,
|
|
477
|
+
scroll: false,
|
|
478
|
+
intercept: isIntercept,
|
|
479
|
+
interceptSourceUrl,
|
|
480
|
+
}),
|
|
481
|
+
isIntercept ? { type: "navigate", interceptSourceUrl } : undefined,
|
|
864
482
|
);
|
|
865
483
|
// Restore scroll position after fetch completes
|
|
866
484
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -884,6 +502,8 @@ export function createNavigationBridge(
|
|
|
884
502
|
}
|
|
885
503
|
|
|
886
504
|
throw error;
|
|
505
|
+
} finally {
|
|
506
|
+
tx[Symbol.dispose]();
|
|
887
507
|
}
|
|
888
508
|
},
|
|
889
509
|
|
|
@@ -914,6 +534,9 @@ export function createNavigationBridge(
|
|
|
914
534
|
"[Browser] Page restored from bfcache, resetting navigation state",
|
|
915
535
|
);
|
|
916
536
|
eventController.abortNavigation();
|
|
537
|
+
// pagehide flips scrollRestoration to "auto" for bfcache compat;
|
|
538
|
+
// restore "manual" so the router controls scroll on SPA navigations.
|
|
539
|
+
window.history.scrollRestoration = "manual";
|
|
917
540
|
}
|
|
918
541
|
};
|
|
919
542
|
|