@ecopages/react-router 0.2.0-alpha.26 → 0.2.0-alpha.28

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/CHANGELOG.md CHANGED
@@ -8,6 +8,7 @@ All notable changes to `@ecopages/react-router` are documented here.
8
8
 
9
9
  ### Bug Fixes
10
10
 
11
+ - Fixed same-page hash links and Shadow DOM TOC clicks to bypass React Router interception so anchor navigation preserves the URL fragment without a document fetch.
11
12
  - Extended page-module extraction to honor explicit hydration markers and self-owned React page entry bundles during navigation.
12
13
  - Fixed current-page reloads to accept HMR module overrides so persisted-layout refreshes import the rebuilt active page entry.
13
14
  - Fixed React-to-browser-router handoffs, queued-click replay, and stale-navigation races during mixed-router navigations.
@@ -16,4 +17,5 @@ All notable changes to `@ecopages/react-router` are documented here.
16
17
  ### Refactoring
17
18
 
18
19
  - Routed browser handoff and current-page reloads through the shared navigation coordinator.
20
+ - Removed the React router adapter `importMapKey` field so the adapter now exposes only the browser bundle import path used by both development and production hydration.
19
21
  - Updated package metadata for the current core, esbuild adapter, and React peer dependency surface.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/react-router",
3
- "version": "0.2.0-alpha.26",
3
+ "version": "0.2.0-alpha.28",
4
4
  "description": "Client-side SPA router for EcoPages React applications",
5
5
  "keywords": [
6
6
  "ecopages",
@@ -32,8 +32,8 @@
32
32
  "directory": "packages/react-router"
33
33
  },
34
34
  "peerDependencies": {
35
- "@ecopages/core": "0.2.0-alpha.26",
36
- "@ecopages/react": "0.2.0-alpha.26"
35
+ "@ecopages/core": "0.2.0-alpha.28",
36
+ "@ecopages/react": "0.2.0-alpha.28"
37
37
  },
