@real-router/browser-plugin 0.6.3 → 0.8.0
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/README.md +12 -21
- package/dist/cjs/index.d.ts +4 -62
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/index.js.map +1 -1
- package/dist/cjs/metafile-cjs.json +1 -1
- package/dist/esm/index.d.mts +4 -62
- package/dist/esm/index.mjs +1 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/metafile-esm.json +1 -1
- package/package.json +3 -3
- package/src/browser.ts +19 -168
- package/src/constants.ts +0 -2
- package/src/factory.ts +88 -0
- package/src/index.ts +4 -10
- package/src/plugin.ts +205 -437
- package/src/popstate-utils.ts +60 -0
- package/src/types.ts +25 -50
- package/src/url-utils.ts +95 -0
- package/src/validation.ts +66 -0
- package/src/utils.ts +0 -294
package/src/plugin.ts
CHANGED
|
@@ -1,483 +1,251 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import { defaultOptions, LOGGER_CONTEXT, source } from "./constants";
|
|
8
|
-
import {
|
|
9
|
-
escapeRegExp,
|
|
10
|
-
createStateFromEvent,
|
|
11
|
-
shouldSkipTransition,
|
|
12
|
-
handleMissingState,
|
|
13
|
-
updateBrowserState,
|
|
14
|
-
handleTransitionResult,
|
|
15
|
-
validateOptions,
|
|
16
|
-
} from "./utils";
|
|
17
|
-
|
|
18
|
-
import type { BrowserPluginOptions, Browser, HistoryState } from "./types";
|
|
1
|
+
import { RouterError } from "@real-router/core";
|
|
2
|
+
|
|
3
|
+
import { LOGGER_CONTEXT } from "./constants";
|
|
4
|
+
import { getRouteFromEvent, updateBrowserState } from "./popstate-utils";
|
|
5
|
+
import { buildUrl, urlToPath } from "./url-utils";
|
|
6
|
+
|
|
19
7
|
import type {
|
|
20
|
-
|
|
8
|
+
Browser,
|
|
9
|
+
BrowserPluginOptions,
|
|
10
|
+
RegExpCache,
|
|
11
|
+
SharedFactoryState,
|
|
12
|
+
URLParseOptions,
|
|
13
|
+
} from "./types";
|
|
14
|
+
import type {
|
|
15
|
+
NavigationOptions,
|
|
16
|
+
Params,
|
|
17
|
+
PluginApi,
|
|
21
18
|
Router,
|
|
22
|
-
RouterError,
|
|
23
19
|
State,
|
|
20
|
+
Plugin,
|
|
24
21
|
} from "@real-router/core";
|
|
25
22
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
* @param opts - Plugin configuration options
|
|
39
|
-
* @param browser - Browser API abstraction (for testing/SSR)
|
|
40
|
-
* @returns Plugin factory function
|
|
41
|
-
*
|
|
42
|
-
* @example
|
|
43
|
-
* ```ts
|
|
44
|
-
* // Hash routing
|
|
45
|
-
* router.usePlugin(browserPluginFactory({ useHash: true, hashPrefix: "!" }));
|
|
46
|
-
*
|
|
47
|
-
* // History routing with hash preservation
|
|
48
|
-
* router.usePlugin(browserPluginFactory({ useHash: false, preserveHash: true }));
|
|
49
|
-
* ```
|
|
50
|
-
*/
|
|
51
|
-
export function browserPluginFactory(
|
|
52
|
-
opts?: Partial<BrowserPluginOptions>,
|
|
53
|
-
browser: Browser = createSafeBrowser(),
|
|
54
|
-
): PluginFactory {
|
|
55
|
-
// Validate user-provided options before merging with defaults
|
|
56
|
-
const hasInvalidTypes = validateOptions(opts, defaultOptions);
|
|
57
|
-
|
|
58
|
-
let options = { ...defaultOptions, ...opts } as BrowserPluginOptions;
|
|
59
|
-
|
|
60
|
-
// Skip normalization if invalid types detected (prevents runtime errors)
|
|
61
|
-
if (hasInvalidTypes) {
|
|
62
|
-
console.warn(
|
|
63
|
-
`[${LOGGER_CONTEXT}] Using default options due to invalid types`,
|
|
64
|
-
);
|
|
65
|
-
options = { ...defaultOptions } as BrowserPluginOptions;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Remove conflicting properties based on mode to prevent misuse
|
|
69
|
-
// This ensures options object is clean even if JS users pass invalid config
|
|
70
|
-
if (options.useHash === true) {
|
|
71
|
-
// Hash mode: remove history-only options
|
|
72
|
-
delete (options as unknown as Record<string, unknown>).preserveHash;
|
|
73
|
-
} else {
|
|
74
|
-
// History mode (default): remove hash-only options
|
|
75
|
-
delete (options as unknown as Record<string, unknown>).hashPrefix;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Normalize base path to prevent common configuration errors
|
|
79
|
-
// Type check needed for runtime safety (JS users may pass wrong types)
|
|
80
|
-
if (options.base && typeof options.base === "string") {
|
|
81
|
-
// Ensure leading slash for absolute paths
|
|
82
|
-
if (!options.base.startsWith("/")) {
|
|
83
|
-
options.base = `/${options.base}`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Remove trailing slash to prevent double slashes
|
|
87
|
-
if (options.base.endsWith("/")) {
|
|
88
|
-
options.base = options.base.slice(0, -1);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Cache RegExp patterns at plugin creation for performance
|
|
93
|
-
const regExpCache = new Map<string, RegExp>();
|
|
94
|
-
const getCachedRegExp = (pattern: string): RegExp => {
|
|
95
|
-
const cached = regExpCache.get(pattern);
|
|
96
|
-
|
|
97
|
-
if (cached !== undefined) {
|
|
98
|
-
return cached;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const newRegExp = new RegExp(pattern);
|
|
102
|
-
|
|
103
|
-
regExpCache.set(pattern, newRegExp);
|
|
104
|
-
|
|
105
|
-
return newRegExp;
|
|
23
|
+
export class BrowserPlugin {
|
|
24
|
+
readonly #router: Router;
|
|
25
|
+
readonly #api: PluginApi;
|
|
26
|
+
readonly #options: BrowserPluginOptions;
|
|
27
|
+
readonly #browser: Browser;
|
|
28
|
+
readonly #regExpCache: RegExpCache;
|
|
29
|
+
readonly #prefix: string;
|
|
30
|
+
readonly #transitionOptions: {
|
|
31
|
+
source: string;
|
|
32
|
+
replace: true;
|
|
33
|
+
forceDeactivate?: boolean;
|
|
106
34
|
};
|
|
35
|
+
readonly #shared: SharedFactoryState;
|
|
36
|
+
|
|
37
|
+
#isTransitioning = false;
|
|
38
|
+
#deferredPopstateEvent: PopStateEvent | null = null;
|
|
39
|
+
readonly #removeStartInterceptor: () => void;
|
|
40
|
+
readonly #removeExtensions: () => void;
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
router: Router,
|
|
44
|
+
api: PluginApi,
|
|
45
|
+
options: BrowserPluginOptions,
|
|
46
|
+
browser: Browser,
|
|
47
|
+
regExpCache: RegExpCache,
|
|
48
|
+
transitionOptions: {
|
|
49
|
+
source: string;
|
|
50
|
+
replace: true;
|
|
51
|
+
forceDeactivate?: boolean;
|
|
52
|
+
},
|
|
53
|
+
shared: SharedFactoryState,
|
|
54
|
+
) {
|
|
55
|
+
this.#router = router;
|
|
56
|
+
this.#api = api;
|
|
57
|
+
this.#options = options;
|
|
58
|
+
this.#browser = browser;
|
|
59
|
+
this.#regExpCache = regExpCache;
|
|
60
|
+
this.#transitionOptions = transitionOptions;
|
|
61
|
+
this.#shared = shared;
|
|
62
|
+
|
|
63
|
+
const normalizedOptions = options as URLParseOptions;
|
|
64
|
+
|
|
65
|
+
this.#prefix = options.useHash ? `#${normalizedOptions.hashPrefix}` : "";
|
|
66
|
+
|
|
67
|
+
this.#removeStartInterceptor = this.#api.addInterceptor(
|
|
68
|
+
"start",
|
|
69
|
+
(next, path) => next(path ?? this.#browser.getLocation(this.#options)),
|
|
70
|
+
);
|
|
107
71
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
/* v8 ignore next 4 -- @preserve both branches tested, coverage tool limitation */
|
|
112
|
-
const transitionOptions =
|
|
113
|
-
forceDeactivate === undefined
|
|
114
|
-
? { source, replace: true }
|
|
115
|
-
: { forceDeactivate, source, replace: true };
|
|
116
|
-
|
|
117
|
-
let removePopStateListener: (() => void) | undefined;
|
|
118
|
-
|
|
119
|
-
return function browserPlugin(routerBase) {
|
|
120
|
-
// Cast to augmented Router (class + module augmentation: buildUrl, matchUrl, etc.)
|
|
121
|
-
const router = routerBase as Router;
|
|
122
|
-
const api = getPluginApi(routerBase);
|
|
123
|
-
|
|
124
|
-
// Store original methods for restoration on teardown
|
|
125
|
-
|
|
126
|
-
const routerStart = router.start;
|
|
127
|
-
|
|
128
|
-
// Transition state management
|
|
129
|
-
let isTransitioning = false;
|
|
130
|
-
|
|
131
|
-
// Deferred popstate event queue (stores only the last event)
|
|
132
|
-
let deferredPopstateEvent: PopStateEvent | null = null;
|
|
133
|
-
|
|
134
|
-
// Frozen copy of lastKnownState for immutability
|
|
135
|
-
let cachedFrozenState: State | undefined;
|
|
136
|
-
|
|
137
|
-
// Options can be changed at runtime in onStart
|
|
138
|
-
/* v8 ignore next -- @preserve fallback for undefined base */
|
|
139
|
-
const getBase = () => options.base ?? "";
|
|
140
|
-
/* v8 ignore next -- @preserve fallback for undefined hashPrefix */
|
|
141
|
-
const hashPrefix = options.hashPrefix ?? "";
|
|
142
|
-
const escapedHashPrefix = escapeRegExp(hashPrefix);
|
|
143
|
-
const prefix = options.useHash ? `#${hashPrefix}` : "";
|
|
144
|
-
|
|
145
|
-
// Pre-compute RegExp patterns
|
|
146
|
-
const hashPrefixRegExp = escapedHashPrefix
|
|
147
|
-
? getCachedRegExp(`^#${escapedHashPrefix}`)
|
|
148
|
-
: null;
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Parses URL and extracts path using native URL API.
|
|
152
|
-
* More robust than regex parsing - handles IPv6, Unicode, edge cases.
|
|
153
|
-
*
|
|
154
|
-
* @param url - URL to parse
|
|
155
|
-
* @returns Path string or null on parse error
|
|
156
|
-
*/
|
|
157
|
-
const urlToPath = (url: string): string | null => {
|
|
158
|
-
try {
|
|
159
|
-
// Use URL API for reliable parsing
|
|
160
|
-
const parsedUrl = new URL(url, globalThis.location.origin);
|
|
161
|
-
const pathname = parsedUrl.pathname;
|
|
162
|
-
const hash = parsedUrl.hash;
|
|
163
|
-
const search = parsedUrl.search;
|
|
164
|
-
const base = getBase();
|
|
165
|
-
|
|
166
|
-
if (!["http:", "https:"].includes(parsedUrl.protocol)) {
|
|
167
|
-
console.warn(`[${LOGGER_CONTEXT}] Invalid URL protocol in ${url}`);
|
|
168
|
-
|
|
169
|
-
return null;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (options.useHash) {
|
|
173
|
-
// Use cached RegExp or simple slice if no prefix
|
|
174
|
-
const path = hashPrefixRegExp
|
|
175
|
-
? hash.replace(hashPrefixRegExp, "")
|
|
176
|
-
: hash.slice(1);
|
|
177
|
-
|
|
178
|
-
return path + search;
|
|
179
|
-
} else if (base) {
|
|
180
|
-
// Remove base prefix
|
|
181
|
-
const escapedBase = escapeRegExp(base);
|
|
182
|
-
const baseRegExp = getCachedRegExp(`^${escapedBase}`);
|
|
183
|
-
const stripped = pathname.replace(baseRegExp, "");
|
|
184
|
-
|
|
185
|
-
return (stripped.startsWith("/") ? "" : "/") + stripped + search;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return pathname + search;
|
|
189
|
-
} catch (error) {
|
|
190
|
-
// Graceful fallback instead of throw
|
|
191
|
-
console.warn(`[${LOGGER_CONTEXT}] Could not parse url ${url}`, error);
|
|
192
|
-
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Overrides router.start to integrate with browser location.
|
|
199
|
-
* When no path is provided, resolves current browser URL automatically.
|
|
200
|
-
*/
|
|
201
|
-
router.start = (path?: string) => {
|
|
202
|
-
return routerStart(path ?? browser.getLocation(options));
|
|
203
|
-
};
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Builds URL from route name and params.
|
|
207
|
-
* Adds base path and hash prefix according to options.
|
|
208
|
-
*
|
|
209
|
-
* @security
|
|
210
|
-
* When using buildUrl output in templates:
|
|
211
|
-
* - ✅ SAFE: Modern frameworks (React, Vue, Angular) auto-escape in templates
|
|
212
|
-
* - ✅ SAFE: Setting href attribute via DOM API (element.href = url)
|
|
213
|
-
* - ❌ UNSAFE: Using innerHTML or similar without escaping
|
|
214
|
-
*
|
|
215
|
-
* @example
|
|
216
|
-
* // Safe - React auto-escapes
|
|
217
|
-
* <Link to={router.buildUrl('users', params)} />
|
|
218
|
-
*
|
|
219
|
-
* // Safe - Vue auto-escapes
|
|
220
|
-
* <router-link :to="router.buildUrl('users', params)" />
|
|
221
|
-
*
|
|
222
|
-
* // Unsafe - manual HTML construction
|
|
223
|
-
* element.innerHTML = `<a href="${router.buildUrl('users', params)}">Link</a>`; // ❌ DON'T
|
|
224
|
-
*/
|
|
225
|
-
router.buildUrl = (route, params) => {
|
|
226
|
-
const path = router.buildPath(route, params);
|
|
227
|
-
|
|
228
|
-
return getBase() + prefix + path;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* Matches URL and returns corresponding state
|
|
233
|
-
*/
|
|
234
|
-
router.matchUrl = (url) => {
|
|
235
|
-
const path = urlToPath(url);
|
|
236
|
-
|
|
237
|
-
return path ? api.matchPath(path) : undefined;
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Replaces current history state without triggering navigation.
|
|
242
|
-
* Useful for updating URL without causing a full transition.
|
|
243
|
-
*/
|
|
244
|
-
router.replaceHistoryState = (name, params = {}) => {
|
|
245
|
-
const state = api.buildState(name, params);
|
|
72
|
+
this.#removeExtensions = this.#api.extendRouter({
|
|
73
|
+
buildUrl: (route: string, params?: Params) => {
|
|
74
|
+
const path = this.#router.buildPath(route, params);
|
|
246
75
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
76
|
+
return buildUrl(
|
|
77
|
+
path,
|
|
78
|
+
(this.#options as URLParseOptions).base,
|
|
79
|
+
this.#prefix,
|
|
250
80
|
);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const builtState = api.makeState(
|
|
254
|
-
state.name,
|
|
255
|
-
state.params,
|
|
256
|
-
router.buildPath(state.name, state.params),
|
|
257
|
-
{
|
|
258
|
-
params: state.meta,
|
|
259
|
-
},
|
|
260
|
-
1, // forceId
|
|
261
|
-
);
|
|
262
|
-
const url = router.buildUrl(name, params);
|
|
263
|
-
|
|
264
|
-
updateBrowserState(builtState, url, true, browser, options);
|
|
265
|
-
};
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* lastKnownState: Immutable reference to last successful state.
|
|
269
|
-
* Uses caching to avoid creating new objects on every read.
|
|
270
|
-
* Optimized: Single copy + freeze operation instead of double copying.
|
|
271
|
-
*/
|
|
272
|
-
Object.defineProperty(router, "lastKnownState", {
|
|
273
|
-
get() {
|
|
274
|
-
// Note: After teardown, this property is deleted from router,
|
|
275
|
-
// so this getter is only called while plugin is active
|
|
276
|
-
return cachedFrozenState;
|
|
277
|
-
},
|
|
278
|
-
set(value?: State) {
|
|
279
|
-
// Create frozen copy in one operation (no double copying)
|
|
280
|
-
cachedFrozenState = value ? Object.freeze({ ...value }) : undefined;
|
|
281
81
|
},
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
* Processes a deferred popstate event if one exists.
|
|
288
|
-
* Called after transition completes.
|
|
289
|
-
*/
|
|
290
|
-
function processDeferredEvent() {
|
|
291
|
-
if (deferredPopstateEvent) {
|
|
292
|
-
const event = deferredPopstateEvent;
|
|
293
|
-
|
|
294
|
-
deferredPopstateEvent = null; // Clear before processing
|
|
295
|
-
console.warn(`[${LOGGER_CONTEXT}] Processing deferred popstate event`);
|
|
296
|
-
void onPopState(event);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Main popstate event handler.
|
|
302
|
-
* Protected against concurrent transitions and handles errors gracefully.
|
|
303
|
-
* Defers events during transitions to prevent browser history desync.
|
|
304
|
-
*/
|
|
305
|
-
async function onPopState(evt: PopStateEvent) {
|
|
306
|
-
// Race condition protection: defer event if transition in progress
|
|
307
|
-
if (isTransitioning) {
|
|
308
|
-
console.warn(
|
|
309
|
-
`[${LOGGER_CONTEXT}] Transition in progress, deferring popstate event`,
|
|
82
|
+
matchUrl: (url: string) => {
|
|
83
|
+
const path = urlToPath(
|
|
84
|
+
url,
|
|
85
|
+
this.#options as URLParseOptions,
|
|
86
|
+
this.#regExpCache,
|
|
310
87
|
);
|
|
311
|
-
// Store only the latest event (skip intermediate states)
|
|
312
|
-
deferredPopstateEvent = evt;
|
|
313
|
-
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Top-level error recovery
|
|
318
|
-
try {
|
|
319
|
-
const routerState = router.getState();
|
|
320
|
-
const state = createStateFromEvent(evt, api, browser, options);
|
|
321
|
-
const isNewState = !isState(evt.state);
|
|
322
88
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// Skip if states are equal
|
|
329
|
-
if (shouldSkipTransition(state, routerState, router)) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
89
|
+
return path ? this.#api.matchPath(path) : undefined;
|
|
90
|
+
},
|
|
91
|
+
replaceHistoryState: (name: string, params: Params = {}) => {
|
|
92
|
+
const state = this.#api.buildState(name, params);
|
|
332
93
|
|
|
333
|
-
// Execute transition with race protection
|
|
334
|
-
// state is guaranteed to be defined here because:
|
|
335
|
-
// 1. handleMissingState handles !state case (line 339)
|
|
336
|
-
// 2. shouldSkipTransition returns true when !state (utils.ts:129)
|
|
337
|
-
/* v8 ignore start: defensive guard - state guaranteed defined by control flow above */
|
|
338
94
|
if (!state) {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
/* v8 ignore stop */
|
|
342
|
-
|
|
343
|
-
isTransitioning = true;
|
|
344
|
-
|
|
345
|
-
try {
|
|
346
|
-
// transitionOptions includes replace: true, which is passed to TRANSITION_SUCCESS
|
|
347
|
-
const toState = await api.navigateToState(
|
|
348
|
-
state,
|
|
349
|
-
routerState,
|
|
350
|
-
transitionOptions,
|
|
95
|
+
throw new Error(
|
|
96
|
+
`[real-router] Cannot replace state: route "${name}" is not found`,
|
|
351
97
|
);
|
|
352
|
-
|
|
353
|
-
handleTransitionResult(
|
|
354
|
-
undefined,
|
|
355
|
-
toState,
|
|
356
|
-
routerState,
|
|
357
|
-
isNewState,
|
|
358
|
-
router,
|
|
359
|
-
browser,
|
|
360
|
-
options,
|
|
361
|
-
);
|
|
362
|
-
} catch (error) {
|
|
363
|
-
handleTransitionResult(
|
|
364
|
-
error as RouterError,
|
|
365
|
-
undefined,
|
|
366
|
-
routerState,
|
|
367
|
-
isNewState,
|
|
368
|
-
router,
|
|
369
|
-
browser,
|
|
370
|
-
options,
|
|
371
|
-
);
|
|
372
|
-
} finally {
|
|
373
|
-
isTransitioning = false;
|
|
374
|
-
// Process any deferred popstate events after transition completes
|
|
375
|
-
processDeferredEvent();
|
|
376
98
|
}
|
|
377
|
-
} catch (error) {
|
|
378
|
-
isTransitioning = false;
|
|
379
|
-
console.error(
|
|
380
|
-
`[${LOGGER_CONTEXT}] Critical error in onPopState`,
|
|
381
|
-
error,
|
|
382
|
-
);
|
|
383
99
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
// If recovery fails, there's nothing more we can do
|
|
395
|
-
console.error(
|
|
396
|
-
`[${LOGGER_CONTEXT}] Failed to recover from critical error`,
|
|
397
|
-
recoveryError,
|
|
398
|
-
);
|
|
399
|
-
}
|
|
100
|
+
const builtState = this.#api.makeState(
|
|
101
|
+
state.name,
|
|
102
|
+
state.params,
|
|
103
|
+
this.#router.buildPath(state.name, state.params),
|
|
104
|
+
{
|
|
105
|
+
params: state.meta,
|
|
106
|
+
},
|
|
107
|
+
1, // forceId
|
|
108
|
+
);
|
|
109
|
+
const url = this.#router.buildUrl(name, params);
|
|
400
110
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
111
|
+
updateBrowserState(builtState, url, true, this.#browser);
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
405
115
|
|
|
116
|
+
getPlugin(): Plugin {
|
|
406
117
|
return {
|
|
407
|
-
/**
|
|
408
|
-
* Called when router.start() is invoked.
|
|
409
|
-
* Sets up browser history integration.
|
|
410
|
-
*/
|
|
411
118
|
onStart: () => {
|
|
412
|
-
if (removePopStateListener) {
|
|
413
|
-
removePopStateListener();
|
|
119
|
+
if (this.#shared.removePopStateListener) {
|
|
120
|
+
this.#shared.removePopStateListener();
|
|
414
121
|
}
|
|
415
122
|
|
|
416
|
-
removePopStateListener = browser.addPopstateListener(
|
|
417
|
-
(evt: PopStateEvent) => void onPopState(evt),
|
|
418
|
-
options,
|
|
123
|
+
this.#shared.removePopStateListener = this.#browser.addPopstateListener(
|
|
124
|
+
(evt: PopStateEvent) => void this.#onPopState(evt),
|
|
419
125
|
);
|
|
420
126
|
},
|
|
421
127
|
|
|
422
|
-
/**
|
|
423
|
-
* Called when router.stop() is invoked.
|
|
424
|
-
* Cleans up event listeners.
|
|
425
|
-
*/
|
|
426
128
|
onStop: () => {
|
|
427
|
-
if (removePopStateListener) {
|
|
428
|
-
removePopStateListener();
|
|
429
|
-
removePopStateListener = undefined;
|
|
129
|
+
if (this.#shared.removePopStateListener) {
|
|
130
|
+
this.#shared.removePopStateListener();
|
|
131
|
+
this.#shared.removePopStateListener = undefined;
|
|
430
132
|
}
|
|
431
133
|
},
|
|
432
134
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// Determine if we should replace or push history entry
|
|
441
|
-
const replaceHistory =
|
|
135
|
+
onTransitionSuccess: (
|
|
136
|
+
toState: State,
|
|
137
|
+
fromState: State | undefined,
|
|
138
|
+
navOptions: NavigationOptions,
|
|
139
|
+
) => {
|
|
140
|
+
const shouldReplaceHistory =
|
|
442
141
|
(navOptions.replace ?? !fromState) ||
|
|
443
142
|
(!!navOptions.reload &&
|
|
444
|
-
router.areStatesEqual(toState, fromState, false));
|
|
143
|
+
this.#router.areStatesEqual(toState, fromState, false));
|
|
445
144
|
|
|
446
|
-
|
|
447
|
-
const url = router.buildUrl(toState.name, toState.params);
|
|
145
|
+
const url = this.#router.buildUrl(toState.name, toState.params);
|
|
448
146
|
|
|
449
|
-
// Preserve hash fragment if configured
|
|
450
|
-
// Note: preserveHash is deleted in hash mode, so it's always undefined there
|
|
451
147
|
const shouldPreserveHash =
|
|
452
|
-
options.preserveHash &&
|
|
148
|
+
!!this.#options.preserveHash &&
|
|
453
149
|
(!fromState || fromState.path === toState.path);
|
|
454
150
|
|
|
455
|
-
const finalUrl = shouldPreserveHash
|
|
151
|
+
const finalUrl = shouldPreserveHash
|
|
152
|
+
? url + this.#browser.getHash()
|
|
153
|
+
: url;
|
|
456
154
|
|
|
457
|
-
|
|
458
|
-
|
|
155
|
+
updateBrowserState(
|
|
156
|
+
toState,
|
|
157
|
+
finalUrl,
|
|
158
|
+
shouldReplaceHistory,
|
|
159
|
+
this.#browser,
|
|
160
|
+
);
|
|
459
161
|
},
|
|
460
162
|
|
|
461
|
-
/**
|
|
462
|
-
* Called when plugin is unsubscribed.
|
|
463
|
-
* Restores original router state for clean teardown.
|
|
464
|
-
*/
|
|
465
163
|
teardown: () => {
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
removePopStateListener
|
|
469
|
-
removePopStateListener = undefined;
|
|
164
|
+
if (this.#shared.removePopStateListener) {
|
|
165
|
+
this.#shared.removePopStateListener();
|
|
166
|
+
this.#shared.removePopStateListener = undefined;
|
|
470
167
|
}
|
|
471
168
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
// Clean up added properties
|
|
476
|
-
delete (router as Partial<Router>).buildUrl;
|
|
477
|
-
delete (router as Partial<Router>).matchUrl;
|
|
478
|
-
delete (router as Partial<Router>).replaceHistoryState;
|
|
479
|
-
delete (router as Partial<Router>).lastKnownState;
|
|
169
|
+
this.#removeStartInterceptor();
|
|
170
|
+
this.#removeExtensions();
|
|
480
171
|
},
|
|
481
172
|
};
|
|
482
|
-
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#processDeferredEvent(): void {
|
|
176
|
+
if (this.#deferredPopstateEvent) {
|
|
177
|
+
const event = this.#deferredPopstateEvent;
|
|
178
|
+
|
|
179
|
+
this.#deferredPopstateEvent = null;
|
|
180
|
+
console.warn(`[${LOGGER_CONTEXT}] Processing deferred popstate event`);
|
|
181
|
+
void this.#onPopState(event);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async #onPopState(evt: PopStateEvent): Promise<void> {
|
|
186
|
+
if (this.#isTransitioning) {
|
|
187
|
+
console.warn(
|
|
188
|
+
`[${LOGGER_CONTEXT}] Transition in progress, deferring popstate event`,
|
|
189
|
+
);
|
|
190
|
+
this.#deferredPopstateEvent = evt;
|
|
191
|
+
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.#isTransitioning = true;
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const route = getRouteFromEvent(
|
|
199
|
+
evt,
|
|
200
|
+
this.#api,
|
|
201
|
+
this.#browser,
|
|
202
|
+
this.#options,
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// eslint-disable-next-line unicorn/prefer-ternary
|
|
206
|
+
if (route) {
|
|
207
|
+
await this.#router.navigate(
|
|
208
|
+
route.name,
|
|
209
|
+
route.params,
|
|
210
|
+
this.#transitionOptions,
|
|
211
|
+
);
|
|
212
|
+
} else {
|
|
213
|
+
await this.#router.navigateToDefault({
|
|
214
|
+
...this.#transitionOptions,
|
|
215
|
+
reload: true,
|
|
216
|
+
replace: true,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (!(error instanceof RouterError)) {
|
|
221
|
+
this.#recoverFromCriticalError(error);
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
this.#isTransitioning = false;
|
|
225
|
+
this.#processDeferredEvent();
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#recoverFromCriticalError(error: unknown): void {
|
|
230
|
+
console.error(`[${LOGGER_CONTEXT}] Critical error in onPopState`, error);
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const currentState = this.#router.getState();
|
|
234
|
+
|
|
235
|
+
/* v8 ignore next -- @preserve: router always has state after start(); defensive guard for edge cases */
|
|
236
|
+
if (currentState) {
|
|
237
|
+
const url = this.#router.buildUrl(
|
|
238
|
+
currentState.name,
|
|
239
|
+
currentState.params,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
this.#browser.replaceState(currentState, url);
|
|
243
|
+
}
|
|
244
|
+
} catch (recoveryError) {
|
|
245
|
+
console.error(
|
|
246
|
+
`[${LOGGER_CONTEXT}] Failed to recover from critical error`,
|
|
247
|
+
recoveryError,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
483
251
|
}
|