@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.
@@ -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, isSyncingFromRouter, base, transitionOptions } =
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 (isSyncingFromRouter()) {
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
- event.intercept({
61
- handler: async () => {},
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
- // api.navigateToState: matchPath already applied forwardState +
130
- // matchSourceTrailingSlash; reusing the State avoids the redundant
131
- // round-trip and preserves trailing slashes (#525). Plugin-only
132
- // entry point not on the public Router/Navigator surface.
133
- //
134
- // Hash extraction (#532): pass through the destination's hash so
135
- // onTransitionSuccess sets state.context.url.hash. When the
136
- // browser fires hashChange (same-document fragment-only nav),
137
- // add force+hashChange to bypass SAME_STATES — subscribers
138
- // disambiguate via state.context.url.hashChanged, not via the
139
- // overloaded force flag.
140
- api.navigateToState(matchedState, {
141
- ...transitionOptions,
142
- hash,
143
- ...(event.hashChange ? { force: true, hashChange: true } : {}),
144
- signal: event.signal,
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
- // The syncing flag is raised/lowered inside NavigationBrowser around
206
- // browser.navigate, including the throw path no manual try/finally
207
- // needed here.
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
- * 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,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
- // Wrap mutations with the syncing flag so the navigate handler can
98
- // short-circuit re-entrant events fired by the plugin's own writes
99
- // (`nav.navigate` and `nav.navigate({history:"replace"})` fire navigate
100
- // events synchronously). The flag is per-instancenever shared across
101
- // plugins so multiple routers running concurrent transitions don't
102
- // bleed syncing state into each other.
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
- if (toState.name === UNKNOWN_ROUTE) {
341
- this.#browser.updateCurrentEntry({ state: historyState });
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
- const replace = frozenMeta.navigationType !== "push";
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: historyState,
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() {
package/src/types.ts CHANGED
@@ -6,7 +6,7 @@ export interface NavigationPluginOptions {
6
6
  /**
7
7
  * Bypass canDeactivate guards on browser back/forward.
8
8
  *
9
- * @default true
9
+ * @default false
10
10
  */
11
11
  forceDeactivate?: boolean;
12
12