@ecopages/react-router 0.2.0-alpha.25 → 0.2.0-alpha.27
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 +2 -0
- package/package.json +3 -3
- package/src/adapter.js +1 -2
- package/src/head-morpher.js +6 -3
- package/src/navigation.d.ts +2 -1
- package/src/navigation.js +14 -2
- package/src/router.js +203 -176
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.
|
|
3
|
+
"version": "0.2.0-alpha.27",
|
|
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.
|
|
36
|
-
"@ecopages/react": "0.2.0-alpha.
|
|
35
|
+
"@ecopages/core": "0.2.0-alpha.27",
|
|
36
|
+
"@ecopages/react": "0.2.0-alpha.27"
|
|
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
|
|
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"
|
package/src/head-morpher.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isReactRouterPageBootstrapAssetSrc } from "./hydration-assets.js";
|
|
2
|
-
const PRESERVE_SELECTORS = [
|
|
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
|
-
|
|
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;
|
package/src/navigation.d.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
setCurrentPage(nextPage);
|
|
279
|
+
commitNextPage();
|
|
259
280
|
});
|
|
260
|
-
await
|
|
281
|
+
await renderPromise;
|
|
261
282
|
if (isStale()) {
|
|
262
283
|
return;
|
|
263
284
|
}
|
|
264
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
)
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
)
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
navigate(window.location.pathname + window.location.search, { isPopState: true });
|
|
467
|
+
const onPointerDown = (event) => {
|
|
468
|
+
handlePointerDown(event);
|
|
448
469
|
};
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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",
|
|
458
|
-
document.removeEventListener("pointerover",
|
|
459
|
-
document.removeEventListener("mousemove",
|
|
460
|
-
document.removeEventListener("pointermove",
|
|
461
|
-
document.removeEventListener("pointerdown",
|
|
462
|
-
document.removeEventListener("click",
|
|
463
|
-
window.removeEventListener("popstate",
|
|
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
|
-
}, [
|
|
492
|
+
}, []);
|
|
466
493
|
useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef);
|
|
467
494
|
return createElement(
|
|
468
495
|
RouterContext.Provider,
|