@equinor/fusion-framework-module-navigation 7.0.0-next.2 → 7.0.1

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +42 -19
  2. package/README.md +97 -614
  3. package/dist/esm/NavigationConfigurator.js +29 -6
  4. package/dist/esm/NavigationConfigurator.js.map +1 -1
  5. package/dist/esm/NavigationProvider.js +57 -23
  6. package/dist/esm/NavigationProvider.js.map +1 -1
  7. package/dist/esm/events.js +20 -3
  8. package/dist/esm/events.js.map +1 -1
  9. package/dist/esm/index.js +23 -0
  10. package/dist/esm/index.js.map +1 -1
  11. package/dist/esm/lib/BaseHistory.js +23 -6
  12. package/dist/esm/lib/BaseHistory.js.map +1 -1
  13. package/dist/esm/lib/BrowserHistoryStack.js +5 -6
  14. package/dist/esm/lib/BrowserHistoryStack.js.map +1 -1
  15. package/dist/esm/lib/MemoryHistory.js +13 -6
  16. package/dist/esm/lib/MemoryHistory.js.map +1 -1
  17. package/dist/esm/lib/MemoryStack.js +1 -0
  18. package/dist/esm/lib/MemoryStack.js.map +1 -1
  19. package/dist/esm/lib/ProxyHistory.js +114 -0
  20. package/dist/esm/lib/ProxyHistory.js.map +1 -0
  21. package/dist/esm/lib/create-history.js +7 -13
  22. package/dist/esm/lib/create-history.js.map +1 -1
  23. package/dist/esm/lib/index.js +10 -0
  24. package/dist/esm/lib/index.js.map +1 -1
  25. package/dist/esm/lib/state/history.flows.js +7 -12
  26. package/dist/esm/lib/state/history.flows.js.map +1 -1
  27. package/dist/esm/lib/state/history.reducer.js +8 -4
  28. package/dist/esm/lib/state/history.reducer.js.map +1 -1
  29. package/dist/esm/lib/state/history.state.js +9 -3
  30. package/dist/esm/lib/state/history.state.js.map +1 -1
  31. package/dist/esm/lib/utils/resolve-browser-location.js +2 -2
  32. package/dist/esm/lib/utils/resolve-browser-location.js.map +1 -1
  33. package/dist/esm/module.js +4 -4
  34. package/dist/esm/version.js +1 -1
  35. package/dist/esm/version.js.map +1 -1
  36. package/dist/tsconfig.tsbuildinfo +1 -1
  37. package/dist/types/NavigationConfigurator.d.ts +10 -1
  38. package/dist/types/NavigationConfigurator.interface.d.ts +25 -5
  39. package/dist/types/NavigationProvider.d.ts +57 -23
  40. package/dist/types/NavigationProvider.interface.d.ts +20 -7
  41. package/dist/types/events.d.ts +20 -3
  42. package/dist/types/index.d.ts +24 -1
  43. package/dist/types/lib/BaseHistory.d.ts +23 -6
  44. package/dist/types/lib/BrowserHistoryStack.d.ts +5 -6
  45. package/dist/types/lib/MemoryHistory.d.ts +5 -5
  46. package/dist/types/lib/ProxyHistory.d.ts +68 -0
  47. package/dist/types/lib/create-history.d.ts +7 -13
  48. package/dist/types/lib/index.d.ts +10 -0
  49. package/dist/types/lib/state/history.flows.d.ts +6 -11
  50. package/dist/types/lib/state/history.reducer.d.ts +8 -4
  51. package/dist/types/lib/state/history.state.d.ts +9 -3
  52. package/dist/types/lib/types.d.ts +39 -8
  53. package/dist/types/version.d.ts +1 -1
  54. package/package.json +12 -13
  55. package/src/NavigationConfigurator.interface.ts +28 -5
  56. package/src/NavigationConfigurator.ts +47 -15
  57. package/src/NavigationProvider.interface.ts +20 -7
  58. package/src/NavigationProvider.ts +57 -23
  59. package/src/__tests__/ProxyHistory.test.ts +149 -0
  60. package/src/events.ts +20 -3
  61. package/src/index.ts +25 -1
  62. package/src/lib/BaseHistory.ts +23 -6
  63. package/src/lib/BrowserHistoryStack.ts +5 -6
  64. package/src/lib/MemoryHistory.ts +13 -6
  65. package/src/lib/MemoryStack.ts +1 -0
  66. package/src/lib/ProxyHistory.ts +144 -0
  67. package/src/lib/create-history.ts +7 -13
  68. package/src/lib/index.ts +11 -0
  69. package/src/lib/state/history.flows.ts +7 -12
  70. package/src/lib/state/history.reducer.ts +8 -4
  71. package/src/lib/state/history.state.ts +9 -3
  72. package/src/lib/types.ts +39 -8
  73. package/src/lib/utils/resolve-browser-location.ts +2 -2
  74. package/src/module.ts +4 -4
  75. package/src/version.ts +1 -1
