@ecopages/browser-router 0.2.0-alpha.1
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 +25 -0
- package/LICENSE +21 -0
- package/README.md +132 -0
- package/package.json +39 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-dispatch-eco-before-swap--eco-after-swap--and-eco-page-load-events-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-dispatch-eco-page-load-event-after-animation-frame-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-Link-Selector-should-work-with-data-attribute-selector-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-Reload-Attribute-should-intercept-links-with-default-reload-attribute-when-custom-is-set-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-link-selector-should-only-intercept-links-matching-custom-selector-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-External-Links--should-NOT-intercept--should-NOT-intercept-external-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Internal-Links-should-intercept-clicks-on-relative-path-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Internal-Links-should-intercept-clicks-on-same-origin-absolute-URLs-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Link-Attributes--should-NOT-intercept--should-NOT-intercept-links-with-download-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Link-Attributes--should-NOT-intercept--should-intercept-links-with-target---self--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-meta-click--cmd-on-Mac--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-middle-mouse-button-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-right-mouse-button-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-data-eco-reload-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-absolute-same-origin-paths-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.ts/EcoRouter-Error-Handling-should-fall-back-to-full-page-navigation-on-fetch-error-1.png +0 -0
- package/src/client/__screenshots__/eco-router.test.ts/EcoRouter-Error-Handling-should-log-error-and-attempt-fallback-navigation-on-fetch-error-1.png +0 -0
- package/src/client/eco-router.d.ts +98 -0
- package/src/client/eco-router.js +228 -0
- package/src/client/eco-router.ts +290 -0
- package/src/client/services/dom-swapper.d.ts +65 -0
- package/src/client/services/dom-swapper.js +237 -0
- package/src/client/services/dom-swapper.ts +325 -0
- package/src/client/services/index.d.ts +8 -0
- package/src/client/services/index.js +10 -0
- package/src/client/services/index.ts +9 -0
- package/src/client/services/prefetch-manager.d.ts +169 -0
- package/src/client/services/prefetch-manager.js +374 -0
- package/src/client/services/prefetch-manager.ts +451 -0
- package/src/client/services/scroll-manager.d.ts +19 -0
- package/src/client/services/scroll-manager.js +36 -0
- package/src/client/services/scroll-manager.ts +48 -0
- package/src/client/services/view-transition-manager.d.ts +23 -0
- package/src/client/services/view-transition-manager.js +38 -0
- package/src/client/services/view-transition-manager.ts +75 -0
- package/src/client/types.d.ts +84 -0
- package/src/client/types.js +19 -0
- package/src/client/types.ts +109 -0
- package/src/client/view-transition-utils.d.ts +14 -0
- package/src/client/view-transition-utils.js +60 -0
- package/src/client/view-transition-utils.ts +98 -0
- package/src/index.d.ts +9 -0
- package/src/index.js +11 -0
- package/src/index.ts +19 -0
- package/src/styles.css +218 -0
- package/src/types.d.ts +15 -0
- package/src/types.js +4 -0
- package/src/types.ts +19 -0
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prefetch manager for client-side navigation.
|
|
3
|
+
* Uses a single IntersectionObserver and hover detection for optimal performance.
|
|
4
|
+
* @module prefetch-manager
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type PrefetchStrategy = 'viewport' | 'hover' | 'intent';
|
|
8
|
+
|
|
9
|
+
export interface PrefetchOptions {
|
|
10
|
+
strategy: PrefetchStrategy;
|
|
11
|
+
delay: number;
|
|
12
|
+
noPrefetchAttribute: string;
|
|
13
|
+
respectDataSaver: boolean;
|
|
14
|
+
linkSelector: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const DEFAULT_PREFETCH_OPTIONS: PrefetchOptions = {
|
|
18
|
+
strategy: 'intent',
|
|
19
|
+
delay: 65,
|
|
20
|
+
noPrefetchAttribute: 'data-eco-no-prefetch',
|
|
21
|
+
respectDataSaver: true,
|
|
22
|
+
linkSelector: 'a[href]',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export class PrefetchManager {
|
|
26
|
+
private options: PrefetchOptions;
|
|
27
|
+
private prefetched: Set<string> = new Set();
|
|
28
|
+
private htmlCache: Map<string, string> = new Map();
|
|
29
|
+
private observer: IntersectionObserver | null = null;
|
|
30
|
+
private hoverTimeouts: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
31
|
+
|
|
32
|
+
constructor(options: Partial<PrefetchOptions>) {
|
|
33
|
+
this.options = { ...DEFAULT_PREFETCH_OPTIONS, ...options };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Initializes prefetching based on the configured strategy.
|
|
38
|
+
*
|
|
39
|
+
* Sets up IntersectionObserver for viewport-based prefetching and/or
|
|
40
|
+
* hover/focus listeners for intent-based prefetching. Immediately begins
|
|
41
|
+
* observing existing links on the page.
|
|
42
|
+
*/
|
|
43
|
+
start(): void {
|
|
44
|
+
if (!this.shouldPrefetch()) return;
|
|
45
|
+
|
|
46
|
+
if (this.options.strategy === 'viewport' || this.options.strategy === 'intent') {
|
|
47
|
+
this.setupIntersectionObserver();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (this.options.strategy === 'hover' || this.options.strategy === 'intent') {
|
|
51
|
+
this.setupHoverListeners();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.observeExistingLinks();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Cleans up all prefetch-related observers and event listeners.
|
|
59
|
+
* Cancels any pending hover timeouts.
|
|
60
|
+
*/
|
|
61
|
+
stop(): void {
|
|
62
|
+
this.observer?.disconnect();
|
|
63
|
+
this.observer = null;
|
|
64
|
+
document.removeEventListener('mouseover', this.handleMouseOver);
|
|
65
|
+
document.removeEventListener('mouseout', this.handleMouseOut);
|
|
66
|
+
document.removeEventListener('focusin', this.handleFocusIn);
|
|
67
|
+
document.removeEventListener('focusout', this.handleFocusOut);
|
|
68
|
+
this.hoverTimeouts.forEach((timeout) => clearTimeout(timeout));
|
|
69
|
+
this.hoverTimeouts.clear();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetches and caches HTML content for a given URL.
|
|
74
|
+
*
|
|
75
|
+
* Skips cross-origin URLs or already-prefetched URLs. On success, both the
|
|
76
|
+
* HTML content is cached and any new stylesheets are preloaded to prevent
|
|
77
|
+
* FOUC during navigation.
|
|
78
|
+
*
|
|
79
|
+
* @param href - The URL to prefetch
|
|
80
|
+
*/
|
|
81
|
+
async prefetch(href: string): Promise<void> {
|
|
82
|
+
const url = new URL(href, window.location.origin);
|
|
83
|
+
|
|
84
|
+
if (url.origin !== window.location.origin) return;
|
|
85
|
+
if (this.prefetched.has(url.href)) return;
|
|
86
|
+
|
|
87
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
88
|
+
const targetPath = url.pathname + url.search;
|
|
89
|
+
if (currentPath === targetPath) return;
|
|
90
|
+
|
|
91
|
+
this.prefetched.add(url.href);
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(url.href, {
|
|
95
|
+
headers: { Accept: 'text/html' },
|
|
96
|
+
priority: 'low',
|
|
97
|
+
} as RequestInit);
|
|
98
|
+
|
|
99
|
+
if (!response.ok) return;
|
|
100
|
+
|
|
101
|
+
const html = await response.text();
|
|
102
|
+
|
|
103
|
+
this.htmlCache.set(url.href, html);
|
|
104
|
+
await this.prefetchStylesheets(html, url);
|
|
105
|
+
} catch {
|
|
106
|
+
this.prefetched.delete(url.href);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Retrieves cached HTML for a URL.
|
|
112
|
+
*
|
|
113
|
+
* Returns cached content without consuming it, enabling stale-while-revalidate:
|
|
114
|
+
* - Returns cached HTML immediately for instant navigation
|
|
115
|
+
* - Use cacheVisitedPage() after navigation to update cache in background
|
|
116
|
+
*
|
|
117
|
+
* @param href - The URL to look up
|
|
118
|
+
* @returns The cached HTML string, or null if not cached
|
|
119
|
+
*/
|
|
120
|
+
getCachedHtml(href: string): string | null {
|
|
121
|
+
const url = new URL(href, window.location.origin);
|
|
122
|
+
return this.htmlCache.get(url.href) ?? null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Caches HTML content for a visited page and triggers background revalidation.
|
|
127
|
+
*
|
|
128
|
+
* Implements stale-while-revalidate pattern:
|
|
129
|
+
* - Immediately caches the provided HTML for instant revisits
|
|
130
|
+
* - Fetches fresh content in background for next visit (unless it's the current page)
|
|
131
|
+
*
|
|
132
|
+
* @param href - The URL of the visited page
|
|
133
|
+
* @param html - The HTML content to cache initially
|
|
134
|
+
*/
|
|
135
|
+
cacheVisitedPage(href: string, html: string): void {
|
|
136
|
+
const url = new URL(href, window.location.origin);
|
|
137
|
+
if (url.origin !== window.location.origin) return;
|
|
138
|
+
|
|
139
|
+
this.htmlCache.set(url.href, html);
|
|
140
|
+
this.prefetched.add(url.href);
|
|
141
|
+
|
|
142
|
+
const currentPath = window.location.pathname + window.location.search;
|
|
143
|
+
const targetPath = url.pathname + url.search;
|
|
144
|
+
if (currentPath === targetPath) return;
|
|
145
|
+
|
|
146
|
+
setTimeout(() => {
|
|
147
|
+
fetch(url.href, {
|
|
148
|
+
headers: { Accept: 'text/html' },
|
|
149
|
+
priority: 'low',
|
|
150
|
+
} as RequestInit)
|
|
151
|
+
.then((response) => {
|
|
152
|
+
if (response.ok) return response.text();
|
|
153
|
+
return null;
|
|
154
|
+
})
|
|
155
|
+
.then((freshHtml) => {
|
|
156
|
+
if (freshHtml) {
|
|
157
|
+
this.htmlCache.set(url.href, freshHtml);
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
.catch(() => {});
|
|
161
|
+
}, 100);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Checks if a URL has already been prefetched.
|
|
166
|
+
* @param href - The URL to check
|
|
167
|
+
*/
|
|
168
|
+
isPrefetched(href: string): boolean {
|
|
169
|
+
const url = new URL(href, window.location.origin);
|
|
170
|
+
return this.prefetched.has(url.href);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Determines if prefetching should be enabled based on network conditions.
|
|
175
|
+
*
|
|
176
|
+
* Respects the user's data saver settings and avoids prefetching on slow
|
|
177
|
+
* connections (2g or slower) when `respectDataSaver` is enabled.
|
|
178
|
+
*/
|
|
179
|
+
private shouldPrefetch(): boolean {
|
|
180
|
+
if (!this.options.respectDataSaver) return true;
|
|
181
|
+
|
|
182
|
+
const conn = (navigator as Navigator & { connection?: NetworkInformation }).connection;
|
|
183
|
+
if (!conn) return true;
|
|
184
|
+
if (conn.saveData) return false;
|
|
185
|
+
if (conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g') return false;
|
|
186
|
+
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Creates an IntersectionObserver to prefetch links as they enter the viewport.
|
|
192
|
+
*
|
|
193
|
+
* Uses a 50px root margin to trigger prefetching slightly before elements
|
|
194
|
+
* become visible, improving perceived performance.
|
|
195
|
+
*/
|
|
196
|
+
private setupIntersectionObserver(): void {
|
|
197
|
+
this.observer = new IntersectionObserver(
|
|
198
|
+
(entries) => {
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (entry.isIntersecting) {
|
|
201
|
+
const link = entry.target as HTMLAnchorElement;
|
|
202
|
+
const strategy = this.getLinkStrategy(link);
|
|
203
|
+
if (strategy === 'viewport' || strategy === 'eager') {
|
|
204
|
+
this.scheduleIdlePrefetch(link.href, strategy === 'eager');
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
{ rootMargin: '50px', threshold: 0 },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Attaches delegated event listeners for hover and focus-based prefetching.
|
|
215
|
+
*
|
|
216
|
+
* Uses event delegation on the document for efficient handling without
|
|
217
|
+
* attaching listeners to individual links.
|
|
218
|
+
*/
|
|
219
|
+
private setupHoverListeners(): void {
|
|
220
|
+
document.addEventListener('mouseover', this.handleMouseOver);
|
|
221
|
+
document.addEventListener('mouseout', this.handleMouseOut);
|
|
222
|
+
document.addEventListener('focusin', this.handleFocusIn);
|
|
223
|
+
document.addEventListener('focusout', this.handleFocusOut);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private handleMouseOver = (event: MouseEvent): void => {
|
|
227
|
+
const link = this.getLinkFromEvent(event);
|
|
228
|
+
if (!link) return;
|
|
229
|
+
|
|
230
|
+
const strategy = this.getLinkStrategy(link);
|
|
231
|
+
if (strategy === 'hover' || strategy === 'intent' || strategy === 'eager') {
|
|
232
|
+
const delay = strategy === 'eager' ? 0 : this.getLinkDelay(link);
|
|
233
|
+
this.scheduleHoverPrefetch(link.href, delay);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
private handleMouseOut = (event: MouseEvent): void => {
|
|
238
|
+
const link = this.getLinkFromEvent(event);
|
|
239
|
+
if (!link) return;
|
|
240
|
+
this.cancelHoverPrefetch(link.href);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
private handleFocusIn = (event: FocusEvent): void => {
|
|
244
|
+
const link = this.getLinkFromEvent(event);
|
|
245
|
+
if (!link) return;
|
|
246
|
+
|
|
247
|
+
const strategy = this.getLinkStrategy(link);
|
|
248
|
+
if (strategy === 'hover' || strategy === 'intent' || strategy === 'eager') {
|
|
249
|
+
const delay = strategy === 'eager' ? 0 : this.getLinkDelay(link);
|
|
250
|
+
this.scheduleHoverPrefetch(link.href, delay);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
private handleFocusOut = (event: FocusEvent): void => {
|
|
255
|
+
const link = this.getLinkFromEvent(event);
|
|
256
|
+
if (!link) return;
|
|
257
|
+
this.cancelHoverPrefetch(link.href);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Extracts a valid anchor element from a DOM event.
|
|
262
|
+
*
|
|
263
|
+
* Returns null for links that should not be prefetched (opt-outs, downloads,
|
|
264
|
+
* hash links, javascript: URLs).
|
|
265
|
+
*/
|
|
266
|
+
private getLinkFromEvent(event: Event): HTMLAnchorElement | null {
|
|
267
|
+
const target = event.target as Element;
|
|
268
|
+
const link = target.closest(this.options.linkSelector) as HTMLAnchorElement | null;
|
|
269
|
+
|
|
270
|
+
if (!link) return null;
|
|
271
|
+
if (link.hasAttribute(this.options.noPrefetchAttribute)) return null;
|
|
272
|
+
if (link.hasAttribute('download')) return null;
|
|
273
|
+
|
|
274
|
+
const href = link.getAttribute('href');
|
|
275
|
+
if (!href || href.startsWith('#') || href.startsWith('javascript:')) return null;
|
|
276
|
+
|
|
277
|
+
return link;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolves the prefetch strategy for a link element.
|
|
282
|
+
*
|
|
283
|
+
* Checks for per-link overrides via `data-eco-prefetch` attribute,
|
|
284
|
+
* falling back to the global strategy.
|
|
285
|
+
*/
|
|
286
|
+
private getLinkStrategy(link: HTMLAnchorElement): PrefetchStrategy | 'eager' | null {
|
|
287
|
+
const override = link.getAttribute('data-eco-prefetch');
|
|
288
|
+
if (override === 'eager' || override === 'viewport' || override === 'hover' || override === 'intent') {
|
|
289
|
+
return override;
|
|
290
|
+
}
|
|
291
|
+
return this.options.strategy;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Resolves the prefetch delay for a link element.
|
|
296
|
+
*
|
|
297
|
+
* Checks for per-link overrides via `data-eco-prefetch-delay` attribute,
|
|
298
|
+
* falling back to the global delay.
|
|
299
|
+
*/
|
|
300
|
+
private getLinkDelay(link: HTMLAnchorElement): number {
|
|
301
|
+
const delayAttr = link.getAttribute('data-eco-prefetch-delay');
|
|
302
|
+
if (delayAttr) {
|
|
303
|
+
const delay = parseInt(delayAttr, 10);
|
|
304
|
+
if (!isNaN(delay) && delay >= 0) return delay;
|
|
305
|
+
}
|
|
306
|
+
return this.options.delay;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Schedules a prefetch request during browser idle time.
|
|
311
|
+
*
|
|
312
|
+
* Uses `requestIdleCallback` when available for non-blocking execution.
|
|
313
|
+
* Eager prefetches execute immediately.
|
|
314
|
+
*
|
|
315
|
+
* @param href - The URL to prefetch
|
|
316
|
+
* @param eager - If true, prefetch immediately without waiting for idle
|
|
317
|
+
*/
|
|
318
|
+
private scheduleIdlePrefetch(href: string, eager: boolean = false): void {
|
|
319
|
+
if (this.prefetched.has(href)) return;
|
|
320
|
+
|
|
321
|
+
const prefetch = () => this.prefetch(href);
|
|
322
|
+
|
|
323
|
+
if (eager) {
|
|
324
|
+
prefetch();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if ('requestIdleCallback' in window) {
|
|
329
|
+
requestIdleCallback(prefetch, { timeout: 2000 });
|
|
330
|
+
} else {
|
|
331
|
+
setTimeout(prefetch, 0);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Schedules a prefetch after a hover delay.
|
|
337
|
+
*
|
|
338
|
+
* The delay prevents prefetching when users briefly pass over links
|
|
339
|
+
* without intent to navigate.
|
|
340
|
+
*
|
|
341
|
+
* @param href - The URL to prefetch
|
|
342
|
+
* @param delay - Milliseconds to wait before prefetching
|
|
343
|
+
*/
|
|
344
|
+
private scheduleHoverPrefetch(href: string, delay: number = this.options.delay): void {
|
|
345
|
+
if (this.prefetched.has(href)) return;
|
|
346
|
+
if (this.hoverTimeouts.has(href)) return;
|
|
347
|
+
|
|
348
|
+
const timeout = setTimeout(() => {
|
|
349
|
+
this.hoverTimeouts.delete(href);
|
|
350
|
+
this.prefetch(href);
|
|
351
|
+
}, delay);
|
|
352
|
+
|
|
353
|
+
this.hoverTimeouts.set(href, timeout);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Cancels a pending hover-initiated prefetch.
|
|
358
|
+
* @param href - The URL whose prefetch should be cancelled
|
|
359
|
+
*/
|
|
360
|
+
private cancelHoverPrefetch(href: string): void {
|
|
361
|
+
const timeout = this.hoverTimeouts.get(href);
|
|
362
|
+
if (timeout) {
|
|
363
|
+
clearTimeout(timeout);
|
|
364
|
+
this.hoverTimeouts.delete(href);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Begins observing all existing links on the page.
|
|
370
|
+
*
|
|
371
|
+
* Called once during initialization. Eager links are prefetched immediately;
|
|
372
|
+
* viewport-strategy links are registered with the IntersectionObserver.
|
|
373
|
+
*/
|
|
374
|
+
private observeExistingLinks(): void {
|
|
375
|
+
const links = document.querySelectorAll<HTMLAnchorElement>(this.options.linkSelector);
|
|
376
|
+
for (const link of links) {
|
|
377
|
+
if (link.hasAttribute(this.options.noPrefetchAttribute)) continue;
|
|
378
|
+
|
|
379
|
+
const strategy = this.getLinkStrategy(link);
|
|
380
|
+
|
|
381
|
+
if (strategy === 'eager') {
|
|
382
|
+
this.scheduleIdlePrefetch(link.href, true);
|
|
383
|
+
} else if (this.observer && strategy === 'viewport') {
|
|
384
|
+
this.observer.observe(link);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Observes newly added links after DOM mutations.
|
|
391
|
+
*
|
|
392
|
+
* Should be called after client-side navigation or dynamic content updates
|
|
393
|
+
* to ensure new links are tracked for prefetching.
|
|
394
|
+
*
|
|
395
|
+
* @param root - The root element to search for links (defaults to document)
|
|
396
|
+
*/
|
|
397
|
+
observeNewLinks(root: Element | Document = document): void {
|
|
398
|
+
const links = root.querySelectorAll<HTMLAnchorElement>(this.options.linkSelector);
|
|
399
|
+
for (const link of links) {
|
|
400
|
+
if (link.hasAttribute(this.options.noPrefetchAttribute)) continue;
|
|
401
|
+
|
|
402
|
+
const strategy = this.getLinkStrategy(link);
|
|
403
|
+
|
|
404
|
+
if (strategy === 'eager') {
|
|
405
|
+
this.scheduleIdlePrefetch(link.href, true);
|
|
406
|
+
} else if (this.observer && strategy === 'viewport') {
|
|
407
|
+
this.observer.observe(link);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Prefetches stylesheets discovered in HTML content.
|
|
414
|
+
*
|
|
415
|
+
* Parses the HTML to find stylesheet links, then creates preload hints
|
|
416
|
+
* for stylesheets not already present in the current document. This ensures
|
|
417
|
+
* styles are cached before navigation to prevent FOUC.
|
|
418
|
+
*
|
|
419
|
+
* @param html - The raw HTML string to parse
|
|
420
|
+
* @param url - The base URL for resolving relative stylesheet paths
|
|
421
|
+
*/
|
|
422
|
+
private async prefetchStylesheets(html: string, url: URL): Promise<void> {
|
|
423
|
+
const parser = new DOMParser();
|
|
424
|
+
const doc = parser.parseFromString(`<base href="${url.href}">${html}`, 'text/html');
|
|
425
|
+
|
|
426
|
+
const existingHrefs = new Set([
|
|
427
|
+
...Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]')).map((l) => l.href),
|
|
428
|
+
...Array.from(document.querySelectorAll<HTMLLinkElement>('link[rel="preload"][as="style"]')).map(
|
|
429
|
+
(l) => l.href,
|
|
430
|
+
),
|
|
431
|
+
]);
|
|
432
|
+
|
|
433
|
+
const newStylesheets = doc.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]');
|
|
434
|
+
|
|
435
|
+
for (const link of newStylesheets) {
|
|
436
|
+
if (!existingHrefs.has(link.href)) {
|
|
437
|
+
const preloadLink = document.createElement('link');
|
|
438
|
+
preloadLink.rel = 'preload';
|
|
439
|
+
preloadLink.as = 'style';
|
|
440
|
+
preloadLink.href = link.href;
|
|
441
|
+
|
|
442
|
+
document.head.appendChild(preloadLink);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
interface NetworkInformation {
|
|
449
|
+
saveData?: boolean;
|
|
450
|
+
effectiveType?: 'slow-2g' | '2g' | '3g' | '4g';
|
|
451
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages scroll position during navigations
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
import type { EcoRouterOptions } from '../types';
|
|
6
|
+
/**
|
|
7
|
+
* Service for handling scroll position during page transitions.
|
|
8
|
+
* Handles window scroll behavior and hash navigation.
|
|
9
|
+
*/
|
|
10
|
+
export declare class ScrollManager {
|
|
11
|
+
private scrollBehavior;
|
|
12
|
+
private smoothScroll;
|
|
13
|
+
constructor(scrollBehavior: Required<EcoRouterOptions>['scrollBehavior'], smoothScroll: boolean);
|
|
14
|
+
/**
|
|
15
|
+
* Handle window scroll position based on scrollBehavior option.
|
|
16
|
+
* Hash links always scroll to target regardless of option.
|
|
17
|
+
*/
|
|
18
|
+
handleScroll(newUrl: URL, previousUrl: URL): void;
|
|
19
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
class ScrollManager {
|
|
2
|
+
scrollBehavior;
|
|
3
|
+
smoothScroll;
|
|
4
|
+
constructor(scrollBehavior, smoothScroll) {
|
|
5
|
+
this.scrollBehavior = scrollBehavior;
|
|
6
|
+
this.smoothScroll = smoothScroll;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Handle window scroll position based on scrollBehavior option.
|
|
10
|
+
* Hash links always scroll to target regardless of option.
|
|
11
|
+
*/
|
|
12
|
+
handleScroll(newUrl, previousUrl) {
|
|
13
|
+
if (newUrl.hash) {
|
|
14
|
+
const target = document.querySelector(newUrl.hash);
|
|
15
|
+
target?.scrollIntoView({ behavior: this.smoothScroll ? "smooth" : "instant" });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const behavior = this.smoothScroll ? "smooth" : "instant";
|
|
19
|
+
switch (this.scrollBehavior) {
|
|
20
|
+
case "preserve":
|
|
21
|
+
break;
|
|
22
|
+
case "auto":
|
|
23
|
+
if (newUrl.pathname !== previousUrl.pathname) {
|
|
24
|
+
window.scrollTo({ top: 0, left: 0, behavior });
|
|
25
|
+
}
|
|
26
|
+
break;
|
|
27
|
+
case "top":
|
|
28
|
+
default:
|
|
29
|
+
window.scrollTo({ top: 0, left: 0, behavior });
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
ScrollManager
|
|
36
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages scroll position during navigations
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { EcoRouterOptions } from '../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Service for handling scroll position during page transitions.
|
|
10
|
+
* Handles window scroll behavior and hash navigation.
|
|
11
|
+
*/
|
|
12
|
+
export class ScrollManager {
|
|
13
|
+
private scrollBehavior: Required<EcoRouterOptions>['scrollBehavior'];
|
|
14
|
+
private smoothScroll: boolean;
|
|
15
|
+
|
|
16
|
+
constructor(scrollBehavior: Required<EcoRouterOptions>['scrollBehavior'], smoothScroll: boolean) {
|
|
17
|
+
this.scrollBehavior = scrollBehavior;
|
|
18
|
+
this.smoothScroll = smoothScroll;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Handle window scroll position based on scrollBehavior option.
|
|
23
|
+
* Hash links always scroll to target regardless of option.
|
|
24
|
+
*/
|
|
25
|
+
handleScroll(newUrl: URL, previousUrl: URL): void {
|
|
26
|
+
if (newUrl.hash) {
|
|
27
|
+
const target = document.querySelector(newUrl.hash);
|
|
28
|
+
target?.scrollIntoView({ behavior: this.smoothScroll ? 'smooth' : 'instant' });
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const behavior = this.smoothScroll ? 'smooth' : 'instant';
|
|
33
|
+
|
|
34
|
+
switch (this.scrollBehavior) {
|
|
35
|
+
case 'preserve':
|
|
36
|
+
break;
|
|
37
|
+
case 'auto':
|
|
38
|
+
if (newUrl.pathname !== previousUrl.pathname) {
|
|
39
|
+
window.scrollTo({ top: 0, left: 0, behavior });
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
case 'top':
|
|
43
|
+
default:
|
|
44
|
+
window.scrollTo({ top: 0, left: 0, behavior });
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages View Transition API integration
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Service for handling View Transition API during page transitions.
|
|
7
|
+
* Falls back to direct execution if the API is not supported.
|
|
8
|
+
*/
|
|
9
|
+
export declare class ViewTransitionManager {
|
|
10
|
+
private enabled;
|
|
11
|
+
constructor(enabled: boolean);
|
|
12
|
+
/**
|
|
13
|
+
* Check if the View Transition API is supported
|
|
14
|
+
*/
|
|
15
|
+
isSupported(): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Execute a callback with view transition if available and enabled.
|
|
18
|
+
* Falls back to direct execution if not supported.
|
|
19
|
+
* @param callback - The DOM update callback to execute
|
|
20
|
+
* @returns Promise that resolves when the transition completes
|
|
21
|
+
*/
|
|
22
|
+
transition(callback: () => void | Promise<void>): Promise<void>;
|
|
23
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { applyViewTransitionNames, clearViewTransitionNames } from "../view-transition-utils.js";
|
|
2
|
+
class ViewTransitionManager {
|
|
3
|
+
enabled;
|
|
4
|
+
constructor(enabled) {
|
|
5
|
+
this.enabled = enabled;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Check if the View Transition API is supported
|
|
9
|
+
*/
|
|
10
|
+
isSupported() {
|
|
11
|
+
return "startViewTransition" in document;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Execute a callback with view transition if available and enabled.
|
|
15
|
+
* Falls back to direct execution if not supported.
|
|
16
|
+
* @param callback - The DOM update callback to execute
|
|
17
|
+
* @returns Promise that resolves when the transition completes
|
|
18
|
+
*/
|
|
19
|
+
async transition(callback) {
|
|
20
|
+
if (!this.enabled || !this.isSupported()) {
|
|
21
|
+
await callback();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
applyViewTransitionNames();
|
|
25
|
+
const transition = document.startViewTransition(async () => {
|
|
26
|
+
await callback();
|
|
27
|
+
applyViewTransitionNames();
|
|
28
|
+
});
|
|
29
|
+
try {
|
|
30
|
+
await transition.finished;
|
|
31
|
+
} finally {
|
|
32
|
+
clearViewTransitionNames();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export {
|
|
37
|
+
ViewTransitionManager
|
|
38
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manages View Transition API integration
|
|
3
|
+
* @module
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { applyViewTransitionNames, clearViewTransitionNames } from '../view-transition-utils.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Service for handling View Transition API during page transitions.
|
|
10
|
+
* Falls back to direct execution if the API is not supported.
|
|
11
|
+
*/
|
|
12
|
+
export class ViewTransitionManager {
|
|
13
|
+
private enabled: boolean;
|
|
14
|
+
|
|
15
|
+
constructor(enabled: boolean) {
|
|
16
|
+
this.enabled = enabled;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Check if the View Transition API is supported
|
|
21
|
+
*/
|
|
22
|
+
isSupported(): boolean {
|
|
23
|
+
return 'startViewTransition' in document;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Execute a callback with view transition if available and enabled.
|
|
28
|
+
* Falls back to direct execution if not supported.
|
|
29
|
+
* @param callback - The DOM update callback to execute
|
|
30
|
+
* @returns Promise that resolves when the transition completes
|
|
31
|
+
*/
|
|
32
|
+
async transition(callback: () => void | Promise<void>): Promise<void> {
|
|
33
|
+
if (!this.enabled || !this.isSupported()) {
|
|
34
|
+
await callback();
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Apply view transition names to current elements before starting the transition.
|
|
40
|
+
* This ensures the "old" state is captured correctly.
|
|
41
|
+
*/
|
|
42
|
+
applyViewTransitionNames();
|
|
43
|
+
|
|
44
|
+
const transition = (
|
|
45
|
+
document as Document & { startViewTransition: (cb: () => void) => ViewTransition }
|
|
46
|
+
).startViewTransition(async () => {
|
|
47
|
+
await callback();
|
|
48
|
+
/**
|
|
49
|
+
* Apply names to the NEW elements after DOM swap and hydration.
|
|
50
|
+
* This ensures the "new" state is captured correctly.
|
|
51
|
+
*/
|
|
52
|
+
applyViewTransitionNames();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
await transition.finished;
|
|
57
|
+
} finally {
|
|
58
|
+
/**
|
|
59
|
+
* Cleanup view transition names and dynamic styles after transition completes.
|
|
60
|
+
* This prevents style pollution.
|
|
61
|
+
*/
|
|
62
|
+
clearViewTransitionNames();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* ViewTransition interface for browsers that support the API
|
|
69
|
+
*/
|
|
70
|
+
interface ViewTransition {
|
|
71
|
+
finished: Promise<void>;
|
|
72
|
+
ready: Promise<void>;
|
|
73
|
+
updateCallbackDone: Promise<void>;
|
|
74
|
+
skipTransition(): void;
|
|
75
|
+
}
|