@ecopages/react-router 0.2.0-alpha.9 → 0.2.0-beta.0
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/README.md +1 -1
- package/package.json +5 -5
- package/src/adapter.js +1 -2
- package/src/head-morpher.d.ts +6 -2
- package/src/head-morpher.js +88 -9
- package/src/hydration-assets.d.ts +12 -0
- package/src/hydration-assets.js +17 -0
- package/src/navigation.d.ts +27 -2
- package/src/navigation.js +40 -7
- package/src/router.d.ts +3 -1
- package/src/router.js +249 -183
- package/src/scroll-persist.js +15 -7
- package/CHANGELOG.md +0 -19
- package/browser.ts +0 -17
- package/src/adapter.ts +0 -48
- package/src/context.ts +0 -25
- package/src/head-morpher.ts +0 -214
- package/src/index.ts +0 -21
- package/src/manage-scroll.ts +0 -47
- package/src/navigation.ts +0 -297
- package/src/props-script.ts +0 -19
- package/src/router.ts +0 -670
- package/src/scroll-persist.ts +0 -96
- package/src/types.ts +0 -64
- package/src/view-transition-manager.ts +0 -30
- package/src/view-transition-utils.ts +0 -95
package/src/router.ts
DELETED
|
@@ -1,670 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SPA router with View Transitions API support for React applications.
|
|
3
|
-
*
|
|
4
|
-
* Intercepts link clicks for client-side navigation using the History API.
|
|
5
|
-
* Supports animated page transitions via CSS view-transition pseudo-elements.
|
|
6
|
-
*
|
|
7
|
-
* @module router
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
useEffect,
|
|
12
|
-
useState,
|
|
13
|
-
useCallback,
|
|
14
|
-
useMemo,
|
|
15
|
-
useRef,
|
|
16
|
-
createContext,
|
|
17
|
-
useContext,
|
|
18
|
-
startTransition,
|
|
19
|
-
createElement,
|
|
20
|
-
type ReactNode,
|
|
21
|
-
type ComponentType,
|
|
22
|
-
type FC,
|
|
23
|
-
type RefObject,
|
|
24
|
-
} from 'react';
|
|
25
|
-
import { type EcoRouterOptions, DEFAULT_OPTIONS } from './types.ts';
|
|
26
|
-
import { RouterContext } from './context.ts';
|
|
27
|
-
import {
|
|
28
|
-
type PageState,
|
|
29
|
-
fetchPageDocument,
|
|
30
|
-
getInterceptDecision,
|
|
31
|
-
loadPageModuleFromDocument,
|
|
32
|
-
shouldInterceptClick,
|
|
33
|
-
} from './navigation.ts';
|
|
34
|
-
import { morphHead } from './head-morpher.ts';
|
|
35
|
-
import { applyViewTransitionNames } from './view-transition-utils.ts';
|
|
36
|
-
import { manageScroll } from './manage-scroll.ts';
|
|
37
|
-
import { saveScrollPositions, restoreScrollPositions } from './scroll-persist.ts';
|
|
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';
|
|
45
|
-
|
|
46
|
-
type PageContextValue = PageState | null;
|
|
47
|
-
|
|
48
|
-
const PageContext = createContext<PageContextValue>(null);
|
|
49
|
-
|
|
50
|
-
const PersistLayoutsContext = createContext<boolean>(false);
|
|
51
|
-
|
|
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;
|
|
67
|
-
return config?.layout;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Props for the {@link EcoRouter} component.
|
|
72
|
-
*/
|
|
73
|
-
export interface EcoRouterProps {
|
|
74
|
-
/** Page component to render */
|
|
75
|
-
page: ComponentType<unknown>;
|
|
76
|
-
/** Props passed to the page component */
|
|
77
|
-
pageProps: Record<string, unknown>;
|
|
78
|
-
/** Router configuration */
|
|
79
|
-
options?: EcoRouterOptions;
|
|
80
|
-
/** Children (should contain {@link PageContent}) */
|
|
81
|
-
children: ReactNode;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/**
|
|
85
|
-
* Cache for layout components to ensure same reference across navigations.
|
|
86
|
-
* When different pages import the same layout, they get different function
|
|
87
|
-
* references. This cache ensures we reuse the first one seen for each displayName.
|
|
88
|
-
*
|
|
89
|
-
* Stored on window to persist across module reloads during HMR/SPA navigation.
|
|
90
|
-
*/
|
|
91
|
-
function getLayoutCache(): Map<string, LayoutComponent> {
|
|
92
|
-
if (typeof window === 'undefined') {
|
|
93
|
-
return new Map();
|
|
94
|
-
}
|
|
95
|
-
const win = window as typeof window & { __ecoLayoutCache?: Map<string, LayoutComponent> };
|
|
96
|
-
if (!win.__ecoLayoutCache) {
|
|
97
|
-
win.__ecoLayoutCache = new Map();
|
|
98
|
-
}
|
|
99
|
-
return win.__ecoLayoutCache;
|
|
100
|
-
}
|
|
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
|
-
*/
|
|
109
|
-
function normalizeLayoutKey(value: string): string {
|
|
110
|
-
const trimmed = value.trim();
|
|
111
|
-
if (!trimmed) return 'layout';
|
|
112
|
-
|
|
113
|
-
try {
|
|
114
|
-
const asUrl = new URL(trimmed);
|
|
115
|
-
return asUrl.pathname.replace(/\/$/, '') || 'layout';
|
|
116
|
-
} catch {
|
|
117
|
-
return trimmed.split('#')[0]?.split('?')[0]?.replace(/\/$/, '') || 'layout';
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Clears the layout cache. Called during HMR to ensure fresh layouts are used.
|
|
123
|
-
*/
|
|
124
|
-
export function clearLayoutCache(): void {
|
|
125
|
-
getLayoutCache().clear();
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Renders the current page with its layout.
|
|
130
|
-
*
|
|
131
|
-
* Must be a child of {@link EcoRouter}. When `persistLayouts` is enabled,
|
|
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.
|
|
135
|
-
*
|
|
136
|
-
* @example
|
|
137
|
-
* ```tsx
|
|
138
|
-
* <EcoRouter page={Page} pageProps={props}>
|
|
139
|
-
* <PageContent />
|
|
140
|
-
* </EcoRouter>
|
|
141
|
-
* ```
|
|
142
|
-
*/
|
|
143
|
-
export const PageContent: FC = () => {
|
|
144
|
-
const pageContext = useContext(PageContext);
|
|
145
|
-
const persistLayouts = useContext(PersistLayoutsContext);
|
|
146
|
-
|
|
147
|
-
if (!pageContext) {
|
|
148
|
-
if (import.meta.env.NODE_ENV !== 'production') {
|
|
149
|
-
console.warn('[EcoRouter] PageContent used outside of EcoRouter');
|
|
150
|
-
}
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const { Component: Page, props } = pageContext;
|
|
155
|
-
const Layout = getLayoutFromPage(Page);
|
|
156
|
-
const pageElement = createElement(Page, props);
|
|
157
|
-
const layoutProps = props?.locals ? { locals: props.locals } : null;
|
|
158
|
-
|
|
159
|
-
if (!Layout) {
|
|
160
|
-
return pageElement;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
if (persistLayouts) {
|
|
164
|
-
const layoutCache = getLayoutCache();
|
|
165
|
-
const layoutConfig = (Layout as LayoutComponent & { config?: { __eco?: EcoInjectedMeta } }).config;
|
|
166
|
-
const layoutKeyRaw = layoutConfig?.__eco?.id || Layout.displayName || Layout.name || 'layout';
|
|
167
|
-
const layoutKey = normalizeLayoutKey(layoutKeyRaw);
|
|
168
|
-
|
|
169
|
-
if (!layoutCache.has(layoutKey)) {
|
|
170
|
-
layoutCache.set(layoutKey, Layout);
|
|
171
|
-
}
|
|
172
|
-
const CachedLayout = layoutCache.get(layoutKey)!;
|
|
173
|
-
|
|
174
|
-
return createElement(CachedLayout, { key: layoutKey, ...(layoutProps ?? {}) }, pageElement);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return createElement(Layout, layoutProps, pageElement);
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
function createDeferred<T>() {
|
|
181
|
-
let resolve!: (value: T) => void;
|
|
182
|
-
const promise = new Promise<T>((res) => {
|
|
183
|
-
resolve = res;
|
|
184
|
-
});
|
|
185
|
-
return { promise, resolve };
|
|
186
|
-
}
|
|
187
|
-
|
|
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
|
-
) {
|
|
203
|
-
useEffect(() => {
|
|
204
|
-
if (typeof window === 'undefined') return;
|
|
205
|
-
|
|
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
|
-
}
|
|
222
|
-
|
|
223
|
-
if (request?.clearCache) {
|
|
224
|
-
clearLayoutCache();
|
|
225
|
-
}
|
|
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;
|
|
240
|
-
return () => {
|
|
241
|
-
runtimeActiveRef.current = false;
|
|
242
|
-
navigationRuntime.releaseOwnership('react-router');
|
|
243
|
-
unregisterRuntime?.();
|
|
244
|
-
unregisterRuntime = null;
|
|
245
|
-
};
|
|
246
|
-
}, [activeNavigationRef, isNavigatingRef, navigate, runtimeActiveRef]);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
/**
|
|
250
|
-
* Root router providing SPA navigation with View Transitions.
|
|
251
|
-
*
|
|
252
|
-
* Coordinates navigation flow:
|
|
253
|
-
* 1. Intercepts link clicks and popstate events
|
|
254
|
-
* 2. Loads page module and updates document head
|
|
255
|
-
* 3. Triggers View Transition (if supported)
|
|
256
|
-
* 4. Updates React state inside transition callback
|
|
257
|
-
* 5. Resolves deferred promise after render
|
|
258
|
-
* 6. Browser captures new DOM snapshot
|
|
259
|
-
*
|
|
260
|
-
* @example
|
|
261
|
-
* ```tsx
|
|
262
|
-
* <EcoRouter
|
|
263
|
-
* page={CurrentPage}
|
|
264
|
-
* pageProps={pageProps}
|
|
265
|
-
* options={{ persistLayouts: true }}
|
|
266
|
-
* >
|
|
267
|
-
* <PageContent />
|
|
268
|
-
* </EcoRouter>
|
|
269
|
-
* ```
|
|
270
|
-
*
|
|
271
|
-
* @example Shared element transitions
|
|
272
|
-
* ```tsx
|
|
273
|
-
* // List page
|
|
274
|
-
* <img data-view-transition={`hero-${id}`} src={src} />
|
|
275
|
-
*
|
|
276
|
-
* // Detail page
|
|
277
|
-
* <img data-view-transition={`hero-${id}`} src={src} />
|
|
278
|
-
* ```
|
|
279
|
-
*/
|
|
280
|
-
export const EcoRouter: FC<EcoRouterProps> = ({ page, pageProps, options: userOptions, children }: EcoRouterProps) => {
|
|
281
|
-
const options = useMemo(() => ({ ...DEFAULT_OPTIONS, ...userOptions }), [userOptions]);
|
|
282
|
-
const [currentPage, setCurrentPage] = useState<PageState>({ Component: page, props: pageProps });
|
|
283
|
-
const [isNavigating, setIsNavigating] = useState(false);
|
|
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);
|
|
291
|
-
const pendingScrollRestoreRef = useRef<{ url: string; isPopState: boolean } | null>(null);
|
|
292
|
-
const previousUrlRef = useRef<string>(typeof window !== 'undefined' ? window.location.href : '');
|
|
293
|
-
|
|
294
|
-
useEffect(() => {
|
|
295
|
-
isNavigatingRef.current = isNavigating;
|
|
296
|
-
}, [isNavigating]);
|
|
297
|
-
|
|
298
|
-
useEffect(() => {
|
|
299
|
-
setCurrentPage({ Component: page, props: pageProps });
|
|
300
|
-
}, [page, pageProps]);
|
|
301
|
-
|
|
302
|
-
useEffect(() => {
|
|
303
|
-
applyViewTransitionNames();
|
|
304
|
-
}, [currentPage]);
|
|
305
|
-
|
|
306
|
-
useEffect(() => {
|
|
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;
|
|
315
|
-
}
|
|
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
|
-
}, []);
|
|
326
|
-
|
|
327
|
-
useEffect(() => {
|
|
328
|
-
if (typeof window === 'undefined') return;
|
|
329
|
-
|
|
330
|
-
const url = new URL(window.location.href);
|
|
331
|
-
const previousUrl = new URL(previousUrlRef.current);
|
|
332
|
-
|
|
333
|
-
if (url.href !== previousUrl.href) {
|
|
334
|
-
manageScroll(url, previousUrl, {
|
|
335
|
-
scrollBehavior: options.scrollBehavior,
|
|
336
|
-
smoothScroll: options.smoothScroll,
|
|
337
|
-
});
|
|
338
|
-
previousUrlRef.current = url.href;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
if (pendingScrollRestoreRef.current) {
|
|
342
|
-
const { url: targetUrl, isPopState } = pendingScrollRestoreRef.current;
|
|
343
|
-
restoreScrollPositions(targetUrl, isPopState);
|
|
344
|
-
pendingScrollRestoreRef.current = null;
|
|
345
|
-
}
|
|
346
|
-
}, [currentPage, options.scrollBehavior, options.smoothScroll]);
|
|
347
|
-
|
|
348
|
-
const navigate = useCallback(
|
|
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;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const result = await loadPageModuleFromDocument(fetchedPage.doc, fetchedPage.finalPath);
|
|
380
|
-
|
|
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
|
-
}
|
|
400
|
-
|
|
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
|
-
});
|
|
438
|
-
});
|
|
439
|
-
await navigationCommitPromise;
|
|
440
|
-
} else {
|
|
441
|
-
commitPageData(moduleUrl, props);
|
|
442
|
-
setCurrentPage(nextPage);
|
|
443
|
-
cleanupHead();
|
|
444
|
-
applyViewTransitionNames();
|
|
445
|
-
}
|
|
446
|
-
} else {
|
|
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
|
-
}
|
|
463
|
-
}
|
|
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
|
-
}
|
|
499
|
-
}
|
|
500
|
-
}
|
|
501
|
-
},
|
|
502
|
-
[options.viewTransitions],
|
|
503
|
-
);
|
|
504
|
-
|
|
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
|
-
|
|
584
|
-
const handleClick = (event: MouseEvent) => {
|
|
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
|
-
}
|
|
606
|
-
if (!shouldInterceptClick(event, link, options)) {
|
|
607
|
-
if (options.debug) {
|
|
608
|
-
const decision = getInterceptDecision(event, link, options);
|
|
609
|
-
if (!decision.shouldIntercept) {
|
|
610
|
-
console.debug('[EcoRouter] Not intercepting link click:', decision.reason, link.href);
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
pendingPointerNavigationRef.current = null;
|
|
614
|
-
pendingHoverNavigationRef.current = null;
|
|
615
|
-
return;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
pendingPointerNavigationRef.current = null;
|
|
619
|
-
pendingHoverNavigationRef.current = null;
|
|
620
|
-
event.preventDefault();
|
|
621
|
-
queuedNavigationHrefRef.current = null;
|
|
622
|
-
const href = link.getAttribute('href')!;
|
|
623
|
-
const url = new URL(href, window.location.origin);
|
|
624
|
-
|
|
625
|
-
if (options.debug) {
|
|
626
|
-
console.debug('[EcoRouter] Intercepting navigation:', url.pathname + url.search);
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
navigate(url.pathname + url.search, { pushHistory: true });
|
|
630
|
-
};
|
|
631
|
-
|
|
632
|
-
const handlePopState = () => {
|
|
633
|
-
if (!runtimeActiveRef.current) {
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
navigate(window.location.pathname + window.location.search, { isPopState: true });
|
|
638
|
-
};
|
|
639
|
-
|
|
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);
|
|
646
|
-
window.addEventListener('popstate', handlePopState);
|
|
647
|
-
|
|
648
|
-
return () => {
|
|
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);
|
|
655
|
-
window.removeEventListener('popstate', handlePopState);
|
|
656
|
-
};
|
|
657
|
-
}, [navigate, options, runtimeActiveRef]);
|
|
658
|
-
|
|
659
|
-
useNavigationCoordinator(navigate, activeNavigationRef, isNavigatingRef, runtimeActiveRef);
|
|
660
|
-
|
|
661
|
-
return createElement(
|
|
662
|
-
RouterContext.Provider,
|
|
663
|
-
{ value: { navigate, isNavigating } },
|
|
664
|
-
createElement(
|
|
665
|
-
PersistLayoutsContext.Provider,
|
|
666
|
-
{ value: options.persistLayouts },
|
|
667
|
-
createElement(PageContext.Provider, { value: currentPage }, children),
|
|
668
|
-
),
|
|
669
|
-
);
|
|
670
|
-
};
|