38
38
  "dependencies": {
39
39
  "react": "^19",
package/src/adapter.js CHANGED
@@ -2,11 +2,10 @@ function ecoRouter(options) {
2
2
  return {
3
3
  name: "eco-router",
4
4
  bundle: {
5
- importPath: "@ecopages/react-router/browser.ts",
5
+ importPath: "@ecopages/react-router/browser",
6
6
  outputName: "react-router-esm",
7
7
  externals: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"]
8
8
  },
9
- importMapKey: "@ecopages/react-router",
10
9
  components: {
11
10
  router: "EcoRouter",
12
11
  pageContent: "PageContent"
@@ -1,5 +1,5 @@
1
1
  import { isReactRouterPageBootstrapAssetSrc } from "./hydration-assets.js";
2
- const PRESERVE_SELECTORS = ['script[type="importmap"]', "meta[charset]", "[data-eco-persist]"];
2
+ const PRESERVE_SELECTORS = ["meta[charset]", "[data-eco-persist]"];
3
3
  const RERUN_SRC_ATTR = "data-eco-rerun-src";
4
4
  let rerunNonce = 0;
5
5
  function isNonExecutableHeadScript(el) {
@@ -37,9 +37,13 @@ function shouldPersistExecutableInlineHeadScript(el) {
37
37
  function isRerunScript(el) {
38
38
  return el.tagName === "SCRIPT" && el.hasAttribute("data-eco-rerun");
39
39
  }
40
+ function isReactRouterPageBootstrapScriptId(scriptId) {
41
+ return !!scriptId && scriptId.startsWith("ecopages-react-") && !scriptId.startsWith("ecopages-react-island-");
42
+ }
40
43
  function isHydrationScript(el) {
41
44
  const src = el.getAttribute("src");
42
- return !!src && isReactRouterPageBootstrapAssetSrc(src);
45
+ const scriptId = el.getAttribute("data-eco-script-id");
46
+ return isReactRouterPageBootstrapScriptId(scriptId) || !!src && isReactRouterPageBootstrapAssetSrc(src);
43
47
  }
44
48
  function getHeadElementKey(el) {
45
49
  const tag = el.tagName.toLowerCase();
@@ -59,7 +63,6 @@ function getHeadElementKey(el) {
59
63
  return href ? `link:${href}` : null;
60
64
  }
61
65
  case "script": {
62
- if (el.getAttribute("type") === "importmap") return "importmap";
63
66
  const scriptId = el.getAttribute("data-eco-script-id") || el.getAttribute("id");
64
67
  if (scriptId) return `script-id:${scriptId}`;
65
68
  const src = el.getAttribute(RERUN_SRC_ATTR) || el.src;
@@ -37,8 +37,9 @@ export type InterceptDecision = {
37
37
  shouldIntercept: true;
38
38
  } | {
39
39
  shouldIntercept: false;
40
- reason: 'modified-click' | 'non-left-click' | 'external-target' | 'explicit-reload' | 'download' | 'invalid-href' | 'cross-origin';
40
+ reason: 'modified-click' | 'non-left-click' | 'external-target' | 'explicit-reload' | 'download' | 'invalid-href' | 'cross-origin' | 'same-page-hash';
41
41
  };
42
+ export declare function isSamePageHashNavigationHref(href: string): boolean;
42
43
  /**
43
44
  * Determines whether a link click should be intercepted for client-side navigation.
44
45
  *
package/src/navigation.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import { getEcoDocumentOwner } from "@ecopages/core/router/navigation-coordinator";
2
2
  import { isReactPageHydrationAssetSrc } from "./hydration-assets.js";
3
3
  const ROUTER_PROPS_SCRIPT_ID = "__ECO_PAGE_DATA__";
4
+ function isSamePageHashNavigationHref(href) {
5
+ if (!href) {
6
+ return false;
7
+ }
8
+ const currentUrl = new URL(window.location.href);
9
+ const targetUrl = new URL(href, currentUrl);
10
+ return targetUrl.origin === currentUrl.origin && targetUrl.hash.length > 0 && targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search;
11
+ }
4
12
  function getInterceptDecision(event, link, options) {
5
13
  if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
6
14
  return { shouldIntercept: false, reason: "modified-click" };
@@ -16,6 +24,9 @@ function getInterceptDecision(event, link, options) {
16
24
  }
17
25
  const url = new URL(href, window.location.origin);
18
26
  if (url.origin !== window.location.origin) return { shouldIntercept: false, reason: "cross-origin" };
27
+ if (isSamePageHashNavigationHref(href)) {
28
+ return { shouldIntercept: false, reason: "same-page-hash" };
29
+ }
19
30
  return { shouldIntercept: true };
20
31
  }
21
32
  function extractComponentUrlFromMarker(doc) {
@@ -26,8 +37,8 @@ function extractComponentUrlFromMarker(doc) {
26
37
  }
27
38
  const DEFAULT_IMPORT_REGEX = /import\s+(\w+)\s+from\s*['"]([^'"]+)['"]/;
28
39
  const NAMESPACE_IMPORT_REGEX = /import\s*\*\s*as\s*(\w+)\s*from\s*['"]([^'"]+)['"]/;
29
- const PAGE_MODULE_MARKER_REGEX = /module\s*:\s*['"]([^'"]+)['"]/;
30
- const PAGE_MODULE_IDENTIFIER_REGEX = /module\s*:\s*([A-Za-z_$][\w$]*)\s*,/;
40
+ const PAGE_MODULE_MARKER_REGEX = /__ECO_PAGES__\.page\s*=\s*\{\s*module\s*:\s*['"]([^'"]+)['"]/;
41
+ const PAGE_MODULE_IDENTIFIER_REGEX = /__ECO_PAGES__\.page\s*=\s*\{\s*module\s*:\s*([A-Za-z_$][\w$]*)\s*,/;
31
42
  function extractModulePathFromCode(code, fallbackUrl) {
32
43
  const markerMatch = code.match(PAGE_MODULE_MARKER_REGEX);
33
44
  if (markerMatch) {
@@ -160,6 +171,7 @@ export {
160
171
  extractProps,
161
172
  fetchPageDocument,
162
173
  getInterceptDecision,
174
+ isSamePageHashNavigationHref,
163
175
  loadPageModule,
164
176
  loadPageModuleFromDocument,
165
177
  shouldInterceptClick
package/src/router.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  useEffect,
3
+ useEffectEvent,
3
4
  useState,
4
5
  useCallback,
5
6
  useMemo,
@@ -14,6 +15,7 @@ import { RouterContext } from "./context.js";
14
15
  import {
15
16
  fetchPageDocument,
16
17
  getInterceptDecision,
18
+ isSamePageHashNavigationHref,
17
19
  loadPageModuleFromDocument,
18
20
  shouldInterceptClick
19
21
  } from "./navigation.js";
@@ -21,7 +23,9 @@ import { morphHead } from "./head-morpher.js";
21
23
  import { applyViewTransitionNames } from "./view-transition-utils.js";
22
24
  import { manageScroll } from "./manage-scroll.js";
23
25
  import { saveScrollPositions, restoreScrollPositions } from "./scroll-persist.js";
24
- import { getEcoNavigationRuntime } from "@ecopages/core/router/navigation-coordinator";
26
+ import {
27
+ getEcoNavigationRuntime
28
+ } from "@ecopages/core/router/navigation-coordinator";
25
29
  import {
26
30
  getAnchorFromNavigationEvent,
27
31
  recoverPendingNavigationHref
@@ -92,35 +96,39 @@ function createDeferred() {
92
96
  return { promise, resolve };
93
97
  }
94
98
  function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef) {
99
+ const handleCoordinatorNavigate = useEffectEvent(async (request) => {
100
+ await navigate(request.href, {
101
+ isPopState: request.direction === "back",
102
+ pushHistory: request.direction === "forward",
103
+ skipViewTransition: request.source === "browser-router"
104
+ });
105
+ return true;
106
+ });
107
+ const handleCoordinatorReload = useEffectEvent(async (request) => {
108
+ if (activeNavigationRef.current || isNavigatingRef.current) {
109
+ return;
110
+ }
111
+ if (request?.clearCache) {
112
+ clearLayoutCache();
113
+ }
114
+ const currentUrl = window.location.pathname + window.location.search;
115
+ await navigate(currentUrl, { moduleUrlOverride: request?.moduleUrl });
116
+ });
117
+ const handleCleanupBeforeHandoff = useEffectEvent(async () => {
118
+ runtimeActiveRef.current = false;
119
+ window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
120
+ });
95
121
  useEffect(() => {
96
- if (typeof window === "undefined") return;
97
122
  const navigationRuntime = getEcoNavigationRuntime(window);
98
123
  let unregisterRuntime = null;
99
124
  const unregister = navigationRuntime.register({
100
125
  owner: "react-router",
101
- navigate: async (request) => {
102
- await navigate(request.href, {
103
- isPopState: request.direction === "back",
104
- pushHistory: request.direction === "forward",
105
- skipViewTransition: request.source === "browser-router"
106
- });
107
- return true;
108
- },
109
- reloadCurrentPage: async (request) => {
110
- if (activeNavigationRef.current || isNavigatingRef.current) {
111
- return;
112
- }
113
- if (request?.clearCache) {
114
- clearLayoutCache();
115
- }
116
- const currentUrl = window.location.pathname + window.location.search;
117
- await navigate(currentUrl, { moduleUrlOverride: request?.moduleUrl });
118
- },
126
+ navigate: handleCoordinatorNavigate,
127
+ reloadCurrentPage: handleCoordinatorReload,
119
128
  cleanupBeforeHandoff: async () => {
120
- runtimeActiveRef.current = false;
121
129
  unregisterRuntime?.();
122
130
  unregisterRuntime = null;
123
- window.__ECO_PAGES__?.react?.cleanupPageRoot?.();
131
+ await handleCleanupBeforeHandoff();
124
132
  }
125
133
  });
126
134
  unregisterRuntime = unregister;
@@ -132,7 +140,7 @@ function useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef
132
140
  unregisterRuntime?.();
133
141
  unregisterRuntime = null;
134
142
  };
135
- }, [activeNavigationRef, isNavigatingRef, navigate, runtimeActiveRef]);
143
+ }, [runtimeActiveRef]);
136
144
  }
137
145
  const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
138
146
  const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
@@ -149,6 +157,9 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
149
157
  const pendingPointerNavigationRef = useRef(null);
150
158
  const pendingHoverNavigationRef = useRef(null);
151
159
  const queuedNavigationHrefRef = useRef(null);
160
+ const committedPathRef = useRef(
161
+ typeof window !== "undefined" ? window.location.pathname + window.location.search : ""
162
+ );
152
163
  const previousUrlRef = useRef(typeof window !== "undefined" ? window.location.href : "");
153
164
  useEffect(() => {
154
165
  isNavigatingRef.current = isNavigating;
@@ -157,25 +168,13 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
157
168
  setCurrentPage({ Component: page, props: pageProps, refreshPersistedLayout: true });
158
169
  }, [page, pageProps]);
159
170
  useEffect(() => {
171
+ committedPathRef.current = window.location.pathname + window.location.search;
160
172
  applyViewTransitionNames();
161
- }, [currentPage]);
162
- useEffect(() => {
163
173
  const pendingRender = pendingRenderRef.current;
164
174
  if (pendingRender && currentPage.Component === pendingRender.page.Component && currentPage.props === pendingRender.page.props) {
165
175
  pendingRender.resolve();
166
176
  pendingRenderRef.current = null;
167
177
  }
168
- }, [currentPage]);
169
- useEffect(() => {
170
- return () => {
171
- activeNavigationRef.current?.cancel();
172
- pendingRenderRef.current?.resolve();
173
- pendingRenderRef.current = null;
174
- queuedNavigationHrefRef.current = null;
175
- };
176
- }, []);
177
- useEffect(() => {
178
- if (typeof window === "undefined") return;
179
178
  const url = new URL(window.location.href);
180
179
  const previousUrl = new URL(previousUrlRef.current);
181
180
  if (url.href !== previousUrl.href) {
@@ -186,6 +185,14 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
186
185
  previousUrlRef.current = url.href;
187
186
  }
188
187
  }, [currentPage, options.scrollBehavior, options.smoothScroll]);
188
+ useEffect(() => {
189
+ return () => {
190
+ activeNavigationRef.current?.cancel();
191
+ pendingRenderRef.current?.resolve();
192
+ pendingRenderRef.current = null;
193
+ queuedNavigationHrefRef.current = null;
194
+ };
195
+ }, []);
189
196
  const navigate = useCallback(
190
197
  async (url, navigationOptions = {}) => {
191
198
  const {
@@ -206,6 +213,16 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
206
213
  props
207
214
  };
208
215
  };
216
+ const preparePendingRender = (nextPage) => {
217
+ pendingRenderRef.current?.resolve();
218
+ const renderDfd = createDeferred();
219
+ pendingRenderRef.current = {
220
+ navigationId,
221
+ page: nextPage,
222
+ resolve: renderDfd.resolve
223
+ };
224
+ return renderDfd.promise;
225
+ };
209
226
  let navigationCommitPromise = null;
210
227
  try {
211
228
  setIsNavigating(true);
@@ -223,6 +240,17 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
223
240
  const { Component, props, doc, finalPath, moduleUrl } = result;
224
241
  const nextPage = { Component, props, refreshPersistedLayout: Boolean(moduleUrlOverride) };
225
242
  const { cleanup: cleanupHead, flushRerunScripts } = await morphHead(doc);
243
+ const finalizeCommittedNavigation = () => {
244
+ committedPathRef.current = finalPath;
245
+ flushRerunScripts();
246
+ cleanupHead();
247
+ applyViewTransitionNames();
248
+ restoreScrollPositions(finalPath, isPopState);
249
+ };
250
+ const commitNextPage = () => {
251
+ commitPageData(moduleUrl, props);
252
+ setCurrentPage(nextPage);
253
+ };
226
254
  if (isStale()) {
227
255
  cleanupHead();
228
256
  return;
@@ -235,13 +263,7 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
235
263
  window.history.replaceState(null, "", finalPath);
236
264
  }
237
265
  if (!skipViewTransition && options.viewTransitions && document.startViewTransition) {
238
- pendingRenderRef.current?.resolve();
239
- const renderDfd = createDeferred();
240
- pendingRenderRef.current = {
241
- navigationId,
242
- page: nextPage,
243
- resolve: renderDfd.resolve
244
- };
266
+ const renderPromise = preparePendingRender(nextPage);
245
267
  navigationCommitPromise = new Promise((resolve) => {
246
268
  document.startViewTransition(async () => {
247
269
  try {
@@ -254,17 +276,13 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
254
276
  return;
255
277
  }
256
278
  startTransition(() => {
257
- commitPageData(moduleUrl, props);
258
- setCurrentPage(nextPage);
279
+ commitNextPage();
259
280
  });
260
- await renderDfd.promise;
281
+ await renderPromise;
261
282
  if (isStale()) {
262
283
  return;
263
284
  }
264
- flushRerunScripts();
265
- cleanupHead();
266
- applyViewTransitionNames();
267
- restoreScrollPositions(finalPath, isPopState);
285
+ finalizeCommittedNavigation();
268
286
  } finally {
269
287
  resolve();
270
288
  }
@@ -272,24 +290,14 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
272
290
  });
273
291
  await navigationCommitPromise;
274
292
  } else {
275
- pendingRenderRef.current?.resolve();
276
- const renderDfd = createDeferred();
277
- pendingRenderRef.current = {
278
- navigationId,
279
- page: nextPage,
280
- resolve: renderDfd.resolve
281
- };
282
- commitPageData(moduleUrl, props);
283
- setCurrentPage(nextPage);
284
- await renderDfd.promise;
293
+ const renderPromise = preparePendingRender(nextPage);
294
+ commitNextPage();
295
+ await renderPromise;
285
296
  if (isStale()) {
286
297
  cleanupHead();
287
298
  return;
288
299
  }
289
- flushRerunScripts();
290
- cleanupHead();
291
- applyViewTransitionNames();
292
- restoreScrollPositions(finalPath, isPopState);
300
+ finalizeCommittedNavigation();
293
301
  }
294
302
  } else {
295
303
  if (isStale()) return;
@@ -313,11 +321,12 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
313
321
  } finally {
314
322
  const shouldReplayQueuedNavigation = activeNavigationRef.current?.id === navigationId;
315
323
  const queuedNavigationHref = shouldReplayQueuedNavigation ? queuedNavigationHrefRef.current : null;
324
+ const queuedNavigationPath = queuedNavigationHref ? new URL(queuedNavigationHref, window.location.origin).pathname + new URL(queuedNavigationHref, window.location.origin).search : null;
316
325
  navigation.complete();
317
326
  if (activeNavigationRef.current?.id === navigationId) {
318
327
  activeNavigationRef.current = null;
319
328
  }
320
- if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
329
+ if (queuedNavigationHref && queuedNavigationPath !== committedPathRef.current) {
321
330
  queuedNavigationHrefRef.current = null;
322
331
  if (runtimeActiveRef.current) {
323
332
  void navigate(queuedNavigationHref, { pushHistory: true });
@@ -337,132 +346,150 @@ const EcoRouter = ({ page, pageProps, options: userOptions, children }) => {
337
346
  },
338
347
  [options.viewTransitions]
339
348
  );
340
- useEffect(() => {
341
- const getLinkFromEvent = (event) => getAnchorFromNavigationEvent(event, options.linkSelector);
342
- const getRecoveredPointerHref = () => {
343
- const href = recoverPendingNavigationHref(
344
- pendingPointerNavigationRef.current,
345
- !!activeNavigationRef.current || isNavigatingRef.current,
346
- performance.now()
347
- );
348
- if (!href) {
349
- pendingPointerNavigationRef.current = null;
350
- }
351
- return href;
352
- };
353
- const getRecoveredHoverHref = () => {
354
- const href = recoverPendingNavigationHref(
355
- pendingHoverNavigationRef.current,
356
- !!activeNavigationRef.current || isNavigatingRef.current,
357
- performance.now()
358
- );
359
- if (!href) {
360
- pendingHoverNavigationRef.current = null;
361
- }
362
- return href;
349
+ const getLinkFromEvent = useEffectEvent(
350
+ (event) => getAnchorFromNavigationEvent(event, options.linkSelector)
351
+ );
352
+ const getRecoveredPointerHref = useEffectEvent(() => {
353
+ const href = recoverPendingNavigationHref(
354
+ pendingPointerNavigationRef.current,
355
+ !!activeNavigationRef.current || isNavigatingRef.current,
356
+ performance.now()
357
+ );
358
+ if (!href) {
359
+ pendingPointerNavigationRef.current = null;
360
+ }
361
+ return href;
362
+ });
363
+ const getRecoveredHoverHref = useEffectEvent(() => {
364
+ const href = recoverPendingNavigationHref(
365
+ pendingHoverNavigationRef.current,
366
+ !!activeNavigationRef.current || isNavigatingRef.current,
367
+ performance.now()
368
+ );
369
+ if (!href) {
370
+ pendingHoverNavigationRef.current = null;
371
+ }
372
+ return href;
373
+ });
374
+ const handleHoverIntent = useEffectEvent((event) => {
375
+ if (!runtimeActiveRef.current) {
376
+ return;
377
+ }
378
+ const link = getLinkFromEvent(event);
379
+ if (!link) {
380
+ return;
381
+ }
382
+ const decision = getInterceptDecision(event, link, options);
383
+ if (!decision.shouldIntercept) {
384
+ return;
385
+ }
386
+ pendingHoverNavigationRef.current = {
387
+ href: link.getAttribute("href"),
388
+ timestamp: performance.now()
363
389
  };
364
- const handleHoverIntent = (event) => {
365
- if (!runtimeActiveRef.current) {
366
- return;
367
- }
368
- const link = getLinkFromEvent(event);
369
- if (!link) {
370
- return;
371
- }
372
- const decision = getInterceptDecision(event, link, options);
373
- if (!decision.shouldIntercept) {
374
- return;
375
- }
376
- pendingHoverNavigationRef.current = {
377
- href: link.getAttribute("href"),
378
- timestamp: performance.now()
379
- };
390
+ queuedNavigationHrefRef.current = link.getAttribute("href");
391
+ });
392
+ const handlePointerDown = useEffectEvent((event) => {
393
+ if (!runtimeActiveRef.current) {
394
+ pendingPointerNavigationRef.current = null;
395
+ return;
396
+ }
397
+ const link = getLinkFromEvent(event);
398
+ if (!link) {
399
+ pendingPointerNavigationRef.current = null;
400
+ return;
401
+ }
402
+ const decision = getInterceptDecision(event, link, options);
403
+ pendingPointerNavigationRef.current = decision.shouldIntercept ? {
404
+ href: link.getAttribute("href"),
405
+ timestamp: performance.now()
406
+ } : null;
407
+ if (decision.shouldIntercept && (activeNavigationRef.current || isNavigatingRef.current)) {
380
408
  queuedNavigationHrefRef.current = link.getAttribute("href");
381
- };
382
- const handlePointerDown = (event) => {
383
- if (!runtimeActiveRef.current) {
384
- pendingPointerNavigationRef.current = null;
385
- return;
386
- }
387
- const link = getLinkFromEvent(event);
388
- if (!link) {
389
- pendingPointerNavigationRef.current = null;
390
- return;
391
- }
392
- const decision = getInterceptDecision(event, link, options);
393
- pendingPointerNavigationRef.current = decision.shouldIntercept ? {
394
- href: link.getAttribute("href"),
395
- timestamp: performance.now()
396
- } : null;
397
- if (decision.shouldIntercept && (activeNavigationRef.current || isNavigatingRef.current)) {
398
- queuedNavigationHrefRef.current = link.getAttribute("href");
399
- }
400
- };
401
- const handleClick = (event) => {
402
- if (!runtimeActiveRef.current) {
403
- pendingPointerNavigationRef.current = null;
404
- pendingHoverNavigationRef.current = null;
409
+ }
410
+ });
411
+ const handleClick = useEffectEvent((event) => {
412
+ if (!runtimeActiveRef.current) {
413
+ pendingPointerNavigationRef.current = null;
414
+ pendingHoverNavigationRef.current = null;
415
+ return;
416
+ }
417
+ const link = getLinkFromEvent(event);
418
+ if (!link) {
419
+ const recoveredHref = getRecoveredPointerHref() ?? getRecoveredHoverHref();
420
+ pendingPointerNavigationRef.current = null;
421
+ pendingHoverNavigationRef.current = null;
422
+ if (!recoveredHref) {
405
423
  return;
406
424
  }
407
- const link = getLinkFromEvent(event);
408
- if (!link) {
409
- const recoveredHref = getRecoveredPointerHref() ?? getRecoveredHoverHref();
410
- pendingPointerNavigationRef.current = null;
411
- pendingHoverNavigationRef.current = null;
412
- if (!recoveredHref) {
413
- return;
414
- }
415
- event.preventDefault();
425
+ if (isSamePageHashNavigationHref(recoveredHref)) {
416
426
  queuedNavigationHrefRef.current = null;
417
- const recoveredUrl = new URL(recoveredHref, window.location.origin);
418
- navigate(recoveredUrl.pathname + recoveredUrl.search, { pushHistory: true });
419
427
  return;
420
428
  }
421
- if (!shouldInterceptClick(event, link, options)) {
422
- if (options.debug) {
423
- const decision = getInterceptDecision(event, link, options);
424
- if (!decision.shouldIntercept) {
425
- console.debug("[EcoRouter] Not intercepting link click:", decision.reason, link.href);
426
- }
427
- }
428
- pendingPointerNavigationRef.current = null;
429
- pendingHoverNavigationRef.current = null;
430
- return;
431
- }
432
- pendingPointerNavigationRef.current = null;
433
- pendingHoverNavigationRef.current = null;
434
429
  event.preventDefault();
435
430
  queuedNavigationHrefRef.current = null;
436
- const href = link.getAttribute("href");
437
- const url = new URL(href, window.location.origin);
431
+ const recoveredUrl = new URL(recoveredHref, window.location.href);
432
+ navigate(recoveredUrl.pathname + recoveredUrl.search, { pushHistory: true });
433
+ return;
434
+ }
435
+ if (!shouldInterceptClick(event, link, options)) {
438
436
  if (options.debug) {
439
- console.debug("[EcoRouter] Intercepting navigation:", url.pathname + url.search);
437
+ const decision = getInterceptDecision(event, link, options);
438
+ if (!decision.shouldIntercept) {
439
+ console.debug("[EcoRouter] Not intercepting link click:", decision.reason, link.href);
440
+ }
440
441
  }
441
- navigate(url.pathname + url.search, { pushHistory: true });
442
+ pendingPointerNavigationRef.current = null;
443
+ pendingHoverNavigationRef.current = null;
444
+ return;
445
+ }
446
+ pendingPointerNavigationRef.current = null;
447
+ pendingHoverNavigationRef.current = null;
448
+ event.preventDefault();
449
+ queuedNavigationHrefRef.current = null;
450
+ const href = link.getAttribute("href");
451
+ const url = new URL(href, window.location.origin);
452
+ if (options.debug) {
453
+ console.debug("[EcoRouter] Intercepting navigation:", url.pathname + url.search);
454
+ }
455
+ navigate(url.pathname + url.search, { pushHistory: true });
456
+ });
457
+ const handlePopState = useEffectEvent(() => {
458
+ if (!runtimeActiveRef.current) {
459
+ return;
460
+ }
461
+ navigate(window.location.pathname + window.location.search, { isPopState: true });
462
+ });
463
+ useEffect(() => {
464
+ const onHoverIntent = (event) => {
465
+ handleHoverIntent(event);
442
466
  };
443
- const handlePopState = () => {
444
- if (!runtimeActiveRef.current) {
445
- return;
446
- }
447
- navigate(window.location.pathname + window.location.search, { isPopState: true });
467
+ const onPointerDown = (event) => {
468
+ handlePointerDown(event);
448
469
  };
449
- document.addEventListener("mouseover", handleHoverIntent, true);
450
- document.addEventListener("pointerover", handleHoverIntent, true);
451
- document.addEventListener("mousemove", handleHoverIntent, true);
452
- document.addEventListener("pointermove", handleHoverIntent, true);
453
- document.addEventListener("pointerdown", handlePointerDown, true);
454
- document.addEventListener("click", handleClick, true);
455
- window.addEventListener("popstate", handlePopState);
470
+ const onClick = (event) => {
471
+ handleClick(event);
472
+ };
473
+ const onPopState = () => {
474
+ handlePopState();
475
+ };
476
+ document.addEventListener("mouseover", onHoverIntent, true);
477
+ document.addEventListener("pointerover", onHoverIntent, true);
478
+ document.addEventListener("mousemove", onHoverIntent, true);
479
+ document.addEventListener("pointermove", onHoverIntent, true);
480
+ document.addEventListener("pointerdown", onPointerDown, true);
481
+ document.addEventListener("click", onClick, true);
482
+ window.addEventListener("popstate", onPopState);
456
483
  return () => {
457
- document.removeEventListener("mouseover", handleHoverIntent, true);
458
- document.removeEventListener("pointerover", handleHoverIntent, true);
459
- document.removeEventListener("mousemove", handleHoverIntent, true);
460
- document.removeEventListener("pointermove", handleHoverIntent, true);
461
- document.removeEventListener("pointerdown", handlePointerDown, true);
462
- document.removeEventListener("click", handleClick, true);
463
- window.removeEventListener("popstate", handlePopState);
484
+ document.removeEventListener("mouseover", onHoverIntent, true);
485
+ document.removeEventListener("pointerover", onHoverIntent, true);
486
+ document.removeEventListener("mousemove", onHoverIntent, true);
487
+ document.removeEventListener("pointermove", onHoverIntent, true);
488
+ document.removeEventListener("pointerdown", onPointerDown, true);
489
+ document.removeEventListener("click", onClick, true);
490
+ window.removeEventListener("popstate", onPopState);
464
491
  };
465
- }, [navigate, options, runtimeActiveRef]);
492
+ }, []);
466
493
  useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef);
467
494
  return createElement(
468
495
  RouterContext.Provider,