@ecopages/react-router 0.2.0-alpha.4 → 0.2.0-alpha.7

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/src/router.ts CHANGED
@@ -20,15 +20,28 @@ import {
20
20
  type ReactNode,
21
21
  type ComponentType,
22
22
  type FC,
23
+ type RefObject,
23
24
  } from 'react';
24
25
  import { type EcoRouterOptions, DEFAULT_OPTIONS } from './types.ts';
25
26
  import { RouterContext } from './context.ts';
26
- import { type PageState, getInterceptDecision, loadPageModule, shouldInterceptClick } from './navigation.ts';
27
+ import {
28
+ type PageState,
29
+ fetchPageDocument,
30
+ getInterceptDecision,
31
+ loadPageModuleFromDocument,
32
+ shouldInterceptClick,
33
+ } from './navigation.ts';
27
34
  import { morphHead } from './head-morpher.ts';
28
35
  import { applyViewTransitionNames } from './view-transition-utils.ts';
29
36
  import { manageScroll } from './manage-scroll.ts';
30
37
  import { saveScrollPositions, restoreScrollPositions } from './scroll-persist.ts';
31
38
  import type { EcoInjectedMeta } from '@ecopages/core';
39
+ import { getEcoNavigationRuntime, type EcoNavigationTransaction } from '@ecopages/core/router/navigation-coordinator';
40
+ import {
41
+ getAnchorFromNavigationEvent,
42
+ recoverPendingNavigationHref,
43
+ type EcoPendingNavigationIntent,
44
+ } from '@ecopages/core/router/link-intent';
32
45
 
33
46
  type PageContextValue = PageState | null;
34
47
 
@@ -36,8 +49,21 @@ const PageContext = createContext<PageContextValue>(null);
36
49
 
37
50
  const PersistLayoutsContext = createContext<boolean>(false);
38
51
 
