@real-router/browser-plugin 0.6.3 → 0.7.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 +11 -20
- 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 +197 -418
- 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,248 +1,153 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
|
|
7
|
+
import type {
|
|
8
|
+
Browser,
|
|
9
|
+
BrowserPluginOptions,
|
|
10
|
+
RegExpCache,
|
|
11
|
+
SharedFactoryState,
|
|
12
|
+
URLParseOptions,
|
|
13
|
+
} from "./types";
|
|
19
14
|
import type {
|
|
20
|
-
|
|
15
|
+
NavigationOptions,
|
|
16
|
+
PluginApi,
|
|
21
17
|
Router,
|
|
22
|
-
RouterError,
|
|
23
18
|
State,
|
|
19
|
+
Plugin,
|
|
24
20
|
} from "@real-router/core";
|
|
25
21
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
22
|
+
export class BrowserPlugin {
|
|
23
|
+
readonly #router: Router;
|
|
24
|
+
readonly #api: PluginApi;
|
|
25
|
+
readonly #options: BrowserPluginOptions;
|
|
26
|
+
readonly #browser: Browser;
|
|
27
|
+
readonly #regExpCache: RegExpCache;
|
|
28
|
+
readonly #prefix: string;
|
|
29
|
+
readonly #transitionOptions: {
|
|
30
|
+
source: string;
|
|
31
|
+
replace: true;
|
|
32
|
+
forceDeactivate?: boolean;
|
|
33
|
+
};
|
|
34
|
+
readonly #shared: SharedFactoryState;
|
|
35
|
+
|
|
36
|
+
#isTransitioning = false;
|
|
37
|
+
#deferredPopstateEvent: PopStateEvent | null = null;
|
|
38
|
+
readonly #removeStartInterceptor: () => void;
|
|
39
|
+
|
|
40
|
+
constructor(
|
|
41
|
+
router: Router,
|
|
42
|
+
api: PluginApi,
|
|
43
|
+
options: BrowserPluginOptions,
|
|
44
|
+
browser: Browser,
|
|
45
|
+
regExpCache: RegExpCache,
|
|
46
|
+
transitionOptions: {
|
|
47
|
+
source: string;
|
|
48
|
+
replace: true;
|
|
49
|
+
forceDeactivate?: boolean;
|
|
50
|
+
},
|
|
51
|
+
shared: SharedFactoryState,
|
|
52
|
+
) {
|
|
53
|
+
this.#router = router;
|
|
54
|
+
this.#api = api;
|
|
55
|
+
this.#options = options;
|
|
56
|
+
this.#browser = browser;
|
|
57
|
+
this.#regExpCache = regExpCache;
|
|
58
|
+
this.#transitionOptions = transitionOptions;
|
|
59
|
+
this.#shared = shared;
|
|
60
|
+
|
|
61
|
+
const normalizedOptions = options as URLParseOptions;
|
|
62
|
+
|
|
63
|
+
this.#prefix = options.useHash ? `#${normalizedOptions.hashPrefix}` : "";
|
|
64
|
+
|
|
65
|
+
this.#removeStartInterceptor = this.#api.addInterceptor(
|
|
66
|
+
"start",
|
|
67
|
+
(next, path) => next(path ?? this.#browser.getLocation(this.#options)),
|
|
64
68
|
);
|
|
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
69
|
|
|
86
|
-
|
|
87
|
-
if (options.base.endsWith("/")) {
|
|
88
|
-
options.base = options.base.slice(0, -1);
|
|
89
|
-
}
|
|
70
|
+
this.#augmentRouter();
|
|
90
71
|
}
|
|
91
72
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
return cached;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const newRegExp = new RegExp(pattern);
|
|
102
|
-
|
|
103
|
-
regExpCache.set(pattern, newRegExp);
|
|
73
|
+
getPlugin(): Plugin {
|
|
74
|
+
return {
|
|
75
|
+
onStart: () => {
|
|
76
|
+
if (this.#shared.removePopStateListener) {
|
|
77
|
+
this.#shared.removePopStateListener();
|
|
78
|
+
}
|
|
104
79
|
|
|
105
|
-
|
|
106
|
-
|
|
80
|
+
this.#shared.removePopStateListener = this.#browser.addPopstateListener(
|
|
81
|
+
(evt: PopStateEvent) => void this.#onPopState(evt),
|
|
82
|
+
);
|
|
83
|
+
},
|
|
107
84
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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;
|
|
85
|
+
onStop: () => {
|
|
86
|
+
if (this.#shared.removePopStateListener) {
|
|
87
|
+
this.#shared.removePopStateListener();
|
|
88
|
+
this.#shared.removePopStateListener = undefined;
|
|
170
89
|
}
|
|
90
|
+
},
|
|
171
91
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
92
|
+
onTransitionSuccess: (
|
|
93
|
+
toState: State,
|
|
94
|
+
fromState: State | undefined,
|
|
95
|
+
navOptions: NavigationOptions,
|
|
96
|
+
) => {
|
|
97
|
+
const shouldReplaceHistory =
|
|
98
|
+
(navOptions.replace ?? !fromState) ||
|
|
99
|
+
(!!navOptions.reload &&
|
|
100
|
+
this.#router.areStatesEqual(toState, fromState, false));
|
|
177
101
|
|
|
178
|
-
|
|
179
|
-
} else if (base) {
|
|
180
|
-
// Remove base prefix
|
|
181
|
-
const escapedBase = escapeRegExp(base);
|
|
182
|
-
const baseRegExp = getCachedRegExp(`^${escapedBase}`);
|
|
183
|
-
const stripped = pathname.replace(baseRegExp, "");
|
|
102
|
+
const url = this.#router.buildUrl(toState.name, toState.params);
|
|
184
103
|
|
|
185
|
-
|
|
186
|
-
|
|
104
|
+
const shouldPreserveHash =
|
|
105
|
+
!!this.#options.preserveHash &&
|
|
106
|
+
(!fromState || fromState.path === toState.path);
|
|
187
107
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
console.warn(`[${LOGGER_CONTEXT}] Could not parse url ${url}`, error);
|
|
108
|
+
const finalUrl = shouldPreserveHash
|
|
109
|
+
? url + this.#browser.getHash()
|
|
110
|
+
: url;
|
|
192
111
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
112
|
+
updateBrowserState(
|
|
113
|
+
toState,
|
|
114
|
+
finalUrl,
|
|
115
|
+
shouldReplaceHistory,
|
|
116
|
+
this.#browser,
|
|
117
|
+
);
|
|
118
|
+
},
|
|
196
119
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
*/
|
|
201
|
-
router.start = (path?: string) => {
|
|
202
|
-
return routerStart(path ?? browser.getLocation(options));
|
|
120
|
+
teardown: () => {
|
|
121
|
+
this.#cleanupAugmentation();
|
|
122
|
+
},
|
|
203
123
|
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#augmentRouter(): void {
|
|
127
|
+
const router = this.#router;
|
|
204
128
|
|
|
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
129
|
router.buildUrl = (route, params) => {
|
|
226
130
|
const path = router.buildPath(route, params);
|
|
227
131
|
|
|
228
|
-
return
|
|
132
|
+
return buildUrl(
|
|
133
|
+
path,
|
|
134
|
+
(this.#options as URLParseOptions).base,
|
|
135
|
+
this.#prefix,
|
|
136
|
+
);
|
|
229
137
|
};
|
|
230
138
|
|
|
231
|
-
/**
|
|
232
|
-
* Matches URL and returns corresponding state
|
|
233
|
-
*/
|
|
234
139
|
router.matchUrl = (url) => {
|
|
235
|
-
const path = urlToPath(
|
|
140
|
+
const path = urlToPath(
|
|
141
|
+
url,
|
|
142
|
+
this.#options as URLParseOptions,
|
|
143
|
+
this.#regExpCache,
|
|
144
|
+
);
|
|
236
145
|
|
|
237
|
-
return path ? api.matchPath(path) : undefined;
|
|
146
|
+
return path ? this.#api.matchPath(path) : undefined;
|
|
238
147
|
};
|
|
239
148
|
|
|
240
|
-
/**
|
|
241
|
-
* Replaces current history state without triggering navigation.
|
|
242
|
-
* Useful for updating URL without causing a full transition.
|
|
243
|
-
*/
|
|
244
149
|
router.replaceHistoryState = (name, params = {}) => {
|
|
245
|
-
const state = api.buildState(name, params);
|
|
150
|
+
const state = this.#api.buildState(name, params);
|
|
246
151
|
|
|
247
152
|
if (!state) {
|
|
248
153
|
throw new Error(
|
|
@@ -250,7 +155,7 @@ export function browserPluginFactory(
|
|
|
250
155
|
);
|
|
251
156
|
}
|
|
252
157
|
|
|
253
|
-
const builtState = api.makeState(
|
|
158
|
+
const builtState = this.#api.makeState(
|
|
254
159
|
state.name,
|
|
255
160
|
state.params,
|
|
256
161
|
router.buildPath(state.name, state.params),
|
|
@@ -261,223 +166,97 @@ export function browserPluginFactory(
|
|
|
261
166
|
);
|
|
262
167
|
const url = router.buildUrl(name, params);
|
|
263
168
|
|
|
264
|
-
updateBrowserState(builtState, url, true, browser
|
|
169
|
+
updateBrowserState(builtState, url, true, this.#browser);
|
|
265
170
|
};
|
|
171
|
+
}
|
|
266
172
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
},
|
|
282
|
-
enumerable: true,
|
|
283
|
-
configurable: true,
|
|
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
|
-
}
|
|
173
|
+
#cleanupAugmentation(): void {
|
|
174
|
+
if (this.#shared.removePopStateListener) {
|
|
175
|
+
this.#shared.removePopStateListener();
|
|
176
|
+
this.#shared.removePopStateListener = undefined;
|
|
298
177
|
}
|
|
299
178
|
|
|
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`,
|
|
310
|
-
);
|
|
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);
|
|
179
|
+
this.#removeStartInterceptor();
|
|
322
180
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
// Skip if states are equal
|
|
329
|
-
if (shouldSkipTransition(state, routerState, router)) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
|
|
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
|
-
if (!state) {
|
|
339
|
-
return;
|
|
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,
|
|
351
|
-
);
|
|
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
|
-
}
|
|
377
|
-
} catch (error) {
|
|
378
|
-
isTransitioning = false;
|
|
379
|
-
console.error(
|
|
380
|
-
`[${LOGGER_CONTEXT}] Critical error in onPopState`,
|
|
381
|
-
error,
|
|
382
|
-
);
|
|
181
|
+
delete (this.#router as Partial<Router>).buildUrl;
|
|
182
|
+
delete (this.#router as Partial<Router>).matchUrl;
|
|
183
|
+
delete (this.#router as Partial<Router>).replaceHistoryState;
|
|
184
|
+
}
|
|
383
185
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
if (currentState) {
|
|
389
|
-
const url = router.buildUrl(currentState.name, currentState.params);
|
|
390
|
-
|
|
391
|
-
browser.replaceState(currentState as HistoryState, "", url);
|
|
392
|
-
}
|
|
393
|
-
} catch (recoveryError) {
|
|
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
|
-
}
|
|
186
|
+
#processDeferredEvent(): void {
|
|
187
|
+
if (this.#deferredPopstateEvent) {
|
|
188
|
+
const event = this.#deferredPopstateEvent;
|
|
400
189
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
190
|
+
this.#deferredPopstateEvent = null;
|
|
191
|
+
console.warn(`[${LOGGER_CONTEXT}] Processing deferred popstate event`);
|
|
192
|
+
void this.#onPopState(event);
|
|
404
193
|
}
|
|
194
|
+
}
|
|
405
195
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
if (removePopStateListener) {
|
|
413
|
-
removePopStateListener();
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
removePopStateListener = browser.addPopstateListener(
|
|
417
|
-
(evt: PopStateEvent) => void onPopState(evt),
|
|
418
|
-
options,
|
|
419
|
-
);
|
|
420
|
-
},
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Called when router.stop() is invoked.
|
|
424
|
-
* Cleans up event listeners.
|
|
425
|
-
*/
|
|
426
|
-
onStop: () => {
|
|
427
|
-
if (removePopStateListener) {
|
|
428
|
-
removePopStateListener();
|
|
429
|
-
removePopStateListener = undefined;
|
|
430
|
-
}
|
|
431
|
-
},
|
|
432
|
-
|
|
433
|
-
/**
|
|
434
|
-
* Called after successful navigation.
|
|
435
|
-
* Updates browser history with new state.
|
|
436
|
-
*/
|
|
437
|
-
onTransitionSuccess: (toState, fromState, navOptions) => {
|
|
438
|
-
router.lastKnownState = toState;
|
|
196
|
+
async #onPopState(evt: PopStateEvent): Promise<void> {
|
|
197
|
+
if (this.#isTransitioning) {
|
|
198
|
+
console.warn(
|
|
199
|
+
`[${LOGGER_CONTEXT}] Transition in progress, deferring popstate event`,
|
|
200
|
+
);
|
|
201
|
+
this.#deferredPopstateEvent = evt;
|
|
439
202
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
(navOptions.replace ?? !fromState) ||
|
|
443
|
-
(!!navOptions.reload &&
|
|
444
|
-
router.areStatesEqual(toState, fromState, false));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
445
205
|
|
|
446
|
-
|
|
447
|
-
const url = router.buildUrl(toState.name, toState.params);
|
|
206
|
+
this.#isTransitioning = true;
|
|
448
207
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
208
|
+
try {
|
|
209
|
+
const route = getRouteFromEvent(
|
|
210
|
+
evt,
|
|
211
|
+
this.#api,
|
|
212
|
+
this.#browser,
|
|
213
|
+
this.#options,
|
|
214
|
+
);
|
|
454
215
|
|
|
455
|
-
|
|
216
|
+
// eslint-disable-next-line unicorn/prefer-ternary
|
|
217
|
+
if (route) {
|
|
218
|
+
await this.#router.navigate(
|
|
219
|
+
route.name,
|
|
220
|
+
route.params,
|
|
221
|
+
this.#transitionOptions,
|
|
222
|
+
);
|
|
223
|
+
} else {
|
|
224
|
+
await this.#router.navigateToDefault({
|
|
225
|
+
...this.#transitionOptions,
|
|
226
|
+
reload: true,
|
|
227
|
+
replace: true,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (!(error instanceof RouterError)) {
|
|
232
|
+
this.#recoverFromCriticalError(error);
|
|
233
|
+
}
|
|
234
|
+
} finally {
|
|
235
|
+
this.#isTransitioning = false;
|
|
236
|
+
this.#processDeferredEvent();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
456
239
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
},
|
|
240
|
+
#recoverFromCriticalError(error: unknown): void {
|
|
241
|
+
console.error(`[${LOGGER_CONTEXT}] Critical error in onPopState`, error);
|
|
460
242
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
* Restores original router state for clean teardown.
|
|
464
|
-
*/
|
|
465
|
-
teardown: () => {
|
|
466
|
-
// Remove event listeners
|
|
467
|
-
if (removePopStateListener) {
|
|
468
|
-
removePopStateListener();
|
|
469
|
-
removePopStateListener = undefined;
|
|
470
|
-
}
|
|
243
|
+
try {
|
|
244
|
+
const currentState = this.#router.getState();
|
|
471
245
|
|
|
472
|
-
|
|
473
|
-
|
|
246
|
+
/* v8 ignore next -- @preserve: router always has state after start(); defensive guard for edge cases */
|
|
247
|
+
if (currentState) {
|
|
248
|
+
const url = this.#router.buildUrl(
|
|
249
|
+
currentState.name,
|
|
250
|
+
currentState.params,
|
|
251
|
+
);
|
|
474
252
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
253
|
+
this.#browser.replaceState(currentState, url);
|
|
254
|
+
}
|
|
255
|
+
} catch (recoveryError) {
|
|
256
|
+
console.error(
|
|
257
|
+
`[${LOGGER_CONTEXT}] Failed to recover from critical error`,
|
|
258
|
+
recoveryError,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
483
262
|
}
|