@equinor/fusion-framework-module-navigation 7.0.3 → 7.0.5

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 (142) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/esm/NavigateEvent.js +24 -0
  3. package/dist/esm/NavigateEvent.js.map +1 -0
  4. package/dist/esm/NavigatedEvent.js +20 -0
  5. package/dist/esm/NavigatedEvent.js.map +1 -0
  6. package/dist/esm/NavigationConfigurator.js +13 -1
  7. package/dist/esm/NavigationConfigurator.js.map +1 -1
  8. package/dist/esm/NavigationProvider.js +15 -3
  9. package/dist/esm/NavigationProvider.js.map +1 -1
  10. package/dist/esm/enable-navigation.js +1 -0
  11. package/dist/esm/enable-navigation.js.map +1 -1
  12. package/dist/esm/index.js +2 -1
  13. package/dist/esm/index.js.map +1 -1
  14. package/dist/esm/lib/BaseHistory.js +9 -0
  15. package/dist/esm/lib/BaseHistory.js.map +1 -1
  16. package/dist/esm/lib/BrowserHistory.js +1 -0
  17. package/dist/esm/lib/BrowserHistory.js.map +1 -1
  18. package/dist/esm/lib/BrowserHistoryHashStack.js +1 -0
  19. package/dist/esm/lib/BrowserHistoryHashStack.js.map +1 -1
  20. package/dist/esm/lib/BrowserHistoryStack.js +15 -0
  21. package/dist/esm/lib/BrowserHistoryStack.js.map +1 -1
  22. package/dist/esm/lib/MemoryHistory.js +4 -1
  23. package/dist/esm/lib/MemoryHistory.js.map +1 -1
  24. package/dist/esm/lib/{MemoryStack.js → MemoryHistoryStack.js} +12 -1
  25. package/dist/esm/lib/MemoryHistoryStack.js.map +1 -0
  26. package/dist/esm/lib/ProxyHistory.js +1 -0
  27. package/dist/esm/lib/ProxyHistory.js.map +1 -1
  28. package/dist/esm/lib/create-history.js +1 -0
  29. package/dist/esm/lib/create-history.js.map +1 -1
  30. package/dist/esm/lib/index.js +1 -1
  31. package/dist/esm/lib/index.js.map +1 -1
  32. package/dist/esm/lib/state/{history.actions.js → actions.js} +1 -1
  33. package/dist/esm/lib/state/actions.js.map +1 -0
  34. package/dist/esm/lib/state/check-blockers.js +36 -0
  35. package/dist/esm/lib/state/check-blockers.js.map +1 -0
  36. package/dist/esm/lib/state/create-flow.js +27 -0
  37. package/dist/esm/lib/state/create-flow.js.map +1 -0
  38. package/dist/esm/lib/state/{history.reducer.js → create-history-reducer.js} +5 -2
  39. package/dist/esm/lib/state/create-history-reducer.js.map +1 -0
  40. package/dist/esm/lib/state/create-store.js +26 -0
  41. package/dist/esm/lib/state/create-store.js.map +1 -0
  42. package/dist/esm/lib/state/flow-creators.js +7 -0
  43. package/dist/esm/lib/state/flow-creators.js.map +1 -0
  44. package/dist/esm/lib/state/go.js +12 -0
  45. package/dist/esm/lib/state/go.js.map +1 -0
  46. package/dist/esm/lib/state/history.state.js +1 -39
  47. package/dist/esm/lib/state/history.state.js.map +1 -1
  48. package/dist/esm/lib/state/index.js +3 -3
  49. package/dist/esm/lib/state/index.js.map +1 -1
  50. package/dist/esm/lib/state/navigate.js +43 -0
  51. package/dist/esm/lib/state/navigate.js.map +1 -0
  52. package/dist/esm/lib/state/pop.js +14 -0
  53. package/dist/esm/lib/state/pop.js.map +1 -0
  54. package/dist/esm/lib/state/validate-current-location.js +26 -0
  55. package/dist/esm/lib/state/validate-current-location.js.map +1 -0
  56. package/dist/esm/lib/utils/index.js +2 -2
  57. package/dist/esm/lib/utils/index.js.map +1 -1
  58. package/dist/esm/lib/utils/resolve-browser-location.js +1 -52
  59. package/dist/esm/lib/utils/resolve-browser-location.js.map +1 -1
  60. package/dist/esm/lib/utils/resolve-hash-location.js +20 -0
  61. package/dist/esm/lib/utils/resolve-hash-location.js.map +1 -0
  62. package/dist/esm/lib/utils/resolve-window-location.js +18 -0
  63. package/dist/esm/lib/utils/resolve-window-location.js.map +1 -0
  64. package/dist/esm/version.js +1 -1
  65. package/dist/tsconfig.tsbuildinfo +1 -1
  66. package/dist/types/NavigateEvent.d.ts +36 -0
  67. package/dist/types/NavigatedEvent.d.ts +28 -0
  68. package/dist/types/NavigationConfigurator.d.ts +3 -0
  69. package/dist/types/NavigationProvider.d.ts +3 -0
  70. package/dist/types/index.d.ts +2 -1
  71. package/dist/types/lib/BaseHistory.d.ts +8 -0
  72. package/dist/types/lib/BrowserHistoryHashStack.d.ts +1 -0
  73. package/dist/types/lib/BrowserHistoryStack.d.ts +14 -0
  74. package/dist/types/lib/MemoryHistory.d.ts +3 -0
  75. package/dist/types/lib/{MemoryStack.d.ts → MemoryHistoryStack.d.ts} +6 -0
  76. package/dist/types/lib/ProxyHistory.d.ts +1 -1
  77. package/dist/types/lib/index.d.ts +1 -1
  78. package/dist/types/lib/state/check-blockers.d.ts +3 -0
  79. package/dist/types/lib/state/create-flow.d.ts +10 -0
  80. package/dist/types/lib/state/{history.reducer.d.ts → create-history-reducer.d.ts} +32 -32
  81. package/dist/types/lib/state/create-store.d.ts +17 -0
  82. package/dist/types/lib/state/flow-creators.d.ts +7 -0
  83. package/dist/types/lib/state/go.d.ts +3 -0
  84. package/dist/types/lib/state/history.state.d.ts +2 -23
  85. package/dist/types/lib/state/index.d.ts +4 -3
  86. package/dist/types/lib/state/navigate.d.ts +9 -0
  87. package/dist/types/lib/state/pop.d.ts +3 -0
  88. package/dist/types/lib/state/validate-current-location.d.ts +3 -0
  89. package/dist/types/lib/types.d.ts +1 -1
  90. package/dist/types/lib/utils/index.d.ts +2 -2
  91. package/dist/types/lib/utils/resolve-browser-location.d.ts +1 -42
  92. package/dist/types/lib/utils/resolve-hash-location.d.ts +10 -0
  93. package/dist/types/lib/utils/resolve-window-location.d.ts +10 -0
  94. package/dist/types/version.d.ts +1 -1
  95. package/package.json +8 -8
  96. package/src/NavigateEvent.ts +47 -0
  97. package/src/NavigatedEvent.ts +35 -0
  98. package/src/NavigationConfigurator.ts +13 -3
  99. package/src/NavigationProvider.interface.ts +1 -1
  100. package/src/NavigationProvider.ts +15 -3
  101. package/src/__tests__/NavigationProvider.test.ts +11 -0
  102. package/src/enable-navigation.ts +1 -0
  103. package/src/index.ts +2 -6
  104. package/src/lib/BaseHistory.ts +9 -0
  105. package/src/lib/BrowserHistory.ts +1 -0
  106. package/src/lib/BrowserHistoryHashStack.ts +1 -0
  107. package/src/lib/BrowserHistoryStack.ts +15 -0
  108. package/src/lib/MemoryHistory.ts +4 -1
  109. package/src/lib/{MemoryStack.ts → MemoryHistoryStack.ts} +11 -0
  110. package/src/lib/ProxyHistory.ts +2 -1
  111. package/src/lib/create-history.ts +1 -0
  112. package/src/lib/index.ts +1 -1
  113. package/src/lib/state/check-blockers.ts +52 -0
  114. package/src/lib/state/create-flow.ts +36 -0
  115. package/src/lib/state/{history.reducer.ts → create-history-reducer.ts} +4 -1
  116. package/src/lib/state/create-store.ts +39 -0
  117. package/src/lib/state/flow-creators.ts +7 -0
  118. package/src/lib/state/go.ts +20 -0
  119. package/src/lib/state/history.state.ts +2 -56
  120. package/src/lib/state/index.ts +4 -3
  121. package/src/lib/state/navigate.ts +58 -0
  122. package/src/lib/state/pop.ts +24 -0
  123. package/src/lib/state/validate-current-location.ts +33 -0
  124. package/src/lib/types.ts +1 -1
  125. package/src/lib/utils/index.ts +2 -2
  126. package/src/lib/utils/resolve-browser-location.ts +1 -60
  127. package/src/lib/utils/resolve-hash-location.ts +26 -0
  128. package/src/lib/utils/resolve-window-location.ts +24 -0
  129. package/src/version.ts +1 -1
  130. package/dist/esm/events.js +0 -40
  131. package/dist/esm/events.js.map +0 -1
  132. package/dist/esm/lib/MemoryStack.js.map +0 -1
  133. package/dist/esm/lib/state/history.actions.js.map +0 -1
  134. package/dist/esm/lib/state/history.flows.js +0 -207
  135. package/dist/esm/lib/state/history.flows.js.map +0 -1
  136. package/dist/esm/lib/state/history.reducer.js.map +0 -1
  137. package/dist/types/events.d.ts +0 -65
  138. package/dist/types/lib/state/history.flows.d.ts +0 -97
  139. package/src/events.ts +0 -82
  140. package/src/lib/state/history.flows.ts +0 -274
  141. /package/dist/types/lib/state/{history.actions.d.ts → actions.d.ts} +0 -0
  142. /package/src/lib/state/{history.actions.ts → actions.ts} +0 -0
