@wavelengthusaf/client-router 0.0.7 → 0.0.8

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.
@@ -1,19 +1,60 @@
1
+ import { createMachine } from '../machine/index.js';
1
2
  import { compileRoute, matchRoute } from '../utils/index.js';
3
+ const reducePreloadState = (state, event) => {
4
+ let nextStatus;
5
+ if (event.type === 'start') {
6
+ nextStatus = 'preloading';
7
+ }
8
+ else if (event.type === 'complete') {
9
+ nextStatus = 'ready';
10
+ }
11
+ else {
12
+ nextStatus = 'failed';
13
+ }
14
+ return { ...state, [event.path]: nextStatus };
15
+ };
2
16
  export class Router {
17
+ static #focusWaitMs = 5000;
18
+ static #navigationRootOwners = new WeakMap();
3
19
  #compiledRoutes;
4
20
  #hashMode;
5
21
  #outlet;
6
22
  #beforeEach;
23
+ #eventTarget;
24
+ #navigationRoot;
25
+ #focusStrategy;
26
+ #onError;
27
+ #preloader;
28
+ #preloadMachine = createMachine(reducePreloadState, {});
7
29
  #currentComponent;
30
+ #currentRouteContext;
31
+ #focusObserver;
32
+ #focusTimeout;
8
33
  #window;
9
34
  #document;
10
35
  #isStarted = false;
36
+ #isDestroyed = false;
37
+ #isNavigationRootRegistered = false;
38
+ #navigationVersion = 0;
11
39
  constructor(routeDefinitions, routerOptions = {}, env = {}) {
12
40
  this.#compiledRoutes = routeDefinitions.map(compileRoute);
13
41
  this.#hashMode = routerOptions.hashMode ?? false;
14
42
  if (routerOptions.beforeEach !== undefined) {
15
43
  this.#beforeEach = routerOptions.beforeEach;
16
44
  }
45
+ if (routerOptions.eventTarget !== undefined) {
46
+ this.#eventTarget = routerOptions.eventTarget;
47
+ }
48
+ if (routerOptions.navigationRoot !== undefined) {
49
+ this.#navigationRoot = routerOptions.navigationRoot;
50
+ }
51
+ this.#focusStrategy = routerOptions.focusStrategy ?? 'none';
52
+ if (routerOptions.onError !== undefined) {
53
+ this.#onError = routerOptions.onError;
54
+ }
55
+ if (routerOptions.preload !== undefined) {
56
+ this.#preloader = routerOptions.preload;
57
+ }
17
58
  if (env.window !== undefined) {
18
59
  this.#window = env.window;
19
60
  }
@@ -21,90 +62,363 @@ export class Router {
21
62
  this.#document = env.document;
22
63
  }
23
64
  this.#outlet = routerOptions.outlet ?? this.#document?.body ?? undefined;
65
+ if (this.#eventTarget === undefined && this.#document !== undefined) {
66
+ this.#eventTarget = this.#document;
67
+ }
24
68
  }
25
69
  start() {
26
- if (!this.#window?.addEventListener || !this.#document?.addEventListener) {
70
+ if (!this.#window?.addEventListener ||
71
+ !this.#eventTarget?.addEventListener) {
27
72
  throw new Error('[Router] Invalid environment: missing DOM APIs');
28
73
  }
29
74
  if (this.#isStarted)
30
75
  return;
31
- this.#bindEvents();
32
- this.#resolve();
33
- this.#isStarted = true;
76
+ try {
77
+ this.#isDestroyed = false;
78
+ this.#registerNavigationRoot();
79
+ this.#bindEvents();
80
+ this.#isStarted = true;
81
+ this.#resolve();
82
+ }
83
+ catch (error) {
84
+ this.destroy();
85
+ throw error;
86
+ }
34
87
  }
35
88
  destroy() {
36
- if (!this.#window || !this.#document)
89
+ this.#isDestroyed = true;
90
+ this.#unregisterNavigationRoot();
91
+ if (!this.#window || !this.#eventTarget)
37
92
  return;
38
- this.#window.removeEventListener('popstate', this.#handlePopState);
39
- this.#document.removeEventListener('click', this.#handleClick);
40
- this.#currentComponent?.unmount?.();
93
+ this.#navigationVersion += 1;
94
+ this.#clearFocusWait();
95
+ this.#window.removeEventListener(this.#hashMode ? 'hashchange' : 'popstate', this.#handleLocationChange);
96
+ this.#eventTarget.removeEventListener('click', this.#handleClick);
97
+ this.#eventTarget.removeEventListener('pointerover', this.#handlePreloadIntent);
98
+ this.#eventTarget.removeEventListener('focusin', this.#handlePreloadIntent);
99
+ this.#eventTarget.removeEventListener('wavelength-navigate', this.#handleNavigation);
100
+ this.#eventTarget.removeEventListener('wavelength-focus-ready', this.#handleFocusReady);
101
+ const currentComponent = this.#currentComponent;
102
+ const currentRouteContext = this.#currentRouteContext;
103
+ this.#currentComponent = undefined;
104
+ this.#currentRouteContext = undefined;
105
+ this.#unmount(currentComponent, currentRouteContext);
41
106
  this.#isStarted = false;
42
107
  }
43
108
  navigate(path) {
44
- if (!this.#window)
109
+ if (this.#isDestroyed)
110
+ return;
111
+ const navigationPath = this.#navigationPath(path);
112
+ if (!navigationPath || !this.#window)
45
113
  return;
46
114
  if (this.#hashMode) {
47
- this.#window.location.hash = path;
48
- }
49
- else {
50
- this.#window.history.pushState(null, '', path);
115
+ this.#window.location.hash = navigationPath;
116
+ return;
51
117
  }
118
+ this.#window.history.pushState(null, '', navigationPath);
52
119
  this.#resolve();
53
120
  }
54
121
  replace(path) {
55
- if (!this.#window)
122
+ if (this.#isDestroyed)
123
+ return;
124
+ const navigationPath = this.#navigationPath(path);
125
+ if (!navigationPath || !this.#window)
56
126
  return;
57
127
  if (this.#hashMode) {
58
- this.#window.location.replace('#' + path);
59
- }
60
- else {
61
- this.#window.history.replaceState(null, '', path);
128
+ this.#window.location.replace('#' + navigationPath);
129
+ return;
62
130
  }
131
+ this.#window.history.replaceState(null, '', navigationPath);
63
132
  this.#resolve();
64
133
  }
65
134
  back() {
135
+ if (this.#isDestroyed)
136
+ return;
66
137
  this.#window?.history.back();
67
138
  }
139
+ preload(path) {
140
+ if (this.#isDestroyed)
141
+ return;
142
+ const navigationPath = this.#navigationPath(path);
143
+ if (!navigationPath)
144
+ return;
145
+ this.#preloadPath(navigationPath);
146
+ }
147
+ getPreloadStatus(path) {
148
+ const navigationPath = this.#navigationPath(path);
149
+ if (!navigationPath)
150
+ return 'idle';
151
+ return this.#preloadMachine.getState()[navigationPath] ?? 'idle';
152
+ }
68
153
  #bindEvents() {
69
- this.#window.addEventListener('popstate', this.#handlePopState);
70
- this.#document.addEventListener('click', this.#handleClick);
154
+ this.#window.addEventListener(this.#hashMode ? 'hashchange' : 'popstate', this.#handleLocationChange);
155
+ this.#eventTarget.addEventListener('click', this.#handleClick);
156
+ this.#eventTarget.addEventListener('pointerover', this.#handlePreloadIntent);
157
+ this.#eventTarget.addEventListener('focusin', this.#handlePreloadIntent);
158
+ this.#eventTarget.addEventListener('wavelength-navigate', this.#handleNavigation);
159
+ this.#eventTarget.addEventListener('wavelength-focus-ready', this.#handleFocusReady);
71
160
  }
72
- #handlePopState = () => {
161
+ #handleLocationChange = () => {
162
+ if (this.#hashMode && !this.#isRouteHash())
163
+ return;
73
164
  this.#resolve();
74
165
  };
75
166
  #handleClick = (event) => {
167
+ if (event.defaultPrevented)
168
+ return;
169
+ const mouseEvent = event;
76
170
  const target = event.target;
77
- const anchor = target?.closest?.('a');
171
+ const composedPath = typeof event.composedPath === 'function' ? event.composedPath() : [];
172
+ const anchor = composedPath.find((pathTarget) => this.#isAnchorElement(pathTarget)) ?? this.#closestAnchor(target);
78
173
  if (!anchor)
79
174
  return;
80
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
175
+ if (!this.#ownsNavigationEvent(event, composedPath))
176
+ return;
177
+ if (mouseEvent.button !== 0)
178
+ return;
179
+ if (mouseEvent.metaKey ||
180
+ mouseEvent.ctrlKey ||
181
+ mouseEvent.shiftKey ||
182
+ mouseEvent.altKey)
183
+ return;
184
+ if (anchor.hasAttribute('download'))
185
+ return;
186
+ if (anchor.target && anchor.target.toLowerCase() !== '_self')
187
+ return;
188
+ const href = anchor.getAttribute('href');
189
+ if (!href)
190
+ return;
191
+ const targetUrl = this.#resolveNavigationUrl(href);
192
+ if (!targetUrl)
81
193
  return;
82
- if (anchor.target === '_blank')
194
+ if (!this.#hashMode && this.#isSameDocumentFragment(targetUrl))
195
+ return;
196
+ event.preventDefault();
197
+ this.navigate(this.#hashMode
198
+ ? this.#hashNavigationPath(targetUrl)
199
+ : `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`);
200
+ };
201
+ #handlePreloadIntent = (event) => {
202
+ if (event.defaultPrevented || !this.#preloader)
203
+ return;
204
+ const target = event.target;
205
+ const composedPath = typeof event.composedPath === 'function' ? event.composedPath() : [];
206
+ const anchor = composedPath.find((pathTarget) => this.#isAnchorElement(pathTarget)) ?? this.#closestAnchor(target);
207
+ if (!anchor || !this.#ownsNavigationEvent(event, composedPath))
208
+ return;
209
+ if (anchor.hasAttribute('download'))
210
+ return;
211
+ if (anchor.target && anchor.target.toLowerCase() !== '_self')
83
212
  return;
84
213
  const href = anchor.getAttribute('href');
85
214
  if (!href)
86
215
  return;
87
- if (href.startsWith('http') ||
88
- href.startsWith('//') ||
89
- href.startsWith('mailto:') ||
90
- href.startsWith('tel:')) {
216
+ const targetUrl = this.#resolveNavigationUrl(href);
217
+ if (!targetUrl)
218
+ return;
219
+ if (!this.#hashMode && this.#isSameDocumentFragment(targetUrl))
220
+ return;
221
+ this.#preloadPath(this.#hashMode
222
+ ? this.#hashNavigationPath(targetUrl)
223
+ : `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`);
224
+ };
225
+ #resolveNavigationUrl(href) {
226
+ if (this.#hashMode && href.startsWith('#') && !href.startsWith('#/')) {
227
+ return null;
228
+ }
229
+ try {
230
+ const targetUrl = new URL(href, this.#window.location.href);
231
+ const currentUrl = new URL(this.#window.location.href);
232
+ if (targetUrl.origin !== currentUrl.origin ||
233
+ (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:')) {
234
+ return null;
235
+ }
236
+ return targetUrl;
237
+ }
238
+ catch {
239
+ return null;
240
+ }
241
+ }
242
+ #isSameDocumentFragment(targetUrl) {
243
+ if (!this.#window || !targetUrl.hash)
244
+ return false;
245
+ const currentUrl = new URL(this.#window.location.href);
246
+ return (targetUrl.pathname === currentUrl.pathname &&
247
+ targetUrl.search === currentUrl.search);
248
+ }
249
+ #hashNavigationPath(targetUrl) {
250
+ if (targetUrl.hash.startsWith('#/'))
251
+ return targetUrl.hash.slice(1);
252
+ return `${targetUrl.pathname}${targetUrl.search}`;
253
+ }
254
+ #navigationPath(path) {
255
+ if (!this.#window)
256
+ return null;
257
+ let targetUrl;
258
+ try {
259
+ targetUrl = new URL(path, this.#window.location.href);
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ const currentUrl = new URL(this.#window.location.href);
265
+ if (targetUrl.origin !== currentUrl.origin ||
266
+ (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:')) {
267
+ return null;
268
+ }
269
+ if (!this.#hashMode) {
270
+ return `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`;
271
+ }
272
+ if (targetUrl.hash.startsWith('#/'))
273
+ return targetUrl.hash.slice(1);
274
+ if (path.startsWith('#'))
275
+ return null;
276
+ return `${targetUrl.pathname}${targetUrl.search}`;
277
+ }
278
+ #preloadPath(navigationPath) {
279
+ if (!this.#preloader)
280
+ return;
281
+ const status = this.#preloadMachine.getState()[navigationPath] ?? 'idle';
282
+ if (status === 'preloading' || status === 'ready')
283
+ return;
284
+ const targetUrl = new URL(navigationPath, this.#window.location.href);
285
+ const path = targetUrl.pathname;
286
+ const match = matchRoute(this.#compiledRoutes, path);
287
+ if (!match)
288
+ return;
289
+ const context = {
290
+ path,
291
+ routeParams: match.routeParams,
292
+ query: new URLSearchParams(targetUrl.search),
293
+ };
294
+ this.#preloadMachine.dispatch({ type: 'start', path: navigationPath });
295
+ try {
296
+ const preloadResult = this.#preloader(context);
297
+ if (this.#isPromiseLike(preloadResult)) {
298
+ void preloadResult
299
+ .then(() => {
300
+ this.#preloadMachine.dispatch({
301
+ type: 'complete',
302
+ path: navigationPath,
303
+ });
304
+ })
305
+ .catch(() => {
306
+ this.#preloadMachine.dispatch({
307
+ type: 'fail',
308
+ path: navigationPath,
309
+ });
310
+ });
311
+ return;
312
+ }
313
+ this.#preloadMachine.dispatch({ type: 'complete', path: navigationPath });
314
+ }
315
+ catch {
316
+ this.#preloadMachine.dispatch({ type: 'fail', path: navigationPath });
317
+ }
318
+ }
319
+ #isRouteHash() {
320
+ const hash = this.#window?.location.hash;
321
+ return hash === '' || hash?.startsWith('#/') === true;
322
+ }
323
+ #handleNavigation = (event) => {
324
+ if (event.defaultPrevented)
325
+ return;
326
+ const navigationEvent = event;
327
+ const detail = navigationEvent.detail;
328
+ const composedPath = typeof event.composedPath === 'function' ? event.composedPath() : [];
329
+ if (!this.#ownsNavigationEvent(event, composedPath))
330
+ return;
331
+ if (typeof detail !== 'object' ||
332
+ detail === null ||
333
+ !('path' in detail) ||
334
+ typeof detail.path !== 'string') {
91
335
  return;
92
336
  }
93
337
  event.preventDefault();
94
- this.navigate(href);
338
+ if ('replace' in detail && detail.replace === true) {
339
+ this.replace(detail.path);
340
+ return;
341
+ }
342
+ this.navigate(detail.path);
95
343
  };
344
+ #handleFocusReady = (event) => {
345
+ if (this.#focusStrategy !== 'heading' || !this.#outlet)
346
+ return;
347
+ const composedPath = typeof event.composedPath === 'function' ? event.composedPath() : [];
348
+ if (!this.#ownsNavigationEvent(event, composedPath))
349
+ return;
350
+ const target = this.#focusReadyTarget(event);
351
+ if (!target || !this.#eventIsFromOutlet(target, composedPath))
352
+ return;
353
+ this.#clearFocusWait();
354
+ this.#focusElement(target);
355
+ };
356
+ #focusReadyTarget(event) {
357
+ const detail = event.detail;
358
+ if (typeof detail === 'object' &&
359
+ detail !== null &&
360
+ 'target' in detail &&
361
+ this.#isFocusableElement(detail.target)) {
362
+ return detail.target;
363
+ }
364
+ return this.#isFocusableElement(event.target) ? event.target : null;
365
+ }
366
+ #eventIsFromOutlet(target, composedPath) {
367
+ const targetRoot = target.getRootNode();
368
+ const rootHost = 'host' in targetRoot ? targetRoot.host : undefined;
369
+ if (!composedPath.includes(target) &&
370
+ (rootHost === undefined || !composedPath.includes(rootHost))) {
371
+ return false;
372
+ }
373
+ return (composedPath.includes(this.#outlet) || this.#outlet.contains(target));
374
+ }
375
+ #ownsNavigationEvent(event, composedPath) {
376
+ if (!this.#navigationRoot)
377
+ return true;
378
+ const closestRouter = composedPath.find((pathTarget) => this.#isElement(pathTarget) &&
379
+ pathTarget.localName === 'wavelength-router');
380
+ if (closestRouter)
381
+ return closestRouter === this.#navigationRoot;
382
+ const closestNavigationRoot = composedPath.find((pathTarget) => this.#isActiveNavigationRoot(pathTarget));
383
+ if (closestNavigationRoot) {
384
+ return closestNavigationRoot.isSameNode(this.#navigationRoot);
385
+ }
386
+ return (composedPath.includes(this.#navigationRoot) ||
387
+ event.currentTarget === this.#navigationRoot);
388
+ }
389
+ #registerNavigationRoot() {
390
+ if (!this.#navigationRoot || this.#isNavigationRootRegistered)
391
+ return;
392
+ const owners = Router.#navigationRootOwners.get(this.#navigationRoot) ?? 0;
393
+ Router.#navigationRootOwners.set(this.#navigationRoot, owners + 1);
394
+ this.#isNavigationRootRegistered = true;
395
+ }
396
+ #unregisterNavigationRoot() {
397
+ if (!this.#navigationRoot || !this.#isNavigationRootRegistered)
398
+ return;
399
+ const owners = Router.#navigationRootOwners.get(this.#navigationRoot);
400
+ if (owners <= 1) {
401
+ Router.#navigationRootOwners.delete(this.#navigationRoot);
402
+ }
403
+ else {
404
+ Router.#navigationRootOwners.set(this.#navigationRoot, owners - 1);
405
+ }
406
+ this.#isNavigationRootRegistered = false;
407
+ }
408
+ #isActiveNavigationRoot(target) {
409
+ return (this.#isElement(target) &&
410
+ Router.#navigationRootOwners.has(target));
411
+ }
96
412
  #currentPath() {
97
- if (!this.#window)
98
- return '/';
99
413
  if (this.#hashMode) {
100
414
  const hash = this.#window.location.hash.slice(1);
101
- return hash || '/';
415
+ const queryIndex = hash.indexOf('?');
416
+ const path = queryIndex >= 0 ? hash.slice(0, queryIndex) : hash;
417
+ return path || '/';
102
418
  }
103
419
  return this.#window.location.pathname || '/';
104
420
  }
105
421
  #currentQuery() {
106
- if (!this.#window)
107
- return new URLSearchParams();
108
422
  if (this.#hashMode) {
109
423
  const hash = this.#window.location.hash;
110
424
  const queryIndex = hash.indexOf('?');
@@ -115,20 +429,153 @@ export class Router {
115
429
  }
116
430
  return new URLSearchParams(this.#window.location.search);
117
431
  }
118
- #mount(routeComponent, routeContext) {
432
+ #mount(routeComponent, routeContext, navigationVersion) {
119
433
  if (!this.#outlet)
120
434
  return;
121
- this.#currentComponent?.unmount?.();
435
+ this.#clearFocusWait();
436
+ const currentComponent = this.#currentComponent;
437
+ const currentRouteContext = this.#currentRouteContext;
438
+ this.#currentComponent = undefined;
439
+ this.#currentRouteContext = undefined;
440
+ this.#unmount(currentComponent, currentRouteContext);
122
441
  this.#outlet.replaceChildren();
123
- routeComponent.mount(this.#outlet, routeContext);
442
+ const mountResult = routeComponent.mount(this.#outlet, routeContext);
443
+ if (this.#isPromiseLike(mountResult)) {
444
+ return mountResult.then(() => {
445
+ this.#completeMount(routeComponent, routeContext, navigationVersion);
446
+ });
447
+ }
448
+ this.#completeMount(routeComponent, routeContext, navigationVersion);
449
+ }
450
+ #completeMount(routeComponent, routeContext, navigationVersion) {
451
+ if (navigationVersion !== this.#navigationVersion) {
452
+ this.#unmount(routeComponent, routeContext);
453
+ return;
454
+ }
124
455
  this.#currentComponent = routeComponent;
456
+ this.#currentRouteContext = routeContext;
457
+ this.#focusAfterNavigation(routeContext);
458
+ }
459
+ #unmount(routeComponent, context) {
460
+ try {
461
+ routeComponent?.unmount?.();
462
+ }
463
+ catch (error) {
464
+ this.#reportRouteError(error, 'unmount', context);
465
+ }
466
+ }
467
+ #isPromiseLike(value) {
468
+ return (typeof value === 'object' &&
469
+ 'then' in value &&
470
+ typeof value.then === 'function');
471
+ }
472
+ #focusAfterNavigation(routeContext) {
473
+ if (this.#focusStrategy === 'none' || !this.#outlet)
474
+ return;
475
+ if (typeof this.#focusStrategy === 'function') {
476
+ this.#focusStrategy(this.#outlet, routeContext)?.focus({
477
+ preventScroll: true,
478
+ });
479
+ return;
480
+ }
481
+ if (this.#focusStrategy === 'outlet') {
482
+ this.#focusElement(this.#outlet);
483
+ return;
484
+ }
485
+ const heading = this.#findPrimaryHeading(this.#outlet);
486
+ if (heading) {
487
+ this.#focusElement(heading);
488
+ return;
489
+ }
490
+ this.#waitForPrimaryHeading();
491
+ }
492
+ #waitForPrimaryHeading() {
493
+ const MutationObserverConstructor = this.#window?.MutationObserver;
494
+ if (!MutationObserverConstructor)
495
+ return;
496
+ const observer = new MutationObserverConstructor(() => {
497
+ const heading = this.#outlet && this.#findPrimaryHeading(this.#outlet);
498
+ if (!heading)
499
+ return;
500
+ if (this.#focusObserver !== observer)
501
+ return;
502
+ this.#clearFocusWait();
503
+ this.#focusElement(heading);
504
+ });
505
+ this.#focusObserver = observer;
506
+ observer.observe(this.#outlet, { childList: true, subtree: true });
507
+ this.#focusTimeout = this.#window?.setTimeout?.(() => {
508
+ if (this.#focusObserver !== observer)
509
+ return;
510
+ observer.disconnect();
511
+ this.#focusObserver = undefined;
512
+ this.#focusTimeout = undefined;
513
+ }, Router.#focusWaitMs);
514
+ }
515
+ #clearFocusWait() {
516
+ this.#focusObserver?.disconnect();
517
+ this.#focusObserver = undefined;
518
+ if (this.#focusTimeout !== undefined) {
519
+ this.#window?.clearTimeout(this.#focusTimeout);
520
+ this.#focusTimeout = undefined;
521
+ }
522
+ }
523
+ #isElement(target) {
524
+ return (typeof target === 'object' &&
525
+ target !== null &&
526
+ 'nodeType' in target &&
527
+ target.nodeType === 1);
528
+ }
529
+ #isAnchorElement(target) {
530
+ return this.#isElement(target) && target.localName === 'a';
531
+ }
532
+ #closestAnchor(target) {
533
+ if (typeof target !== 'object' ||
534
+ target === null ||
535
+ !('closest' in target) ||
536
+ typeof target.closest !== 'function') {
537
+ return null;
538
+ }
539
+ return target.closest('a');
540
+ }
541
+ #isFocusableElement(target) {
542
+ return (this.#isElement(target) &&
543
+ 'focus' in target &&
544
+ typeof target.focus === 'function');
545
+ }
546
+ #findPrimaryHeading(root) {
547
+ const heading = Array.from(root.querySelectorAll('h1, [role="heading"][aria-level="1"]')).find((candidate) => this.#isOwnedHeading(candidate));
548
+ if (heading)
549
+ return heading;
550
+ for (const element of Array.from(root.querySelectorAll('*'))) {
551
+ if (element.localName === 'wavelength-router' || !element.shadowRoot) {
552
+ continue;
553
+ }
554
+ const shadowHeading = this.#findPrimaryHeading(element.shadowRoot);
555
+ if (shadowHeading)
556
+ return shadowHeading;
557
+ }
558
+ return null;
559
+ }
560
+ #isOwnedHeading(heading) {
561
+ const closestRouter = heading.closest('wavelength-router');
562
+ return (closestRouter === null ||
563
+ closestRouter.isSameNode(this.#navigationRoot ?? null));
564
+ }
565
+ #focusElement(element) {
566
+ if (!element.hasAttribute('tabindex'))
567
+ element.tabIndex = -1;
568
+ element.focus({ preventScroll: true });
125
569
  }
126
570
  #resolve() {
571
+ const navigationVersion = ++this.#navigationVersion;
127
572
  const pathname = this.#currentPath();
128
573
  const query = this.#currentQuery();
129
574
  const match = matchRoute(this.#compiledRoutes, pathname);
130
575
  if (!match) {
131
- console.warn(`[Router] No route matched: ${pathname}`);
576
+ const error = new Error(`[Router] No route matched: ${pathname}`);
577
+ console.warn(error.message);
578
+ this.#reportRouteError(error, 'match');
132
579
  return;
133
580
  }
134
581
  const context = {
@@ -136,23 +583,92 @@ export class Router {
136
583
  routeParams: match.routeParams,
137
584
  query,
138
585
  };
586
+ this.#dispatchLifecycleEvent('wavelength-route-start', {
587
+ path: pathname,
588
+ context,
589
+ });
139
590
  if (!this.#beforeEach) {
140
- this.#mount(match.routeDefinition.routeComponent, context);
591
+ this.#mountRoute(match.routeDefinition.routeComponent, context, navigationVersion);
141
592
  return;
142
593
  }
143
594
  let redirected = false;
144
595
  const next = (redirect) => {
145
- if (redirected)
596
+ if (redirected || navigationVersion !== this.#navigationVersion)
146
597
  return;
147
598
  redirected = true;
148
599
  if (redirect && redirect !== context.path) {
149
600
  this.navigate(redirect);
150
601
  }
151
602
  else {
152
- this.#mount(match.routeDefinition.routeComponent, context);
603
+ this.#mountRoute(match.routeDefinition.routeComponent, context, navigationVersion);
153
604
  }
154
605
  };
155
- this.#beforeEach(context, next);
606
+ try {
607
+ this.#beforeEach(context, next);
608
+ }
609
+ catch (error) {
610
+ if (!this.#reportRouteError(error, 'guard', context))
611
+ throw error;
612
+ }
613
+ }
614
+ #mountRoute(routeComponent, context, navigationVersion) {
615
+ try {
616
+ const mountResult = this.#mount(routeComponent, context, navigationVersion);
617
+ if (this.#isPromiseLike(mountResult)) {
618
+ void mountResult
619
+ .then(() => {
620
+ if (navigationVersion !== this.#navigationVersion)
621
+ return;
622
+ this.#dispatchLifecycleEvent('wavelength-route-complete', {
623
+ path: context.path,
624
+ context,
625
+ });
626
+ })
627
+ .catch((error) => {
628
+ if (navigationVersion !== this.#navigationVersion)
629
+ return;
630
+ this.#reportRouteError(error, 'mount', context);
631
+ });
632
+ return;
633
+ }
634
+ if (navigationVersion === this.#navigationVersion) {
635
+ this.#dispatchLifecycleEvent('wavelength-route-complete', {
636
+ path: context.path,
637
+ context,
638
+ });
639
+ }
640
+ }
641
+ catch (error) {
642
+ if (navigationVersion !== this.#navigationVersion)
643
+ return;
644
+ if (!this.#reportRouteError(error, 'mount', context))
645
+ throw error;
646
+ }
647
+ }
648
+ #reportRouteError(error, phase, context) {
649
+ const contextDetail = context
650
+ ? { context }
651
+ : {};
652
+ this.#dispatchLifecycleEvent('wavelength-route-error', {
653
+ path: context?.path ?? this.#currentPath(),
654
+ error,
655
+ phase,
656
+ ...contextDetail,
657
+ });
658
+ if (!this.#onError)
659
+ return false;
660
+ this.#onError({ error, phase, ...contextDetail });
661
+ return true;
662
+ }
663
+ #dispatchLifecycleEvent(type, detail) {
664
+ const CustomEventConstructor = this.#window?.CustomEvent;
665
+ if (!CustomEventConstructor || !this.#eventTarget?.dispatchEvent)
666
+ return;
667
+ this.#eventTarget.dispatchEvent(new CustomEventConstructor(type, {
668
+ bubbles: true,
669
+ composed: true,
670
+ detail,
671
+ }));
156
672
  }
157
673
  }
158
674
  //# sourceMappingURL=Router.js.map