@real-router/navigation-plugin 0.7.2 → 0.7.4

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.
@@ -3,20 +3,42 @@ import { safelyEncodePath, extractPath } from "./browser-env";
3
3
  import type { NavigationBrowser } from "./types";
4
4
 
5
5
  /**
6
- * Mutable cell carrying the "syncing-from-router" flag shared between
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
- * Internal to navigation-plugin not part of the public type surface.
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 interface SyncingFlag {
14
- current: boolean;
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
- // Wrap mutations with the syncing flag so the navigate handler can
115
- // short-circuit re-entrant events fired by the plugin's own writes
116
- // (`nav.navigate` and `nav.navigate({history:"replace"})` fire navigate
117
- // events synchronously). The flag is per-instancenever shared across
118
- // plugins so multiple routers running concurrent transitions don't
119
- // bleed syncing state into each other.
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
- if (toState.name === UNKNOWN_ROUTE) {
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
  });