@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/CHANGELOG.md +9 -2
- package/README.md +46 -120
- package/package.json +3 -2
- package/src/head-morpher.js +37 -1
- package/src/head-morpher.ts +45 -1
- package/src/navigation.d.ts +21 -8
- package/src/navigation.js +59 -33
- package/src/navigation.ts +86 -36
- package/src/props-script.d.ts +1 -1
- package/src/props-script.ts +1 -1
- package/src/router.d.ts +3 -1
- package/src/router.js +295 -59
- package/src/router.ts +392 -70
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 {
|
|
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
|
-
|
|
40
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
const
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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 (
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
pendingScrollRestoreRef.current = { url, isPopState };
|
|
379
|
+
const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
|
|
269
380
|
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
439
|
+
await navigationCommitPromise;
|
|
440
|
+
} else {
|
|
441
|
+
commitPageData(moduleUrl, props);
|
|
442
|
+
setCurrentPage(nextPage);
|
|
279
443
|
cleanupHead();
|
|
280
444
|
applyViewTransitionNames();
|
|
281
|
-
}
|
|
445
|
+
}
|
|
282
446
|
} else {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
321
|
-
navigate(url.pathname + url.search);
|
|
629
|
+
navigate(url.pathname + url.search, { pushHistory: true });
|
|
322
630
|
};
|
|
323
631
|
|
|
324
632
|
const handlePopState = () => {
|
|
325
|
-
|
|
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('
|
|
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('
|
|
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
|
-
|
|
659
|
+
useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef);
|
|
338
660
|
|
|
339
661
|
return createElement(
|
|
340
662
|
RouterContext.Provider,
|