@real-router/navigation-plugin 0.7.5 → 0.7.7
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/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/package.json +6 -7
- package/src/constants.ts +0 -19
- package/src/factory.ts +0 -64
- package/src/history-extensions.ts +0 -214
- package/src/href-utils.ts +0 -65
- package/src/index.ts +0 -65
- package/src/navigate-handler.ts +0 -260
- package/src/navigation-browser.ts +0 -92
- package/src/plugin.ts +0 -468
- package/src/ssr-fallback.ts +0 -48
- package/src/types.ts +0 -71
- package/src/validation.ts +0 -10
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { extractPathFromAbsoluteUrl } from "./browser-env";
|
|
2
|
-
|
|
3
|
-
import type { NavigationBrowser } from "./types";
|
|
4
|
-
import type { State } from "@real-router/core";
|
|
5
|
-
import type { PluginApi } from "@real-router/core/api";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Validates a candidate history entry for `traverseToLast(routeName)` and
|
|
9
|
-
* returns both the entry (now known non-null) and the matched router state.
|
|
10
|
-
* Extracted from `NavigationPlugin` so the three error branches (missing
|
|
11
|
-
* entry, null url, unmatched url) can be tested directly without vi.spyOn
|
|
12
|
-
* on module namespaces — the star-import spy pattern is fragile under ESM
|
|
13
|
-
* and was working by accident in history-extensions.test.ts.
|
|
14
|
-
*
|
|
15
|
-
* Throws a descriptive Error on any failure; the caller (NavigationPlugin)
|
|
16
|
-
* propagates it as the rejection of `traverseToLast`.
|
|
17
|
-
*/
|
|
18
|
-
export function resolveEntryToMatchedState(
|
|
19
|
-
entry: NavigationHistoryEntry | undefined,
|
|
20
|
-
routeName: string,
|
|
21
|
-
api: PluginApi,
|
|
22
|
-
base: string,
|
|
23
|
-
): { entry: NavigationHistoryEntry; entryUrl: string; matchedState: State } {
|
|
24
|
-
if (!entry) {
|
|
25
|
-
throw new Error(`No history entry for route "${routeName}"`);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
const entryUrl = entry.url;
|
|
29
|
-
|
|
30
|
-
if (!entryUrl) {
|
|
31
|
-
throw new Error(`No matching route for entry URL "${entryUrl}"`);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const path = extractPathFromAbsoluteUrl(entryUrl, base);
|
|
35
|
-
const matchedState = api.matchPath(path);
|
|
36
|
-
|
|
37
|
-
if (!matchedState) {
|
|
38
|
-
throw new Error(`No matching route for entry URL "${entryUrl}"`);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// entryUrl is returned alongside `entry` so callers can read the validated
|
|
42
|
-
// URL without re-doing the null check — TypeScript cannot narrow a property
|
|
43
|
-
// access through a control-flow guard on `entry.url`.
|
|
44
|
-
return { entry, entryUrl, matchedState };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Converts a NavigationHistoryEntry to a State via URL matching.
|
|
49
|
-
* Uses URL matching (not entry.getState()) because:
|
|
50
|
-
* - Entries before plugin init have no state
|
|
51
|
-
* - Entries after router.replace(routes) may have stale state
|
|
52
|
-
* - Entries from other SPAs on the same origin have foreign state
|
|
53
|
-
*/
|
|
54
|
-
export function entryToState(
|
|
55
|
-
entry: NavigationHistoryEntry | undefined,
|
|
56
|
-
api: PluginApi,
|
|
57
|
-
base: string,
|
|
58
|
-
): State | undefined {
|
|
59
|
-
if (!entry?.url) {
|
|
60
|
-
return undefined;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
api.matchPath(extractPathFromAbsoluteUrl(entry.url, base)) ?? undefined
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function peekAt(
|
|
69
|
-
browser: NavigationBrowser,
|
|
70
|
-
api: PluginApi,
|
|
71
|
-
base: string,
|
|
72
|
-
offset: number,
|
|
73
|
-
): State | undefined {
|
|
74
|
-
const idx = browser.currentEntry?.index;
|
|
75
|
-
|
|
76
|
-
if (idx == null) {
|
|
77
|
-
return undefined;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return entryToState(browser.entries()[idx + offset], api, base);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function peekBack(
|
|
84
|
-
browser: NavigationBrowser,
|
|
85
|
-
api: PluginApi,
|
|
86
|
-
base: string,
|
|
87
|
-
): State | undefined {
|
|
88
|
-
return peekAt(browser, api, base, -1);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function peekForward(
|
|
92
|
-
browser: NavigationBrowser,
|
|
93
|
-
api: PluginApi,
|
|
94
|
-
base: string,
|
|
95
|
-
): State | undefined {
|
|
96
|
-
return peekAt(browser, api, base, 1);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function hasVisited(
|
|
100
|
-
browser: NavigationBrowser,
|
|
101
|
-
api: PluginApi,
|
|
102
|
-
base: string,
|
|
103
|
-
routeName: string,
|
|
104
|
-
): boolean {
|
|
105
|
-
return browser.entries().some((entry) => {
|
|
106
|
-
const state = entryToState(entry, api, base);
|
|
107
|
-
|
|
108
|
-
return state?.name === routeName;
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function getVisitedRoutes(
|
|
113
|
-
browser: NavigationBrowser,
|
|
114
|
-
api: PluginApi,
|
|
115
|
-
base: string,
|
|
116
|
-
): string[] {
|
|
117
|
-
const names = new Set<string>();
|
|
118
|
-
|
|
119
|
-
for (const entry of browser.entries()) {
|
|
120
|
-
const state = entryToState(entry, api, base);
|
|
121
|
-
|
|
122
|
-
if (state) {
|
|
123
|
-
names.add(state.name);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return [...names];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function getRouteVisitCount(
|
|
131
|
-
browser: NavigationBrowser,
|
|
132
|
-
api: PluginApi,
|
|
133
|
-
base: string,
|
|
134
|
-
routeName: string,
|
|
135
|
-
): number {
|
|
136
|
-
let count = 0;
|
|
137
|
-
|
|
138
|
-
for (const entry of browser.entries()) {
|
|
139
|
-
if (entryToState(entry, api, base)?.name === routeName) {
|
|
140
|
-
count++;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return count;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Finds the last NavigationHistoryEntry matching the given route name,
|
|
149
|
-
* excluding the current entry (to avoid SAME_STATES on traverseToLast("current-route")).
|
|
150
|
-
*/
|
|
151
|
-
export function findLastEntryForRoute(
|
|
152
|
-
entries: NavigationHistoryEntry[],
|
|
153
|
-
routeName: string,
|
|
154
|
-
api: PluginApi,
|
|
155
|
-
base: string,
|
|
156
|
-
currentKey: string | undefined,
|
|
157
|
-
): NavigationHistoryEntry | undefined {
|
|
158
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
159
|
-
const entry = entries[i];
|
|
160
|
-
|
|
161
|
-
if (entry.key === currentKey) {
|
|
162
|
-
continue;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
const state = entryToState(entry, api, base);
|
|
166
|
-
|
|
167
|
-
if (state?.name === routeName) {
|
|
168
|
-
return entry;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function canGoBack(browser: NavigationBrowser): boolean {
|
|
176
|
-
const idx = browser.currentEntry?.index;
|
|
177
|
-
|
|
178
|
-
return idx != null && idx > 0;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export function canGoForward(browser: NavigationBrowser): boolean {
|
|
182
|
-
const idx = browser.currentEntry?.index;
|
|
183
|
-
|
|
184
|
-
if (idx == null) {
|
|
185
|
-
return false;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return idx < browser.entries().length - 1;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
export function canGoBackTo(
|
|
192
|
-
browser: NavigationBrowser,
|
|
193
|
-
api: PluginApi,
|
|
194
|
-
base: string,
|
|
195
|
-
routeName: string,
|
|
196
|
-
): boolean {
|
|
197
|
-
const idx = browser.currentEntry?.index;
|
|
198
|
-
|
|
199
|
-
if (idx == null) {
|
|
200
|
-
return false;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
const entries = browser.entries();
|
|
204
|
-
|
|
205
|
-
for (let i = idx - 1; i >= 0; i--) {
|
|
206
|
-
const state = entryToState(entries[i], api, base);
|
|
207
|
-
|
|
208
|
-
if (state?.name === routeName) {
|
|
209
|
-
return true;
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return false;
|
|
214
|
-
}
|
package/src/href-utils.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pure URL-comparison helper extracted from `plugin.ts` so it can be unit and
|
|
3
|
-
* property-tested in isolation. See INVARIANTS.md section K for the formal
|
|
4
|
-
* properties this function satisfies.
|
|
5
|
-
*
|
|
6
|
-
* Sole production call site: the same-URL guard in
|
|
7
|
-
* `NavigationPlugin.onTransitionSuccess` (#580).
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Returns `true` when resolving `target` (a relative or absolute URL) against
|
|
12
|
-
* `currentHref` identifies the same logical document — same protocol, host,
|
|
13
|
-
* pathname (with empty pathname normalised to `"/"`), search, and hash.
|
|
14
|
-
*
|
|
15
|
-
* Why component-wise (not raw `.href` equality):
|
|
16
|
-
*
|
|
17
|
-
* For **special schemes** (`http:`, `https:`, `ws:`, `wss:`, `file:`) the URL
|
|
18
|
-
* parser canonicalises empty pathname to `"/"`, so `http://x` and `http://x/`
|
|
19
|
-
* share the same `.href` and either comparison would work.
|
|
20
|
-
*
|
|
21
|
-
* For **non-special schemes** (`tauri://`, `app://`, custom protocols used by
|
|
22
|
-
* Tauri/Electron) the parser preserves the empty pathname:
|
|
23
|
-
*
|
|
24
|
-
* new URL("tauri://localhost").href === "tauri://localhost"
|
|
25
|
-
* new URL("/", "tauri://localhost").href === "tauri://localhost/"
|
|
26
|
-
*
|
|
27
|
-
* A naive `.href` equality would treat these as different. `nav.navigate("/")`
|
|
28
|
-
* against `tauri://localhost` would then go through the navigate path — and
|
|
29
|
-
* under Safari 26.2 WKWebView that round-trip triggers a cross-document
|
|
30
|
-
* reload (the #580 root cause). The first time the user observed this, the
|
|
31
|
-
* `same-URL guard` fix only kicked in on the SECOND iteration (after the
|
|
32
|
-
* URL had been auto-normalised to include the trailing slash). Component-
|
|
33
|
-
* wise comparison with `pathname || "/"` closes that first-iteration hole.
|
|
34
|
-
*
|
|
35
|
-
* Returns `false` when:
|
|
36
|
-
* - `currentHref` is null, undefined or empty (SSR fallback / pre-start),
|
|
37
|
-
* - either URL construction throws (malformed input).
|
|
38
|
-
*
|
|
39
|
-
* Total over all string inputs: never throws.
|
|
40
|
-
*
|
|
41
|
-
* @internal — exported for property testing; not part of the public surface.
|
|
42
|
-
*/
|
|
43
|
-
export function isSameHref(
|
|
44
|
-
target: string,
|
|
45
|
-
currentHref: string | null | undefined,
|
|
46
|
-
): boolean {
|
|
47
|
-
if (!currentHref) {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
try {
|
|
52
|
-
const resolved = new URL(target, currentHref);
|
|
53
|
-
const base = new URL(currentHref);
|
|
54
|
-
|
|
55
|
-
return (
|
|
56
|
-
resolved.protocol === base.protocol &&
|
|
57
|
-
resolved.host === base.host &&
|
|
58
|
-
(resolved.pathname || "/") === (base.pathname || "/") &&
|
|
59
|
-
resolved.search === base.search &&
|
|
60
|
-
resolved.hash === base.hash
|
|
61
|
-
);
|
|
62
|
-
} catch {
|
|
63
|
-
return false;
|
|
64
|
-
}
|
|
65
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
/* eslint-disable @typescript-eslint/method-signature-style -- method syntax required for declaration merging overload (property syntax causes TS2717) */
|
|
2
|
-
|
|
3
|
-
import type { Params, State } from "@real-router/core";
|
|
4
|
-
|
|
5
|
-
export { navigationPluginFactory } from "./factory";
|
|
6
|
-
|
|
7
|
-
export { PLUGIN_SYNC_INFO } from "./navigation-browser";
|
|
8
|
-
|
|
9
|
-
export type {
|
|
10
|
-
NavigationPluginOptions,
|
|
11
|
-
NavigationBrowser,
|
|
12
|
-
NavigationMeta,
|
|
13
|
-
NavigationDirection,
|
|
14
|
-
} from "./types";
|
|
15
|
-
|
|
16
|
-
declare module "@real-router/types" {
|
|
17
|
-
interface StateContext {
|
|
18
|
-
navigation?: import("./types").NavigationMeta;
|
|
19
|
-
/**
|
|
20
|
-
* URL fragment ("hash") layer state (#532). Populated by both URL plugins
|
|
21
|
-
* (navigation-plugin, browser-plugin) — they are mutually exclusive at
|
|
22
|
-
* runtime, so only one writes to this namespace.
|
|
23
|
-
*/
|
|
24
|
-
url?: import("./browser-env").UrlContext;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface NavigationOptions {
|
|
28
|
-
/**
|
|
29
|
-
* URL fragment override (decoded, no leading "#") (#532).
|
|
30
|
-
* Tri-state: `undefined` → preserve current; `""` → clear; non-empty → set.
|
|
31
|
-
*/
|
|
32
|
-
hash?: string;
|
|
33
|
-
/**
|
|
34
|
-
* @internal — set by URL plugins on hash-only browser-driven navigation.
|
|
35
|
-
* Subscribers should branch on `state.context.url.hashChanged` instead.
|
|
36
|
-
*/
|
|
37
|
-
hashChange?: boolean;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
declare module "@real-router/core" {
|
|
42
|
-
interface Router {
|
|
43
|
-
buildUrl(
|
|
44
|
-
name: string,
|
|
45
|
-
params?: Params,
|
|
46
|
-
options?: { hash?: string },
|
|
47
|
-
): string;
|
|
48
|
-
matchUrl(url: string): State | undefined;
|
|
49
|
-
replaceHistoryState(
|
|
50
|
-
name: string,
|
|
51
|
-
params?: Params,
|
|
52
|
-
options?: { hash?: string },
|
|
53
|
-
): void;
|
|
54
|
-
peekBack(): State | undefined;
|
|
55
|
-
peekForward(): State | undefined;
|
|
56
|
-
hasVisited(routeName: string): boolean;
|
|
57
|
-
getVisitedRoutes(): string[];
|
|
58
|
-
getRouteVisitCount(routeName: string): number;
|
|
59
|
-
traverseToLast(routeName: string): Promise<State>;
|
|
60
|
-
canGoBack(): boolean;
|
|
61
|
-
canGoForward(): boolean;
|
|
62
|
-
canGoBackTo(routeName: string): boolean;
|
|
63
|
-
start(path?: string): Promise<State>;
|
|
64
|
-
}
|
|
65
|
-
}
|
package/src/navigate-handler.ts
DELETED
|
@@ -1,260 +0,0 @@
|
|
|
1
|
-
import { errorCodes, RouterError } from "@real-router/core";
|
|
2
|
-
|
|
3
|
-
import { urlToPathAndHash } from "./browser-env";
|
|
4
|
-
import { PLUGIN_SYNC_INFO } from "./navigation-browser";
|
|
5
|
-
|
|
6
|
-
import type {
|
|
7
|
-
NavigationBrowser,
|
|
8
|
-
NavigationDirection,
|
|
9
|
-
NavigationMeta,
|
|
10
|
-
} from "./types";
|
|
11
|
-
import type { Router } from "@real-router/core";
|
|
12
|
-
import type { PluginApi } from "@real-router/core/api";
|
|
13
|
-
|
|
14
|
-
// Hoisted noop intercept options — reused on every plugin-originated
|
|
15
|
-
// navigate event (the hot path). `event.intercept` reads `handler` once per
|
|
16
|
-
// call per Navigation API spec, so a shared object/function is safe and
|
|
17
|
-
// saves two allocations per intercepted event.
|
|
18
|
-
//
|
|
19
|
-
// `scroll: "manual"` is critical for plugin-originated re-emits: by the time
|
|
20
|
-
// the navigate event fires for a router-driven mutation (e.g. scroll-spy's
|
|
21
|
-
// hash-only nav, scroll-restoration's URL sync), the router has already
|
|
22
|
-
// committed the transition and the app owns scroll position. Default
|
|
23
|
-
// `scroll: "after-transition"` would auto-scroll the new URL fragment into
|
|
24
|
-
// view, fighting against the user's own scroll motion (concrete bug:
|
|
25
|
-
// scroll-spy + slow user scroll → viewport jump on every emit).
|
|
26
|
-
// Aligns with browser-plugin (History API has no auto-scroll on
|
|
27
|
-
// programmatic URL changes). Apps that want hash-anchor auto-scroll opt
|
|
28
|
-
// in via `createScrollRestoration({ anchorScrolling: true })`.
|
|
29
|
-
const NOOP_ASYNC = async (): Promise<void> => {};
|
|
30
|
-
const NOOP_INTERCEPT: NavigationInterceptOptions = {
|
|
31
|
-
handler: NOOP_ASYNC,
|
|
32
|
-
scroll: "manual",
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
interface NavigateHandlerDeps {
|
|
36
|
-
router: Router;
|
|
37
|
-
api: PluginApi;
|
|
38
|
-
browser: NavigationBrowser;
|
|
39
|
-
setCapturedMeta: (meta: NavigationMeta) => void;
|
|
40
|
-
base: string;
|
|
41
|
-
transitionOptions: {
|
|
42
|
-
source: string;
|
|
43
|
-
replace: true;
|
|
44
|
-
forceDeactivate?: boolean;
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function computeDirection(
|
|
49
|
-
navigationType: NavigationMeta["navigationType"],
|
|
50
|
-
destinationIndex: number,
|
|
51
|
-
currentIndex: number,
|
|
52
|
-
): NavigationDirection {
|
|
53
|
-
if (navigationType === "traverse") {
|
|
54
|
-
if (destinationIndex === currentIndex) {
|
|
55
|
-
return "unknown";
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return destinationIndex > currentIndex ? "forward" : "back";
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
return navigationType === "push" ? "forward" : "unknown";
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function createNavigateHandler(deps: NavigateHandlerDeps) {
|
|
65
|
-
const { router, api, browser, base, transitionOptions } = deps;
|
|
66
|
-
const { allowNotFound } = api.getOptions();
|
|
67
|
-
|
|
68
|
-
return function handleNavigateEvent(event: NavigateEvent): void {
|
|
69
|
-
if (!event.canIntercept || !router.isActive()) {
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
if (event.info === PLUGIN_SYNC_INFO) {
|
|
74
|
-
// Plugin-originated navigate event after its own successful transition
|
|
75
|
-
// (onTransitionSuccess calls browser.navigate to sync URL). We must still
|
|
76
|
-
// intercept — a bare `return` leaves the event un-intercepted, and
|
|
77
|
-
// Chromium falls back to a cross-document navigation (full page reload).
|
|
78
|
-
// The noop handler cancels the fallback without running router logic;
|
|
79
|
-
// state is already committed.
|
|
80
|
-
//
|
|
81
|
-
// Detection by `event.info` (identity) instead of a synchronous flag
|
|
82
|
-
// (timing) so this works under Safari 26.2 WKWebView, which delivers
|
|
83
|
-
// navigate events on a subsequent task — by then a `finally`-cleared
|
|
84
|
-
// flag would already be false and the handler would loop (#580).
|
|
85
|
-
//
|
|
86
|
-
// NOOP_INTERCEPT is module-level so the intercept options + handler
|
|
87
|
-
// are not re-allocated per navigation (hot path).
|
|
88
|
-
event.intercept(NOOP_INTERCEPT);
|
|
89
|
-
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const { path, hash } = urlToPathAndHash(event.destination.url, base);
|
|
94
|
-
const matchedState = api.matchPath(path);
|
|
95
|
-
|
|
96
|
-
const navType = event.navigationType;
|
|
97
|
-
const currentIndex = browser.currentEntry?.index ?? -1;
|
|
98
|
-
|
|
99
|
-
deps.setCapturedMeta({
|
|
100
|
-
navigationType: navType,
|
|
101
|
-
userInitiated: event.userInitiated,
|
|
102
|
-
info: event.info,
|
|
103
|
-
direction: computeDirection(
|
|
104
|
-
navType,
|
|
105
|
-
event.destination.index,
|
|
106
|
-
currentIndex,
|
|
107
|
-
),
|
|
108
|
-
sourceElement: event.sourceElement ?? null,
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
if (matchedState) {
|
|
112
|
-
event.intercept({
|
|
113
|
-
handler: () =>
|
|
114
|
-
withRecovery(
|
|
115
|
-
() =>
|
|
116
|
-
// api.navigateToState: matchPath already applied forwardState +
|
|
117
|
-
// matchSourceTrailingSlash; reusing the State avoids the redundant
|
|
118
|
-
// round-trip and preserves trailing slashes (#525). Plugin-only
|
|
119
|
-
// entry point — not on the public Router/Navigator surface.
|
|
120
|
-
//
|
|
121
|
-
// Hash extraction (#532): pass through the destination's hash so
|
|
122
|
-
// onTransitionSuccess sets state.context.url.hash. When the
|
|
123
|
-
// browser fires hashChange (same-document fragment-only nav),
|
|
124
|
-
// add force+hashChange to bypass SAME_STATES — subscribers
|
|
125
|
-
// disambiguate via state.context.url.hashChanged, not via the
|
|
126
|
-
// overloaded force flag.
|
|
127
|
-
api.navigateToState(matchedState, {
|
|
128
|
-
...transitionOptions,
|
|
129
|
-
hash,
|
|
130
|
-
...(event.hashChange ? { force: true, hashChange: true } : {}),
|
|
131
|
-
signal: event.signal,
|
|
132
|
-
}),
|
|
133
|
-
router,
|
|
134
|
-
browser,
|
|
135
|
-
),
|
|
136
|
-
});
|
|
137
|
-
} else if (allowNotFound) {
|
|
138
|
-
event.intercept({
|
|
139
|
-
handler: () => {
|
|
140
|
-
router.navigateToNotFound(path);
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
} else {
|
|
144
|
-
// Strict mode — unmatched URL is an error. Emit $$error and reject the
|
|
145
|
-
// intercept so the Navigation API auto-rolls back the URL. No silent
|
|
146
|
-
// fallback to defaultRoute.
|
|
147
|
-
event.intercept({
|
|
148
|
-
// eslint-disable-next-line @typescript-eslint/require-await -- Navigation API requires async handler; synchronous throw is the rollback signal
|
|
149
|
-
handler: async () => {
|
|
150
|
-
const err = new RouterError(errorCodes.ROUTE_NOT_FOUND, { path });
|
|
151
|
-
|
|
152
|
-
api.emitTransitionError(err);
|
|
153
|
-
|
|
154
|
-
throw err;
|
|
155
|
-
},
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
};
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Module-scope helper hoisted out of handleNavigateEvent so the closure is
|
|
163
|
-
* not re-allocated on every navigate event. The router/browser refs come from
|
|
164
|
-
* arguments instead of an enclosing scope; identical behaviour, fewer GC'd
|
|
165
|
-
* closures.
|
|
166
|
-
*/
|
|
167
|
-
async function withRecovery(
|
|
168
|
-
run: () => Promise<unknown>,
|
|
169
|
-
router: Router,
|
|
170
|
-
browser: NavigationBrowser,
|
|
171
|
-
): Promise<void> {
|
|
172
|
-
try {
|
|
173
|
-
await run();
|
|
174
|
-
} catch (error) {
|
|
175
|
-
if (!(error instanceof RouterError)) {
|
|
176
|
-
recoverFromNavigateError(error, router, browser);
|
|
177
|
-
|
|
178
|
-
return;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// TRANSITION_CANCELLED: a newer navigation aborted this one — the newer
|
|
182
|
-
// navigate event is (or will be) handled by this same plugin, and THAT
|
|
183
|
-
// event is responsible for syncing URL/state. Firing our own sync here
|
|
184
|
-
// races against it: browser.navigate(replace, same-url) would cancel the
|
|
185
|
-
// in-flight newer transition, which is exactly the rapid-fire-events storm
|
|
186
|
-
// failure mode.
|
|
187
|
-
//
|
|
188
|
-
// SAME_STATES: router refused because router.getState() already equals the
|
|
189
|
-
// target. URL and router state are already consistent — no sync needed.
|
|
190
|
-
if (
|
|
191
|
-
error.code === errorCodes.TRANSITION_CANCELLED ||
|
|
192
|
-
error.code === errorCodes.SAME_STATES
|
|
193
|
-
) {
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,
|
|
198
|
-
// ROUTE_NOT_FOUND, …) — router rejected the transition, state is
|
|
199
|
-
// unchanged, but URL may have already committed to a different value by
|
|
200
|
-
// the Navigation API. Sync the URL back to the current router state in a
|
|
201
|
-
// single visible transition (headless Chromium and some cross-origin
|
|
202
|
-
// setups leave "committed-then-reverted" windows if we relied on the
|
|
203
|
-
// native rollback via intercept reject). Observers that care about the
|
|
204
|
-
// error see it through the router's TRANSITION_ERROR event.
|
|
205
|
-
syncUrlToRouterState(router, browser);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function recoverFromNavigateError(
|
|
210
|
-
error: unknown,
|
|
211
|
-
router: Router,
|
|
212
|
-
browser: NavigationBrowser,
|
|
213
|
-
): void {
|
|
214
|
-
console.error(
|
|
215
|
-
"[navigation-plugin] Critical error in navigate handler",
|
|
216
|
-
error,
|
|
217
|
-
);
|
|
218
|
-
|
|
219
|
-
syncUrlToRouterState(router, browser);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function syncUrlToRouterState(
|
|
223
|
-
router: Router,
|
|
224
|
-
browser: NavigationBrowser,
|
|
225
|
-
): void {
|
|
226
|
-
try {
|
|
227
|
-
const currentState = router.getState();
|
|
228
|
-
|
|
229
|
-
if (currentState) {
|
|
230
|
-
// Preserve hash on recovery (#532): reading from state.context.url
|
|
231
|
-
// keeps the visible URL fragment intact when a guard rejects a hash-
|
|
232
|
-
// bearing navigation.
|
|
233
|
-
const ctxHash = (
|
|
234
|
-
currentState.context as { url?: { hash?: string } } | undefined
|
|
235
|
-
)?.url?.hash;
|
|
236
|
-
const url = router.buildUrl(
|
|
237
|
-
currentState.name,
|
|
238
|
-
currentState.params,
|
|
239
|
-
ctxHash ? { hash: ctxHash } : undefined,
|
|
240
|
-
);
|
|
241
|
-
|
|
242
|
-
// browser.navigate inside `createNavigationBrowser` tags `info` with
|
|
243
|
-
// PLUGIN_SYNC_INFO so the navigate event this fires is recognised by
|
|
244
|
-
// the handler and short-circuited — no manual flag management here.
|
|
245
|
-
browser.navigate(url, {
|
|
246
|
-
state: {
|
|
247
|
-
name: currentState.name,
|
|
248
|
-
params: currentState.params,
|
|
249
|
-
path: currentState.path,
|
|
250
|
-
},
|
|
251
|
-
history: "replace",
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
} catch (syncError) {
|
|
255
|
-
console.error(
|
|
256
|
-
"[navigation-plugin] Failed to sync URL to router state",
|
|
257
|
-
syncError,
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { safelyEncodePath, extractPath } from "./browser-env";
|
|
2
|
-
|
|
3
|
-
import type { NavigationBrowser } from "./types";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Sentinel carried on `event.info` for every router-driven mutation.
|
|
7
|
-
*
|
|
8
|
-
* The navigate-event handler reads `event.info === PLUGIN_SYNC_INFO` to detect
|
|
9
|
-
* plugin-originated events and short-circuit them with a noop intercept.
|
|
10
|
-
* Identity-based detection works regardless of whether the navigate event is
|
|
11
|
-
* delivered synchronously inside `nav.navigate(...)` (Chromium) or
|
|
12
|
-
* asynchronously on the next task (Safari 26.2 WKWebView — #580).
|
|
13
|
-
*
|
|
14
|
-
* The previous `SyncingFlag` mechanism raised a per-instance boolean before
|
|
15
|
-
* the call and lowered it in a synchronous `finally`. Under Safari WKWebView
|
|
16
|
-
* the flag was already `false` by the time the event arrived, so the handler
|
|
17
|
-
* treated the plugin's own write as user-initiated and re-issued
|
|
18
|
-
* `router.navigate(...)` — render loop on macOS 26.2 Tauri release.
|
|
19
|
-
*
|
|
20
|
-
* Consumers supplying a custom `NavigationBrowser` should pass this value as
|
|
21
|
-
* `info` in their `nav.navigate` / `nav.traverseTo` calls so the plugin can
|
|
22
|
-
* recognise plugin-initiated events. See packages/navigation-plugin/CLAUDE.md.
|
|
23
|
-
*/
|
|
24
|
-
export const PLUGIN_SYNC_INFO = "@real-router/navigation-plugin:syncing";
|
|
25
|
-
|
|
26
|
-
// `traverseTo` options never carry per-call data — the sentinel `info` is the
|
|
27
|
-
// only field — so a single frozen constant is reused across every traversal.
|
|
28
|
-
// Saves one allocation per `nav.traverseTo` on the hot path.
|
|
29
|
-
const TRAVERSE_OPTS: NavigationOptions = Object.freeze({
|
|
30
|
-
info: PLUGIN_SYNC_INFO,
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Creates a NavigationBrowser wrapping the real Navigation API.
|
|
35
|
-
* Only call this when `"navigation" in globalThis` is true.
|
|
36
|
-
*
|
|
37
|
-
* Every router-driven mutation (`navigate`, `replaceState`, `traverseTo`)
|
|
38
|
-
* tags `info` with `PLUGIN_SYNC_INFO` so the navigate-event handler can
|
|
39
|
-
* recognise and short-circuit the event it fires — see `PLUGIN_SYNC_INFO`
|
|
40
|
-
* for the rationale. `updateCurrentEntry` is excluded because it fires
|
|
41
|
-
* `currententrychange`, not `navigate`. Scroll-after-transition suppression
|
|
42
|
-
* (`scroll: "manual"`) lives in `navigate-handler.ts`'s `NOOP_INTERCEPT`
|
|
43
|
-
* — `scroll` is an `event.intercept()` option, not a `nav.navigate()`
|
|
44
|
-
* option per WHATWG Navigation API spec.
|
|
45
|
-
*/
|
|
46
|
-
export function createNavigationBrowser(base: string): NavigationBrowser {
|
|
47
|
-
const nav = globalThis.navigation;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
getLocation: () =>
|
|
51
|
-
safelyEncodePath(extractPath(globalThis.location.pathname, base)) +
|
|
52
|
-
globalThis.location.search,
|
|
53
|
-
|
|
54
|
-
getHash: () => globalThis.location.hash,
|
|
55
|
-
|
|
56
|
-
navigate: (url, options) => {
|
|
57
|
-
nav.navigate(url, { ...options, info: PLUGIN_SYNC_INFO });
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
replaceState: (state, url) => {
|
|
61
|
-
nav.navigate(url, {
|
|
62
|
-
state,
|
|
63
|
-
history: "replace",
|
|
64
|
-
info: PLUGIN_SYNC_INFO,
|
|
65
|
-
});
|
|
66
|
-
},
|
|
67
|
-
|
|
68
|
-
updateCurrentEntry: (options) => {
|
|
69
|
-
nav.updateCurrentEntry(options);
|
|
70
|
-
},
|
|
71
|
-
|
|
72
|
-
traverseTo: (key) => {
|
|
73
|
-
nav.traverseTo(key, TRAVERSE_OPTS);
|
|
74
|
-
},
|
|
75
|
-
|
|
76
|
-
addNavigateListener: (fn) => {
|
|
77
|
-
nav.addEventListener("navigate", fn);
|
|
78
|
-
|
|
79
|
-
return () => {
|
|
80
|
-
nav.removeEventListener("navigate", fn);
|
|
81
|
-
};
|
|
82
|
-
},
|
|
83
|
-
|
|
84
|
-
entries: () => nav.entries(),
|
|
85
|
-
|
|
86
|
-
get currentEntry() {
|
|
87
|
-
return nav.currentEntry;
|
|
88
|
-
},
|
|
89
|
-
|
|
90
|
-
getActivationType: () => nav.activation?.navigationType,
|
|
91
|
-
};
|
|
92
|
-
}
|