@@ -0,0 +1,47 @@
1
+ import { FrameworkEvent, type FrameworkEventInit } from '@equinor/fusion-framework-module-event';
2
+
3
+ import type { INavigationProvider } from './NavigationProvider.interface';
4
+ import type { NavigationUpdate, Path } from './lib/types';
5
+
6
+ /** Event detail for navigation events (before navigation). */
7
+ export interface NavigateEventDetail {
8
+ /** The target path or location to navigate to */
9
+ location: NavigationUpdate;
10
+ /** Optional current location before navigation */
11
+ currentLocation?: Path;
12
+ }
13
+
14
+ /**
15
+ * Event emitted before navigation occurs.
16
+ * Can be canceled by calling `preventDefault()` to block the navigation.
17
+ *
18
+ * @example
19
+ * ```ts
20
+ * eventProvider.addEventListener('onNavigate', (event) => {
21
+ * if (hasUnsavedChanges) {
22
+ * event.preventDefault();
23
+ * }
24
+ * });
25
+ * ```
26
+ */
27
+ export class NavigateEvent extends FrameworkEvent<
28
+ FrameworkEventInit<NavigateEventDetail, INavigationProvider>
29
+ > {
30
+ /** @param detail - Navigation target details. @param source - Provider dispatching the event. */
31
+ constructor(detail: NavigateEventDetail, source: INavigationProvider) {
32
+ super('onNavigate', {
33
+ detail,
34
+ source,
35
+ });
36
+ }
37
+ }
38
+
39
+ /** Declares navigation events in the FrameworkEventMap for type safety. */
40
+ declare module '@equinor/fusion-framework-module-event' {
41
+ interface FrameworkEventMap {
42
+ /** Event emitted before navigation occurs, can be canceled to prevent navigation */
43
+ onNavigate: NavigateEvent;
44
+ /** Event emitted after navigation occurs */
45
+ onNavigated: import('./NavigatedEvent').NavigatedEvent;
46
+ }
47
+ }
@@ -0,0 +1,35 @@
1
+ import { FrameworkEvent, type FrameworkEventInit } from '@equinor/fusion-framework-module-event';
2
+
3
+ import type { INavigationProvider } from './NavigationProvider.interface';
4
+ import type { Action, NavigationUpdate } from './lib/types';
5
+
6
+ /** Event detail for navigated events (after navigation). */
7
+ export interface NavigatedEventDetail {
8
+ /** The navigation action that occurred (PUSH, POP, REPLACE) */
9
+ action: Action;
10
+ /** The new location after navigation */
11
+ current: NavigationUpdate;
12
+ /** The previous location before navigation */
13
+ previous: NavigationUpdate;
14
+ }
15
+
16
+ /**
17
+ * Event emitted after navigation completes.
18
+ * Contains the navigation action type and both current and previous locations.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * eventProvider.addEventListener('onNavigated', (event) => {
23
+ * const { action, current, previous } = event.detail;
24
+ * console.log(`${action}: ${previous.location.pathname} -> ${current.location.pathname}`);
25
+ * });
26
+ * ```
27
+ */
28
+ export class NavigatedEvent extends FrameworkEvent<
29
+ FrameworkEventInit<NavigatedEventDetail, INavigationProvider>
30
+ > {
31
+ /** @param detail - Completed navigation details. @param source - Provider dispatching the event. */
32
+ constructor(detail: NavigatedEventDetail, source: INavigationProvider) {
33
+ super('onNavigated', { detail, source });
34
+ }
35
+ }
@@ -67,9 +67,11 @@ const parseNavigationConfig = (config: unknown): INavigationConfigurator => {
67
67
  * Extends BaseConfigBuilder to provide fluent configuration API with Zod validation.
68
68
  */
69
69
  export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigurator> {
70
+ /** Creates a navigation configurator with default history and event providers. */
70
71
  constructor() {
71
72
  super();
72
73
  this.setEventProvider(async (args) => {
74
+ // Only resolve the optional provider when the event module participates in this framework.
73
75
  if (args.hasModule('event')) {
74
76
  return await args.requireInstance('event');
75
77
  }
@@ -77,10 +79,12 @@ export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigu
77
79
  this.setHistory(
78
80
  async (args) => {
79
81
  const history = (args.ref as ModulesInstance<[NavigationModule]>)?.navigation?.history;
82
+ // Wrap supplied history so the module owns only the proxy lifecycle.
80
83
  if (history) {
81
84
  // Wrap the provided history in a ProxyHistory to ensure the module can manage its own teardowns without affecting the original instance.
82
85
  return new ProxyHistory(history);
83
86
  }
87
+ // Prefer browser history when a DOM is available, otherwise use memory history for SSR.
84
88
  if (typeof window !== 'undefined') {
85
89
  return createHistory('browser');
86
90
  }
@@ -92,8 +96,10 @@ export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigu
92
96
  }
93
97
  /**
94
98
  * @deprecated Use `setBasename()` method instead
99
+ * @param value - Deprecated basename value to configure.
95
100
  */
96
101
  public set basename(value: string | undefined) {
102
+ // Warn only during development so deprecated setters remain silent in production.
97
103
  if (process.env.NODE_ENV === 'development') {
98
104
  console.warn('NavigationConfigurator.basename', 'use setBasename() method instead');
99
105
  }
@@ -102,8 +108,10 @@ export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigu
102
108
 
103
109
  /**
104
110
  * @deprecated Use `setHistory()` method instead
111
+ * @param value - Deprecated history value to configure.
105
112
  */
106
113
  public set history(value: History | undefined) {
114
+ // Warn only during development so deprecated setters remain silent in production.
107
115
  if (process.env.NODE_ENV === 'development') {
108
116
  console.warn('NavigationConfigurator.history', 'use setHistory() method instead');
109
117
  }
@@ -149,14 +157,16 @@ export class NavigationConfigurator extends BaseConfigBuilder<INavigationConfigu
149
157
  : // Normalize a direct instance to a callback for consistent handling.
150
158
  async () => historyOrCallback;
151
159
 
160
+ // Proxying isolates teardown ownership when callers provide an existing history instance.
152
161
  if (proxy) {
153
162
  // Wrap each emitted history in a ProxyHistory so dispose only tears down
154
163
  // proxy-owned listeners/blockers, never the underlying history itself.
155
164
  this._set('history', (args) =>
156
- from(resolve(args) as ObservableInput<History | undefined>).pipe(
157
- map((history) => (history ? new ProxyHistory(history) : undefined)),
158
- ),
165
+ from(resolve(args) as ObservableInput<History | undefined>)
166
+ // Transform each resolved history into an independently disposable proxy.
167
+ .pipe(map((history) => (history ? new ProxyHistory(history) : undefined))),
159
168
  );
169
+ // Direct histories are already owned by the navigation module and can be used unchanged.
160
170
  } else {
161
171
  this._set('history', resolve);
162
172
  }
@@ -1,4 +1,4 @@
1
- // TODO: Remove @remix-run/router dependency once all apps have migrated to @equinor/fusion-framework-react-router
1
+ // TODO(#5158): Remove @remix-run/router dependency once all apps have migrated to @equinor/fusion-framework-react-router
2
2
  import type { AgnosticRouteObject, Router } from '@remix-run/router';
3
3
 
4
4
  import type { History, NavigateOptions } from './lib/types';
@@ -1,4 +1,4 @@
1
- // TODO: Remove @remix-run/router dependency once all apps have migrated to @equinor/fusion-framework-react-router
1
+ // TODO(#5158): Remove @remix-run/router dependency once all apps have migrated to @equinor/fusion-framework-react-router
2
2
  import { type AgnosticRouteObject, createRouter } from '@remix-run/router';
3
3
  import type { Observable } from 'rxjs';
4
4
  import { filter, pairwise, shareReplay } from 'rxjs/operators';
@@ -17,7 +17,7 @@ import {
17
17
  type ITelemetryProvider,
18
18
  } from '@equinor/fusion-framework-module-telemetry';
19
19
  import type { IEventModuleProvider } from '@equinor/fusion-framework-module-event';
20
- import { NavigatedEvent } from './events';
20
+ import { NavigatedEvent } from './NavigatedEvent';
21
21
  import { pathToString } from './lib/utils';
22
22
  import type { BaseHistory } from './lib';
23
23
 
@@ -68,6 +68,7 @@ export class NavigationProvider
68
68
 
69
69
  /**
70
70
  * Observable stream of navigation state updates.
71
+ * @returns Observable of navigation updates.
71
72
  *
72
73
  * Emits localized paths (with basename removed) and filters to only
73
74
  * paths within the basename scope. Late subscribers receive the last
@@ -88,6 +89,7 @@ export class NavigationProvider
88
89
 
89
90
  /**
90
91
  * @deprecated Use `history` instead
92
+ * @returns The underlying `History` instance.
91
93
  */
92
94
  public get navigator(): History {
93
95
  this.#telemetry?.trackException({
@@ -101,6 +103,7 @@ export class NavigationProvider
101
103
 
102
104
  /**
103
105
  * Gets the underlying history instance.
106
+ * @returns The {@link History} instance used for navigation
104
107
  *
105
108
  * @returns The {@link History} instance used for navigation
106
109
  */
@@ -129,7 +132,13 @@ export class NavigationProvider
129
132
  // Extract configuration values
130
133
  const { basename, history, telemetry, eventProvider } = args.config;
131
134
 
132
- this.#basename = basename;
135
+ // Normalize the basename to strip trailing slashes. React Router requires
136
+ // the current URL to start with the exact basename string, so a basename
137
+ // of "/apps/my-app/" would fail to match the URL "/apps/my-app" and
138
+ // render nothing (blank page).
139
+ // Preserve slash-only basenames (e.g. "/") by falling back to the
140
+ // original input when normalization collapses to an empty string.
141
+ this.#basename = basename ? normalizePathname(basename) || basename : basename;
133
142
  this.#event = eventProvider;
134
143
  this.#telemetry = telemetry;
135
144
 
@@ -229,6 +238,9 @@ export class NavigationProvider
229
238
  level: TelemetryLevel.Warning,
230
239
  scope: ['navigation', 'deprecated', TelemetryScope.Application],
231
240
  });
241
+ // `this.#history` is typed as the framework's minimal history interface, but Remix Router
242
+ // requires its own richer `History` type — the two are runtime-compatible, so cast through
243
+ // `unknown`.
232
244
  const router = createRouter({
233
245
  basename: this.#basename,
234
246
  history: this.#history as unknown as import('@remix-run/router').History,
@@ -57,4 +57,15 @@ describe('NavigationProvider', () => {
57
57
  provider.dispose();
58
58
  expect(disposeSpy).toHaveBeenCalled();
59
59
  });
60
+
61
+ it('should preserve root basename when set to slash', () => {
62
+ const providerWithRootBasename = new NavigationProvider({
63
+ version: '1.0.0',
64
+ config: { history, basename: '/' },
65
+ });
66
+
67
+ expect(providerWithRootBasename.basename).toBe('/');
68
+
69
+ providerWithRootBasename.dispose();
70
+ });
60
71
  });
@@ -47,6 +47,7 @@ export const enableNavigation = <TRef = unknown>(
47
47
  configurator.addConfig({
48
48
  module,
49
49
  configure(config, ref) {
50
+ // A string shortcut sets the basename directly; otherwise defer to the caller's configure callback.
50
51
  if (typeof basenameOrOptions === 'string') {
51
52
  config.setBasename(basenameOrOptions);
52
53
  } else if (typeof basenameOrOptions === 'object' && 'configure' in basenameOrOptions) {
package/src/index.ts CHANGED
@@ -33,12 +33,8 @@ export { NavigationProvider } from './NavigationProvider';
33
33
 
34
34
  export { createHistory } from './lib/create-history';
35
35
 
36
- export {
37
- NavigateEvent,
38
- NavigatedEvent,
39
- type NavigateEventDetail,
40
- type NavigatedEventDetail,
41
- } from './events';
36
+ export { NavigateEvent, type NavigateEventDetail } from './NavigateEvent';
37
+ export { NavigatedEvent, type NavigatedEventDetail } from './NavigatedEvent';
42
38
 
43
39
  export type {
44
40
  Path,
@@ -49,6 +49,7 @@ export abstract class BaseHistory implements History {
49
49
  /**
50
50
  * Observable stream of navigation state changes.
51
51
  * Emits on all navigation events (push, replace, pop).
52
+ * @returns Observable of the current navigation update.
52
53
  */
53
54
  public get state$(): History['state$'] {
54
55
  return this.#state.subject.select((state) => state.current);
@@ -56,6 +57,7 @@ export abstract class BaseHistory implements History {
56
57
 
57
58
  /**
58
59
  * Observable stream of navigation actions.
60
+ * @returns Observable of navigation actions.
59
61
  */
60
62
  public get action$(): History['action$'] {
61
63
  return this.#state.subject.action$;
@@ -70,6 +72,7 @@ export abstract class BaseHistory implements History {
70
72
  return this.#state.subject.value.blockers.length > 0;
71
73
  }
72
74
 
75
+ /** @param state - Reactive history state backing this implementation. */
73
76
  protected constructor(state: HistoryState) {
74
77
  this.#state = state;
75
78
  }
@@ -117,6 +120,8 @@ export abstract class BaseHistory implements History {
117
120
 
118
121
  /**
119
122
  * Pushes a new location onto the history stack.
123
+ * @param to - Target path or partial path object.
124
+ * @param state - Optional state associated with the new location.
120
125
  */
121
126
  public push(to: To, state?: unknown): void {
122
127
  this.navigate(to, { state });
@@ -124,6 +129,8 @@ export abstract class BaseHistory implements History {
124
129
 
125
130
  /**
126
131
  * Replaces the current location in the history stack.
132
+ * @param to - Target path or partial path object.
133
+ * @param state - Optional state associated with the replacement location.
127
134
  */
128
135
  public replace(to: To, state?: unknown): void {
129
136
  this.navigate(to, { replace: true, state });
@@ -185,6 +192,7 @@ export abstract class BaseHistory implements History {
185
192
  return this._addTeardown(removeBlocker, { executeOnRemove: true });
186
193
  }
187
194
 
195
+ /** Triggers a POP action for the current history location. */
188
196
  public pop(): void {
189
197
  this.#state.subject.next(this.#state.actions.pop());
190
198
  }
@@ -225,6 +233,7 @@ export abstract class BaseHistory implements History {
225
233
  ): VoidFunction {
226
234
  this.#teardowns.add(teardown);
227
235
  return () => {
236
+ // Execute teardown callbacks immediately when callers explicitly request removal side effects.
228
237
  if (options?.executeOnRemove) {
229
238
  typeof teardown === 'function' ? teardown() : teardown.unsubscribe();
230
239
  }
@@ -59,6 +59,7 @@ export class BrowserHistory extends BaseHistory {
59
59
  // Use provided stack or default to BrowserHistoryStack
60
60
  const Stack: StackConstructor = options.stack ?? BrowserHistoryStack;
61
61
  const browserWindow = options.window ?? document.defaultView;
62
+ // Fail fast without a window since the browser stack depends on the DOM history API.
62
63
  if (!browserWindow) {
63
64
  throw new Error('Window is required');
64
65
  }
@@ -18,6 +18,7 @@ import { resolveHashLocation, resolvePath, pathToString } from './utils';
18
18
  export class BrowserHistoryHashStack extends BrowserHistoryStack {
19
19
  /**
20
20
  * Gets the current location from window.location.hash.
21
+ * @returns The current hash-derived navigation location.
21
22
  */
22
23
  public get current(): Readonly<Location> {
23
24
  return resolveHashLocation(this._window);
@@ -22,6 +22,7 @@ export class BrowserHistoryStack implements HistoryStack {
22
22
 
23
23
  /**
24
24
  * Gets the origin of the history stack.
25
+ * @returns The browser origin used as the URL base.
25
26
  */
26
27
  public get origin(): string {
27
28
  return this._window.location.origin;
@@ -29,6 +30,7 @@ export class BrowserHistoryStack implements HistoryStack {
29
30
 
30
31
  /**
31
32
  * Gets the current location.
33
+ * @returns The current browser location converted to a navigation location.
32
34
  */
33
35
  public get current(): Location {
34
36
  return resolveWindowLocation(this._window);
@@ -36,6 +38,7 @@ export class BrowserHistoryStack implements HistoryStack {
36
38
 
37
39
  /**
38
40
  * Pushes a new entry onto the history stack.
41
+ * @param location - Location to append to browser history.
39
42
  */
40
43
  public push(location: Location): void {
41
44
  this.navigate(location, 'PUSH');
@@ -43,6 +46,7 @@ export class BrowserHistoryStack implements HistoryStack {
43
46
 
44
47
  /**
45
48
  * Replaces the current entry in the history stack.
49
+ * @param location - Location to replace in browser history.
46
50
  */
47
51
  public replace(location: Location): void {
48
52
  this.navigate(location, 'REPLACE');
@@ -50,10 +54,13 @@ export class BrowserHistoryStack implements HistoryStack {
50
54
 
51
55
  /**
52
56
  * Navigates to a location with the specified action.
57
+ * @param location - Location to navigate to.
58
+ * @param action - Native history action to perform.
53
59
  */
54
60
  public navigate(location: Location, action: 'PUSH' | 'REPLACE'): void {
55
61
  const relativePath = this._createRelativePath(location);
56
62
  const state = { value: location.state, key: location.key };
63
+ // Select the native operation matching the requested navigation action.
57
64
  if (action === 'PUSH') {
58
65
  this._window.history.pushState(state, '', relativePath);
59
66
  } else {
@@ -63,6 +70,7 @@ export class BrowserHistoryStack implements HistoryStack {
63
70
 
64
71
  /**
65
72
  * Navigates backward or forward in the history stack.
73
+ * @param delta - Number of entries to move backward or forward.
66
74
  */
67
75
  public go(delta: number): void {
68
76
  this._window.history.go(delta);
@@ -70,11 +78,18 @@ export class BrowserHistoryStack implements HistoryStack {
70
78
 
71
79
  /**
72
80
  * Creates a URL object for a given path.
81
+ * @param to - Target path or partial path object.
82
+ * @returns URL resolved against the browser origin.
73
83
  */
74
84
  public createURL(to: To): URL {
75
85
  return new URL(pathToString(resolvePath(to)), this.origin);
76
86
  }
77
87
 
88
+ /**
89
+ * Creates the relative URL path used by native browser history.
90
+ * @param to - Target path or partial path object.
91
+ * @returns Relative URL path for browser history.
92
+ */
78
93
  protected _createRelativePath(to: To): string {
79
94
  return pathToString(this.createURL(to));
80
95
  }
@@ -1,6 +1,6 @@
1
1
  import { type To, type LocationState, type NavigationUpdate, Action } from './types';
2
2
  import { pathToString, resolvePath } from './utils';
3
- import { MemoryHistoryStack } from './MemoryStack';
3
+ import { MemoryHistoryStack } from './MemoryHistoryStack';
4
4
  import { BaseHistory } from './BaseHistory';
5
5
  import { createHistoryReducer, createStore } from './state';
6
6
 
@@ -40,6 +40,7 @@ export type MemoryHistoryOptions = {
40
40
  * - **Node.js** — any environment without browser history APIs
41
41
  */
42
42
  export class MemoryHistory extends BaseHistory {
43
+ /** @param options - Optional initial location and history entries. */
43
44
  public constructor(options?: MemoryHistoryOptions) {
44
45
  const { initialLocation, initialHistory } = options ?? {};
45
46
  const initial = initialLocation ?? defaultInitialLocation;
@@ -65,6 +66,8 @@ export class MemoryHistory extends BaseHistory {
65
66
 
66
67
  /**
67
68
  * Creates a URL object for a given path using memory:// origin.
69
+ * @param to - Target path or partial path object.
70
+ * @returns URL resolved against memory://.
68
71
  */
69
72
  public createURL(to: To): URL {
70
73
  const path = pathToString(resolvePath(to));
@@ -14,6 +14,7 @@ export class MemoryHistoryStack implements HistoryStack {
14
14
  /**
15
15
  * Gets the origin of the history stack.
16
16
  * Always returns 'memory://' for in-memory storage.
17
+ * @returns The fixed origin used for in-memory URLs.
17
18
  */
18
19
  get origin(): string {
19
20
  return 'memory://';
@@ -21,6 +22,7 @@ export class MemoryHistoryStack implements HistoryStack {
21
22
 
22
23
  /**
23
24
  * Gets the current location.
25
+ * @returns The current in-memory navigation location.
24
26
  */
25
27
  get current(): Location {
26
28
  return this.#current;
@@ -47,6 +49,7 @@ export class MemoryHistoryStack implements HistoryStack {
47
49
  * Pushes a new entry onto the history stack.
48
50
  *
49
51
  * Only updates the current location. History entries are managed by the reducer.
52
+ * @param location - Location to make current.
50
53
  */
51
54
  push(location: Location): void {
52
55
  this.#current = location;
@@ -56,6 +59,7 @@ export class MemoryHistoryStack implements HistoryStack {
56
59
  * Replaces the current entry in the history stack.
57
60
  *
58
61
  * Only updates the current location. History entries are managed by the reducer.
62
+ * @param location - Location to make current.
59
63
  */
60
64
  replace(location: Location): void {
61
65
  this.#current = location;
@@ -74,10 +78,13 @@ export class MemoryHistoryStack implements HistoryStack {
74
78
  const { history, current } = state;
75
79
  const currentLocation = current.location ?? this.#current;
76
80
  // Find current location in history by key
81
+ // Locate the current entry so relative navigation can preserve stack boundaries.
77
82
  const currentIndex = history.findIndex((entry) => entry.location.key === currentLocation.key);
78
83
 
79
84
  // If current location not found, use last entry
85
+ // Fall back to the newest entry when the stack cannot identify the current location.
80
86
  if (currentIndex === -1) {
87
+ // Restore the latest known entry when the history cannot locate the current key.
81
88
  if (history.length > 0) {
82
89
  this.#current = history[history.length - 1].location;
83
90
  }
@@ -86,8 +93,10 @@ export class MemoryHistoryStack implements HistoryStack {
86
93
 
87
94
  // Calculate target index and clamp to valid range
88
95
  const newIndex = currentIndex + delta;
96
+ // Clamp the destination to the available history range.
89
97
  if (newIndex < 0) {
90
98
  this.#current = state.history[0].location;
99
+ // Use the oldest entry when navigation moves beyond the end of the stack.
91
100
  } else if (newIndex >= state.history.length) {
92
101
  this.#current = state.history[state.history.length - 1].location;
93
102
  } else {
@@ -99,6 +108,8 @@ export class MemoryHistoryStack implements HistoryStack {
99
108
  * Creates a URL object for a given path.
100
109
  *
101
110
  * All URLs use the 'memory://' origin since this is in-memory storage.
111
+ * @param to - Target path or partial path object.
112
+ * @returns URL resolved against memory://.
102
113
  */
103
114
  createURL(to: To): URL {
104
115
  const path = resolvePath(to);
@@ -11,7 +11,7 @@ import type {
11
11
  Path,
12
12
  To,
13
13
  } from './types';
14
- import type { Actions } from './state/history.actions';
14
+ import type { Actions } from './state/actions';
15
15
 
16
16
  /**
17
17
  * A lightweight proxy that delegates every {@link History} operation to an
@@ -109,6 +109,7 @@ export class ProxyHistory implements History {
109
109
  * instance; otherwise this is a no-op.
110
110
  */
111
111
  pop(): void {
112
+ // Only BaseHistory implementations expose pop(); anything else is a no-op.
112
113
  if ('pop' in this.#target && typeof this.#target.pop === 'function') {
113
114
  (this.#target as BaseHistory).pop();
114
115
  }
@@ -33,6 +33,7 @@ export const createHistory = <T extends keyof HistoryCtorMap>(
33
33
  type: T,
34
34
  ...args: Parameters<HistoryCtorMap[T]>
35
35
  ): ReturnType<HistoryCtorMap[T]> => {
36
+ // Dispatch to the history implementation matching the requested type.
36
37
  switch (type) {
37
38
  case 'memory':
38
39
  return new MemoryHistory(...(args as [MemoryHistoryOptions])) as ReturnType<
package/src/lib/index.ts CHANGED
@@ -17,7 +17,7 @@ export { ProxyHistory } from './ProxyHistory';
17
17
  // History stacks
18
18
  export { BrowserHistoryStack } from './BrowserHistoryStack';
19
19
  export { BrowserHistoryHashStack as HashHistoryStack } from './BrowserHistoryHashStack';
20
- export { MemoryHistoryStack } from './MemoryStack';
20
+ export { MemoryHistoryStack } from './MemoryHistoryStack';
21
21
 
22
22
  // Types
23
23
  export type {
@@ -0,0 +1,52 @@
1
+ import { Observable, of } from 'rxjs';
2
+ import { concatMap, map, withLatestFrom } from 'rxjs/operators';
3
+ import type { HistoryFlowCreator } from './navigate';
4
+ import { actions, type Actions } from './actions';
5
+ import { filterAction } from '@equinor/fusion-observable/operators';
6
+ import { Action, type HistoryStack, type LocationState } from '../types';
7
+
8
+ /** Flow creator that gates navigation actions through registered blockers. */
9
+ export const checkBlockers: HistoryFlowCreator =
10
+ (stack: HistoryStack) => (action$: Observable<Actions>, state$: Observable<LocationState>) =>
11
+ // Normalize each navigation-related action into a common shape before gating on blockers.
12
+ action$.pipe(
13
+ filterAction(
14
+ actions.navigate.type,
15
+ actions.pop.type,
16
+ actions.go.type,
17
+ actions.validateLocation.type,
18
+ ),
19
+ map((action) => {
20
+ // Only `navigate` carries a replace flag; the rest are treated as POP transitions.
21
+ switch (action.type) {
22
+ case actions.navigate.type:
23
+ return { replace: action.payload.options.replace, action };
24
+ case actions.go.type:
25
+ case actions.pop.type:
26
+ case actions.validateLocation.type:
27
+ return { type: 'POP' as Action, action };
28
+ }
29
+ }),
30
+ withLatestFrom(state$),
31
+ concatMap(([{ action, replace }, state]) => {
32
+ // Allow actions through immediately when no blocker can veto the transition.
33
+ if (!state.blockers.length) {
34
+ return of(action);
35
+ }
36
+ return new Observable<Actions>((subscriber) => {
37
+ const location = stack.current;
38
+ // Invoke all blockers and complete once every asynchronous decision settles.
39
+ const blockers = state.blockers.map((blocker) =>
40
+ Promise.resolve(
41
+ blocker({
42
+ delta: 0,
43
+ action: replace ? Action.Replace : Action.Push,
44
+ location,
45
+ retry: () => subscriber.next(action),
46
+ }),
47
+ ),
48
+ );
49
+ Promise.allSettled(blockers).then(() => subscriber.complete());
50
+ });
51
+ }),
52
+ );
@@ -0,0 +1,36 @@
1
+ import { merge, type Observable } from 'rxjs';
2
+ import type { HistoryFlow, HistoryFlowCreator } from './navigate';
3
+ import { checkBlockers } from './check-blockers';
4
+ import type { Actions } from './actions';
5
+ import type { HistoryStack, LocationState } from '../types';
6
+
7
+ /**
8
+ * Creates a combined history flow from multiple flow creators.
9
+ * @param flowCreators - Flow creators to combine.
10
+ * @param options - Optional blocker configuration.
11
+ * @returns A combined history flow creator.
12
+ */
13
+ export const createFlow = (
14
+ flowCreators: HistoryFlowCreator[],
15
+ options?: { skipBlockCheck?: boolean },
16
+ ): HistoryFlowCreator => {
17
+ return (stack: HistoryStack): HistoryFlow => {
18
+ const preProcessActions = options?.skipBlockCheck
19
+ ? (action$: Observable<Actions>) => action$
20
+ : checkBlockers(stack);
21
+ const flows: HistoryFlow = (
22
+ action$: Observable<Actions>,
23
+ state$: Observable<LocationState>,
24
+ ) => {
25
+ // Initialize each creator against the same stack before merging their action streams.
26
+ const initializedFlows = flowCreators
27
+ .map((initializer) => initializer(stack))
28
+ .map((flow) => flow(action$, state$));
29
+ return merge(...initializedFlows);
30
+ };
31
+ return (action$: Observable<Actions>, state$: Observable<LocationState>) => {
32
+ // Gate actions before dispatching them to the parallel history flows.
33
+ return preProcessActions(action$, state$).pipe((source$) => flows(source$, state$));
34
+ };
35
+ };
36
+ };
@@ -1,6 +1,6 @@
1
1
  import { createReducer } from '@equinor/fusion-observable';
2
2
  import type { LocationState, NavigationUpdate } from '../types';
3
- import { actions, type Actions } from './history.actions';
3
+ import { actions, type Actions } from './actions';
4
4
  import { isSuccessAction } from '@equinor/fusion-observable/actions';
5
5
 
6
6
  /**
@@ -8,6 +8,7 @@ import { isSuccessAction } from '@equinor/fusion-observable/actions';
8
8
  * Used to determine where to insert/replace entries for REPLACE actions.
9
9
  */
10
10
  const findIndex = (state: LocationState) => {
11
+ // Locate the current entry by key rather than reference, since updates are cloned.
11
12
  return state.history.findIndex((update) => update.location.key === state.current.location.key);
12
13
  };
13
14
 
@@ -47,6 +48,7 @@ export const createHistoryReducer = (
47
48
  // and removes any forward history when not at the end
48
49
  if (update.action === 'REPLACE') {
49
50
  const currentIndex = findIndex(state);
51
+ // Only trim when the current entry is actually present in history.
50
52
  if (currentIndex !== -1) {
51
53
  // Remove all entries from current position onwards
52
54
  state.history.splice(currentIndex, state.history.length - currentIndex);
@@ -64,6 +66,7 @@ export const createHistoryReducer = (
64
66
  builder.addMatcher(
65
67
  () => true,
66
68
  (state) => {
69
+ // Trim only once history has actually grown past the configured cap.
67
70
  if (state.history.length > maxHistory) {
68
71
  // Keep only the most recent entries, removing oldest ones
69
72
  state.history = state.history.slice(-maxHistory);