@equinor/fusion-framework-module-navigation 6.0.1 → 7.0.0-next.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.
Files changed (159) hide show
  1. package/CHANGELOG.md +83 -5
  2. package/README.md +694 -0
  3. package/dist/esm/NavigationConfigurator.interface.js +2 -0
  4. package/dist/esm/NavigationConfigurator.interface.js.map +1 -0
  5. package/dist/esm/NavigationConfigurator.js +144 -0
  6. package/dist/esm/NavigationConfigurator.js.map +1 -0
  7. package/dist/esm/NavigationProvider.interface.js +2 -0
  8. package/dist/esm/NavigationProvider.interface.js.map +1 -0
  9. package/dist/esm/NavigationProvider.js +256 -0
  10. package/dist/esm/NavigationProvider.js.map +1 -0
  11. package/dist/esm/enable-navigation.js +52 -0
  12. package/dist/esm/enable-navigation.js.map +1 -0
  13. package/dist/esm/events.js +23 -0
  14. package/dist/esm/events.js.map +1 -0
  15. package/dist/esm/index.js +6 -5
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/lib/BaseHistory.js +189 -0
  18. package/dist/esm/lib/BaseHistory.js.map +1 -0
  19. package/dist/esm/lib/BrowserHistory.js +91 -0
  20. package/dist/esm/lib/BrowserHistory.js.map +1 -0
  21. package/dist/esm/lib/BrowserHistoryHashStack.js +53 -0
  22. package/dist/esm/lib/BrowserHistoryHashStack.js.map +1 -0
  23. package/dist/esm/lib/BrowserHistoryStack.js +77 -0
  24. package/dist/esm/lib/BrowserHistoryStack.js.map +1 -0
  25. package/dist/esm/lib/MemoryHistory.js +47 -0
  26. package/dist/esm/lib/MemoryHistory.js.map +1 -0
  27. package/dist/esm/lib/MemoryStack.js +98 -0
  28. package/dist/esm/lib/MemoryStack.js.map +1 -0
  29. package/dist/esm/lib/create-history.js +48 -0
  30. package/dist/esm/lib/create-history.js.map +1 -0
  31. package/dist/esm/lib/index.js +8 -1
  32. package/dist/esm/lib/index.js.map +1 -1
  33. package/dist/esm/lib/state/history.actions.js +71 -0
  34. package/dist/esm/lib/state/history.actions.js.map +1 -0
  35. package/dist/esm/lib/state/history.flows.js +212 -0
  36. package/dist/esm/lib/state/history.flows.js.map +1 -0
  37. package/dist/esm/lib/state/history.reducer.js +59 -0
  38. package/dist/esm/lib/state/history.reducer.js.map +1 -0
  39. package/dist/esm/lib/state/history.state.js +37 -0
  40. package/dist/esm/lib/state/history.state.js.map +1 -0
  41. package/dist/esm/lib/state/index.js +4 -0
  42. package/dist/esm/lib/state/index.js.map +1 -0
  43. package/dist/esm/lib/types.js +25 -0
  44. package/dist/esm/lib/types.js.map +1 -0
  45. package/dist/esm/lib/utils/encode-trailing-whitespace.js +19 -0
  46. package/dist/esm/lib/utils/encode-trailing-whitespace.js.map +1 -0
  47. package/dist/esm/lib/utils/has-protocol.js +22 -0
  48. package/dist/esm/lib/utils/has-protocol.js.map +1 -0
  49. package/dist/esm/lib/utils/index.js +6 -0
  50. package/dist/esm/lib/utils/index.js.map +1 -0
  51. package/dist/esm/lib/utils/path-to-string.js +23 -0
  52. package/dist/esm/lib/utils/path-to-string.js.map +1 -0
  53. package/dist/esm/lib/utils/path-to-url.js +47 -0
  54. package/dist/esm/lib/utils/path-to-url.js.map +1 -0
  55. package/dist/esm/lib/utils/resolve-browser-location.js +53 -0
  56. package/dist/esm/lib/utils/resolve-browser-location.js.map +1 -0
  57. package/dist/esm/lib/utils/resolve-path.js +21 -0
  58. package/dist/esm/lib/utils/resolve-path.js.map +1 -0
  59. package/dist/esm/module.js +34 -25
  60. package/dist/esm/module.js.map +1 -1
  61. package/dist/esm/version.js +1 -1
  62. package/dist/esm/version.js.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/dist/types/NavigationConfigurator.d.ts +60 -0
  65. package/dist/types/NavigationConfigurator.interface.d.ts +17 -0
  66. package/dist/types/NavigationProvider.d.ts +112 -0
  67. package/dist/types/NavigationProvider.interface.d.ts +75 -0
  68. package/dist/types/enable-navigation.d.ts +40 -0
  69. package/dist/types/events.d.ts +48 -0
  70. package/dist/types/index.d.ts +13 -6
  71. package/dist/types/lib/BaseHistory.d.ts +122 -0
  72. package/dist/types/lib/BrowserHistory.d.ts +53 -0
  73. package/dist/types/lib/BrowserHistoryHashStack.d.ts +41 -0
  74. package/dist/types/lib/BrowserHistoryStack.d.ts +51 -0
  75. package/dist/types/lib/MemoryHistory.d.ts +27 -0
  76. package/dist/types/lib/MemoryStack.d.ts +57 -0
  77. package/dist/types/lib/create-history.d.ts +35 -0
  78. package/dist/types/lib/index.d.ts +7 -1
  79. package/dist/types/lib/state/history.actions.d.ts +61 -0
  80. package/dist/types/lib/state/history.flows.d.ts +102 -0
  81. package/dist/types/lib/state/history.reducer.d.ts +85 -0
  82. package/dist/types/lib/state/history.state.d.ts +27 -0
  83. package/dist/types/lib/state/index.d.ts +3 -0
  84. package/dist/types/lib/types.d.ts +145 -0
  85. package/dist/types/lib/utils/encode-trailing-whitespace.d.ts +16 -0
  86. package/dist/types/lib/utils/has-protocol.d.ts +19 -0
  87. package/dist/types/lib/utils/index.d.ts +5 -0
  88. package/dist/types/lib/utils/path-to-string.d.ts +17 -0
  89. package/dist/types/lib/utils/path-to-url.d.ts +32 -0
  90. package/dist/types/lib/utils/resolve-browser-location.d.ts +42 -0
  91. package/dist/types/lib/utils/resolve-path.d.ts +17 -0
  92. package/dist/types/module.d.ts +33 -5
  93. package/dist/types/version.d.ts +1 -1
  94. package/package.json +25 -7
  95. package/src/NavigationConfigurator.interface.ts +18 -0
  96. package/src/NavigationConfigurator.ts +184 -0
  97. package/src/NavigationProvider.interface.ts +86 -0
  98. package/src/NavigationProvider.ts +313 -0
  99. package/src/__tests__/BrowserHistory.test.ts +151 -0
  100. package/src/__tests__/HashHistory.test.ts +121 -0
  101. package/src/__tests__/MemoryHistory.test.ts +185 -0
  102. package/src/__tests__/NavigationProvider.test.ts +60 -0
  103. package/src/__tests__/setup.ts +8 -0
  104. package/src/enable-navigation.ts +59 -0
  105. package/src/events.ts +65 -0
  106. package/src/index.ts +27 -6
  107. package/src/lib/BaseHistory.ts +229 -0
  108. package/src/lib/BrowserHistory.ts +123 -0
  109. package/src/lib/BrowserHistoryHashStack.ts +56 -0
  110. package/src/lib/BrowserHistoryStack.ts +82 -0
  111. package/src/lib/MemoryHistory.ts +66 -0
  112. package/src/lib/MemoryStack.ts +106 -0
  113. package/src/lib/create-history.ts +63 -0
  114. package/src/lib/index.ts +22 -1
  115. package/src/lib/state/history.actions.ts +108 -0
  116. package/src/lib/state/history.flows.ts +279 -0
  117. package/src/lib/state/history.reducer.ts +70 -0
  118. package/src/lib/state/history.state.ts +62 -0
  119. package/src/lib/state/index.ts +3 -0
  120. package/src/lib/types.ts +159 -0
  121. package/src/lib/utils/encode-trailing-whitespace.ts +18 -0
  122. package/src/lib/utils/has-protocol.ts +21 -0
  123. package/src/lib/utils/index.ts +5 -0
  124. package/src/lib/utils/path-to-string.ts +24 -0
  125. package/src/lib/utils/path-to-url.ts +52 -0
  126. package/src/lib/utils/resolve-browser-location.ts +60 -0
  127. package/src/lib/utils/resolve-path.ts +22 -0
  128. package/src/module.ts +53 -37
  129. package/src/version.ts +1 -1
  130. package/tsconfig.json +10 -1
  131. package/vitest.config.ts +14 -0
  132. package/dist/esm/configurator.js +0 -5
  133. package/dist/esm/configurator.js.map +0 -1
  134. package/dist/esm/createHistory.js +0 -18
  135. package/dist/esm/createHistory.js.map +0 -1
  136. package/dist/esm/lib/provider/INavigationProvider.js +0 -2
  137. package/dist/esm/lib/provider/INavigationProvider.js.map +0 -1
  138. package/dist/esm/lib/provider/NavigationProvider.js +0 -82
  139. package/dist/esm/lib/provider/NavigationProvider.js.map +0 -1
  140. package/dist/esm/lib/provider/index.js +0 -2
  141. package/dist/esm/lib/provider/index.js.map +0 -1
  142. package/dist/esm/navigator.js +0 -158
  143. package/dist/esm/navigator.js.map +0 -1
  144. package/dist/esm/types.js +0 -2
  145. package/dist/esm/types.js.map +0 -1
  146. package/dist/types/configurator.d.ts +0 -9
  147. package/dist/types/createHistory.d.ts +0 -4
  148. package/dist/types/lib/provider/INavigationProvider.d.ts +0 -38
  149. package/dist/types/lib/provider/NavigationProvider.d.ts +0 -22
  150. package/dist/types/lib/provider/index.d.ts +0 -2
  151. package/dist/types/navigator.d.ts +0 -105
  152. package/dist/types/types.d.ts +0 -3
  153. package/src/configurator.ts +0 -11
  154. package/src/createHistory.ts +0 -21
  155. package/src/lib/provider/INavigationProvider.ts +0 -46
  156. package/src/lib/provider/NavigationProvider.ts +0 -117
  157. package/src/lib/provider/index.ts +0 -2
  158. package/src/navigator.ts +0 -204
  159. package/src/types.ts +0 -4
