@equinor/fusion-framework-module-navigation 7.0.0 → 7.0.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.
@@ -28,10 +28,19 @@ export declare class NavigationConfigurator extends BaseConfigBuilder<INavigatio
28
28
  /**
29
29
  * Sets a custom history instance for the navigation module.
30
30
  *
31
+ * By default the resolved history is wrapped in a {@link ProxyHistory} so the
32
+ * module gets its own disposable handle without owning (or accidentally
33
+ * disposing) the original instance. Set `proxy` to `false` to use the
34
+ * history as-is.
35
+ *
31
36
  * @param historyOrCallback - History instance or configuration callback
37
+ * @param options - Optional settings for history wrapping
38
+ * @param options.proxy - Wrap the history in a {@link ProxyHistory} (default: `true`)
32
39
  * @returns The configurator instance for method chaining
33
40
  */
34
- setHistory(historyOrCallback?: History | ConfigBuilderCallback<History>): this;
41
+ setHistory(historyOrCallback?: History | ConfigBuilderCallback<History>, options?: {
42
+ proxy?: boolean;
43
+ }): this;
35
44
  /**
36
45
  * Sets telemetry configuration for navigation-specific events.
37
46
  *
@@ -0,0 +1,68 @@
1
+ import type { Observable } from 'rxjs';
2
+ import type { History, NavigateOptions, NavigationBlocker, NavigationListener, NavigationUpdate, Path, To } from './types';
3
+ import type { Actions } from './state/history.actions';
4
+ /**
5
+ * A lightweight proxy that delegates every {@link History} operation to an
6
+ * underlying target instance.
7
+ *
8
+ * Use this when you need to pass a conforming `History` object whose backing
9
+ * implementation can be swapped or is not yet available at construction time,
10
+ * or when you want a thin indirection layer without subclassing
11
+ * {@link BaseHistory}.
12
+ *
13
+ * The proxy does **not** own the underlying history; disposing it only tears
14
+ * down subscriptions and blockers registered through the proxy itself.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const browser = createHistory('browser');
19
+ * const proxy = new ProxyHistory(browser);
20
+ * proxy.push('/dashboard'); // delegates to browser.push
21
+ * ```
22
+ */
23
+ export declare class ProxyHistory implements History {
24
+ #private;
25
+ /**
26
+ * @param target - The history instance to delegate all operations to
27
+ */
28
+ constructor(target: History);
29
+ /** @inheritdoc */
30
+ get state$(): Observable<NavigationUpdate>;
31
+ /** @inheritdoc */
32
+ get action$(): Observable<Actions>;
33
+ /** @inheritdoc */
34
+ get action(): History['action'];
35
+ /** @inheritdoc */
36
+ get location(): History['location'];
37
+ /** @inheritdoc */
38
+ createHref(to: To): string;
39
+ /** @inheritdoc */
40
+ createURL(to: To): URL;
41
+ /** @inheritdoc */
42
+ encodeLocation(to: To): Path;
43
+ /** @inheritdoc */
44
+ push(to: To, state?: unknown): void;
45
+ /** @inheritdoc */
46
+ replace(to: To, state?: unknown): void;
47
+ /** @inheritdoc */
48
+ navigate(to: To, options?: NavigateOptions): void;
49
+ /** @inheritdoc */
50
+ go(delta: number): void;
51
+ /**
52
+ * Triggers a POP action on the underlying history to notify framework
53
+ * listeners (e.g. React Router) after programmatic navigation.
54
+ *
55
+ * Delegates to the target's `pop()` when it is a {@link BaseHistory}
56
+ * instance; otherwise this is a no-op.
57
+ */
58
+ pop(): void;
59
+ /** @inheritdoc */
60
+ listen(listener: NavigationListener): () => void;
61
+ /** @inheritdoc */
62
+ block(blocker: NavigationBlocker): VoidFunction;
63
+ /**
64
+ * Disposes all listeners and blockers registered through this proxy.
65
+ * Does **not** dispose the underlying history.
66
+ */
67
+ [Symbol.dispose](): void;
68
+ }
@@ -10,6 +10,7 @@
10
10
  export { BaseHistory } from './BaseHistory';
11
11
  export { BrowserHistory } from './BrowserHistory';
12
12
  export { MemoryHistory } from './MemoryHistory';
13
+ export { ProxyHistory } from './ProxyHistory';
13
14
  export { BrowserHistoryStack } from './BrowserHistoryStack';
14
15
  export { BrowserHistoryHashStack as HashHistoryStack } from './BrowserHistoryHashStack';
15
16
  export { MemoryHistoryStack } from './MemoryStack';
@@ -1 +1 @@
1
- export declare const version = "7.0.0";
1
+ export declare const version = "7.0.2";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@equinor/fusion-framework-module-navigation",
3
- "version": "7.0.0",
3
+ "version": "7.0.2",
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",
@@ -32,24 +32,24 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@remix-run/router": "^1.23.0",
35
- "uuid": "^13.0.0",
35
+ "uuid": "^14.0.0",
36
36
  "zod": "^4.3.6"
37
37
  },
38
38
  "devDependencies": {
39
- "jsdom": "^27.1.0",
39
+ "jsdom": "^29.0.2",
40
40
  "rxjs": "^7.8.1",
41
41
  "typescript": "^5.9.3",
42
42
  "vitest": "^4.1.0",
43
- "@equinor/fusion-framework-module": "^6.0.0",
44
43
  "@equinor/fusion-framework-module-event": "^6.0.0",
45
- "@equinor/fusion-framework-module-telemetry": "^5.0.0",
46
- "@equinor/fusion-observable": "^9.0.0"
44
+ "@equinor/fusion-observable": "^9.0.1",
45
+ "@equinor/fusion-framework-module-telemetry": "^5.0.1",
46
+ "@equinor/fusion-framework-module": "^6.0.0"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@remix-run/router": "^1.0.0",
50
50
  "rxjs": "^7.0.0",
51
51
  "@equinor/fusion-framework-module": "^6.0.0",
52
- "@equinor/fusion-observable": "^9.0.0"
52
+ "@equinor/fusion-observable": "^9.0.1"
53
53
  },
