@equinor/fusion-framework-module-navigation 7.0.9 → 7.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/version.js +1 -1
- package/dist/esm/version.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/version.d.ts +1 -1
- package/package.json +10 -7
- package/CHANGELOG.md +0 -600
- package/src/NavigateEvent.ts +0 -47
- package/src/NavigatedEvent.ts +0 -35
- package/src/NavigationConfigurator.interface.ts +0 -41
- package/src/NavigationConfigurator.ts +0 -226
- package/src/NavigationProvider.interface.ts +0 -99
- package/src/NavigationProvider.ts +0 -436
- package/src/__tests__/BrowserHistory.test.ts +0 -151
- package/src/__tests__/HashHistory.test.ts +0 -121
- package/src/__tests__/MemoryHistory.test.ts +0 -185
- package/src/__tests__/NavigationProvider.test.ts +0 -302
- package/src/__tests__/ProxyHistory.test.ts +0 -149
- package/src/__tests__/setup.ts +0 -8
- package/src/enable-navigation.ts +0 -60
- package/src/index.ts +0 -51
- package/src/lib/BaseHistory.ts +0 -255
- package/src/lib/BrowserHistory.ts +0 -124
- package/src/lib/BrowserHistoryHashStack.ts +0 -57
- package/src/lib/BrowserHistoryStack.ts +0 -96
- package/src/lib/MemoryHistory.ts +0 -76
- package/src/lib/MemoryHistoryStack.ts +0 -118
- package/src/lib/ProxyHistory.ts +0 -145
- package/src/lib/create-history.ts +0 -58
- package/src/lib/index.ts +0 -33
- package/src/lib/state/actions.ts +0 -108
- package/src/lib/state/check-blockers.ts +0 -52
- package/src/lib/state/create-flow.ts +0 -36
- package/src/lib/state/create-history-reducer.ts +0 -77
- package/src/lib/state/create-store.ts +0 -39
- package/src/lib/state/flow-creators.ts +0 -7
- package/src/lib/state/go.ts +0 -20
- package/src/lib/state/history.state.ts +0 -14
- package/src/lib/state/index.ts +0 -4
- package/src/lib/state/navigate.ts +0 -58
- package/src/lib/state/pop.ts +0 -24
- package/src/lib/state/validate-current-location.ts +0 -33
- package/src/lib/types.ts +0 -190
- package/src/lib/utils/encode-trailing-whitespace.ts +0 -18
- package/src/lib/utils/has-protocol.ts +0 -21
- package/src/lib/utils/index.ts +0 -5
- package/src/lib/utils/path-to-string.ts +0 -24
- package/src/lib/utils/path-to-url.ts +0 -52
- package/src/lib/utils/resolve-browser-location.ts +0 -1
- package/src/lib/utils/resolve-hash-location.ts +0 -26
- package/src/lib/utils/resolve-path.ts +0 -22
- package/src/lib/utils/resolve-window-location.ts +0 -24
- package/src/module.ts +0 -80
- package/src/version.ts +0 -2
- package/tsconfig.json +0 -24
- package/vitest.config.ts +0 -14
|
@@ -1,436 +0,0 @@
|
|
|
1
|
-
// TODO(#5158): 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 './NavigatedEvent';
|
|
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
|
-
*
|
|
28
|
-
* @example
|
|
29
|
-
* normalizePathname("/app//users///profile/") // returns "/app/users/profile/"
|
|
30
|
-
* normalizePathname("///multiple///slashes///") // returns "/multiple/slashes/"
|
|
31
|
-
*
|
|
32
|
-
* @param path - The pathname to normalize
|
|
33
|
-
* @returns The normalized pathname without consecutive slashes
|
|
34
|
-
*/
|
|
35
|
-
const normalizePathname = (path: string): string => {
|
|
36
|
-
// Use iterative approach instead of regex to avoid potential ReDoS with untrusted input
|
|
37
|
-
let result = '';
|
|
38
|
-
let lastWasSlash = false;
|
|
39
|
-
|
|
40
|
-
// Walk each character once to collapse runs of slashes in a single pass
|
|
41
|
-
for (let i = 0; i < path.length; i++) {
|
|
42
|
-
const char = path[i];
|
|
43
|
-
// Only slashes need de-duplication; every other character passes through
|
|
44
|
-
if (char === '/') {
|
|
45
|
-
// Keep the first slash of a run, drop the rest
|
|
46
|
-
if (!lastWasSlash) {
|
|
47
|
-
result += char;
|
|
48
|
-
lastWasSlash = true;
|
|
49
|
-
}
|
|
50
|
-
// Skip consecutive slashes
|
|
51
|
-
} else {
|
|
52
|
-
result += char;
|
|
53
|
-
lastWasSlash = false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return result;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Removes trailing slashes from a path string.
|
|
62
|
-
*
|
|
63
|
-
* @param path - The path to trim
|
|
64
|
-
* @returns The path without trailing slashes
|
|
65
|
-
*
|
|
66
|
-
* @example
|
|
67
|
-
* stripTrailingSlashes("/apps/my-app/") // returns "/apps/my-app"
|
|
68
|
-
* stripTrailingSlashes("/apps/my-app///") // returns "/apps/my-app"
|
|
69
|
-
*/
|
|
70
|
-
const stripTrailingSlashes = (path: string): string => {
|
|
71
|
-
// Use iterative approach to avoid ReDoS vulnerability
|
|
72
|
-
let endIndex = path.length;
|
|
73
|
-
// Shrink endIndex past every trailing slash
|
|
74
|
-
while (endIndex > 0 && path[endIndex - 1] === '/') {
|
|
75
|
-
endIndex--;
|
|
76
|
-
}
|
|
77
|
-
return path.substring(0, endIndex);
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Navigation provider implementation.
|
|
82
|
-
*
|
|
83
|
-
* Manages routing and navigation state with automatic basename localization.
|
|
84
|
-
* Wraps a {@link History} instance to expose observable state, path localization,
|
|
85
|
-
* and router creation.
|
|
86
|
-
*
|
|
87
|
-
* @remarks
|
|
88
|
-
* - Emits localized paths (basename removed) to consumers via {@link NavigationProvider.state$ | state$}
|
|
89
|
-
* - Internally prefixes paths with the basename before forwarding to the history stack
|
|
90
|
-
* - Creates routers compatible with Remix / React Router via {@link NavigationProvider.createRouter | createRouter}
|
|
91
|
-
* - Dispatches {@link NavigatedEvent} and telemetry on navigation changes
|
|
92
|
-
*
|
|
93
|
-
* @example
|
|
94
|
-
* ```ts
|
|
95
|
-
* const provider = new NavigationProvider({ version, config });
|
|
96
|
-
* provider.push('/users');
|
|
97
|
-
* console.log(provider.path.pathname); // '/users'
|
|
98
|
-
* ```
|
|
99
|
-
*/
|
|
100
|
-
export class NavigationProvider
|
|
101
|
-
extends BaseModuleProvider<INavigationConfigurator>
|
|
102
|
-
implements INavigationProvider
|
|
103
|
-
{
|
|
104
|
-
#history: History;
|
|
105
|
-
#basename?: string;
|
|
106
|
-
#state$: Observable<NavigationUpdate>;
|
|
107
|
-
|
|
108
|
-
#telemetry?: ITelemetryProvider;
|
|
109
|
-
#event?: IEventModuleProvider;
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Observable stream of navigation state updates.
|
|
113
|
-
* @returns Observable of navigation updates.
|
|
114
|
-
*
|
|
115
|
-
* Emits localized paths (with basename removed) and filters to only
|
|
116
|
-
* paths within the basename scope. Late subscribers receive the last
|
|
117
|
-
* emitted value immediately.
|
|
118
|
-
*/
|
|
119
|
-
public get state$(): Observable<NavigationUpdate> {
|
|
120
|
-
return this.#state$;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Gets the basename prefix configured for this provider.
|
|
125
|
-
*
|
|
126
|
-
* @returns The basename string, or an empty string if none is configured
|
|
127
|
-
*/
|
|
128
|
-
public get basename(): string {
|
|
129
|
-
return this.#basename ?? '';
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* @deprecated Use `history` instead
|
|
134
|
-
* @returns The underlying `History` instance.
|
|
135
|
-
*/
|
|
136
|
-
public get navigator(): History {
|
|
137
|
-
this.#telemetry?.trackException({
|
|
138
|
-
name: 'Navigation::navigator.deprecated',
|
|
139
|
-
exception: new Error('navigator is deprecated, use history instead'),
|
|
140
|
-
level: TelemetryLevel.Warning,
|
|
141
|
-
scope: ['navigation', TelemetryScope.Framework],
|
|
142
|
-
});
|
|
143
|
-
return this.#history;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Gets the underlying history instance.
|
|
148
|
-
* @returns The {@link History} instance used for navigation
|
|
149
|
-
*
|
|
150
|
-
* @returns The {@link History} instance used for navigation
|
|
151
|
-
*/
|
|
152
|
-
public get history(): History {
|
|
153
|
-
return this.#history;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/**
|
|
157
|
-
* Gets the current localized path with the basename prefix removed.
|
|
158
|
-
*
|
|
159
|
-
* @returns A {@link Path} object representing the current location without basename
|
|
160
|
-
*/
|
|
161
|
-
public get path(): Path {
|
|
162
|
-
return this._localizePath(this.#history.location);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Creates a new {@link NavigationProvider}.
|
|
167
|
-
*
|
|
168
|
-
* @param args - Configuration arguments containing module config
|
|
169
|
-
* @throws {Error} If no history instance is provided in the configuration
|
|
170
|
-
*/
|
|
171
|
-
constructor(args: BaseModuleProviderCtorArgs<INavigationConfigurator>) {
|
|
172
|
-
super(args);
|
|
173
|
-
|
|
174
|
-
// Extract configuration values
|
|
175
|
-
const { basename, history, telemetry, eventProvider } = args.config;
|
|
176
|
-
|
|
177
|
-
// Normalize the basename to strip trailing slashes and collapse consecutive
|
|
178
|
-
// slashes. React Router requires the current URL to start with the exact
|
|
179
|
-
// basename string, so a basename of "/apps/my-app/" would fail to match
|
|
180
|
-
// the URL "/apps/my-app" and render nothing (blank page).
|
|
181
|
-
// Treat '/' as "no basename" (empty string) since all paths start with '/'.
|
|
182
|
-
const normalizedBasename = basename ? stripTrailingSlashes(normalizePathname(basename)) : '';
|
|
183
|
-
this.#basename = normalizedBasename || undefined;
|
|
184
|
-
this.#event = eventProvider;
|
|
185
|
-
this.#telemetry = telemetry;
|
|
186
|
-
|
|
187
|
-
// History is required - validate and track error if missing
|
|
188
|
-
if (!history) {
|
|
189
|
-
this.#telemetry?.trackException({
|
|
190
|
-
name: 'Navigation::history.required',
|
|
191
|
-
exception: new Error('no history provided!'),
|
|
192
|
-
level: TelemetryLevel.Error,
|
|
193
|
-
scope: ['navigation', TelemetryScope.Application],
|
|
194
|
-
});
|
|
195
|
-
throw Error('no history provided!');
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
this.#history = history;
|
|
199
|
-
|
|
200
|
-
// Create state$ observable that filters for paths within basename scope
|
|
201
|
-
// shareReplay ensures subscribers get the latest value and share the subscription
|
|
202
|
-
this.#state$ = this.#history.state$.pipe(
|
|
203
|
-
filter((update: NavigationUpdate) => this._isWithinBasenameScope(update.location.pathname)),
|
|
204
|
-
shareReplay({ bufferSize: 1, refCount: true }),
|
|
205
|
-
);
|
|
206
|
-
|
|
207
|
-
this._initialize();
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* Sets up navigation subscriptions for events and telemetry.
|
|
212
|
-
*
|
|
213
|
-
* Configures subscriptions for:
|
|
214
|
-
* - NavigatedEvent dispatching when navigation changes
|
|
215
|
-
* - Telemetry tracking for navigation actions and events
|
|
216
|
-
* - History disposal cleanup
|
|
217
|
-
*/
|
|
218
|
-
protected _initialize(): void {
|
|
219
|
-
// Dispatch NavigatedEvent and track telemetry when navigation changes
|
|
220
|
-
// pairwise() gives us [previous, current] pairs to compare navigation state
|
|
221
|
-
this._addTeardown(
|
|
222
|
-
this.#state$.pipe(pairwise()).subscribe(([previous, current]) => {
|
|
223
|
-
// emit NavigatedEvent for other modules to listen to when navigation changes
|
|
224
|
-
this.#event?.dispatchEvent(
|
|
225
|
-
new NavigatedEvent(
|
|
226
|
-
{
|
|
227
|
-
action: current.action,
|
|
228
|
-
current,
|
|
229
|
-
previous,
|
|
230
|
-
},
|
|
231
|
-
this,
|
|
232
|
-
),
|
|
233
|
-
);
|
|
234
|
-
// track telemetry for navigation changes
|
|
235
|
-
this.#telemetry?.trackEvent({
|
|
236
|
-
name: 'Navigation::navigated',
|
|
237
|
-
level: TelemetryLevel.Information,
|
|
238
|
-
scope: ['navigation', TelemetryScope.Application],
|
|
239
|
-
properties: {
|
|
240
|
-
action: current.action,
|
|
241
|
-
location: pathToString(current.location),
|
|
242
|
-
previousLocation: pathToString(previous.location),
|
|
243
|
-
},
|
|
244
|
-
});
|
|
245
|
-
}),
|
|
246
|
-
);
|
|
247
|
-
|
|
248
|
-
// Track all navigation actions for debugging (if telemetry is enabled)
|
|
249
|
-
if (this.#telemetry) {
|
|
250
|
-
this._addTeardown(
|
|
251
|
-
this.#history.action$.subscribe((action) => {
|
|
252
|
-
this.#telemetry?.trackEvent({
|
|
253
|
-
name: `Navigation::action:${action.type}`,
|
|
254
|
-
level: TelemetryLevel.Debug,
|
|
255
|
-
scope: ['navigation', TelemetryScope.Framework],
|
|
256
|
-
properties: {
|
|
257
|
-
type: action.type,
|
|
258
|
-
action: action,
|
|
259
|
-
},
|
|
260
|
-
});
|
|
261
|
-
}),
|
|
262
|
-
);
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
// Clean up history instance when provider is disposed
|
|
266
|
-
this._addTeardown(() => this.#history[Symbol.dispose]());
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Creates a router instance from route configuration.
|
|
271
|
-
*
|
|
272
|
-
* @deprecated Use `@equinor/fusion-framework-react-router` instead.
|
|
273
|
-
*
|
|
274
|
-
* @param routes - Route configuration objects compatible with Remix/React Router
|
|
275
|
-
* @returns A configured and initialized {@link Router} instance with basename applied
|
|
276
|
-
*/
|
|
277
|
-
public createRouter(routes: AgnosticRouteObject[]) {
|
|
278
|
-
this.#telemetry?.trackEvent({
|
|
279
|
-
name: 'Navigation::createRouter',
|
|
280
|
-
level: TelemetryLevel.Warning,
|
|
281
|
-
scope: ['navigation', 'deprecated', TelemetryScope.Application],
|
|
282
|
-
});
|
|
283
|
-
// `this.#history` is typed as the framework's minimal history interface, but Remix Router
|
|
284
|
-
// requires its own richer `History` type — the two are runtime-compatible, so cast through
|
|
285
|
-
// `unknown`.
|
|
286
|
-
const router = createRouter({
|
|
287
|
-
basename: this.#basename,
|
|
288
|
-
history: this.#history as unknown as import('@remix-run/router').History,
|
|
289
|
-
routes,
|
|
290
|
-
future: {
|
|
291
|
-
v7_prependBasename: true,
|
|
292
|
-
},
|
|
293
|
-
});
|
|
294
|
-
router.initialize();
|
|
295
|
-
return router;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Creates a localized href string including the basename prefix.
|
|
300
|
-
*
|
|
301
|
-
* @param to - Path or location to resolve (defaults to current path)
|
|
302
|
-
* @returns Fully-qualified href string with basename included
|
|
303
|
-
*
|
|
304
|
-
* @example
|
|
305
|
-
* ```ts
|
|
306
|
-
* // basename = '/apps/my-app'
|
|
307
|
-
* provider.createHref('/users'); // '/apps/my-app/users'
|
|
308
|
-
* ```
|
|
309
|
-
*/
|
|
310
|
-
public createHref(to?: To): string {
|
|
311
|
-
return this.#history.createHref(this._createToPath(to ?? this.path));
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Creates a full {@link URL} object including the basename prefix.
|
|
316
|
-
*
|
|
317
|
-
* @param to - Path or location to resolve (defaults to current path)
|
|
318
|
-
* @returns A {@link URL} instance representing the resolved navigation target
|
|
319
|
-
*/
|
|
320
|
-
public createURL(to?: To): URL {
|
|
321
|
-
return this.#history.createURL(this._createToPath(to ?? this.path));
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
/**
|
|
325
|
-
* Pushes a new navigation entry onto the history stack.
|
|
326
|
-
*
|
|
327
|
-
* @param to - Path or location to navigate to (relative to basename)
|
|
328
|
-
* @param state - Optional state to associate with the navigation
|
|
329
|
-
*/
|
|
330
|
-
public push(to: To, state?: unknown): void {
|
|
331
|
-
this.navigate(to, { state });
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* Replaces the current history entry with a new one.
|
|
336
|
-
*
|
|
337
|
-
* @param to - Path or location to navigate to (relative to basename)
|
|
338
|
-
* @param state - Optional state to associate with the navigation
|
|
339
|
-
*/
|
|
340
|
-
public replace(to: To, state?: unknown): void {
|
|
341
|
-
this.navigate(to, { replace: true, state });
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Navigate to a location with explicit options.
|
|
346
|
-
*
|
|
347
|
-
* @param to - Optional path or location (defaults to current path)
|
|
348
|
-
* @param options - Optional navigation options
|
|
349
|
-
*/
|
|
350
|
-
public navigate(to?: To, options?: Partial<NavigateOptions>): void {
|
|
351
|
-
const { replace = false, state } = options ?? {};
|
|
352
|
-
this.#history.navigate(this._createToPath(to ?? this.path), { replace, state });
|
|
353
|
-
// we need to pop the history to update the notify history listeners
|
|
354
|
-
// Frameworks as react router have their internal state management,
|
|
355
|
-
// so we need to force a pop to notify the framework that the history has changed
|
|
356
|
-
(this.#history as BaseHistory).pop();
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* Checks whether a pathname falls within the configured basename scope.
|
|
360
|
-
*
|
|
361
|
-
* Uses a path-boundary check to avoid false positives from apps with
|
|
362
|
-
* overlapping name prefixes (e.g. `/apps/my-app` must not match
|
|
363
|
-
* `/apps/my-app-other/foo`).
|
|
364
|
-
*
|
|
365
|
-
* @param pathname - The pathname to check
|
|
366
|
-
* @returns `true` if the pathname matches the basename exactly or starts
|
|
367
|
-
* with the basename followed by `/` (or no basename is set)
|
|
368
|
-
*/
|
|
369
|
-
protected _isWithinBasenameScope(pathname: string): boolean {
|
|
370
|
-
// No basename means everything is in scope
|
|
371
|
-
if (!this.#basename) return true;
|
|
372
|
-
|
|
373
|
-
// Normalize the pathname for comparison (collapse consecutive slashes)
|
|
374
|
-
const normalized = normalizePathname(pathname);
|
|
375
|
-
|
|
376
|
-
// Check exact match or path-boundary prefix
|
|
377
|
-
return normalized === this.#basename || normalized.startsWith(`${this.#basename}/`);
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/**
|
|
381
|
-
* Localizes a path by stripping the basename prefix from the pathname.
|
|
382
|
-
*
|
|
383
|
-
* Only removes the basename when it matches on a path boundary to avoid
|
|
384
|
-
* incorrectly stripping partial matches.
|
|
385
|
-
*
|
|
386
|
-
* @param location - The full path to localize
|
|
387
|
-
* @returns A new {@link Path} with the basename removed from the pathname
|
|
388
|
-
*/
|
|
389
|
-
protected _localizePath(location: Path): Path {
|
|
390
|
-
const { pathname, search, hash } = location;
|
|
391
|
-
|
|
392
|
-
// No basename - return normalized pathname as-is
|
|
393
|
-
if (!this.#basename) {
|
|
394
|
-
return {
|
|
395
|
-
pathname: normalizePathname(pathname) || '/',
|
|
396
|
-
search,
|
|
397
|
-
hash,
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
const normalized = normalizePathname(pathname);
|
|
402
|
-
let localized = normalized;
|
|
403
|
-
|
|
404
|
-
// Strip basename only if it matches at path boundary
|
|
405
|
-
if (normalized === this.#basename) {
|
|
406
|
-
localized = '/';
|
|
407
|
-
} else if (normalized.startsWith(`${this.#basename}/`)) {
|
|
408
|
-
localized = normalized.slice(this.#basename.length);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return {
|
|
412
|
-
pathname: localized || '/',
|
|
413
|
-
search,
|
|
414
|
-
hash,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/**
|
|
419
|
-
* Creates a full path object from a target location, prepending the basename prefix.
|
|
420
|
-
*
|
|
421
|
-
* @param to - The target location (string path or partial {@link Path} object)
|
|
422
|
-
* @returns A partial {@link Path} with basename prepended to the pathname
|
|
423
|
-
*/
|
|
424
|
-
protected _createToPath(to: To): Partial<Path> {
|
|
425
|
-
// Parse the 'to' parameter into path components
|
|
426
|
-
const pathComponents = typeof to === 'string' ? { pathname: to } : to;
|
|
427
|
-
|
|
428
|
-
// Extract path parts, defaulting to current path values
|
|
429
|
-
const rawPathname = pathComponents.pathname ?? this.path.pathname;
|
|
430
|
-
const pathname = normalizePathname(`${this.#basename ?? ''}/${rawPathname}`);
|
|
431
|
-
const search = pathComponents.search ?? this.path.search;
|
|
432
|
-
const hash = pathComponents.hash ?? this.path.hash;
|
|
433
|
-
|
|
434
|
-
return { pathname, search, hash };
|
|
435
|
-
}
|
|
436
|
-
}
|
|
@@ -1,151 +0,0 @@
|
|
|
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
|
-
});
|