39
- function getLayoutFromPage(Page: ComponentType<unknown>): ComponentType | undefined {
40
- const config = (Page as ComponentType & { config?: { layout?: ComponentType } }).config;
52
+ type LayoutComponent = ComponentType<Record<string, unknown>>;
53
+
54
+ /**
55
+ * Reads the optional layout assigned to a page component.
56
+ *
57
+ * The router recreates the server-rendered tree on the client, so it needs to
58
+ * recover the layout reference from page config before rendering `PageContent`.
59
+ * The returned type is widened to a generic record-based component because
60
+ * layouts may receive serialized `locals` during hydration.
61
+ *
62
+ * @param Page - Hydrated page component.
63
+ * @returns Configured layout component when present.
64
+ */
65
+ function getLayoutFromPage(Page: ComponentType<unknown>): LayoutComponent | undefined {
66
+ const config = (Page as ComponentType & { config?: { layout?: LayoutComponent } }).config;
41
67
  return config?.layout;
42
68
  }
43
69
 
@@ -62,17 +88,24 @@ export interface EcoRouterProps {
62
88
  *
63
89
  * Stored on window to persist across module reloads during HMR/SPA navigation.
64
90
  */
65
- function getLayoutCache(): Map<string, ComponentType> {
91
+ function getLayoutCache(): Map<string, LayoutComponent> {
66
92
  if (typeof window === 'undefined') {
67
93
  return new Map();
68
94
  }
69
- const win = window as typeof window & { __ecoLayoutCache?: Map<string, ComponentType> };
95
+ const win = window as typeof window & { __ecoLayoutCache?: Map<string, LayoutComponent> };
70
96
  if (!win.__ecoLayoutCache) {
71
97
  win.__ecoLayoutCache = new Map();
72
98
  }
73
99
  return win.__ecoLayoutCache;
74
100
  }
75
101
 
102
+ /**
103
+ * Normalizes a layout cache key so logically identical layouts reuse the same
104
+ * persistent instance across SPA navigations and HMR cycles.
105
+ *
106
+ * @param value - Raw layout identifier, display name, or injected module id.
107
+ * @returns Stable cache key.
108
+ */
76
109
  function normalizeLayoutKey(value: string): string {
77
110
  const trimmed = value.trim();
78
111
  if (!trimmed) return 'layout';
@@ -96,7 +129,9 @@ export function clearLayoutCache(): void {
96
129
  * Renders the current page with its layout.
97
130
  *
98
131
  * Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
99
- * shared layouts remain mounted across navigations.
132
+ * shared layouts remain mounted across navigations. When the server serialized
133
+ * request `locals` for hydration, the same `locals` object is passed to the
134
+ * layout on the client so the hydrated tree matches SSR.
100
135
  *
101
136
  * @example
102
137
  * ```tsx
@@ -119,6 +154,7 @@ export const PageContent: FC = () => {
119
154
  const { Component: Page, props } = pageContext;
120
155
  const Layout = getLayoutFromPage(Page);
121
156
  const pageElement = createElement(Page, props);
157
+ const layoutProps = props?.locals ? { locals: props.locals } : null;
122
158
 
123
159
  if (!Layout) {
124
160
  return pageElement;
@@ -126,7 +162,7 @@ export const PageContent: FC = () => {
126
162
 
127
163
  if (persistLayouts) {
128
164
  const layoutCache = getLayoutCache();
129
- const layoutConfig = (Layout as ComponentType & { config?: { __eco?: EcoInjectedMeta } }).config;
165
+ const layoutConfig = (Layout as LayoutComponent & { config?: { __eco?: EcoInjectedMeta } }).config;
130
166
  const layoutKeyRaw = layoutConfig?.__eco?.id || Layout.displayName || Layout.name || 'layout';
131
167
  const layoutKey = normalizeLayoutKey(layoutKeyRaw);
132
168
 
@@ -135,10 +171,10 @@ export const PageContent: FC = () => {
135
171
  }
136
172
  const CachedLayout = layoutCache.get(layoutKey)!;
137
173
 
138
- return createElement(CachedLayout, { key: layoutKey }, pageElement);
174
+ return createElement(CachedLayout, { key: layoutKey, ...(layoutProps ?? {}) }, pageElement);
139
175
  }
140
176
 
141
- return createElement(Layout, null, pageElement);
177
+ return createElement(Layout, layoutProps, pageElement);
142
178
  };
143
179
 
144
180
  function createDeferred<T>() {
@@ -149,27 +185,65 @@ function createDeferred<T>() {
149
185
  return { promise, resolve };
150
186
  }
151
187
 
152
- function useHmrReload(navigate: (url: string) => Promise<void>) {
188
+ type PendingRender = {
189
+ navigationId: number;
190
+ page: PageState;
191
+ resolve: () => void;
192
+ };
193
+
194
+ function useNavigationCoordinator(
195
+ navigate: (
196
+ url: string,
197
+ options?: { isPopState?: boolean; pushHistory?: boolean; skipViewTransition?: boolean },
198
+ ) => Promise<void>,
199
+ activeNavigationRef: RefObject<EcoNavigationTransaction | null>,
200
+ isNavigatingRef: RefObject<boolean>,
201
+ runtimeActiveRef: RefObject<boolean>,
202
+ ) {
153
203
  useEffect(() => {
154
204
  if (typeof window === 'undefined') return;
155
- if (import.meta.env?.MODE === 'production' || import.meta.env?.PROD) return;
156
205
 
157
- const windowWithHmr = window as typeof window & {
158
- __ecopages_reload_current_page__?: (options?: { clearCache?: boolean }) => Promise<void>;
159
- };
206
+ const navigationRuntime = getEcoNavigationRuntime(window);
207
+ let unregisterRuntime: (() => void) | null = null;
208
+ const unregister = navigationRuntime.register({
209
+ owner: 'react-router',
210
+ navigate: async (request) => {
211
+ await navigate(request.href, {
212
+ isPopState: request.direction === 'back',
213
+ pushHistory: request.direction === 'forward',
214
+ skipViewTransition: request.source === 'browser-router',
215
+ });
216
+ return true;
217
+ },
218
+ reloadCurrentPage: async (request) => {
219
+ if (activeNavigationRef.current || isNavigatingRef.current) {
220
+ return;
221
+ }
160
222
 
161
- windowWithHmr.__ecopages_reload_current_page__ = async (options?: { clearCache?: boolean }) => {
162
- if (options?.clearCache) {
163
- clearLayoutCache();
164
- }
165
- const currentUrl = window.location.pathname + window.location.search;
166
- await navigate(currentUrl);
167
- };
223
+ if (request?.clearCache) {
224
+ clearLayoutCache();
225
+ }
168
226
 
227
+ const currentUrl = window.location.pathname + window.location.search;
228
+ await navigate(currentUrl);
229
+ },
230
+ cleanupBeforeHandoff: async () => {
231
+ runtimeActiveRef.current = false;
232
+ unregisterRuntime?.();
233
+ unregisterRuntime = null;
234
+ window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
235
+ },
236
+ });
237
+ unregisterRuntime = unregister;
238
+ navigationRuntime.claimOwnership('react-router');
239
+ runtimeActiveRef.current = true;
169
240
  return () => {
170
- windowWithHmr.__ecopages_reload_current_page__ = undefined;
241
+ runtimeActiveRef.current = false;
242
+ navigationRuntime.releaseOwnership('react-router');
243
+ unregisterRuntime?.();
244
+ unregisterRuntime = null;
171
245
  };
172
- }, [navigate]);
246
+ }, [activeNavigationRef, isNavigatingRef, navigate, runtimeActiveRef]);
173
247
  }
174
248
 
175
249
  /**
@@ -207,11 +281,20 @@ export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOp
207
281
  const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
208
282
  const [currentPage, setCurrentPage] = useState<PageState>({ Component: page, props: pageProps });
209
283
  const [isNavigating, setIsNavigating] = useState(false);
210
- const [pendingPage, setPendingPage] = useState<PageState | null>(null);
211
- const renderDfd = useRef<{ promise: Promise<void>; resolve: () => void } | null>(null);
284
+ const pendingRenderRef = useRef<PendingRender | null>(null);
285
+ const activeNavigationRef = useRef<EcoNavigationTransaction | null>(null);
286
+ const isNavigatingRef = useRef(false);
287
+ const runtimeActiveRef = useRef(true);
288
+ const pendingPointerNavigationRef = useRef<EcoPendingNavigationIntent | null>(null);
289
+ const pendingHoverNavigationRef = useRef<EcoPendingNavigationIntent | null>(null);
290
+ const queuedNavigationHrefRef = useRef<string | null>(null);
212
291
  const pendingScrollRestoreRef = useRef<{ url: string; isPopState: boolean } | null>(null);
213
292
  const previousUrlRef = useRef<string>(typeof window !== 'undefined' ? window.location.href : '');
214
293
 
294
+ useEffect(() => {
295
+ isNavigatingRef.current = isNavigating;
296
+ }, [isNavigating]);
297
+
215
298
  useEffect(() => {
216
299
  setCurrentPage({ Component: page, props: pageProps });
217
300
  }, [page, pageProps]);
@@ -221,12 +304,25 @@ export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOp
221
304
  }, [currentPage]);
222
305
 
223
306
  useEffect(() => {
224
- if (pendingPage && currentPage.Component === pendingPage.Component && renderDfd.current) {
225
- renderDfd.current.resolve();
226
- renderDfd.current = null;
227
- setPendingPage(null);
307
+ const pendingRender = pendingRenderRef.current;
308
+ if (
309
+ pendingRender &&
310
+ currentPage.Component === pendingRender.page.Component &&
311
+ currentPage.props === pendingRender.page.props
312
+ ) {
313
+ pendingRender.resolve();
314
+ pendingRenderRef.current = null;
228
315
  }
229
- }, [currentPage, pendingPage]);
316
+ }, [currentPage]);
317
+
318
+ useEffect(() => {
319
+ return () => {
320
+ activeNavigationRef.current?.cancel();
321
+ pendingRenderRef.current?.resolve();
322
+ pendingRenderRef.current = null;
323
+ queuedNavigationHrefRef.current = null;
324
+ };
325
+ }, []);
230
326
 
231
327
  useEffect(() => {
232
328
  if (typeof window === 'undefined') return;
@@ -250,55 +346,263 @@ export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOp
250
346
  }, [currentPage, options.scrollBehavior, options.smoothScroll]);
251
347
 
252
348
  const navigate = useCallback(
253
- async (url: string, isPopState = false) => {
254
- setIsNavigating(true);
255
- const result = await loadPageModule(url);
256
-
257
- if (result) {
258
- const { Component, props, doc, finalPath } = result;
259
- const nextPage = { Component, props };
260
- const cleanupHead = await morphHead(doc);
261
- applyViewTransitionNames();
262
-
263
- if (finalPath !== url) {
264
- window.history.replaceState(null, '', finalPath);
349
+ async (
350
+ url: string,
351
+ navigationOptions: { isPopState?: boolean; pushHistory?: boolean; skipViewTransition?: boolean } = {},
352
+ ) => {
353
+ const { isPopState = false, pushHistory = false, skipViewTransition = false } = navigationOptions;
354
+ const navigationRuntime = getEcoNavigationRuntime(window);
355
+ const navigation = navigationRuntime.beginNavigationTransaction();
356
+ activeNavigationRef.current = navigation;
357
+ const navigationId = navigation.id;
358
+ const isStale = () => !navigation.isCurrent();
359
+ const commitPageData = (moduleUrl: string, props: Record<string, unknown>) => {
360
+ window.__ECO_PAGES__ = window.__ECO_PAGES__ || {};
361
+ window.__ECO_PAGES__.page = {
362
+ module: moduleUrl,
363
+ props,
364
+ };
365
+ };
366
+ let navigationCommitPromise: Promise<void> | null = null;
367
+
368
+ try {
369
+ setIsNavigating(true);
370
+ const fetchedPage = await fetchPageDocument(url, { signal: navigation.signal });
371
+
372
+ if (isStale()) return;
373
+
374
+ if (!fetchedPage) {
375
+ window.location.href = url;
376
+ return;
265
377
  }
266
378
 
267
- saveScrollPositions();
268
- pendingScrollRestoreRef.current = { url, isPopState };
379
+ const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
269
380
 
270
- if (options.viewTransitions && document.startViewTransition) {
271
- renderDfd.current = createDeferred<void>();
272
- setPendingPage(nextPage);
381
+ if (isStale()) return;
382
+
383
+ if (result) {
384
+ const { Component, props, doc, finalPath, moduleUrl } = result;
385
+ const nextPage = { Component, props };
386
+ const cleanupHead = await morphHead(doc);
387
+
388
+ if (isStale()) {
389
+ cleanupHead();
390
+ return;
391
+ }
392
+
393
+ applyViewTransitionNames();
394
+
395
+ if (pushHistory) {
396
+ window.history.pushState(null, '', finalPath);
397
+ } else if (finalPath !== url) {
398
+ window.history.replaceState(null, '', finalPath);
399
+ }
273
400
 
274
- document.startViewTransition(async () => {
275
- startTransition(() => {
276
- setCurrentPage(nextPage);
401
+ saveScrollPositions();
402
+ pendingScrollRestoreRef.current = { url, isPopState };
403
+
404
+ if (!skipViewTransition && options.viewTransitions && document.startViewTransition) {
405
+ pendingRenderRef.current?.resolve();
406
+ const renderDfd = createDeferred<void>();
407
+ pendingRenderRef.current = {
408
+ navigationId,
409
+ page: nextPage,
410
+ resolve: renderDfd.resolve,
411
+ };
412
+
413
+ navigationCommitPromise = new Promise<void>((resolve) => {
414
+ document.startViewTransition(async () => {
415
+ try {
416
+ if (isStale()) {
417
+ if (pendingRenderRef.current?.navigationId === navigationId) {
418
+ pendingRenderRef.current.resolve();
419
+ pendingRenderRef.current = null;
420
+ }
421
+ cleanupHead();
422
+ return;
423
+ }
424
+ startTransition(() => {
425
+ commitPageData(moduleUrl, props);
426
+ setCurrentPage(nextPage);
427
+ });
428
+ await renderDfd.promise;
429
+ if (isStale()) {
430
+ return;
431
+ }
432
+ cleanupHead();
433
+ applyViewTransitionNames();
434
+ } finally {
435
+ resolve();
436
+ }
437
+ });
277
438
  });
278
- await renderDfd.current?.promise;
439
+ await navigationCommitPromise;
440
+ } else {
441
+ commitPageData(moduleUrl, props);
442
+ setCurrentPage(nextPage);
279
443
  cleanupHead();
280
444
  applyViewTransitionNames();
281
- });
445
+ }
282
446
  } else {
283
- setCurrentPage(nextPage);
284
- cleanupHead();
285
- applyViewTransitionNames();
447
+ if (isStale()) return;
448
+
449
+ const handled = await navigationRuntime.requestHandoff({
450
+ href: url,
451
+ finalHref: fetchedPage.finalPath,
452
+ direction: isPopState ? 'back' : pushHistory ? 'forward' : 'replace',
453
+ source: 'react-router',
454
+ targetOwner: 'browser-router',
455
+ document: fetchedPage.doc,
456
+ html: fetchedPage.html,
457
+ isStaleSourceNavigation: isStale,
458
+ });
459
+
460
+ if (!handled) {
461
+ window.location.assign(fetchedPage.finalPath);
462
+ }
286
463
  }
287
- } else {
288
- if (options.debug) {
289
- console.error('[EcoRouter] Falling back to full page navigation:', url);
464
+ if (!isStale()) {
465
+ setIsNavigating(false);
466
+ }
467
+ } finally {
468
+ const shouldReplayQueuedNavigation = activeNavigationRef.current?.id === navigationId;
469
+ const queuedNavigationHref = shouldReplayQueuedNavigation ? queuedNavigationHrefRef.current : null;
470
+ navigation.complete();
471
+ if (activeNavigationRef.current?.id === navigationId) {
472
+ activeNavigationRef.current = null;
473
+ }
474
+
475
+ if (
476
+ queuedNavigationHref &&
477
+ queuedNavigationHref !== window.location.pathname + window.location.search
478
+ ) {
479
+ queuedNavigationHrefRef.current = null;
480
+
481
+ if (runtimeActiveRef.current) {
482
+ void navigate(queuedNavigationHref, { pushHistory: true });
483
+ } else {
484
+ // React may finish after control has already moved to another runtime.
485
+ // In that case replay the queued click through the shared coordinator
486
+ // so the newest navigation still lands on its intended owner.
487
+ void navigationRuntime
488
+ .requestNavigation({
489
+ href: queuedNavigationHref,
490
+ direction: 'forward',
491
+ source: 'react-router',
492
+ })
493
+ .then((handled) => {
494
+ if (!handled) {
495
+ window.location.assign(queuedNavigationHref);
496
+ }
497
+ });
498
+ }
290
499
  }
291
- window.location.href = url;
292
500
  }
293
- setIsNavigating(false);
294
501
  },
295
- [options.viewTransitions, options.debug],
502
+ [options.viewTransitions],
296
503
  );
297
504
 
298
505
  useEffect(() => {
506
+ const getLinkFromEvent = (event: MouseEvent | PointerEvent) =>
507
+ getAnchorFromNavigationEvent(event, options.linkSelector);
508
+
509
+ const getRecoveredPointerHref = () => {
510
+ const href = recoverPendingNavigationHref(
511
+ pendingPointerNavigationRef.current,
512
+ !!activeNavigationRef.current || isNavigatingRef.current,
513
+ performance.now(),
514
+ );
515
+
516
+ if (!href) {
517
+ pendingPointerNavigationRef.current = null;
518
+ }
519
+
520
+ return href;
521
+ };
522
+
523
+ const getRecoveredHoverHref = () => {
524
+ const href = recoverPendingNavigationHref(
525
+ pendingHoverNavigationRef.current,
526
+ !!activeNavigationRef.current || isNavigatingRef.current,
527
+ performance.now(),
528
+ );
529
+
530
+ if (!href) {
531
+ pendingHoverNavigationRef.current = null;
532
+ }
533
+
534
+ return href;
535
+ };
536
+
537
+ const handleHoverIntent = (event: MouseEvent | PointerEvent) => {
538
+ if (!runtimeActiveRef.current) {
539
+ return;
540
+ }
541
+
542
+ const link = getLinkFromEvent(event);
543
+ if (!link) {
544
+ return;
545
+ }
546
+
547
+ const decision = getInterceptDecision(event, link, options);
548
+ if (!decision.shouldIntercept) {
549
+ return;
550
+ }
551
+
552
+ pendingHoverNavigationRef.current = {
553
+ href: link.getAttribute('href')!,
554
+ timestamp: performance.now(),
555
+ };
556
+ queuedNavigationHrefRef.current = link.getAttribute('href')!;
557
+ };
558
+
559
+ const handlePointerDown = (event: PointerEvent) => {
560
+ if (!runtimeActiveRef.current) {
561
+ pendingPointerNavigationRef.current = null;
562
+ return;
563
+ }
564
+
565
+ const link = getLinkFromEvent(event);
566
+ if (!link) {
567
+ pendingPointerNavigationRef.current = null;
568
+ return;
569
+ }
570
+
571
+ const decision = getInterceptDecision(event as unknown as MouseEvent, link, options);
572
+ pendingPointerNavigationRef.current = decision.shouldIntercept
573
+ ? {
574
+ href: link.getAttribute('href')!,
575
+ timestamp: performance.now(),
576
+ }
577
+ : null;
578
+
579
+ if (decision.shouldIntercept && (activeNavigationRef.current || isNavigatingRef.current)) {
580
+ queuedNavigationHrefRef.current = link.getAttribute('href')!;
581
+ }
582
+ };
583
+
299
584
  const handleClick = (event: MouseEvent) => {
300
- const link = (event.target as Element).closest(options.linkSelector) as HTMLAnchorElement | null;
301
- if (!link) return;
585
+ if (!runtimeActiveRef.current) {
586
+ pendingPointerNavigationRef.current = null;
587
+ pendingHoverNavigationRef.current = null;
588
+ return;
589
+ }
590
+
591
+ const link = getLinkFromEvent(event);
592
+ if (!link) {
593
+ const recoveredHref = getRecoveredPointerHref() ?? getRecoveredHoverHref();
594
+ pendingPointerNavigationRef.current = null;
595
+ pendingHoverNavigationRef.current = null;
596
+ if (!recoveredHref) {
597
+ return;
598
+ }
599
+
600
+ event.preventDefault();
601
+ queuedNavigationHrefRef.current = null;
602
+ const recoveredUrl = new URL(recoveredHref, window.location.origin);
603
+ navigate(recoveredUrl.pathname + recoveredUrl.search, { pushHistory: true });
604
+ return;
605
+ }
302
606
  if (!shouldInterceptClick(event, link, options)) {
303
607
  if (options.debug) {
304
608
  const decision = getInterceptDecision(event, link, options);
@@ -306,10 +610,15 @@ export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOp
306
610
  console.debug('[EcoRouter] Not intercepting link click:', decision.reason, link.href);
307
611
  }
308
612
  }
613
+ pendingPointerNavigationRef.current = null;
614
+ pendingHoverNavigationRef.current = null;
309
615
  return;
310
616
  }
311
617
 
618
+ pendingPointerNavigationRef.current = null;
619
+ pendingHoverNavigationRef.current = null;
312
620
  event.preventDefault();
621
+ queuedNavigationHrefRef.current = null;
313
622
  const href = link.getAttribute('href')!;
314
623
  const url = new URL(href, window.location.origin);
315
624
 
@@ -317,24 +626,37 @@ export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOp
317
626
  console.debug('[EcoRouter] Intercepting navigation:', url.pathname + url.search);
318
627
  }
319
628
 
320
- window.history.pushState(null, '', url.href);
321
- navigate(url.pathname + url.search);
629
+ navigate(url.pathname + url.search, { pushHistory: true });
322
630
  };
323
631
 
324
632
  const handlePopState = () => {
325
- navigate(window.location.pathname + window.location.search, true);
633
+ if (!runtimeActiveRef.current) {
634
+ return;
635
+ }
636
+
637
+ navigate(window.location.pathname + window.location.search, { isPopState: true });
326
638
  };
327
639
 
328
- document.addEventListener('click', handleClick);
640
+ document.addEventListener('mouseover', handleHoverIntent, true);
641
+ document.addEventListener('pointerover', handleHoverIntent, true);
642
+ document.addEventListener('mousemove', handleHoverIntent, true);
643
+ document.addEventListener('pointermove', handleHoverIntent, true);
644
+ document.addEventListener('pointerdown', handlePointerDown, true);
645
+ document.addEventListener('click', handleClick, true);
329
646
  window.addEventListener('popstate', handlePopState);
330
647
 
331
648
  return () => {
332
- document.removeEventListener('click', handleClick);
649
+ document.removeEventListener('mouseover', handleHoverIntent, true);
650
+ document.removeEventListener('pointerover', handleHoverIntent, true);
651
+ document.removeEventListener('mousemove', handleHoverIntent, true);
652
+ document.removeEventListener('pointermove', handleHoverIntent, true);
653
+ document.removeEventListener('pointerdown', handlePointerDown, true);
654
+ document.removeEventListener('click', handleClick, true);
333
655
  window.removeEventListener('popstate', handlePopState);
334
656
  };
335
- }, [navigate, options]);
657
+ }, [navigate, options, runtimeActiveRef]);
336
658
 
337
- useHmrReload(navigate);
659
+ useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef);
338
660
 
339
661
  return createElement(
340
662
  RouterContext.Provider,