@@ -38,11 +38,15 @@ export type Path = {
38
38
  };
39
39
  /**
40
40
  * Location object representing a navigation entry.
41
- * Extends Path with state and a unique key.
41
+ * Extends {@link Path} with arbitrary state data and a unique key.
42
+ *
43
+ * @template T - The type of the state payload stored in this location entry
42
44
  */
43
45
  export type Location<T = any> = Path & {
44
46
  state: T;
45
47
  key: string;
48
+ /** Masked path used by react-router 7.13+ for unstable view-transition masking. */
49
+ unstable_mask: Path | undefined;
46
50
  };
47
51
  /**
48
52
  * Internal state for history management.
@@ -57,7 +61,10 @@ export type LocationState = {
57
61
  */
58
62
  export type NavigationListener = (update: Readonly<NavigationUpdate>) => void;
59
63
  /**
60
- * Navigation update event containing action and location.
64
+ * Navigation update event containing the action, location, and stack delta.
65
+ *
66
+ * @template A - The type of navigation action (defaults to {@link Action})
67
+ * @template T - The type of the state payload in the location
61
68
  */
62
69
  export type NavigationUpdate<A extends string = Action, T = unknown> = {
63
70
  delta: number;
@@ -96,7 +103,16 @@ export interface NavigateOptions {
96
103
  }
97
104
  /**
98
105
  * History interface for managing navigation state.
99
- * Compatible with industry-standard routers (Remix/React Router) and provides observable state management.
106
+ *
107
+ * Compatible with Remix / React Router and provides observable
108
+ * state management via RxJS.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * const history = createHistory('browser');
113
+ * history.push('/dashboard');
114
+ * history.state$.subscribe(update => console.log(update.location.pathname));
115
+ * ```
100
116
  */
101
117
  export interface History extends Disposable {
102
118
  /** Observable stream of navigation state updates. */
@@ -107,11 +123,20 @@ export interface History extends Disposable {
107
123
  readonly action: Action;
108
124
  /** Current location in the history stack. */
109
125
  readonly location: Location;
110
- /** Creates a valid href string for a given path. */
126
+ /** Creates a valid href string for a given path.
127
+ * @param to - Target path or partial path object
128
+ * @returns Fully-qualified href string
129
+ */
111
130
  createHref(to: To): string;
112
- /** Creates a URL object for a given path. */
131
+ /** Creates a {@link URL} object for a given path.
132
+ * @param to - Target path or partial path object
133
+ * @returns Resolved {@link URL} instance
134
+ */
113
135
  createURL(to: To): URL;
114
- /** Encodes a location by properly URL-encoding the pathname. */
136
+ /** Encodes a location by properly URL-encoding the pathname.
137
+ * @param to - Target path or partial path object
138
+ * @returns A {@link Path} with URL-encoded components
139
+ */
115
140
  encodeLocation(to: To): Path;
116
141
  /** Pushes a new navigation entry onto the history stack. */
117
142
  push(to: To, state?: unknown): void;
@@ -121,9 +146,15 @@ export interface History extends Disposable {
121
146
  navigate(to: To, options?: NavigateOptions): void;
122
147
  /** Navigates backward or forward in the history stack. */
123
148
  go(delta: number): void;
124
- /** Sets up a listener for navigation changes. */
149
+ /** Sets up a listener for navigation changes.
150
+ * @param listener - Callback invoked on POP actions (browser back/forward)
151
+ * @returns A function that unsubscribes the listener when called
152
+ */
125
153
  listen(listener: NavigationListener): () => void;
126
- /** Registers a blocker to intercept navigation attempts. */
154
+ /** Registers a blocker to intercept navigation attempts.
155
+ * @param blocker - Callback invoked before each navigation to allow or prevent it
156
+ * @returns A function that removes the blocker when called
157
+ */
127
158
  block(blocker: NavigationBlocker): VoidFunction;
128
159
  }
129
160
  /**
@@ -1 +1 @@
1
- export declare const version = "7.0.0-next.2";
1
+ export declare const version = "7.0.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-module-navigation",
3
- "version": "7.0.0-next.2",
3
+ "version": "7.0.1",
4
4
  "description": "Navigation module for Fusion Framework providing routing and navigation capabilities using React Router 7",
5
5
  "sideEffects": false,
6
6
  "main": "dist/esm/index.js",
@@ -33,24 +33,23 @@
33
33
  "dependencies": {
34
34
  "@remix-run/router": "^1.23.0",
35
35
  "uuid": "^13.0.0",
36
- "zod": "^4.1.11"
36
+ "zod": "^4.3.6"
37
37
  },
38
38
  "devDependencies": {
39
- "@types/uuid": "^11.0.0",
40
- "jsdom": "^27.1.0",
39
+ "jsdom": "^29.0.2",
41
40
  "rxjs": "^7.8.1",
42
- "typescript": "^5.8.2",
43
- "vitest": "^3.2.4",
44
- "@equinor/fusion-framework-module": "^5.0.7-next.0",
45
- "@equinor/fusion-framework-module-event": "^5.0.2-next.0",
46
- "@equinor/fusion-framework-module-telemetry": "^4.6.5-next.0",
47
- "@equinor/fusion-observable": "^8.5.9-next.0"
41
+ "typescript": "^5.9.3",
42
+ "vitest": "^4.1.0",
43
+ "@equinor/fusion-framework-module-event": "^6.0.0",
44
+ "@equinor/fusion-framework-module-telemetry": "^5.0.0",
45
+ "@equinor/fusion-framework-module": "^6.0.0",
46
+ "@equinor/fusion-observable": "^9.0.0"
48
47
  },
49
48
  "peerDependencies": {
50
- "@remix-run/router": "^1.23.0",
49
+ "@remix-run/router": "^1.0.0",
51
50
  "rxjs": "^7.0.0",
52
- "@equinor/fusion-framework-module": "^5.0.7-next.0",
53
- "@equinor/fusion-observable": "^8.5.9-next.0"
51
+ "@equinor/fusion-observable": "^9.0.0",
52
+ "@equinor/fusion-framework-module": "^6.0.0"
54
53
  },
55
54
  "scripts": {
56
55
  "build": "tsc -b",
@@ -4,15 +4,38 @@ import type { IEventModuleProvider } from '@equinor/fusion-framework-module-even
4
4
 
5
5
  /**
6
6
  * Configuration object for the navigation module.
7
- * Provides options for customizing history, basename, telemetry, and event settings.
7
+ *
8
+ * Provides options for customizing the history implementation, basename prefix,
9
+ * telemetry tracking, and event dispatching used by the {@link NavigationProvider}.
8
10
  */
9
11
  export interface INavigationConfigurator {
10
- /** Optional base pathname for the application (e.g., "/app") */
12
+ /**
13
+ * Base pathname prefix for the application (e.g. `"/apps/my-app"`).
14
+ *
15
+ * When set, the navigation provider automatically prepends this prefix to
16
+ * outgoing paths and strips it from incoming paths, so consumer code
17
+ * operates on clean, basename-free paths.
18
+ */
11
19
  basename?: string;
12
- /** Optional custom history instance (browser, hash, or memory). If not provided, defaults to browser history. */
20
+
21
+ /**
22
+ * Custom {@link History} instance for navigation.
23
+ *
24
+ * If not provided, defaults to browser history in browser environments
25
+ * or memory history in Node.js environments. Create instances with
26
+ * {@link createHistory}.
27
+ */
13
28
  history?: History;
14
- /** Optional telemetry provider for tracking navigation events */
29
+
30
+ /**
31
+ * Telemetry provider for tracking navigation events, location changes,
32
+ * and errors for monitoring and debugging.
33
+ */
15
34
  telemetry?: ITelemetryProvider;
16
- /** Optional event provider for dispatching navigation events */
35
+
36
+ /**
37
+ * Event provider for dispatching {@link NavigateEvent} and {@link NavigatedEvent}.
38
+ * Allows other modules to listen for and react to navigation changes.
39
+ */
17
40
  eventProvider?: IEventModuleProvider;
18
41
  }
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
- import { of, type ObservableInput } from 'rxjs';
2
+ import { of, from, type ObservableInput } from 'rxjs';
3
+ import { map } from 'rxjs/operators';
3
4
  import {
4
5
  BaseConfigBuilder,
5
6
  type ModulesInstance,
@@ -12,6 +13,7 @@ import type { INavigationConfigurator } from './NavigationConfigurator.interface
12
13
 
13
14
  import type { History } from './lib/types';
14
15
  import { createHistory } from './lib/create-history';
16
+ import { ProxyHistory } from './lib/ProxyHistory';
15
17
  import type { NavigationModule } from './module';
16
18
 
17
19
  /**
@@ -72,16 +74,21 @@ export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigu
72
74
  return await args.requireInstance('event');
73
75
  }
74
76
  });
75
- this.setHistory(async (args) => {
76
- const history = (args.ref as ModulesInstance<[NavigationModule]>)?.navigation?.history;
77
- if (history) {
78
- return history;
79
- }
80
- if (typeof window !== 'undefined') {
81
- return createHistory('browser');
82
- }
83
- return createHistory('memory');
84
- });
77
+ this.setHistory(
78
+ async (args) => {
79
+ const history = (args.ref as ModulesInstance<[NavigationModule]>)?.navigation?.history;
80
+ if (history) {
81
+ // Wrap the provided history in a ProxyHistory to ensure the module can manage its own teardowns without affecting the original instance.
82
+ return new ProxyHistory(history);
83
+ }
84
+ if (typeof window !== 'undefined') {
85
+ return createHistory('browser');
86
+ }
87
+ return createHistory('memory');
88
+ },
89
+ // Don't wrap the default history in a ProxyHistory since it's already owned by the module and will be properly disposed.
90
+ { proxy: false },
91
+ );
85
92
  }
86
93
  /**
87
94
  * @deprecated Use `setBasename()` method instead
@@ -121,13 +128,38 @@ export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigu
121
128
  /**
122
129
  * Sets a custom history instance for the navigation module.
123
130
  *
131
+ * By default the resolved history is wrapped in a {@link ProxyHistory} so the
132
+ * module gets its own disposable handle without owning (or accidentally
133
+ * disposing) the original instance. Set `proxy` to `false` to use the
134
+ * history as-is.
135
+ *
124
136
  * @param historyOrCallback - History instance or configuration callback
137
+ * @param options - Optional settings for history wrapping
138
+ * @param options.proxy - Wrap the history in a {@link ProxyHistory} (default: `true`)
125
139
  * @returns The configurator instance for method chaining
126
140
  */
127
- public setHistory(historyOrCallback?: History | ConfigBuilderCallback<History>): this {
128
- const fn =
129
- typeof historyOrCallback === 'function' ? historyOrCallback : async () => historyOrCallback;
130
- this._set('history', fn);
141
+ public setHistory(
142
+ historyOrCallback?: History | ConfigBuilderCallback<History>,
143
+ options?: { proxy?: boolean },
144
+ ): this {
145
+ const { proxy = true } = options ?? {};
146
+ const resolve =
147
+ typeof historyOrCallback === 'function'
148
+ ? historyOrCallback
149
+ : // Normalize a direct instance to a callback for consistent handling.
150
+ async () => historyOrCallback;
151
+
152
+ if (proxy) {
153
+ // Wrap each emitted history in a ProxyHistory so dispose only tears down
154
+ // proxy-owned listeners/blockers, never the underlying history itself.
155
+ this._set('history', (args) =>
156
+ from(resolve(args) as ObservableInput<History | undefined>).pipe(
157
+ map((history) => (history ? new ProxyHistory(history) : undefined)),
158
+ ),
159
+ );
160
+ } else {
161
+ this._set('history', resolve);
162
+ }
131
163
  return this;
132
164
  }
133
165
 
@@ -9,7 +9,17 @@ import type { IModuleProvider } from '@equinor/fusion-framework-module';
9
9
 
10
10
  /**
11
11
  * Navigation provider interface.
12
- * Provides routing and navigation capabilities with basename localization.
12
+ *
13
+ * Provides routing and navigation capabilities with automatic basename
14
+ * localization. Consumers work with clean paths (e.g. `/users`) while the
15
+ * underlying history operates on full paths (e.g. `/apps/my-app/users`).
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const nav: INavigationProvider = framework.modules.navigation;
20
+ * nav.push('/users');
21
+ * console.log(nav.path.pathname); // '/users'
22
+ * ```
13
23
  */
14
24
  export interface INavigationProvider extends IModuleProvider {
15
25
  /**
@@ -41,22 +51,25 @@ export interface INavigationProvider extends IModuleProvider {
41
51
  /**
42
52
  * Creates a router instance from route configuration.
43
53
  *
44
- * @param routes - Route configuration objects compatible with industry-standard routers (Remix/React Router)
45
- * @returns A configured and initialized router instance
54
+ * @deprecated Use `@equinor/fusion-framework-react-router` instead.
55
+ * @param routes - Route configuration objects compatible with Remix/React Router
56
+ * @returns A configured and initialized {@link Router} instance with basename applied
46
57
  */
47
58
  createRouter(routes: AgnosticRouteObject[]): Router;
48
59
 
49
60
  /**
50
- * Creates a localized href string for navigation.
61
+ * Creates a localized href string including the basename prefix.
51
62
  *
52
- * @param to - Optional path or location (defaults to current path)
63
+ * @param to - Path or location to resolve (defaults to current path)
64
+ * @returns Fully-qualified href string with basename included
53
65
  */
54
66
  createHref(to?: To): string;
55
67
 
56
68
  /**
57
- * Creates a full URL object for navigation.
69
+ * Creates a full {@link URL} object including the basename prefix.
58
70
  *
59
- * @param to - Optional path or location (defaults to current path)
71
+ * @param to - Path or location to resolve (defaults to current path)
72
+ * @returns A {@link URL} instance representing the resolved navigation target
60
73
  */
61
74
  createURL(to?: To): URL;
62
75
 
@@ -37,17 +37,23 @@ const normalizePathname = (path: string) => path.replace(/\/+/g, '/').replace(/\
37
37
 
38
38
  /**
39
39
  * Navigation provider implementation.
40
- * Manages routing and navigation state with basename localization.
40
+ *
41
+ * Manages routing and navigation state with automatic basename localization.
42
+ * Wraps a {@link History} instance to expose observable state, path localization,
43
+ * and router creation.
41
44
  *
42
45
  * @remarks
43
- * This provider:
44
- * - Wraps the Navigator to provide observable navigation state
45
- * - Localizes paths by removing basename prefix
46
- * - Creates routers compatible with industry-standard routers (Remix/React Router)
47
- * - Handles navigation actions (push, replace, createHref, etc.)
46
+ * - Emits localized paths (basename removed) to consumers via {@link NavigationProvider.state$ | state$}
47
+ * - Internally prefixes paths with the basename before forwarding to the history stack
48
+ * - Creates routers compatible with Remix / React Router via {@link NavigationProvider.createRouter | createRouter}
49
+ * - Dispatches {@link NavigatedEvent} and telemetry on navigation changes
48
50
  *
49
- * Path localization ensures that consumers receive paths relative to the basename,
50
- * while internally we work with full paths including basename.
51
+ * @example
52
+ * ```ts
53
+ * const provider = new NavigationProvider({ version, config });
54
+ * provider.push('/users');
55
+ * console.log(provider.path.pathname); // '/users'
56
+ * ```
51
57
  */
52
58
  export class NavigationProvider
53
59
  extends BaseModuleProvider<INavigationConfigurator>
@@ -62,14 +68,19 @@ export class NavigationProvider
62
68
 
63
69
  /**
64
70
  * Observable stream of navigation state updates.
65
- * Emits localized paths (with basename removed) for consumers.
71
+ *
72
+ * Emits localized paths (with basename removed) and filters to only
73
+ * paths within the basename scope. Late subscribers receive the last
74
+ * emitted value immediately.
66
75
  */
67
76
  public get state$(): Observable<NavigationUpdate> {
68
77
  return this.#state$;
69
78
  }
70
79
 
71
80
  /**
72
- * Gets the basename.
81
+ * Gets the basename prefix configured for this provider.
82
+ *
83
+ * @returns The basename string, or an empty string if none is configured
73
84
  */
74
85
  public get basename(): string {
75
86
  return this.#basename ?? '';
@@ -89,22 +100,28 @@ export class NavigationProvider
89
100
  }
90
101
 
91
102
  /**
92
- * Gets the history instance.
103
+ * Gets the underlying history instance.
104
+ *
105
+ * @returns The {@link History} instance used for navigation
93
106
  */
94
107
  public get history(): History {
95
108
  return this.#history;
96
109
  }
97
110
 
98
111
  /**
99
- * Gets the current localized path (basename removed).
112
+ * Gets the current localized path with the basename prefix removed.
113
+ *
114
+ * @returns A {@link Path} object representing the current location without basename
100
115
  */
101
116
  public get path(): Path {
102
117
  return this._localizePath(this.#history.location);
103
118
  }
104
119
 
105
120
  /**
121
+ * Creates a new {@link NavigationProvider}.
122
+ *
106
123
  * @param args - Configuration arguments containing module config
107
- * @throws {Error} If no history is provided in the configuration
124
+ * @throws {Error} If no history instance is provided in the configuration
108
125
  */
109
126
  constructor(args: BaseModuleProviderCtorArgs<INavigationConfigurator>) {
110
127
  super(args);
@@ -201,10 +218,10 @@ export class NavigationProvider
201
218
  /**
202
219
  * Creates a router instance from route configuration.
203
220
  *
204
- * @deprecated Use `@equinor/fusion-framework-react-router` instead
221
+ * @deprecated Use `@equinor/fusion-framework-react-router` instead.
205
222
  *
206
- * @param routes - Route configuration objects compatible with industry-standard routers (Remix/React Router)
207
- * @returns A configured and initialized router instance
223
+ * @param routes - Route configuration objects compatible with Remix/React Router
224
+ * @returns A configured and initialized {@link Router} instance with basename applied
208
225
  */
209
226
  public createRouter(routes: AgnosticRouteObject[]) {
210
227
  this.#telemetry?.trackEvent({
@@ -225,18 +242,26 @@ export class NavigationProvider
225
242
  }
226
243
 
227
244
  /**
228
- * Creates a localized href string for navigation.
245
+ * Creates a localized href string including the basename prefix.
229
246
  *
230
- * @param to - Optional path or location (defaults to current path)
247
+ * @param to - Path or location to resolve (defaults to current path)
248
+ * @returns Fully-qualified href string with basename included
249
+ *
250
+ * @example
251
+ * ```ts
252
+ * // basename = '/apps/my-app'
253
+ * provider.createHref('/users'); // '/apps/my-app/users'
254
+ * ```
231
255
  */
232
256
  public createHref(to?: To): string {
233
257
  return this.#history.createHref(this._createToPath(to ?? this.path));
234
258
  }
235
259
 
236
260
  /**
237
- * Creates a full URL object for navigation.
261
+ * Creates a full {@link URL} object including the basename prefix.
238
262
  *
239
- * @param to - Optional path or location (defaults to current path)
263
+ * @param to - Path or location to resolve (defaults to current path)
264
+ * @returns A {@link URL} instance representing the resolved navigation target
240
265
  */
241
266
  public createURL(to?: To): URL {
242
267
  return this.#history.createURL(this._createToPath(to ?? this.path));
@@ -277,14 +302,20 @@ export class NavigationProvider
277
302
  (this.#history as BaseHistory).pop();
278
303
  }
279
304
  /**
280
- * Checks if a pathname is within the basename scope.
305
+ * Checks whether a pathname falls within the configured basename scope.
306
+ *
307
+ * @param pathname - The pathname to check
308
+ * @returns `true` if the pathname starts with the basename (or no basename is set)
281
309
  */
282
310
  protected _isWithinBasenameScope(pathname: string): boolean {
283
311
  return this.#basename ? pathname.startsWith(this.#basename) : true;
284
312
  }
285
313
 
286
314
  /**
287
- * Localizes a path by removing the basename prefix.
315
+ * Localizes a path by stripping the basename prefix from the pathname.
316
+ *
317
+ * @param location - The full path to localize
318
+ * @returns A new {@link Path} with the basename removed from the pathname
288
319
  */
289
320
  protected _localizePath(location: Path): Path {
290
321
  const { pathname, search, hash } = location;
@@ -296,7 +327,10 @@ export class NavigationProvider
296
327
  }
297
328
 
298
329
  /**
299
- * Creates a full path object from a target location, adding basename prefix.
330
+ * Creates a full path object from a target location, prepending the basename prefix.
331
+ *
332
+ * @param to - The target location (string path or partial {@link Path} object)
333
+ * @returns A partial {@link Path} with basename prepended to the pathname
300
334
  */
301
335
  protected _createToPath(to: To): Partial<Path> {
302
336
  // Parse the 'to' parameter into path components
@@ -0,0 +1,149 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { firstValueFrom, skip } from 'rxjs';
3
+ import { MemoryHistory } from '../lib/MemoryHistory';
4
+ import { ProxyHistory } from '../lib/ProxyHistory';
5
+
6
+ /**
7
+ * Awaits the next state emission after performing a navigation action.
8
+ * MemoryHistory processes state updates asynchronously through the subject.
9
+ */
10
+ const awaitNavigation = async (
11
+ history: MemoryHistory | ProxyHistory,
12
+ action: () => void,
13
+ ): Promise<void> => {
14
+ const next = firstValueFrom(history.state$.pipe(skip(1)));
15
+ action();
16
+ await next;
17
+ };
18
+
19
+ describe('ProxyHistory', () => {
20
+ let target: MemoryHistory;
21
+ let proxy: ProxyHistory;
22
+
23
+ beforeEach(() => {
24
+ target = new MemoryHistory();
25
+ proxy = new ProxyHistory(target);
26
+ });
27
+
28
+ afterEach(() => {
29
+ proxy[Symbol.dispose]();
30
+ target[Symbol.dispose]();
31
+ });
32
+
33
+ describe('delegation', () => {
34
+ it('should delegate location to the target', () => {
35
+ expect(proxy.location).toBe(target.location);
36
+ });
37
+
38
+ it('should delegate action to the target', () => {
39
+ expect(proxy.action).toBe(target.action);
40
+ });
41
+
42
+ it('should delegate push to the target', async () => {
43
+ await awaitNavigation(proxy, () => proxy.push('/test'));
44
+
45
+ expect(target.location.pathname).toBe('/test');
46
+ expect(proxy.location.pathname).toBe('/test');
47
+ });
48
+
49
+ it('should delegate replace to the target', async () => {
50
+ await awaitNavigation(proxy, () => proxy.replace('/replaced'));
51
+
52
+ expect(target.location.pathname).toBe('/replaced');
53
+ expect(proxy.location.pathname).toBe('/replaced');
54
+ });
55
+
56
+ it('should delegate navigate to the target', async () => {
57
+ await awaitNavigation(proxy, () =>
58
+ proxy.navigate('/nav', { replace: true, state: { foo: 1 } }),
59
+ );
60
+
61
+ expect(target.location.pathname).toBe('/nav');
62
+ expect(target.location.state).toEqual({ foo: 1 });
63
+ });
64
+
65
+ it('should delegate createHref to the target', () => {
66
+ expect(proxy.createHref('/path')).toBe(target.createHref('/path'));
67
+ });
68
+
69
+ it('should delegate createURL to the target', () => {
70
+ expect(proxy.createURL('/path').href).toBe(target.createURL('/path').href);
71
+ });
72
+
73
+ it('should delegate encodeLocation to the target', () => {
74
+ const encoded = proxy.encodeLocation('/path');
75
+ expect(encoded).toEqual(target.encodeLocation('/path'));
76
+ });
77
+ });
78
+
79
+ describe('pop()', () => {
80
+ it('should delegate pop to the target when target supports it', () => {
81
+ const popSpy = vi.spyOn(target, 'pop');
82
+ proxy.pop();
83
+ expect(popSpy).toHaveBeenCalledOnce();
84
+ });
85
+ });
86
+
87
+ describe('teardown isolation', () => {
88
+ it('should not dispose the target when proxy is disposed', async () => {
89
+ // Dispose the proxy
90
+ proxy[Symbol.dispose]();
91
+
92
+ // Target should still be functional — push should work
93
+ await awaitNavigation(target, () => target.push('/after-proxy-dispose'));
94
+ expect(target.location.pathname).toBe('/after-proxy-dispose');
95
+ });
96
+
97
+ it('should clean up proxy-owned listeners on dispose', async () => {
98
+ const proxyListener = vi.fn();
99
+ proxy.listen(proxyListener);
100
+
101
+ // Dispose the proxy — listener teardown should be called on the target
102
+ proxy[Symbol.dispose]();
103
+
104
+ // Target is still alive, push something — the disposed proxy listener
105
+ // should not fire (listen only triggers on POP, but the underlying
106
+ // subscription should be removed entirely)
107
+ await awaitNavigation(target, () => target.push('/after-dispose'));
108
+ expect(target.location.pathname).toBe('/after-dispose');
109
+ });
110
+
111
+ it('should not affect target listeners when proxy is disposed', async () => {
112
+ // Register a listener directly on the target
113
+ const targetListener = vi.fn();
114
+ target.listen(targetListener);
115
+
116
+ // Register a listener through the proxy
117
+ proxy.listen(vi.fn());
118
+
119
+ // Dispose proxy — only proxy listener should be removed
120
+ proxy[Symbol.dispose]();
121
+
122
+ // Target should still be functional
123
+ await awaitNavigation(target, () => target.push('/still-works'));
124
+ expect(target.location.pathname).toBe('/still-works');
125
+ });
126
+ });
127
+
128
+ describe('listen/block unsubscribe', () => {
129
+ it('should allow manual unsubscribe of a listener', () => {
130
+ const listener = vi.fn();
131
+ const unlisten = proxy.listen(listener);
132
+
133
+ unlisten();
134
+
135
+ // No error on dispose — the listener was already removed
136
+ proxy[Symbol.dispose]();
137
+ });
138
+
139
+ it('should allow manual unsubscribe of a blocker', () => {
140
+ const blocker = vi.fn();
141
+ const unblock = proxy.block(blocker);
142
+
143
+ unblock();
144
+
145
+ // No error on dispose — the blocker was already removed
146
+ proxy[Symbol.dispose]();
147
+ });
148
+ });
149
+ });
package/src/events.ts CHANGED
@@ -15,7 +15,16 @@ export interface NavigateEventDetail {
15
15
 
16
16
  /**
17
17
  * Event emitted before navigation occurs.
18
- * Can be canceled by calling `preventDefault()`.
18
+ * Can be canceled by calling `preventDefault()` to block the navigation.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * eventProvider.addEventListener('onNavigate', (event) => {
23
+ * if (hasUnsavedChanges) {
24
+ * event.preventDefault();
25
+ * }
26
+ * });
27
+ * ```
19
28
  */
20
29
  export class NavigateEvent extends FrameworkEvent<
21
30
  FrameworkEventInit<NavigateEventDetail, INavigationProvider>
@@ -41,8 +50,16 @@ export interface NavigatedEventDetail {
41
50
  }
42
51
 
43
52
  /**
44
- * Event emitted after navigation occurs.
45
- * Contains the navigation action and location details.
53
+ * Event emitted after navigation completes.
54
+ * Contains the navigation action type and both current and previous locations.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * eventProvider.addEventListener('onNavigated', (event) => {
59
+ * const { action, current, previous } = event.detail;
60
+ * console.log(`${action}: ${previous.location.pathname} → ${current.location.pathname}`);
61
+ * });
62
+ * ```
46
63
  */
47
64
  export class NavigatedEvent extends FrameworkEvent<
48
65
  FrameworkEventInit<NavigatedEventDetail, INavigationProvider>