@real-router/navigation-plugin 0.7.1 → 0.7.3
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.d.ts +24 -2
- package/dist/cjs/index.d.ts.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/esm/index.d.mts +24 -2
- package/dist/esm/index.d.mts.map +1 -1
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/history-extensions.ts +11 -6
- package/src/href-utils.ts +65 -0
- package/src/index.ts +2 -0
- package/src/navigate-handler.ts +91 -68
- package/src/navigation-browser.ts +33 -70
- package/src/plugin.ts +114 -43
- package/src/types.ts +1 -1
package/src/navigate-handler.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { errorCodes, RouterError } from "@real-router/core";
|
|
2
2
|
|
|
3
3
|
import { urlToPathAndHash } from "./browser-env";
|
|
4
|
+
import { PLUGIN_SYNC_INFO } from "./navigation-browser";
|
|
4
5
|
|
|
5
6
|
import type {
|
|
6
7
|
NavigationBrowser,
|
|
@@ -10,11 +11,17 @@ import type {
|
|
|
10
11
|
import type { Router } from "@real-router/core";
|
|
11
12
|
import type { PluginApi } from "@real-router/core/api";
|
|
12
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
|
+
const NOOP_ASYNC = async (): Promise<void> => {};
|
|
19
|
+
const NOOP_INTERCEPT: NavigationInterceptOptions = { handler: NOOP_ASYNC };
|
|
20
|
+
|
|
13
21
|
interface NavigateHandlerDeps {
|
|
14
22
|
router: Router;
|
|
15
23
|
api: PluginApi;
|
|
16
24
|
browser: NavigationBrowser;
|
|
17
|
-
isSyncingFromRouter: () => boolean;
|
|
18
25
|
setCapturedMeta: (meta: NavigationMeta) => void;
|
|
19
26
|
base: string;
|
|
20
27
|
transitionOptions: {
|
|
@@ -41,8 +48,7 @@ export function computeDirection(
|
|
|
41
48
|
}
|
|
42
49
|
|
|
43
50
|
export function createNavigateHandler(deps: NavigateHandlerDeps) {
|
|
44
|
-
const { router, api, browser,
|
|
45
|
-
deps;
|
|
51
|
+
const { router, api, browser, base, transitionOptions } = deps;
|
|
46
52
|
const { allowNotFound } = api.getOptions();
|
|
47
53
|
|
|
48
54
|
return function handleNavigateEvent(event: NavigateEvent): void {
|
|
@@ -50,16 +56,22 @@ export function createNavigateHandler(deps: NavigateHandlerDeps) {
|
|
|
50
56
|
return;
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
if (
|
|
59
|
+
if (event.info === PLUGIN_SYNC_INFO) {
|
|
54
60
|
// Plugin-originated navigate event after its own successful transition
|
|
55
61
|
// (onTransitionSuccess calls browser.navigate to sync URL). We must still
|
|
56
62
|
// intercept — a bare `return` leaves the event un-intercepted, and
|
|
57
63
|
// Chromium falls back to a cross-document navigation (full page reload).
|
|
58
64
|
// The noop handler cancels the fallback without running router logic;
|
|
59
65
|
// state is already committed.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
//
|
|
67
|
+
// Detection by `event.info` (identity) instead of a synchronous flag
|
|
68
|
+
// (timing) so this works under Safari 26.2 WKWebView, which delivers
|
|
69
|
+
// navigate events on a subsequent task — by then a `finally`-cleared
|
|
70
|
+
// flag would already be false and the handler would loop (#580).
|
|
71
|
+
//
|
|
72
|
+
// NOOP_INTERCEPT is module-level so the intercept options + handler
|
|
73
|
+
// are not re-allocated per navigation (hot path).
|
|
74
|
+
event.intercept(NOOP_INTERCEPT);
|
|
63
75
|
|
|
64
76
|
return;
|
|
65
77
|
}
|
|
@@ -82,67 +94,30 @@ export function createNavigateHandler(deps: NavigateHandlerDeps) {
|
|
|
82
94
|
sourceElement: event.sourceElement ?? null,
|
|
83
95
|
});
|
|
84
96
|
|
|
85
|
-
const withRecovery = async (run: () => Promise<unknown>): Promise<void> => {
|
|
86
|
-
try {
|
|
87
|
-
await run();
|
|
88
|
-
} catch (error) {
|
|
89
|
-
if (!(error instanceof RouterError)) {
|
|
90
|
-
recoverFromNavigateError(error, router, browser);
|
|
91
|
-
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// TRANSITION_CANCELLED: a newer navigation aborted this one — the
|
|
96
|
-
// newer navigate event is (or will be) handled by this same plugin,
|
|
97
|
-
// and THAT event is responsible for syncing URL/state. Firing our
|
|
98
|
-
// own sync here races against it: browser.navigate(replace, same-url)
|
|
99
|
-
// would cancel the in-flight newer transition, which is exactly the
|
|
100
|
-
// rapid-fire-events storm failure mode.
|
|
101
|
-
//
|
|
102
|
-
// SAME_STATES: router refused because router.getState() already equals
|
|
103
|
-
// the target. URL and router state are already consistent — no sync
|
|
104
|
-
// needed.
|
|
105
|
-
if (
|
|
106
|
-
error.code === errorCodes.TRANSITION_CANCELLED ||
|
|
107
|
-
error.code === errorCodes.SAME_STATES
|
|
108
|
-
) {
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,
|
|
113
|
-
// ROUTE_NOT_FOUND, …) — router rejected the transition, state is
|
|
114
|
-
// unchanged, but URL may have already committed to a different
|
|
115
|
-
// value by the Navigation API. Sync the URL back to the current
|
|
116
|
-
// router state in a single visible transition (headless Chromium
|
|
117
|
-
// and some cross-origin setups leave "committed-then-reverted"
|
|
118
|
-
// windows if we relied on the native rollback via intercept reject).
|
|
119
|
-
// Observers that care about the error see it through the router's
|
|
120
|
-
// TRANSITION_ERROR event.
|
|
121
|
-
syncUrlToRouterState(router, browser);
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
|
|
125
97
|
if (matchedState) {
|
|
126
98
|
event.intercept({
|
|
127
99
|
handler: () =>
|
|
128
|
-
withRecovery(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
100
|
+
withRecovery(
|
|
101
|
+
() =>
|
|
102
|
+
// api.navigateToState: matchPath already applied forwardState +
|
|
103
|
+
// matchSourceTrailingSlash; reusing the State avoids the redundant
|
|
104
|
+
// round-trip and preserves trailing slashes (#525). Plugin-only
|
|
105
|
+
// entry point — not on the public Router/Navigator surface.
|
|
106
|
+
//
|
|
107
|
+
// Hash extraction (#532): pass through the destination's hash so
|
|
108
|
+
// onTransitionSuccess sets state.context.url.hash. When the
|
|
109
|
+
// browser fires hashChange (same-document fragment-only nav),
|
|
110
|
+
// add force+hashChange to bypass SAME_STATES — subscribers
|
|
111
|
+
// disambiguate via state.context.url.hashChanged, not via the
|
|
112
|
+
// overloaded force flag.
|
|
113
|
+
api.navigateToState(matchedState, {
|
|
114
|
+
...transitionOptions,
|
|
115
|
+
hash,
|
|
116
|
+
...(event.hashChange ? { force: true, hashChange: true } : {}),
|
|
117
|
+
signal: event.signal,
|
|
118
|
+
}),
|
|
119
|
+
router,
|
|
120
|
+
browser,
|
|
146
121
|
),
|
|
147
122
|
});
|
|
148
123
|
} else if (allowNotFound) {
|
|
@@ -169,6 +144,54 @@ export function createNavigateHandler(deps: NavigateHandlerDeps) {
|
|
|
169
144
|
};
|
|
170
145
|
}
|
|
171
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Module-scope helper hoisted out of handleNavigateEvent so the closure is
|
|
149
|
+
* not re-allocated on every navigate event. The router/browser refs come from
|
|
150
|
+
* arguments instead of an enclosing scope; identical behaviour, fewer GC'd
|
|
151
|
+
* closures.
|
|
152
|
+
*/
|
|
153
|
+
async function withRecovery(
|
|
154
|
+
run: () => Promise<unknown>,
|
|
155
|
+
router: Router,
|
|
156
|
+
browser: NavigationBrowser,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
try {
|
|
159
|
+
await run();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!(error instanceof RouterError)) {
|
|
162
|
+
recoverFromNavigateError(error, router, browser);
|
|
163
|
+
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// TRANSITION_CANCELLED: a newer navigation aborted this one — the newer
|
|
168
|
+
// navigate event is (or will be) handled by this same plugin, and THAT
|
|
169
|
+
// event is responsible for syncing URL/state. Firing our own sync here
|
|
170
|
+
// races against it: browser.navigate(replace, same-url) would cancel the
|
|
171
|
+
// in-flight newer transition, which is exactly the rapid-fire-events storm
|
|
172
|
+
// failure mode.
|
|
173
|
+
//
|
|
174
|
+
// SAME_STATES: router refused because router.getState() already equals the
|
|
175
|
+
// target. URL and router state are already consistent — no sync needed.
|
|
176
|
+
if (
|
|
177
|
+
error.code === errorCodes.TRANSITION_CANCELLED ||
|
|
178
|
+
error.code === errorCodes.SAME_STATES
|
|
179
|
+
) {
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Other RouterError codes (CANNOT_DEACTIVATE, CANNOT_ACTIVATE,
|
|
184
|
+
// ROUTE_NOT_FOUND, …) — router rejected the transition, state is
|
|
185
|
+
// unchanged, but URL may have already committed to a different value by
|
|
186
|
+
// the Navigation API. Sync the URL back to the current router state in a
|
|
187
|
+
// single visible transition (headless Chromium and some cross-origin
|
|
188
|
+
// setups leave "committed-then-reverted" windows if we relied on the
|
|
189
|
+
// native rollback via intercept reject). Observers that care about the
|
|
190
|
+
// error see it through the router's TRANSITION_ERROR event.
|
|
191
|
+
syncUrlToRouterState(router, browser);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
function recoverFromNavigateError(
|
|
173
196
|
error: unknown,
|
|
174
197
|
router: Router,
|
|
@@ -202,9 +225,9 @@ function syncUrlToRouterState(
|
|
|
202
225
|
ctxHash ? { hash: ctxHash } : undefined,
|
|
203
226
|
);
|
|
204
227
|
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
228
|
+
// browser.navigate inside `createNavigationBrowser` tags `info` with
|
|
229
|
+
// PLUGIN_SYNC_INFO so the navigate event this fires is recognised by
|
|
230
|
+
// the handler and short-circuited — no manual flag management here.
|
|
208
231
|
browser.navigate(url, {
|
|
209
232
|
state: {
|
|
210
233
|
name: currentState.name,
|
|
@@ -3,20 +3,42 @@ import { safelyEncodePath, extractPath } from "./browser-env";
|
|
|
3
3
|
import type { NavigationBrowser } from "./types";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
7
|
-
* `wrapNavigationBrowserWithSyncing` (which raises it around every router-driven
|
|
8
|
-
* mutation) and the plugin's navigate handler (which reads it to short-circuit
|
|
9
|
-
* the event fired by the plugin's own write).
|
|
6
|
+
* Sentinel carried on `event.info` for every router-driven mutation.
|
|
10
7
|
*
|
|
11
|
-
*
|
|
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.
|
|
12
23
|
*/
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
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
|
+
});
|
|
16
32
|
|
|
17
33
|
/**
|
|
18
34
|
* Creates a NavigationBrowser wrapping the real Navigation API.
|
|
19
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`.
|
|
20
42
|
*/
|
|
21
43
|
export function createNavigationBrowser(base: string): NavigationBrowser {
|
|
22
44
|
const nav = globalThis.navigation;
|
|
@@ -29,13 +51,14 @@ export function createNavigationBrowser(base: string): NavigationBrowser {
|
|
|
29
51
|
getHash: () => globalThis.location.hash,
|
|
30
52
|
|
|
31
53
|
navigate: (url, options) => {
|
|
32
|
-
nav.navigate(url, options);
|
|
54
|
+
nav.navigate(url, { ...options, info: PLUGIN_SYNC_INFO });
|
|
33
55
|
},
|
|
34
56
|
|
|
35
57
|
replaceState: (state, url) => {
|
|
36
58
|
nav.navigate(url, {
|
|
37
59
|
state,
|
|
38
60
|
history: "replace",
|
|
61
|
+
info: PLUGIN_SYNC_INFO,
|
|
39
62
|
});
|
|
40
63
|
},
|
|
41
64
|
|
|
@@ -44,7 +67,7 @@ export function createNavigationBrowser(base: string): NavigationBrowser {
|
|
|
44
67
|
},
|
|
45
68
|
|
|
46
69
|
traverseTo: (key) => {
|
|
47
|
-
nav.traverseTo(key);
|
|
70
|
+
nav.traverseTo(key, TRAVERSE_OPTS);
|
|
48
71
|
},
|
|
49
72
|
|
|
50
73
|
addNavigateListener: (fn) => {
|
|
@@ -64,63 +87,3 @@ export function createNavigationBrowser(base: string): NavigationBrowser {
|
|
|
64
87
|
getActivationType: () => nav.activation?.navigationType,
|
|
65
88
|
};
|
|
66
89
|
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Wraps every router-driven mutation of a NavigationBrowser with the syncing
|
|
70
|
-
* flag — raised before the underlying call, lowered after, including the
|
|
71
|
-
* throw path. The plugin's navigate handler reads `syncing.current` to
|
|
72
|
-
* short-circuit the navigate event fired by the plugin's own write
|
|
73
|
-
* (`nav.navigate(...)` and `nav.navigate({history:"replace"})` both fire
|
|
74
|
-
* navigate events synchronously).
|
|
75
|
-
*
|
|
76
|
-
* Applied at the factory level to both the built-in `createNavigationBrowser`
|
|
77
|
-
* and any user-supplied browser, so consumers don't need to manage the flag.
|
|
78
|
-
*/
|
|
79
|
-
export function wrapNavigationBrowserWithSyncing(
|
|
80
|
-
browser: NavigationBrowser,
|
|
81
|
-
syncing: SyncingFlag,
|
|
82
|
-
): NavigationBrowser {
|
|
83
|
-
const wrap = <T>(fn: () => T): T => {
|
|
84
|
-
syncing.current = true;
|
|
85
|
-
try {
|
|
86
|
-
return fn();
|
|
87
|
-
} finally {
|
|
88
|
-
syncing.current = false;
|
|
89
|
-
}
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
return {
|
|
93
|
-
getLocation: () => browser.getLocation(),
|
|
94
|
-
getHash: () => browser.getHash(),
|
|
95
|
-
|
|
96
|
-
navigate: (url, options) => {
|
|
97
|
-
wrap(() => {
|
|
98
|
-
browser.navigate(url, options);
|
|
99
|
-
});
|
|
100
|
-
},
|
|
101
|
-
replaceState: (state, url) => {
|
|
102
|
-
wrap(() => {
|
|
103
|
-
browser.replaceState(state, url);
|
|
104
|
-
});
|
|
105
|
-
},
|
|
106
|
-
updateCurrentEntry: (options) => {
|
|
107
|
-
wrap(() => {
|
|
108
|
-
browser.updateCurrentEntry(options);
|
|
109
|
-
});
|
|
110
|
-
},
|
|
111
|
-
traverseTo: (key) => {
|
|
112
|
-
wrap(() => {
|
|
113
|
-
browser.traverseTo(key);
|
|
114
|
-
});
|
|
115
|
-
},
|
|
116
|
-
|
|
117
|
-
addNavigateListener: (fn) => browser.addNavigateListener(fn),
|
|
118
|
-
entries: () => browser.entries(),
|
|
119
|
-
|
|
120
|
-
get currentEntry() {
|
|
121
|
-
return browser.currentEntry;
|
|
122
|
-
},
|
|
123
|
-
|
|
124
|
-
getActivationType: () => browser.getActivationType(),
|
|
125
|
-
};
|
|
126
|
-
}
|
package/src/plugin.ts
CHANGED
|
@@ -4,11 +4,14 @@ import {
|
|
|
4
4
|
shouldReplaceHistory,
|
|
5
5
|
buildUrl,
|
|
6
6
|
urlToPath,
|
|
7
|
+
createPluginBuildUrl,
|
|
7
8
|
createStartInterceptor,
|
|
8
9
|
createReplaceHistoryState,
|
|
9
10
|
encodeHashFragment,
|
|
10
11
|
getDecodedHash,
|
|
11
12
|
normalizeHashInput,
|
|
13
|
+
safeParseUrl,
|
|
14
|
+
decodeHashFragment,
|
|
12
15
|
} from "./browser-env";
|
|
13
16
|
import {
|
|
14
17
|
peekBack,
|
|
@@ -22,11 +25,10 @@ import {
|
|
|
22
25
|
canGoForward,
|
|
23
26
|
canGoBackTo,
|
|
24
27
|
} from "./history-extensions";
|
|
28
|
+
import { isSameHref } from "./href-utils";
|
|
25
29
|
import { createNavigateHandler } from "./navigate-handler";
|
|
26
|
-
import { wrapNavigationBrowserWithSyncing } from "./navigation-browser";
|
|
27
30
|
|
|
28
31
|
import type { UrlContext } from "./browser-env";
|
|
29
|
-
import type { SyncingFlag } from "./navigation-browser";
|
|
30
32
|
import type {
|
|
31
33
|
NavigationBrowser,
|
|
32
34
|
NavigationMeta,
|
|
@@ -35,7 +37,6 @@ import type {
|
|
|
35
37
|
} from "./types";
|
|
36
38
|
import type {
|
|
37
39
|
NavigationOptions,
|
|
38
|
-
Params,
|
|
39
40
|
Router,
|
|
40
41
|
State,
|
|
41
42
|
Plugin,
|
|
@@ -74,10 +75,24 @@ export class NavigationPlugin {
|
|
|
74
75
|
release: () => void;
|
|
75
76
|
};
|
|
76
77
|
readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
|
|
77
|
-
readonly #syncing: SyncingFlag = { current: false };
|
|
78
78
|
|
|
79
79
|
#capturedMeta: NavigationMeta | undefined;
|
|
80
80
|
#pendingTraverseKey: string | undefined;
|
|
81
|
+
// Always set together with #pendingTraverseKey; `""` means "destination has
|
|
82
|
+
// no fragment". Typed as `string` (not `string | undefined`) so the traverse
|
|
83
|
+
// branch reads it without a redundant `?? ""` fallback that coverage cannot
|
|
84
|
+
// exercise.
|
|
85
|
+
#pendingTraverseHash = "";
|
|
86
|
+
// Reusable buffer for the {name, params, path} payload passed to
|
|
87
|
+
// browser.navigate / browser.updateCurrentEntry. The Navigation API
|
|
88
|
+
// structured-clones state synchronously inside the call, so this object
|
|
89
|
+
// never escapes — same trick createReplaceHistoryState uses.
|
|
90
|
+
readonly #historyStateBuffer: { name: string; params: object; path: string } =
|
|
91
|
+
{
|
|
92
|
+
name: "",
|
|
93
|
+
params: {},
|
|
94
|
+
path: "",
|
|
95
|
+
};
|
|
81
96
|
|
|
82
97
|
constructor(
|
|
83
98
|
router: Router,
|
|
@@ -94,13 +109,12 @@ export class NavigationPlugin {
|
|
|
94
109
|
this.#router = router;
|
|
95
110
|
this.#api = api;
|
|
96
111
|
this.#options = options;
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
this.#browser = wrapNavigationBrowserWithSyncing(browser, this.#syncing);
|
|
112
|
+
// The navigate handler short-circuits re-entrant events from plugin-
|
|
113
|
+
// initiated writes by checking `event.info === PLUGIN_SYNC_INFO`. The
|
|
114
|
+
// built-in `createNavigationBrowser` tags every mutation with that
|
|
115
|
+
// sentinel; consumer-supplied browsers must do the same — see CLAUDE.md
|
|
116
|
+
// "Router-driven mutations re-enter the navigate handler".
|
|
117
|
+
this.#browser = browser;
|
|
104
118
|
|
|
105
119
|
this.#claim = api.claimContextNamespace("navigation");
|
|
106
120
|
this.#urlClaim = api.claimContextNamespace("url");
|
|
@@ -133,22 +147,7 @@ export class NavigationPlugin {
|
|
|
133
147
|
// bar entry: by the time onTransitionSuccess fires the browser already
|
|
134
148
|
// reflects the destination URL.
|
|
135
149
|
|
|
136
|
-
const pluginBuildUrl = (
|
|
137
|
-
route: string,
|
|
138
|
-
params?: Params,
|
|
139
|
-
opts?: { hash?: string },
|
|
140
|
-
) => {
|
|
141
|
-
const path = router.buildPath(route, params);
|
|
142
|
-
const url = buildUrl(path, options.base);
|
|
143
|
-
|
|
144
|
-
if (opts?.hash === undefined) {
|
|
145
|
-
return url;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const norm = normalizeHashInput(opts.hash);
|
|
149
|
-
|
|
150
|
-
return norm ? `${url}#${encodeHashFragment(norm)}` : url;
|
|
151
|
-
};
|
|
150
|
+
const pluginBuildUrl = createPluginBuildUrl(router, options.base);
|
|
152
151
|
|
|
153
152
|
this.#removeExtensions = api.extendRouter({
|
|
154
153
|
buildUrl: pluginBuildUrl,
|
|
@@ -180,7 +179,6 @@ export class NavigationPlugin {
|
|
|
180
179
|
router,
|
|
181
180
|
api,
|
|
182
181
|
browser: this.#browser,
|
|
183
|
-
isSyncingFromRouter: () => this.#syncing.current,
|
|
184
182
|
setCapturedMeta: (meta) => {
|
|
185
183
|
this.#capturedMeta = meta;
|
|
186
184
|
},
|
|
@@ -216,7 +214,7 @@ export class NavigationPlugin {
|
|
|
216
214
|
// unmatched url — same three error branches the old inline checks
|
|
217
215
|
// produced. Extracted so the error paths can be unit-tested directly
|
|
218
216
|
// without namespace-level vi.spyOn gymnastics.
|
|
219
|
-
const { entry, matchedState } = resolveEntryToMatchedState(
|
|
217
|
+
const { entry, entryUrl, matchedState } = resolveEntryToMatchedState(
|
|
220
218
|
candidate,
|
|
221
219
|
routeName,
|
|
222
220
|
this.#api,
|
|
@@ -243,6 +241,10 @@ export class NavigationPlugin {
|
|
|
243
241
|
sourceElement: null,
|
|
244
242
|
};
|
|
245
243
|
this.#pendingTraverseKey = entry.key;
|
|
244
|
+
// Capture the destination entry's hash so onTransitionSuccess can populate
|
|
245
|
+
// state.context.url for the traverse branch — mirrors what navigate-handler
|
|
246
|
+
// does via navOptions.hash for browser-initiated navigation.
|
|
247
|
+
this.#pendingTraverseHash = extractHashFromEntryUrl(entryUrl);
|
|
246
248
|
|
|
247
249
|
return this.#router.navigate(matchedState.name, matchedState.params);
|
|
248
250
|
}
|
|
@@ -287,13 +289,27 @@ export class NavigationPlugin {
|
|
|
287
289
|
// under memory pressure), we must not leave the stale key behind —
|
|
288
290
|
// otherwise the NEXT transition's onTransitionSuccess would see it
|
|
289
291
|
// and replay the traverse against the same already-broken key.
|
|
290
|
-
// The syncing flag is raised/lowered inside NavigationBrowser around
|
|
291
|
-
// each mutation, so we do not need to manage it here.
|
|
292
292
|
const traverseKey = this.#pendingTraverseKey;
|
|
293
|
+
const traverseHash = this.#pendingTraverseHash;
|
|
293
294
|
|
|
294
295
|
this.#pendingTraverseKey = undefined;
|
|
296
|
+
this.#pendingTraverseHash = "";
|
|
297
|
+
|
|
298
|
+
const publishedPrevHash = readPublishedHash(fromState);
|
|
295
299
|
|
|
296
300
|
if (traverseKey) {
|
|
301
|
+
// Mirror the urlClaim.write the `else` branch does for non-traverse
|
|
302
|
+
// navigations — without this, `router.traverseToLast(name)` leaves
|
|
303
|
+
// state.context.url undefined for subscribers (#urlClaim was set in
|
|
304
|
+
// navigate-handler for browser-driven traverse, but programmatic
|
|
305
|
+
// traverseToLast bypasses that path).
|
|
306
|
+
this.#urlClaim.write(
|
|
307
|
+
toState,
|
|
308
|
+
Object.freeze({
|
|
309
|
+
hash: traverseHash,
|
|
310
|
+
hashChanged: traverseHash !== publishedPrevHash,
|
|
311
|
+
}),
|
|
312
|
+
);
|
|
297
313
|
this.#browser.traverseTo(traverseKey);
|
|
298
314
|
} else {
|
|
299
315
|
// Tri-state hash resolution (#532).
|
|
@@ -312,9 +328,6 @@ export class NavigationPlugin {
|
|
|
312
328
|
// a true signal regardless of whether the value came from
|
|
313
329
|
// navOptions or the browser.
|
|
314
330
|
const browserHash = getDecodedHash(this.#browser);
|
|
315
|
-
const publishedPrevHash =
|
|
316
|
-
(fromState?.context as { url?: { hash?: string } } | undefined)?.url
|
|
317
|
-
?.hash ?? "";
|
|
318
331
|
|
|
319
332
|
const hash =
|
|
320
333
|
navOptions.hash === undefined
|
|
@@ -331,19 +344,48 @@ export class NavigationPlugin {
|
|
|
331
344
|
|
|
332
345
|
const url = buildUrl(toState.path, this.#options.base);
|
|
333
346
|
const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;
|
|
334
|
-
const historyState = {
|
|
335
|
-
name: toState.name,
|
|
336
|
-
params: toState.params,
|
|
337
|
-
path: toState.path,
|
|
338
|
-
};
|
|
339
347
|
|
|
340
|
-
|
|
341
|
-
|
|
348
|
+
this.#historyStateBuffer.name = toState.name;
|
|
349
|
+
this.#historyStateBuffer.params = toState.params;
|
|
350
|
+
this.#historyStateBuffer.path = toState.path;
|
|
351
|
+
|
|
352
|
+
// Two cases route through `updateCurrentEntry` (state-only mutation
|
|
353
|
+
// of the current history entry, no navigate event):
|
|
354
|
+
//
|
|
355
|
+
// 1. UNKNOWN_ROUTE — URL stays as the browser had it; we only need
|
|
356
|
+
// to tag the entry's state with the router's `name/params/path`.
|
|
357
|
+
// 2. Same-URL transition (#580) — the target URL is what the
|
|
358
|
+
// browser already shows, so a `nav.navigate(url,
|
|
359
|
+
// {history:"replace"})` would either be a no-op (Chromium fires
|
|
360
|
+
// a navigate event we short-circuit via `event.info ===
|
|
361
|
+
// PLUGIN_SYNC_INFO`) or — on Safari 26.2 WKWebView under custom
|
|
362
|
+
// protocols (`tauri://`, `app://`) — a *cross-document*
|
|
363
|
+
// navigation that discards the JS context. The bootstrap then
|
|
364
|
+
// re-runs the plugin which re-issues the same call, and the
|
|
365
|
+
// cycle becomes a render loop the user perceives as flicker.
|
|
366
|
+
// `updateCurrentEntry` is the spec-correct primitive for a
|
|
367
|
+
// state-only mutation and avoids both behaviours.
|
|
368
|
+
if (
|
|
369
|
+
toState.name === UNKNOWN_ROUTE ||
|
|
370
|
+
isSameHref(finalUrl, this.#browser.currentEntry?.url)
|
|
371
|
+
) {
|
|
372
|
+
this.#browser.updateCurrentEntry({
|
|
373
|
+
state: this.#historyStateBuffer,
|
|
374
|
+
});
|
|
342
375
|
} else {
|
|
343
|
-
|
|
376
|
+
// Initial transition (no fromState) means router.start() is
|
|
377
|
+
// resolving the cross-document load — the browser already created
|
|
378
|
+
// a history entry for it. A `push` here would duplicate that
|
|
379
|
+
// entry. Always `replace` on the first transition so the
|
|
380
|
+
// back/forward stack has only one entry (canGoBack === false).
|
|
381
|
+
// navigationType metadata stays "push"/"reload"/"replace" for
|
|
382
|
+
// downstream consumers (scroll restore, direction tracker).
|
|
383
|
+
const isInitialTransition = fromState === undefined;
|
|
384
|
+
const replace =
|
|
385
|
+
frozenMeta.navigationType !== "push" || isInitialTransition;
|
|
344
386
|
|
|
345
387
|
this.#browser.navigate(finalUrl, {
|
|
346
|
-
state:
|
|
388
|
+
state: this.#historyStateBuffer,
|
|
347
389
|
history: replace ? "replace" : "push",
|
|
348
390
|
});
|
|
349
391
|
}
|
|
@@ -353,11 +395,13 @@ export class NavigationPlugin {
|
|
|
353
395
|
onTransitionCancel: () => {
|
|
354
396
|
this.#capturedMeta = undefined;
|
|
355
397
|
this.#pendingTraverseKey = undefined;
|
|
398
|
+
this.#pendingTraverseHash = "";
|
|
356
399
|
},
|
|
357
400
|
|
|
358
401
|
onTransitionError: () => {
|
|
359
402
|
this.#capturedMeta = undefined;
|
|
360
403
|
this.#pendingTraverseKey = undefined;
|
|
404
|
+
this.#pendingTraverseHash = "";
|
|
361
405
|
},
|
|
362
406
|
};
|
|
363
407
|
}
|
|
@@ -372,6 +416,33 @@ interface NavigateLifecycleDeps {
|
|
|
372
416
|
shared: NavigationSharedState;
|
|
373
417
|
}
|
|
374
418
|
|
|
419
|
+
/**
|
|
420
|
+
* Reads the previously published hash from `fromState.context.url`.
|
|
421
|
+
* Returns `""` for the initial transition (no `fromState`), for states whose
|
|
422
|
+
* `context.url` namespace was not claimed yet, or for the documented `{ hash:
|
|
423
|
+
* "" }` cleared form. Extracted from `onTransitionSuccess` to share between
|
|
424
|
+
* the traverse and non-traverse branches.
|
|
425
|
+
*/
|
|
426
|
+
function readPublishedHash(fromState: State | undefined): string {
|
|
427
|
+
return (
|
|
428
|
+
(fromState?.context as { url?: { hash?: string } } | undefined)?.url
|
|
429
|
+
?.hash ?? ""
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Decodes the URL fragment from a NavigationHistoryEntry's url string.
|
|
435
|
+
* Returns `""` when no fragment is present. The caller (NavigationPlugin's
|
|
436
|
+
* `traverseToLast`) only reaches here AFTER `resolveEntryToMatchedState`,
|
|
437
|
+
* which has already rejected `entry.url === null`, so the input is guaranteed
|
|
438
|
+
* non-null at runtime.
|
|
439
|
+
*/
|
|
440
|
+
function extractHashFromEntryUrl(entryUrl: string): string {
|
|
441
|
+
const rawHash = safeParseUrl(entryUrl).hash;
|
|
442
|
+
|
|
443
|
+
return rawHash ? decodeHashFragment(rawHash.slice(1)) : "";
|
|
444
|
+
}
|
|
445
|
+
|
|
375
446
|
function createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {
|
|
376
447
|
return {
|
|
377
448
|
onStart() {
|