@real-router/navigation-plugin 0.7.5 → 0.7.7

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 DELETED
@@ -1,468 +0,0 @@
1
- import { UNKNOWN_ROUTE } from "@real-router/core";
2
-
3
- import {
4
- shouldReplaceHistory,
5
- buildUrl,
6
- urlToPath,
7
- createPluginBuildUrl,
8
- createStartInterceptor,
9
- createReplaceHistoryState,
10
- encodeHashFragment,
11
- getDecodedHash,
12
- normalizeHashInput,
13
- safeParseUrl,
14
- decodeHashFragment,
15
- } from "./browser-env";
16
- import {
17
- peekBack,
18
- peekForward,
19
- hasVisited,
20
- getVisitedRoutes,
21
- getRouteVisitCount,
22
- findLastEntryForRoute,
23
- resolveEntryToMatchedState,
24
- canGoBack,
25
- canGoForward,
26
- canGoBackTo,
27
- } from "./history-extensions";
28
- import { isSameHref } from "./href-utils";
29
- import { createNavigateHandler } from "./navigate-handler";
30
-
31
- import type { UrlContext } from "./browser-env";
32
- import type {
33
- NavigationBrowser,
34
- NavigationMeta,
35
- NavigationPluginOptions,
36
- NavigationSharedState,
37
- } from "./types";
38
- import type {
39
- NavigationOptions,
40
- Router,
41
- State,
42
- Plugin,
43
- } from "@real-router/core";
44
- import type { PluginApi } from "@real-router/core/api";
45
-
46
- export function deriveNavigationType(
47
- navOptions: NavigationOptions,
48
- toState: State,
49
- fromState: State | undefined,
50
- ): NavigationMeta["navigationType"] {
51
- if (navOptions.reload && toState.path === fromState?.path) {
52
- return "reload";
53
- }
54
-
55
- if (shouldReplaceHistory(navOptions, toState, fromState)) {
56
- return "replace";
57
- }
58
-
59
- return "push";
60
- }
61
-
62
- export class NavigationPlugin {
63
- readonly #router: Router;
64
- readonly #api: PluginApi;
65
- readonly #options: Required<NavigationPluginOptions>;
66
- readonly #browser: NavigationBrowser;
67
- readonly #removeStartInterceptor: () => void;
68
- readonly #removeExtensions: () => void;
69
- readonly #claim: {
70
- write: (state: State, value: NavigationMeta) => void;
71
- release: () => void;
72
- };
73
- readonly #urlClaim: {
74
- write: (state: State, value: UrlContext) => void;
75
- release: () => void;
76
- };
77
- readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
78
-
79
- #capturedMeta: NavigationMeta | undefined;
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
- };
96
-
97
- constructor(
98
- router: Router,
99
- api: PluginApi,
100
- options: Required<NavigationPluginOptions>,
101
- browser: NavigationBrowser,
102
- transitionOptions: {
103
- source: string;
104
- replace: true;
105
- forceDeactivate?: boolean;
106
- },
107
- shared: NavigationSharedState,
108
- ) {
109
- this.#router = router;
110
- this.#api = api;
111
- this.#options = options;
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;
118
-
119
- this.#claim = api.claimContextNamespace("navigation");
120
- this.#urlClaim = api.claimContextNamespace("url");
121
- this.#removeStartInterceptor = createStartInterceptor(api, this.#browser);
122
-
123
- // Cross-document load priming (#531). On F5, browser back/forward across
124
- // a page boundary, or a fresh URL bar entry, the prior JS context is
125
- // discarded — the navigate event handler never sees the activation.
126
- // Without this, deriveNavigationType in onTransitionSuccess falls through
127
- // to "replace" for every initial transition, breaking scroll restore on
128
- // reload (#497) and any consumer branching on navigationType.
129
- // navigation.activation reflects the cross-document navigation that
130
- // activated this document; it stays constant across same-document
131
- // navigations, so this only affects the FIRST transition.
132
- const activationType = this.#browser.getActivationType();
133
-
134
- if (activationType) {
135
- this.#capturedMeta = {
136
- navigationType: activationType,
137
- userInitiated: false,
138
- direction: activationType === "push" ? "forward" : "unknown",
139
- sourceElement: null,
140
- };
141
- }
142
-
143
- // Hash for the first transition (#532) is read lazily inside
144
- // onTransitionSuccess via `getDecodedHash(browser)` — capturing in the
145
- // constructor is too eager (in tests, the mock URL is set after the
146
- // plugin is constructed). The lazy read still covers F5 / fresh URL
147
- // bar entry: by the time onTransitionSuccess fires the browser already
148
- // reflects the destination URL.
149
-
150
- const pluginBuildUrl = createPluginBuildUrl(router, options.base);
151
-
152
- this.#removeExtensions = api.extendRouter({
153
- buildUrl: pluginBuildUrl,
154
- matchUrl: (url: string) =>
155
- api.matchPath(urlToPath(url, options.base)) ?? undefined,
156
- replaceHistoryState: createReplaceHistoryState(
157
- api,
158
- router,
159
- this.#browser,
160
- pluginBuildUrl,
161
- ),
162
-
163
- peekBack: () => peekBack(this.#browser, api, options.base),
164
- peekForward: () => peekForward(this.#browser, api, options.base),
165
- hasVisited: (routeName: string) =>
166
- hasVisited(this.#browser, api, options.base, routeName),
167
- getVisitedRoutes: () =>
168
- getVisitedRoutes(this.#browser, api, options.base),
169
- getRouteVisitCount: (routeName: string) =>
170
- getRouteVisitCount(this.#browser, api, options.base, routeName),
171
- traverseToLast: (routeName: string) => this.traverseToLast(routeName),
172
- canGoBack: () => canGoBack(this.#browser),
173
- canGoForward: () => canGoForward(this.#browser),
174
- canGoBackTo: (routeName: string) =>
175
- canGoBackTo(this.#browser, api, options.base, routeName),
176
- });
177
-
178
- const handler = createNavigateHandler({
179
- router,
180
- api,
181
- browser: this.#browser,
182
- setCapturedMeta: (meta) => {
183
- this.#capturedMeta = meta;
184
- },
185
- base: options.base,
186
- transitionOptions,
187
- });
188
-
189
- this.#lifecycle = createNavigateLifecycle({
190
- browser: this.#browser,
191
- shared,
192
- handler,
193
- removeStartInterceptor: this.#removeStartInterceptor,
194
- removeExtensions: this.#removeExtensions,
195
- releaseClaim: () => {
196
- this.#claim.release();
197
- this.#urlClaim.release();
198
- },
199
- });
200
- }
201
-
202
- async traverseToLast(routeName: string): Promise<State> {
203
- const entries = this.#browser.entries();
204
- const currentKey = this.#browser.currentEntry?.key;
205
- const candidate = findLastEntryForRoute(
206
- entries,
207
- routeName,
208
- this.#api,
209
- this.#options.base,
210
- currentKey,
211
- );
212
-
213
- // resolveEntryToMatchedState throws for missing entry, null url, or
214
- // unmatched url — same three error branches the old inline checks
215
- // produced. Extracted so the error paths can be unit-tested directly
216
- // without namespace-level vi.spyOn gymnastics.
217
- const { entry, entryUrl, matchedState } = resolveEntryToMatchedState(
218
- candidate,
219
- routeName,
220
- this.#api,
221
- this.#options.base,
222
- );
223
-
224
- const currentEntry = this.#browser.currentEntry;
225
-
226
- if (!currentEntry) {
227
- // Invariant violation: traverseToLast is only callable after
228
- // router.start(), which guarantees a current entry. A null here means
229
- // the plugin was stopped mid-call or the browser abstraction is
230
- // broken — either way, silently picking direction "forward" from a
231
- // fallback `-1` would mask the bug. Fail loudly instead.
232
- throw new Error(
233
- `[navigation-plugin] Cannot determine direction for traverseToLast("${routeName}"): browser.currentEntry is null. The plugin must be started before calling traverseToLast.`,
234
- );
235
- }
236
-
237
- this.#capturedMeta = {
238
- navigationType: "traverse",
239
- userInitiated: false,
240
- direction: entry.index > currentEntry.index ? "forward" : "back",
241
- sourceElement: null,
242
- };
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);
248
-
249
- return this.#router.navigate(matchedState.name, matchedState.params);
250
- }
251
-
252
- getPlugin(): Plugin {
253
- return {
254
- ...this.#lifecycle,
255
-
256
- onTransitionStart: (toState: State) => {
257
- if (this.#capturedMeta) {
258
- this.#claim.write(toState, this.#capturedMeta);
259
- }
260
- },
261
-
262
- onTransitionSuccess: (
263
- toState: State,
264
- fromState: State | undefined,
265
- navOptions: NavigationOptions,
266
- ) => {
267
- if (!this.#capturedMeta) {
268
- const navigationType = deriveNavigationType(
269
- navOptions,
270
- toState,
271
- fromState,
272
- );
273
-
274
- this.#capturedMeta = {
275
- navigationType,
276
- userInitiated: false,
277
- direction: navigationType === "push" ? "forward" : "unknown",
278
- sourceElement: null,
279
- };
280
- }
281
-
282
- const frozenMeta = Object.freeze(this.#capturedMeta);
283
-
284
- this.#claim.write(toState, frozenMeta);
285
- this.#capturedMeta = undefined;
286
-
287
- // Consume pendingTraverseKey BEFORE calling browser.traverseTo.
288
- // If traverseTo throws (Navigation API can reject on evicted keys
289
- // under memory pressure), we must not leave the stale key behind —
290
- // otherwise the NEXT transition's onTransitionSuccess would see it
291
- // and replay the traverse against the same already-broken key.
292
- const traverseKey = this.#pendingTraverseKey;
293
- const traverseHash = this.#pendingTraverseHash;
294
-
295
- this.#pendingTraverseKey = undefined;
296
- this.#pendingTraverseHash = "";
297
-
298
- const publishedPrevHash = readPublishedHash(fromState);
299
-
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
- );
313
- this.#browser.traverseTo(traverseKey);
314
- } else {
315
- // Tri-state hash resolution (#532).
316
- // navOptions.hash === undefined → preserve current browser hash
317
- // navOptions.hash === "" → explicitly clear
318
- // navOptions.hash === "value" → explicitly set
319
- //
320
- // The "preserve" branch reads location.hash from the browser, not
321
- // fromState.context.url.hash — this captures dynamic fragment
322
- // changes the user makes outside the plugin (anchor clicks,
323
- // manual location.hash assignment) instead of replaying the
324
- // last-published value.
325
- //
326
- // hashChanged compares the chosen hash against the *published*
327
- // previous hash (fromState.context.url.hash), so subscribers see
328
- // a true signal regardless of whether the value came from
329
- // navOptions or the browser.
330
- const browserHash = getDecodedHash(this.#browser);
331
-
332
- const hash =
333
- navOptions.hash === undefined
334
- ? browserHash
335
- : normalizeHashInput(navOptions.hash);
336
-
337
- this.#urlClaim.write(
338
- toState,
339
- Object.freeze({
340
- hash,
341
- hashChanged: navOptions.hashChange ?? hash !== publishedPrevHash,
342
- }),
343
- );
344
-
345
- const url = buildUrl(toState.path, this.#options.base);
346
- const finalUrl = hash ? `${url}#${encodeHashFragment(hash)}` : url;
347
-
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
- });
375
- } else {
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;
386
-
387
- this.#browser.navigate(finalUrl, {
388
- state: this.#historyStateBuffer,
389
- history: replace ? "replace" : "push",
390
- });
391
- }
392
- }
393
- },
394
-
395
- onTransitionCancel: () => {
396
- this.#capturedMeta = undefined;
397
- this.#pendingTraverseKey = undefined;
398
- this.#pendingTraverseHash = "";
399
- },
400
-
401
- onTransitionError: () => {
402
- this.#capturedMeta = undefined;
403
- this.#pendingTraverseKey = undefined;
404
- this.#pendingTraverseHash = "";
405
- },
406
- };
407
- }
408
- }
409
-
410
- interface NavigateLifecycleDeps {
411
- browser: NavigationBrowser;
412
- handler: (event: NavigateEvent) => void;
413
- removeStartInterceptor: () => void;
414
- removeExtensions: () => void;
415
- releaseClaim: () => void;
416
- shared: NavigationSharedState;
417
- }
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
-
446
- function createNavigateLifecycle(deps: NavigateLifecycleDeps): Plugin {
447
- return {
448
- onStart() {
449
- deps.shared.removeNavigateListener?.();
450
- deps.shared.removeNavigateListener = deps.browser.addNavigateListener(
451
- deps.handler,
452
- );
453
- },
454
-
455
- onStop() {
456
- deps.shared.removeNavigateListener?.();
457
- deps.shared.removeNavigateListener = undefined;
458
- },
459
-
460
- teardown() {
461
- deps.shared.removeNavigateListener?.();
462
- deps.shared.removeNavigateListener = undefined;
463
- deps.removeStartInterceptor();
464
- deps.removeExtensions();
465
- deps.releaseClaim();
466
- },
467
- };
468
- }
@@ -1,48 +0,0 @@
1
- import { createWarnOnce } from "./browser-env";
2
-
3
- import type { NavigationBrowser } from "./types";
4
-
5
- const NOOP = (): void => {};
6
-
7
- export const createNavigationFallbackBrowser = (
8
- context: string,
9
- ): NavigationBrowser => {
10
- const warnOnce = createWarnOnce(context);
11
-
12
- return {
13
- getLocation: () => {
14
- warnOnce("getLocation");
15
-
16
- return "/";
17
- },
18
- getHash: () => {
19
- warnOnce("getHash");
20
-
21
- return "";
22
- },
23
- navigate: () => {
24
- warnOnce("navigate");
25
- },
26
- replaceState: () => {
27
- warnOnce("replaceState");
28
- },
29
- updateCurrentEntry: () => {
30
- warnOnce("updateCurrentEntry");
31
- },
32
- traverseTo: () => {
33
- warnOnce("traverseTo");
34
- },
35
- addNavigateListener: () => {
36
- warnOnce("addNavigateListener");
37
-
38
- return NOOP;
39
- },
40
- entries: () => {
41
- warnOnce("entries");
42
-
43
- return [];
44
- },
45
- currentEntry: null,
46
- getActivationType: () => undefined,
47
- };
48
- };
package/src/types.ts DELETED
@@ -1,71 +0,0 @@
1
- /**
2
- * Navigation plugin configuration.
3
- * Same options as browser-plugin — plugins are interchangeable.
4
- */
5
- export interface NavigationPluginOptions {
6
- /**
7
- * Bypass canDeactivate guards on browser back/forward.
8
- *
9
- * @default false
10
- */
11
- forceDeactivate?: boolean;
12
-
13
- /**
14
- * Base path for all routes (e.g., "/app" for hosted at /app/).
15
- *
16
- * @default ""
17
- */
18
- base?: string;
19
- }
20
-
21
- /**
22
- * Browser abstraction over Navigation API.
23
- * Replaces History API's Browser interface with Navigation API equivalents.
24
- */
25
- export interface NavigationBrowser {
26
- getLocation: () => string;
27
- getHash: () => string;
28
- navigate: (
29
- url: string,
30
- options: { state: unknown; history: "push" | "replace" },
31
- ) => void;
32
- replaceState: (state: unknown, url: string) => void;
33
- updateCurrentEntry: (options: { state: unknown }) => void;
34
- traverseTo: (key: string) => void;
35
- addNavigateListener: (fn: (evt: NavigateEvent) => void) => () => void;
36
- entries: () => NavigationHistoryEntry[];
37
- currentEntry: NavigationHistoryEntry | null;
38
- /**
39
- * Type of the cross-document navigation that activated this document.
40
- * Reads `navigation.activation.navigationType` (Baseline 2026 — Chrome 123+, Firefox 147+, Safari 26.2+).
41
- * Returns `undefined` when activation is unavailable (older browsers, SSR).
42
- */
43
- getActivationType: () => NavigationMeta["navigationType"] | undefined;
44
- }
45
-
46
- /**
47
- * Shared mutable state across plugin instances created by the same factory.
48
- * Enables cleanup of a previous instance's navigate listener when the factory is reused.
49
- */
50
- export interface NavigationSharedState {
51
- removeNavigateListener: (() => void) | undefined;
52
- }
53
-
54
- export type NavigationDirection = "forward" | "back" | "unknown";
55
-
56
- /**
57
- * Navigation metadata attached to State via state.context.navigation.
58
- * Available in subscribe callbacks and components after transition completes.
59
- */
60
- export interface NavigationMeta {
61
- /** Type of navigation: push, replace, traverse, or reload */
62
- navigationType: "push" | "replace" | "traverse" | "reload";
63
- /** Whether the navigation was initiated by the user (back/forward button, link click) */
64
- userInitiated: boolean;
65
- /** Ephemeral info passed via navigation.navigate({ info }) — lost on page reload */
66
- info?: unknown;
67
- /** Direction of navigation in the history stack */
68
- direction: NavigationDirection;
69
- /** The DOM element that initiated the navigation (e.g., anchor tag), or null for programmatic */
70
- sourceElement: Element | null;
71
- }
package/src/validation.ts DELETED
@@ -1,10 +0,0 @@
1
- import { createOptionsValidator, safeBaseRule } from "./browser-env";
2
- import { LOGGER_CONTEXT, defaultOptions } from "./constants";
3
-
4
- import type { NavigationPluginOptions } from "./types";
5
-
6
- export const validateOptions = createOptionsValidator<NavigationPluginOptions>(
7
- defaultOptions,
8
- LOGGER_CONTEXT,
9
- { base: safeBaseRule },
10
- );