54
54
  "scripts": {
55
55
  "build": "tsc -b",
@@ -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
 
@@ -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
+ });
@@ -0,0 +1,144 @@
1
+ import { Subscription } from 'rxjs';
2
+ import type { Observable } from 'rxjs';
3
+
4
+ import type { BaseHistory } from './BaseHistory';
5
+ import type {
6
+ History,
7
+ NavigateOptions,
8
+ NavigationBlocker,
9
+ NavigationListener,
10
+ NavigationUpdate,
11
+ Path,
12
+ To,
13
+ } from './types';
14
+ import type { Actions } from './state/history.actions';
15
+
16
+ /**
17
+ * A lightweight proxy that delegates every {@link History} operation to an
18
+ * underlying target instance.
19
+ *
20
+ * Use this when you need to pass a conforming `History` object whose backing
21
+ * implementation can be swapped or is not yet available at construction time,
22
+ * or when you want a thin indirection layer without subclassing
23
+ * {@link BaseHistory}.
24
+ *
25
+ * The proxy does **not** own the underlying history; disposing it only tears
26
+ * down subscriptions and blockers registered through the proxy itself.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const browser = createHistory('browser');
31
+ * const proxy = new ProxyHistory(browser);
32
+ * proxy.push('/dashboard'); // delegates to browser.push
33
+ * ```
34
+ */
35
+ export class ProxyHistory implements History {
36
+ /** The underlying history instance all calls are forwarded to. */
37
+ readonly #target: History;
38
+
39
+ /** Teardowns owned by this proxy, cleaned up on dispose. */
40
+ readonly #teardowns = new Subscription();
41
+
42
+ /**
43
+ * @param target - The history instance to delegate all operations to
44
+ */
45
+ constructor(target: History) {
46
+ this.#target = target;
47
+ }
48
+
49
+ /** @inheritdoc */
50
+ get state$(): Observable<NavigationUpdate> {
51
+ return this.#target.state$;
52
+ }
53
+
54
+ /** @inheritdoc */
55
+ get action$(): Observable<Actions> {
56
+ return this.#target.action$;
57
+ }
58
+
59
+ /** @inheritdoc */
60
+ get action(): History['action'] {
61
+ return this.#target.action;
62
+ }
63
+
64
+ /** @inheritdoc */
65
+ get location(): History['location'] {
66
+ return this.#target.location;
67
+ }
68
+
69
+ /** @inheritdoc */
70
+ createHref(to: To): string {
71
+ return this.#target.createHref(to);
72
+ }
73
+
74
+ /** @inheritdoc */
75
+ createURL(to: To): URL {
76
+ return this.#target.createURL(to);
77
+ }
78
+
79
+ /** @inheritdoc */
80
+ encodeLocation(to: To): Path {
81
+ return this.#target.encodeLocation(to);
82
+ }
83
+
84
+ /** @inheritdoc */
85
+ push(to: To, state?: unknown): void {
86
+ this.#target.push(to, state);
87
+ }
88
+
89
+ /** @inheritdoc */
90
+ replace(to: To, state?: unknown): void {
91
+ this.#target.replace(to, state);
92
+ }
93
+
94
+ /** @inheritdoc */
95
+ navigate(to: To, options?: NavigateOptions): void {
96
+ this.#target.navigate(to, options);
97
+ }
98
+
99
+ /** @inheritdoc */
100
+ go(delta: number): void {
101
+ this.#target.go(delta);
102
+ }
103
+
104
+ /**
105
+ * Triggers a POP action on the underlying history to notify framework
106
+ * listeners (e.g. React Router) after programmatic navigation.
107
+ *
108
+ * Delegates to the target's `pop()` when it is a {@link BaseHistory}
109
+ * instance; otherwise this is a no-op.
110
+ */
111
+ pop(): void {
112
+ if ('pop' in this.#target && typeof this.#target.pop === 'function') {
113
+ (this.#target as BaseHistory).pop();
114
+ }
115
+ }
116
+
117
+ /** @inheritdoc */
118
+ listen(listener: NavigationListener): () => void {
119
+ const unlisten = this.#target.listen(listener);
120
+ this.#teardowns.add(unlisten);
121
+ return () => {
122
+ unlisten();
123
+ this.#teardowns.remove(unlisten);
124
+ };
125
+ }
126
+
127
+ /** @inheritdoc */
128
+ block(blocker: NavigationBlocker): VoidFunction {
129
+ const unblock = this.#target.block(blocker);
130
+ this.#teardowns.add(unblock);
131
+ return () => {
132
+ unblock();
133
+ this.#teardowns.remove(unblock);
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Disposes all listeners and blockers registered through this proxy.
139
+ * Does **not** dispose the underlying history.
140
+ */
141
+ [Symbol.dispose](): void {
142
+ this.#teardowns.unsubscribe();
143
+ }
144
+ }
package/src/lib/index.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  export { BaseHistory } from './BaseHistory';
13
13
  export { BrowserHistory } from './BrowserHistory';
14
14
  export { MemoryHistory } from './MemoryHistory';
15
+ export { ProxyHistory } from './ProxyHistory';
15
16
 
16
17
  // History stacks
17
18
  export { BrowserHistoryStack } from './BrowserHistoryStack';
package/src/version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by genversion.
2
- export const version = '7.0.0';
2
+ export const version = '7.0.2';