@real-router/navigation-plugin 0.7.1 → 0.7.2

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/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,
@@ -35,7 +38,6 @@ import type {
35
38
  } from "./types";
36
39
  import type {
37
40
  NavigationOptions,
38
- Params,
39
41
  Router,
40
42
  State,
41
43
  Plugin,
@@ -78,6 +80,21 @@ export class NavigationPlugin {
78
80
 
79
81
  #capturedMeta: NavigationMeta | undefined;
80
82
  #pendingTraverseKey: string | undefined;
83
+ // Always set together with #pendingTraverseKey; `""` means "destination has
84
+ // no fragment". Typed as `string` (not `string | undefined`) so the traverse
85
+ // branch reads it without a redundant `?? ""` fallback that coverage cannot
86
+ // exercise.
87
+ #pendingTraverseHash = "";
88
+ // Reusable buffer for the {name, params, path} payload passed to
89
+ // browser.navigate / browser.updateCurrentEntry. The Navigation API
90
+ // structured-clones state synchronously inside the call, so this object
91
+ // never escapes — same trick createReplaceHistoryState uses.
92
+ readonly #historyStateBuffer: { name: string; params: object; path: string } =
93
+ {
94
+ name: "",
95
+ params: {},
96
+ path: "",
97
+ };
81
98
 
82
99
  constructor(
83
100
  router: Router,
@@ -133,22 +150,7 @@ export class NavigationPlugin {
133
150
  // bar entry: by the time onTransitionSuccess fires the browser already
134
151
  // reflects the destination URL.
135
152
 
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
- };
153
+ const pluginBuildUrl = createPluginBuildUrl(router, options.base);
152
154
 
153
155
  this.#removeExtensions = api.extendRouter({
154
156
  buildUrl: pluginBuildUrl,
@@ -216,7 +218,7 @@ export class NavigationPlugin {
216
218
  // unmatched url — same three error branches the old inline checks
217
219
  // produced. Extracted so the error paths can be unit-tested directly
218
220
  // without namespace-level vi.spyOn gymnastics.
219
- const { entry, matchedState } = resolveEntryToMatchedState(
221
+ const { entry, entryUrl, matchedState } = resolveEntryToMatchedState(
220
222
  candidate,
221
223
  routeName,
222
224
  this.#api,
@@ -243,6 +245,10 @@ export class NavigationPlugin {
243
245
  sourceElement: null,
244
246
  };
245
247
  this.#pendingTraverseKey = entry.key;
248
+ // Capture the destination entry's hash so onTransitionSuccess can populate
249
+ // state.context.url for the traverse branch — mirrors what navigate-handler
250
+ // does via navOptions.hash for browser-initiated navigation.
251
+ this.#pendingTraverseHash = extractHashFromEntryUrl(entryUrl);
246
252
 
247
253
  return this.#router.navigate(matchedState.name, matchedState.params);
248
254
  }
@@ -290,10 +296,26 @@ export class NavigationPlugin {
290
296
  // The syncing flag is raised/lowered inside NavigationBrowser around
291
297
  // each mutation, so we do not need to manage it here.
292
298
  const traverseKey = this.#pendingTraverseKey;
299
+ const traverseHash = this.#pendingTraverseHash;
293
300
 
294
301
  this.#pendingTraverseKey = undefined;
302
+ this.#pendingTraverseHash = "";
303
+
304
+ const publishedPrevHash = readPublishedHash(fromState);
295
305
 
296
306
  if (traverseKey) {
307
+ // Mirror the urlClaim.write the `else` branch does for non-traverse
308
+ // navigations — without this, `router.traverseToLast(name)` leaves
309
+ // state.context.url undefined for subscribers (#urlClaim was set in
310
+ // navigate-handler for browser-driven traverse, but programmatic
311
+ // traverseToLast bypasses that path).
312
+ this.#urlClaim.write(
313
+ toState,
314
+ Object.freeze({
315
+ hash: traverseHash,
316
+ hashChanged: traverseHash !== publishedPrevHash,
317
+ }),
318
+ );
297
319
  this.#browser.traverseTo(traverseKey);
298
320
  } else {
299
321
  // Tri-state hash resolution (#532).
@@ -312,9 +334,6 @@ export class NavigationPlugin {
312
334
  // a true signal regardless of whether the value came from
313
335
  // navOptions or the browser.
314
336
  const browserHash = getDecodedHash(this.#browser);
315
- const publishedPrevHash =
316
- (fromState?.context as { url?: { hash?: string } } | undefined)?.url
317
- ?.hash ?? "";
318
337
 
319
338
  const hash =
320
339
  navOptions.hash === undefined
@@ -331,19 +350,29 @@ export class NavigationPlugin {
331
350
 
332
351
  const url = buildUrl(toState.path, this.#options.base);
333
352
  const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;
334
- const historyState = {
335
- name: toState.name,
336
- params: toState.params,
337
- path: toState.path,
338
- };
353
+
354
+ this.#historyStateBuffer.name = toState.name;
355
+ this.#historyStateBuffer.params = toState.params;
356
+ this.#historyStateBuffer.path = toState.path;
339
357
 
340
358
  if (toState.name === UNKNOWN_ROUTE) {
341
- this.#browser.updateCurrentEntry({ state: historyState });
359
+ this.#browser.updateCurrentEntry({
360
+ state: this.#historyStateBuffer,
361
+ });
342
362
  } else {
343
- const replace = frozenMeta.navigationType !== "push";
363
+ // Initial transition (no fromState) means router.start() is
364
+ // resolving the cross-document load — the browser already created
365
+ // a history entry for it. A `push` here would duplicate that
366
+ // entry. Always `replace` on the first transition so the
367
+ // back/forward stack has only one entry (canGoBack === false).
368
+ // navigationType metadata stays "push"/"reload"/"replace" for
369
+ // downstream consumers (scroll restore, direction tracker).
370
+ const isInitialTransition = fromState === undefined;
371
+ const replace =
372
+ frozenMeta.navigationType !== "push" || isInitialTransition;
344
373
 
345
374
  this.#browser.navigate(finalUrl, {
346
- state: historyState,
375
+ state: this.#historyStateBuffer,
347
376
  history: replace ? "replace" : "push",
348
377
  });
349
378
  }
@@ -353,11 +382,13 @@ export class NavigationPlugin {
353
382
  onTransitionCancel: () => {
354
383
  this.#capturedMeta = undefined;
355
384
  this.#pendingTraverseKey = undefined;
385
+ this.#pendingTraverseHash = "";
356
386
  },
357
387
 
358
388
  onTransitionError: () => {
359
389
  this.#capturedMeta = undefined;
360
390
  this.#pendingTraverseKey = undefined;
391
+ this.#pendingTraverseHash = "";
361
392
  },
362
393
  };
363
394
  }
@@ -372,6 +403,33 @@ interface NavigateLifecycleDeps {
372
403
  shared: NavigationSharedState;
373
404
  }
374
405
 
406
+ /**
407
+ * Reads the previously published hash from `fromState.context.url`.
408
+ * Returns `""` for the initial transition (no `fromState`), for states whose
409
+ * `context.url` namespace was not claimed yet, or for the documented `{ hash:
410
+ * "" }` cleared form. Extracted from `onTransitionSuccess` to share between
411
+ * the traverse and non-traverse branches.
412
+ */
413
+ function readPublishedHash(fromState: State | undefined): string {
414
+ return (
415
+ (fromState?.context as { url?: { hash?: string } } | undefined)?.url
416
+ ?.hash ?? ""
417
+ );
418
+ }
419
+
420
+ /**
421
+ * Decodes the URL fragment from a NavigationHistoryEntry's url string.
422
+ * Returns `""` when no fragment is present. The caller (NavigationPlugin's
423
+ * `traverseToLast`) only reaches here AFTER `resolveEntryToMatchedState`,
424
+ * which has already rejected `entry.url === null`, so the input is guaranteed
425
+ * non-null at runtime.
426
+ */
427
+ function extractHashFromEntryUrl(entryUrl: string): string {
428
+ const rawHash = safeParseUrl(entryUrl).hash;
429
+
430
+ return rawHash ? decodeHashFragment(rawHash.slice(1)) : "";
431
+ }
432
+
375
433
  function createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {
376
434
  return {
377
435
  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