@real-router/navigation-plugin 0.7.2 → 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 +23 -1
- 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 +23 -1
- 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 +1 -1
- package/src/href-utils.ts +65 -0
- package/src/index.ts +2 -0
- package/src/navigate-handler.ts +22 -10
- package/src/navigation-browser.ts +33 -78
- package/src/plugin.ts +27 -14
|
@@ -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,71 +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
|
-
// Hot path: each mutation is called on every navigation. Inline the
|
|
84
|
-
// try/finally instead of routing through a generic `wrap` helper — that
|
|
85
|
-
// helper created two closure layers (outer arrow + the `() => fn()` arg)
|
|
86
|
-
// per call. Inlining drops to a single closure and lets V8 monomorphize
|
|
87
|
-
// the call sites.
|
|
88
|
-
return {
|
|
89
|
-
getLocation: () => browser.getLocation(),
|
|
90
|
-
getHash: () => browser.getHash(),
|
|
91
|
-
|
|
92
|
-
navigate: (url, options) => {
|
|
93
|
-
syncing.current = true;
|
|
94
|
-
try {
|
|
95
|
-
browser.navigate(url, options);
|
|
96
|
-
} finally {
|
|
97
|
-
syncing.current = false;
|
|
98
|
-
}
|
|
99
|
-
},
|
|
100
|
-
replaceState: (state, url) => {
|
|
101
|
-
syncing.current = true;
|
|
102
|
-
try {
|
|
103
|
-
browser.replaceState(state, url);
|
|
104
|
-
} finally {
|
|
105
|
-
syncing.current = false;
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
updateCurrentEntry: (options) => {
|
|
109
|
-
syncing.current = true;
|
|
110
|
-
try {
|
|
111
|
-
browser.updateCurrentEntry(options);
|
|
112
|
-
} finally {
|
|
113
|
-
syncing.current = false;
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
traverseTo: (key) => {
|
|
117
|
-
syncing.current = true;
|
|
118
|
-
try {
|
|
119
|
-
browser.traverseTo(key);
|
|
120
|
-
} finally {
|
|
121
|
-
syncing.current = false;
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
|
|
125
|
-
addNavigateListener: (fn) => browser.addNavigateListener(fn),
|
|
126
|
-
entries: () => browser.entries(),
|
|
127
|
-
|
|
128
|
-
get currentEntry() {
|
|
129
|
-
return browser.currentEntry;
|
|
130
|
-
},
|
|
131
|
-
|
|
132
|
-
getActivationType: () => browser.getActivationType(),
|
|
133
|
-
};
|
|
134
|
-
}
|
package/src/plugin.ts
CHANGED
|
@@ -25,11 +25,10 @@ import {
|
|
|
25
25
|
canGoForward,
|
|
26
26
|
canGoBackTo,
|
|
27
27
|
} from "./history-extensions";
|
|
28
|
+
import { isSameHref } from "./href-utils";
|
|
28
29
|
import { createNavigateHandler } from "./navigate-handler";
|
|
29
|
-
import { wrapNavigationBrowserWithSyncing } from "./navigation-browser";
|
|
30
30
|
|
|
31
31
|
import type { UrlContext } from "./browser-env";
|
|
32
|
-
import type { SyncingFlag } from "./navigation-browser";
|
|
33
32
|
import type {
|
|
34
33
|
NavigationBrowser,
|
|
35
34
|
NavigationMeta,
|
|
@@ -76,7 +75,6 @@ export class NavigationPlugin {
|
|
|
76
75
|
release: () => void;
|
|
77
76
|
};
|
|
78
77
|
readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
|
|
79
|
-
readonly #syncing: SyncingFlag = { current: false };
|
|
80
78
|
|
|
81
79
|
#capturedMeta: NavigationMeta | undefined;
|
|
82
80
|
#pendingTraverseKey: string | undefined;
|
|
@@ -111,13 +109,12 @@ export class NavigationPlugin {
|
|
|
111
109
|
this.#router = router;
|
|
112
110
|
this.#api = api;
|
|
113
111
|
this.#options = options;
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
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;
|
|
121
118
|
|
|
122
119
|
this.#claim = api.claimContextNamespace("navigation");
|
|
123
120
|
this.#urlClaim = api.claimContextNamespace("url");
|
|
@@ -182,7 +179,6 @@ export class NavigationPlugin {
|
|
|
182
179
|
router,
|
|
183
180
|
api,
|
|
184
181
|
browser: this.#browser,
|
|
185
|
-
isSyncingFromRouter: () => this.#syncing.current,
|
|
186
182
|
setCapturedMeta: (meta) => {
|
|
187
183
|
this.#capturedMeta = meta;
|
|
188
184
|
},
|
|
@@ -293,8 +289,6 @@ export class NavigationPlugin {
|
|
|
293
289
|
// under memory pressure), we must not leave the stale key behind —
|
|
294
290
|
// otherwise the NEXT transition's onTransitionSuccess would see it
|
|
295
291
|
// and replay the traverse against the same already-broken key.
|
|
296
|
-
// The syncing flag is raised/lowered inside NavigationBrowser around
|
|
297
|
-
// each mutation, so we do not need to manage it here.
|
|
298
292
|
const traverseKey = this.#pendingTraverseKey;
|
|
299
293
|
const traverseHash = this.#pendingTraverseHash;
|
|
300
294
|
|
|
@@ -355,7 +349,26 @@ export class NavigationPlugin {
|
|
|
355
349
|
this.#historyStateBuffer.params = toState.params;
|
|
356
350
|
this.#historyStateBuffer.path = toState.path;
|
|
357
351
|
|
|
358
|
-
|
|
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
|
+
) {
|
|
359
372
|
this.#browser.updateCurrentEntry({
|
|
360
373
|
state: this.#historyStateBuffer,
|
|
361
374
|
});
|