@@ -0,0 +1,313 @@
1
+ // TODO: Remove @remix-run/router dependency once all apps have migrated to @equinor/fusion-framework-react-router
2
+ import { type AgnosticRouteObject, createRouter } from '@remix-run/router';
3
+ import type { Observable } from 'rxjs';
4
+ import { filter, pairwise, shareReplay } from 'rxjs/operators';
5
+
6
+ import {
7
+ BaseModuleProvider,
8
+ type BaseModuleProviderCtorArgs,
9
+ } from '@equinor/fusion-framework-module/provider';
10
+
11
+ import type { INavigationProvider } from './NavigationProvider.interface';
12
+ import type { INavigationConfigurator } from './NavigationConfigurator.interface';
13
+ import type { History, NavigateOptions, NavigationUpdate, Path, To } from './lib/types';
14
+ import {
15
+ TelemetryLevel,
16
+ TelemetryScope,
17
+ type ITelemetryProvider,
18
+ } from '@equinor/fusion-framework-module-telemetry';
19
+ import type { IEventModuleProvider } from '@equinor/fusion-framework-module-event';
20
+ import { NavigatedEvent } from './events';
21
+ import { pathToString } from './lib/utils';
22
+ import type { BaseHistory } from './lib';
23
+
24
+ /**
25
+ * Normalizes a pathname by:
26
+ * - Collapsing multiple consecutive slashes into a single slash
27
+ * - Removing trailing slashes
28
+ *
29
+ * @example
30
+ * normalizePathname("/app//users///profile/") // returns "/app/users/profile"
31
+ * normalizePathname("///multiple///slashes///") // returns "/multiple/slashes"
32
+ *
33
+ * @param path - The pathname to normalize
34
+ * @returns The normalized pathname without consecutive or trailing slashes
35
+ */
36
+ const normalizePathname = (path: string) => path.replace(/\/+/g, '/').replace(/\/$/, '');
37
+
38
+ /**
39
+ * Navigation provider implementation.
40
+ * Manages routing and navigation state with basename localization.
41
+ *
42
+ * @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.)
48
+ *
49
+ * Path localization ensures that consumers receive paths relative to the basename,
50
+ * while internally we work with full paths including basename.
51
+ */
52
+ export class NavigationProvider
53
+ extends BaseModuleProvider<INavigationConfigurator>
54
+ implements INavigationProvider
55
+ {
56
+ #history: History;
57
+ #basename?: string;
58
+ #state$: Observable<NavigationUpdate>;
59
+
60
+ #telemetry?: ITelemetryProvider;
61
+ #event?: IEventModuleProvider;
62
+
63
+ /**
64
+ * Observable stream of navigation state updates.
65
+ * Emits localized paths (with basename removed) for consumers.
66
+ */
67
+ public get state$(): Observable<NavigationUpdate> {
68
+ return this.#state$;
69
+ }
70
+
71
+ /**
72
+ * Gets the basename.
73
+ */
74
+ public get basename(): string {
75
+ return this.#basename ?? '';
76
+ }
77
+
78
+ /**
79
+ * @deprecated Use `history` instead
80
+ */
81
+ public get navigator(): History {
82
+ this.#telemetry?.trackException({
83
+ name: 'Navigation::navigator.deprecated',
84
+ exception: new Error('navigator is deprecated, use history instead'),
85
+ level: TelemetryLevel.Warning,
86
+ scope: ['navigation', TelemetryScope.Framework],
87
+ });
88
+ return this.#history;
89
+ }
90
+
91
+ /**
92
+ * Gets the history instance.
93
+ */
94
+ public get history(): History {
95
+ return this.#history;
96
+ }
97
+
98
+ /**
99
+ * Gets the current localized path (basename removed).
100
+ */
101
+ public get path(): Path {
102
+ return this._localizePath(this.#history.location);
103
+ }
104
+
105
+ /**
106
+ * @param args - Configuration arguments containing module config
107
+ * @throws {Error} If no history is provided in the configuration
108
+ */
109
+ constructor(args: BaseModuleProviderCtorArgs<INavigationConfigurator>) {
110
+ super(args);
111
+
112
+ // Extract configuration values
113
+ const { basename, history, telemetry, eventProvider } = args.config;
114
+
115
+ this.#basename = basename;
116
+ this.#event = eventProvider;
117
+ this.#telemetry = telemetry;
118
+
119
+ // History is required - validate and track error if missing
120
+ if (!history) {
121
+ this.#telemetry?.trackException({
122
+ name: 'Navigation::history.required',
123
+ exception: new Error('no history provided!'),
124
+ level: TelemetryLevel.Error,
125
+ scope: ['navigation', TelemetryScope.Application],
126
+ });
127
+ throw Error('no history provided!');
128
+ }
129
+
130
+ this.#history = history;
131
+
132
+ // Create state$ observable that filters for paths within basename scope
133
+ // shareReplay ensures subscribers get the latest value and share the subscription
134
+ this.#state$ = this.#history.state$.pipe(
135
+ filter((update: NavigationUpdate) => this._isWithinBasenameScope(update.location.pathname)),
136
+ shareReplay({ bufferSize: 1, refCount: true }),
137
+ );
138
+
139
+ this._initialize();
140
+ }
141
+
142
+ /**
143
+ * Sets up navigation subscriptions for events and telemetry.
144
+ *
145
+ * Configures subscriptions for:
146
+ * - NavigatedEvent dispatching when navigation changes
147
+ * - Telemetry tracking for navigation actions and events
148
+ * - History disposal cleanup
149
+ */
150
+ protected _initialize(): void {
151
+ // Dispatch NavigatedEvent and track telemetry when navigation changes
152
+ // pairwise() gives us [previous, current] pairs to compare navigation state
153
+ this._addTeardown(
154
+ this.#state$.pipe(pairwise()).subscribe(([previous, current]) => {
155
+ // emit NavigatedEvent for other modules to listen to when navigation changes
156
+ this.#event?.dispatchEvent(
157
+ new NavigatedEvent(
158
+ {
159
+ action: current.action,
160
+ current,
161
+ previous,
162
+ },
163
+ this,
164
+ ),
165
+ );
166
+ // track telemetry for navigation changes
167
+ this.#telemetry?.trackEvent({
168
+ name: 'Navigation::navigated',
169
+ level: TelemetryLevel.Information,
170
+ scope: ['navigation', TelemetryScope.Application],
171
+ properties: {
172
+ action: current.action,
173
+ location: pathToString(current.location),
174
+ previousLocation: pathToString(previous.location),
175
+ },
176
+ });
177
+ }),
178
+ );
179
+
180
+ // Track all navigation actions for debugging (if telemetry is enabled)
181
+ if (this.#telemetry) {
182
+ this._addTeardown(
183
+ this.#history.action$.subscribe((action) => {
184
+ this.#telemetry?.trackEvent({
185
+ name: `Navigation::action:${action.type}`,
186
+ level: TelemetryLevel.Debug,
187
+ scope: ['navigation', TelemetryScope.Framework],
188
+ properties: {
189
+ type: action.type,
190
+ action: action,
191
+ },
192
+ });
193
+ }),
194
+ );
195
+ }
196
+
197
+ // Clean up history instance when provider is disposed
198
+ this._addTeardown(() => this.#history[Symbol.dispose]());
199
+ }
200
+
201
+ /**
202
+ * Creates a router instance from route configuration.
203
+ *
204
+ * @deprecated Use `@equinor/fusion-framework-react-router` instead
205
+ *
206
+ * @param routes - Route configuration objects compatible with industry-standard routers (Remix/React Router)
207
+ * @returns A configured and initialized router instance
208
+ */
209
+ public createRouter(routes: AgnosticRouteObject[]) {
210
+ this.#telemetry?.trackEvent({
211
+ name: 'Navigation::createRouter',
212
+ level: TelemetryLevel.Warning,
213
+ scope: ['navigation', 'deprecated', TelemetryScope.Application],
214
+ });
215
+ const router = createRouter({
216
+ basename: this.#basename,
217
+ history: this.#history as unknown as import('@remix-run/router').History,
218
+ routes,
219
+ future: {
220
+ v7_prependBasename: true,
221
+ },
222
+ });
223
+ router.initialize();
224
+ return router;
225
+ }
226
+
227
+ /**
228
+ * Creates a localized href string for navigation.
229
+ *
230
+ * @param to - Optional path or location (defaults to current path)
231
+ */
232
+ public createHref(to?: To): string {
233
+ return this.#history.createHref(this._createToPath(to ?? this.path));
234
+ }
235
+
236
+ /**
237
+ * Creates a full URL object for navigation.
238
+ *
239
+ * @param to - Optional path or location (defaults to current path)
240
+ */
241
+ public createURL(to?: To): URL {
242
+ return this.#history.createURL(this._createToPath(to ?? this.path));
243
+ }
244
+
245
+ /**
246
+ * Pushes a new navigation entry onto the history stack.
247
+ *
248
+ * @param to - Path or location to navigate to (relative to basename)
249
+ * @param state - Optional state to associate with the navigation
250
+ */
251
+ public push(to: To, state?: unknown): void {
252
+ this.navigate(to, { state });
253
+ }
254
+
255
+ /**
256
+ * Replaces the current history entry with a new one.
257
+ *
258
+ * @param to - Path or location to navigate to (relative to basename)
259
+ * @param state - Optional state to associate with the navigation
260
+ */
261
+ public replace(to: To, state?: unknown): void {
262
+ this.navigate(to, { replace: true, state });
263
+ }
264
+
265
+ /**
266
+ * Navigate to a location with explicit options.
267
+ *
268
+ * @param to - Optional path or location (defaults to current path)
269
+ * @param options - Optional navigation options
270
+ */
271
+ public navigate(to?: To, options?: Partial<NavigateOptions>): void {
272
+ const { replace = false, state } = options ?? {};
273
+ this.#history.navigate(this._createToPath(to ?? this.path), { replace, state });
274
+ // we need to pop the history to update the notify history listeners
275
+ // Frameworks as react router have their internal state management,
276
+ // so we need to force a pop to notify the framework that the history has changed
277
+ (this.#history as BaseHistory).pop();
278
+ }
279
+ /**
280
+ * Checks if a pathname is within the basename scope.
281
+ */
282
+ protected _isWithinBasenameScope(pathname: string): boolean {
283
+ return this.#basename ? pathname.startsWith(this.#basename) : true;
284
+ }
285
+
286
+ /**
287
+ * Localizes a path by removing the basename prefix.
288
+ */
289
+ protected _localizePath(location: Path): Path {
290
+ const { pathname, search, hash } = location;
291
+ return {
292
+ pathname: normalizePathname(pathname.replace(this.#basename ?? '', '')),
293
+ search,
294
+ hash,
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Creates a full path object from a target location, adding basename prefix.
300
+ */
301
+ protected _createToPath(to: To): Partial<Path> {
302
+ // Parse the 'to' parameter into path components
303
+ const pathComponents = typeof to === 'string' ? { pathname: to } : to;
304
+
305
+ // Extract path parts, defaulting to current path values
306
+ const rawPathname = pathComponents.pathname ?? this.path.pathname;
307
+ const pathname = normalizePathname(`${this.#basename ?? ''}/${rawPathname}`);
308
+ const search = pathComponents.search ?? this.path.search;
309
+ const hash = pathComponents.hash ?? this.path.hash;
310
+
311
+ return { pathname, search, hash };
312
+ }
313
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { firstValueFrom, skip } from 'rxjs';
3
+ import { filter } from 'rxjs/operators';
4
+ import { NavigationProvider } from '../NavigationProvider';
5
+ import { createHistory } from '../lib/create-history';
6
+ import type { History } from '../lib/types';
7
+
8
+ describe('BrowserHistory', () => {
9
+ let provider: NavigationProvider;
10
+ let history: History;
11
+
12
+ beforeEach(() => {
13
+ history = createHistory('browser');
14
+ provider = new NavigationProvider({
15
+ version: '1.0.0',
16
+ config: { history },
17
+ });
18
+ });
19
+
20
+ afterEach(() => {
21
+ provider?.dispose();
22
+ history[Symbol.dispose]();
23
+ });
24
+
25
+ describe('push', () => {
26
+ it('should navigate when push is called', async () => {
27
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
28
+ provider.push('/about', { test: 'test' });
29
+ await updatePromise;
30
+
31
+ expect(provider.path.pathname).toBe(history.location.pathname);
32
+
33
+ expect(history.location.pathname).toBe('/about');
34
+ expect(history.action).toBe('PUSH');
35
+ expect(history.location.state).toEqual({ test: 'test' });
36
+ expect(window.location.pathname).toBe('/about');
37
+ });
38
+
39
+ it('should support browser back/forward navigation', async () => {
40
+ history.push('/page1', { page: 1 });
41
+ await firstValueFrom(history.state$.pipe(skip(1)));
42
+ expect(history.location.pathname).toBe('/page1');
43
+
44
+ history.push('/page2', { page: 2 });
45
+ await firstValueFrom(history.state$.pipe(skip(1)));
46
+ expect(history.location.pathname).toBe('/page2');
47
+
48
+ // Go back
49
+ const backPromise = firstValueFrom(history.state$.pipe(skip(1)));
50
+ window.history.back();
51
+ await backPromise;
52
+ expect(history.location.pathname).toBe('/page1');
53
+ expect(history.action).toBe('POP');
54
+ expect(history.location.state).toEqual({ page: 1 });
55
+
56
+ // Go forward
57
+ const forwardPromise = firstValueFrom(history.state$.pipe(skip(1)));
58
+ window.history.forward();
59
+ await forwardPromise;
60
+ expect(history.location.pathname).toBe('/page2');
61
+ expect(history.action).toBe('POP');
62
+ expect(history.location.state).toEqual({ page: 2 });
63
+ });
64
+
65
+ it('should support go() method', async () => {
66
+ history.push('/page1');
67
+ await firstValueFrom(history.state$.pipe(skip(1)));
68
+ expect(history.location.pathname).toBe('/page1');
69
+
70
+ history.push('/page2');
71
+ await firstValueFrom(history.state$.pipe(skip(1)));
72
+ expect(history.location.pathname).toBe('/page2');
73
+
74
+ history.push('/page3');
75
+ await firstValueFrom(history.state$.pipe(skip(1)));
76
+ expect(history.location.pathname).toBe('/page3');
77
+
78
+ // Go back - wait for state update to /page2
79
+ const goBackPromise = firstValueFrom(
80
+ history.state$.pipe(
81
+ skip(1),
82
+ filter((update) => update.location.pathname === '/page2'),
83
+ ),
84
+ );
85
+ history.go(-1);
86
+ await goBackPromise;
87
+ expect(history.location.pathname).toBe('/page2');
88
+ expect(history.action).toBe('POP');
89
+
90
+ // Go forward - wait for state update to /page3
91
+ const goForwardPromise = firstValueFrom(
92
+ history.state$.pipe(
93
+ skip(1),
94
+ filter((update) => update.location.pathname === '/page3'),
95
+ ),
96
+ );
97
+ history.go(1);
98
+ await goForwardPromise;
99
+ expect(history.location.pathname).toBe('/page3');
100
+ expect(history.action).toBe('POP');
101
+ });
102
+ });
103
+
104
+ describe('replace', () => {
105
+ it('should navigate when replace is called', async () => {
106
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
107
+ provider.replace('/about', { test: 'test' });
108
+ await updatePromise;
109
+
110
+ expect(history.location.pathname).toBe('/about');
111
+ expect(history.action).toBe('REPLACE');
112
+ expect(history.location.state).toEqual({ test: 'test' });
113
+ expect(window.location.pathname).toBe('/about');
114
+ });
115
+
116
+ it('should replace current entry without adding to history stack', async () => {
117
+ // Start at root
118
+ expect(history.location.pathname).toBe('/');
119
+
120
+ // Push an entry
121
+ history.push('/page1', { page: 1 });
122
+ await firstValueFrom(history.state$.pipe(skip(1)));
123
+ expect(history.location.pathname).toBe('/page1');
124
+
125
+ // Replace current entry
126
+ history.replace('/page2', { page: 2 });
127
+ await firstValueFrom(history.state$.pipe(skip(1)));
128
+ expect(history.location.pathname).toBe('/page2');
129
+ expect(history.action).toBe('REPLACE');
130
+
131
+ // Go back - should go to root, not page1 (because page1 was replaced)
132
+ const backPromise = firstValueFrom(history.state$.pipe(skip(1)));
133
+ window.history.back();
134
+ await backPromise;
135
+ expect(history.location.pathname).toBe('/');
136
+ expect(history.action).toBe('POP');
137
+ });
138
+ });
139
+
140
+ describe('state$ observable', () => {
141
+ it('should emit state updates on navigation', async () => {
142
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
143
+ history.push('/test', { data: 'test' });
144
+ const update = await updatePromise;
145
+
146
+ expect(update.action).toBe('PUSH');
147
+ expect(update.location.pathname).toBe('/test');
148
+ expect(update.location.state).toEqual({ data: 'test' });
149
+ });
150
+ });
151
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { firstValueFrom, skip } from 'rxjs';
3
+ import { createHistory } from '../lib/create-history';
4
+ import type { History } from '../lib/types';
5
+
6
+ describe('HashHistory', () => {
7
+ let history: History;
8
+
9
+ beforeEach(() => {
10
+ history = createHistory('hash');
11
+ });
12
+
13
+ afterEach(() => {
14
+ history[Symbol.dispose]();
15
+ });
16
+
17
+ describe('hash-based navigation', () => {
18
+ it('should update hash instead of pathname on push', async () => {
19
+ const initialPathname = window.location.pathname;
20
+
21
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
22
+ history.push('/about', { test: 'test' });
23
+ await updatePromise;
24
+
25
+ expect(history.location.pathname).toBe('/about');
26
+ expect(history.action).toBe('PUSH');
27
+ expect(history.location.state).toEqual({ test: 'test' });
28
+ // Pathname should remain unchanged
29
+ expect(window.location.pathname).toBe(initialPathname);
30
+ // Hash should contain the route
31
+ expect(window.location.hash).toBe('#/about');
32
+ });
33
+
34
+ it('should update hash instead of pathname on replace', async () => {
35
+ const initialPathname = window.location.pathname;
36
+
37
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
38
+ history.replace('/profile', { userId: 123 });
39
+ await updatePromise;
40
+
41
+ expect(history.location.pathname).toBe('/profile');
42
+ expect(history.action).toBe('REPLACE');
43
+ expect(history.location.state).toEqual({ userId: 123 });
44
+ // Pathname should remain unchanged
45
+ expect(window.location.pathname).toBe(initialPathname);
46
+ // Hash should contain the route
47
+ expect(window.location.hash).toBe('#/profile');
48
+ });
49
+
50
+ it('should resolve location from hash', async () => {
51
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
52
+ history.push('/test?query=1#section');
53
+ await updatePromise;
54
+
55
+ const location = history.location;
56
+ expect(location.pathname).toBe('/test');
57
+ expect(location.search).toBe('?query=1');
58
+ expect(location.hash).toBe('#section');
59
+ expect(window.location.hash).toBe('#/test?query=1#section');
60
+ });
61
+
62
+ it('should handle hashchange events for back/forward', async () => {
63
+ const initialPathname = window.location.pathname;
64
+
65
+ // Push multiple entries
66
+ history.push('/page1', { page: 1 });
67
+ await firstValueFrom(history.state$.pipe(skip(1)));
68
+ expect(window.location.hash).toBe('#/page1');
69
+
70
+ history.push('/page2', { page: 2 });
71
+ await firstValueFrom(history.state$.pipe(skip(1)));
72
+ expect(window.location.hash).toBe('#/page2');
73
+
74
+ // Go back - should trigger hashchange event
75
+ const backPromise = firstValueFrom(history.state$.pipe(skip(1)));
76
+ window.history.back();
77
+ await backPromise;
78
+ expect(history.location.pathname).toBe('/page1');
79
+ expect(history.action).toBe('POP');
80
+ expect(history.location.state).toEqual({ page: 1 });
81
+ expect(window.location.hash).toBe('#/page1');
82
+ expect(window.location.pathname).toBe(initialPathname);
83
+
84
+ // Go forward
85
+ const forwardPromise = firstValueFrom(history.state$.pipe(skip(1)));
86
+ window.history.forward();
87
+ await forwardPromise;
88
+ expect(history.location.pathname).toBe('/page2');
89
+ expect(history.action).toBe('POP');
90
+ expect(history.location.state).toEqual({ page: 2 });
91
+ expect(window.location.hash).toBe('#/page2');
92
+ expect(window.location.pathname).toBe(initialPathname);
93
+ });
94
+
95
+ it('should handle hash with search and hash fragments', async () => {
96
+ const initialPathname = window.location.pathname;
97
+
98
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
99
+ history.push('/users?filter=active#list', { filter: 'active' });
100
+ await updatePromise;
101
+
102
+ expect(history.location.pathname).toBe('/users');
103
+ expect(history.location.search).toBe('?filter=active');
104
+ expect(history.location.hash).toBe('#list');
105
+ // Hash should contain full path with search and hash
106
+ expect(window.location.hash).toBe('#/users?filter=active#list');
107
+ expect(window.location.pathname).toBe(initialPathname);
108
+ });
109
+ });
110
+
111
+ describe('state$ observable', () => {
112
+ it('should emit state updates on navigation', async () => {
113
+ const updatePromise = firstValueFrom(history.state$.pipe(skip(1)));
114
+ history.push('/test');
115
+ const update = await updatePromise;
116
+
117
+ expect(update.action).toBe('PUSH');
118
+ expect(update.location.pathname).toBe('/test');
119
+ });
120
+ });
121
+ });