@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +132 -0
  4. package/package.json +39 -0
  5. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
  6. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
  7. 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
  8. 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
  9. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
  10. 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
  11. 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
  12. 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
  13. 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
  14. 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
  15. 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
  16. 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
  17. 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
  18. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
  19. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
  20. 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
  21. 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
  22. 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
  23. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
  24. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
  25. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
  26. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
  27. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
  28. 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
  29. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
  30. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
  31. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
  32. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
  33. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
  34. 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
  35. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
  36. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
  37. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
  38. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
  39. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
  40. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
  41. 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
  42. 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
  43. package/src/client/eco-router.d.ts +98 -0
  44. package/src/client/eco-router.js +228 -0
  45. package/src/client/eco-router.ts +290 -0
  46. package/src/client/services/dom-swapper.d.ts +65 -0
  47. package/src/client/services/dom-swapper.js +237 -0
  48. package/src/client/services/dom-swapper.ts +325 -0
  49. package/src/client/services/index.d.ts +8 -0
  50. package/src/client/services/index.js +10 -0
  51. package/src/client/services/index.ts +9 -0
  52. package/src/client/services/prefetch-manager.d.ts +169 -0
  53. package/src/client/services/prefetch-manager.js +374 -0
  54. package/src/client/services/prefetch-manager.ts +451 -0
  55. package/src/client/services/scroll-manager.d.ts +19 -0
  56. package/src/client/services/scroll-manager.js +36 -0
  57. package/src/client/services/scroll-manager.ts +48 -0
  58. package/src/client/services/view-transition-manager.d.ts +23 -0
  59. package/src/client/services/view-transition-manager.js +38 -0
  60. package/src/client/services/view-transition-manager.ts +75 -0
  61. package/src/client/types.d.ts +84 -0
  62. package/src/client/types.js +19 -0
  63. package/src/client/types.ts +109 -0
  64. package/src/client/view-transition-utils.d.ts +14 -0
  65. package/src/client/view-transition-utils.js +60 -0
  66. package/src/client/view-transition-utils.ts +98 -0
  67. package/src/index.d.ts +9 -0
  68. package/src/index.js +11 -0
  69. package/src/index.ts +19 -0
  70. package/src/styles.css +218 -0
  71. package/src/types.d.ts +15 -0
  72. package/src/types.js +4 -0
  73. package/src/types.ts +19 -0
@@ -0,0 +1,374 @@
1
+ const DEFAULT_PREFETCH_OPTIONS = {
2
+ strategy: "intent",
3
+ delay: 65,
4
+ noPrefetchAttribute: "data-eco-no-prefetch",
5
+ respectDataSaver: true,
6
+ linkSelector: "a[href]"
7
+ };
8
+ class PrefetchManager {
9
+ options;
10
+ prefetched = /* @__PURE__ */ new Set();
11
+ htmlCache = /* @__PURE__ */ new Map();
12
+ observer = null;
13
+ hoverTimeouts = /* @__PURE__ */ new Map();
14
+ constructor(options) {
15
+ this.options = { ...DEFAULT_PREFETCH_OPTIONS, ...options };
16
+ }
17
+ /**
18
+ * Initializes prefetching based on the configured strategy.
19
+ *
20
+ * Sets up IntersectionObserver for viewport-based prefetching and/or
21
+ * hover/focus listeners for intent-based prefetching. Immediately begins
22
+ * observing existing links on the page.
23
+ */
24
+ start() {
25
+ if (!this.shouldPrefetch()) return;
26
+ if (this.options.strategy === "viewport" || this.options.strategy === "intent") {
27
+ this.setupIntersectionObserver();
28
+ }
29
+ if (this.options.strategy === "hover" || this.options.strategy === "intent") {
30
+ this.setupHoverListeners();
31
+ }
32
+ this.observeExistingLinks();
33
+ }
34
+ /**
35
+ * Cleans up all prefetch-related observers and event listeners.
36
+ * Cancels any pending hover timeouts.
37
+ */
38
+ stop() {
39
+ this.observer?.disconnect();
40
+ this.observer = null;
41
+ document.removeEventListener("mouseover", this.handleMouseOver);
42
+ document.removeEventListener("mouseout", this.handleMouseOut);
43
+ document.removeEventListener("focusin", this.handleFocusIn);
44
+ document.removeEventListener("focusout", this.handleFocusOut);
45
+ this.hoverTimeouts.forEach((timeout) => clearTimeout(timeout));
46
+ this.hoverTimeouts.clear();
47
+ }
48
+ /**
49
+ * Fetches and caches HTML content for a given URL.
50
+ *
51
+ * Skips cross-origin URLs or already-prefetched URLs. On success, both the
52
+ * HTML content is cached and any new stylesheets are preloaded to prevent
53
+ * FOUC during navigation.
54
+ *
55
+ * @param href - The URL to prefetch
56
+ */
57
+ async prefetch(href) {
58
+ const url = new URL(href, window.location.origin);
59
+ if (url.origin !== window.location.origin) return;
60
+ if (this.prefetched.has(url.href)) return;
61
+ const currentPath = window.location.pathname + window.location.search;
62
+ const targetPath = url.pathname + url.search;
63
+ if (currentPath === targetPath) return;
64
+ this.prefetched.add(url.href);
65
+ try {
66
+ const response = await fetch(url.href, {
67
+ headers: { Accept: "text/html" },
68
+ priority: "low"
69
+ });
70
+ if (!response.ok) return;
71
+ const html = await response.text();
72
+ this.htmlCache.set(url.href, html);
73
+ await this.prefetchStylesheets(html, url);
74
+ } catch {
75
+ this.prefetched.delete(url.href);
76
+ }
77
+ }
78
+ /**
79
+ * Retrieves cached HTML for a URL.
80
+ *
81
+ * Returns cached content without consuming it, enabling stale-while-revalidate:
82
+ * - Returns cached HTML immediately for instant navigation
83
+ * - Use cacheVisitedPage() after navigation to update cache in background
84
+ *
85
+ * @param href - The URL to look up
86
+ * @returns The cached HTML string, or null if not cached
87
+ */
88
+ getCachedHtml(href) {
89
+ const url = new URL(href, window.location.origin);
90
+ return this.htmlCache.get(url.href) ?? null;
91
+ }
92
+ /**
93
+ * Caches HTML content for a visited page and triggers background revalidation.
94
+ *
95
+ * Implements stale-while-revalidate pattern:
96
+ * - Immediately caches the provided HTML for instant revisits
97
+ * - Fetches fresh content in background for next visit (unless it's the current page)
98
+ *
99
+ * @param href - The URL of the visited page
100
+ * @param html - The HTML content to cache initially
101
+ */
102
+ cacheVisitedPage(href, html) {
103
+ const url = new URL(href, window.location.origin);
104
+ if (url.origin !== window.location.origin) return;
105
+ this.htmlCache.set(url.href, html);
106
+ this.prefetched.add(url.href);
107
+ const currentPath = window.location.pathname + window.location.search;
108
+ const targetPath = url.pathname + url.search;
109
+ if (currentPath === targetPath) return;
110
+ setTimeout(() => {
111
+ fetch(url.href, {
112
+ headers: { Accept: "text/html" },
113
+ priority: "low"
114
+ }).then((response) => {
115
+ if (response.ok) return response.text();
116
+ return null;
117
+ }).then((freshHtml) => {
118
+ if (freshHtml) {
119
+ this.htmlCache.set(url.href, freshHtml);
120
+ }
121
+ }).catch(() => {
122
+ });
123
+ }, 100);
124
+ }
125
+ /**
126
+ * Checks if a URL has already been prefetched.
127
+ * @param href - The URL to check
128
+ */
129
+ isPrefetched(href) {
130
+ const url = new URL(href, window.location.origin);
131
+ return this.prefetched.has(url.href);
132
+ }
133
+ /**
134
+ * Determines if prefetching should be enabled based on network conditions.
135
+ *
136
+ * Respects the user's data saver settings and avoids prefetching on slow
137
+ * connections (2g or slower) when `respectDataSaver` is enabled.
138
+ */
139
+ shouldPrefetch() {
140
+ if (!this.options.respectDataSaver) return true;
141
+ const conn = navigator.connection;
142
+ if (!conn) return true;
143
+ if (conn.saveData) return false;
144
+ if (conn.effectiveType === "2g" || conn.effectiveType === "slow-2g") return false;
145
+ return true;
146
+ }
147
+ /**
148
+ * Creates an IntersectionObserver to prefetch links as they enter the viewport.
149
+ *
150
+ * Uses a 50px root margin to trigger prefetching slightly before elements
151
+ * become visible, improving perceived performance.
152
+ */
153
+ setupIntersectionObserver() {
154
+ this.observer = new IntersectionObserver(
155
+ (entries) => {
156
+ for (const entry of entries) {
157
+ if (entry.isIntersecting) {
158
+ const link = entry.target;
159
+ const strategy = this.getLinkStrategy(link);
160
+ if (strategy === "viewport" || strategy === "eager") {
161
+ this.scheduleIdlePrefetch(link.href, strategy === "eager");
162
+ }
163
+ }
164
+ }
165
+ },
166
+ { rootMargin: "50px", threshold: 0 }
167
+ );
168
+ }
169
+ /**
170
+ * Attaches delegated event listeners for hover and focus-based prefetching.
171
+ *
172
+ * Uses event delegation on the document for efficient handling without
173
+ * attaching listeners to individual links.
174
+ */
175
+ setupHoverListeners() {
176
+ document.addEventListener("mouseover", this.handleMouseOver);
177
+ document.addEventListener("mouseout", this.handleMouseOut);
178
+ document.addEventListener("focusin", this.handleFocusIn);
179
+ document.addEventListener("focusout", this.handleFocusOut);
180
+ }
181
+ handleMouseOver = (event) => {
182
+ const link = this.getLinkFromEvent(event);
183
+ if (!link) return;
184
+ const strategy = this.getLinkStrategy(link);
185
+ if (strategy === "hover" || strategy === "intent" || strategy === "eager") {
186
+ const delay = strategy === "eager" ? 0 : this.getLinkDelay(link);
187
+ this.scheduleHoverPrefetch(link.href, delay);
188
+ }
189
+ };
190
+ handleMouseOut = (event) => {
191
+ const link = this.getLinkFromEvent(event);
192
+ if (!link) return;
193
+ this.cancelHoverPrefetch(link.href);
194
+ };
195
+ handleFocusIn = (event) => {
196
+ const link = this.getLinkFromEvent(event);
197
+ if (!link) return;
198
+ const strategy = this.getLinkStrategy(link);
199
+ if (strategy === "hover" || strategy === "intent" || strategy === "eager") {
200
+ const delay = strategy === "eager" ? 0 : this.getLinkDelay(link);
201
+ this.scheduleHoverPrefetch(link.href, delay);
202
+ }
203
+ };
204
+ handleFocusOut = (event) => {
205
+ const link = this.getLinkFromEvent(event);
206
+ if (!link) return;
207
+ this.cancelHoverPrefetch(link.href);
208
+ };
209
+ /**
210
+ * Extracts a valid anchor element from a DOM event.
211
+ *
212
+ * Returns null for links that should not be prefetched (opt-outs, downloads,
213
+ * hash links, javascript: URLs).
214
+ */
215
+ getLinkFromEvent(event) {
216
+ const target = event.target;
217
+ const link = target.closest(this.options.linkSelector);
218
+ if (!link) return null;
219
+ if (link.hasAttribute(this.options.noPrefetchAttribute)) return null;
220
+ if (link.hasAttribute("download")) return null;
221
+ const href = link.getAttribute("href");
222
+ if (!href || href.startsWith("#") || href.startsWith("javascript:")) return null;
223
+ return link;
224
+ }
225
+ /**
226
+ * Resolves the prefetch strategy for a link element.
227
+ *
228
+ * Checks for per-link overrides via `data-eco-prefetch` attribute,
229
+ * falling back to the global strategy.
230
+ */
231
+ getLinkStrategy(link) {
232
+ const override = link.getAttribute("data-eco-prefetch");
233
+ if (override === "eager" || override === "viewport" || override === "hover" || override === "intent") {
234
+ return override;
235
+ }
236
+ return this.options.strategy;
237
+ }
238
+ /**
239
+ * Resolves the prefetch delay for a link element.
240
+ *
241
+ * Checks for per-link overrides via `data-eco-prefetch-delay` attribute,
242
+ * falling back to the global delay.
243
+ */
244
+ getLinkDelay(link) {
245
+ const delayAttr = link.getAttribute("data-eco-prefetch-delay");
246
+ if (delayAttr) {
247
+ const delay = parseInt(delayAttr, 10);
248
+ if (!isNaN(delay) && delay >= 0) return delay;
249
+ }
250
+ return this.options.delay;
251
+ }
252
+ /**
253
+ * Schedules a prefetch request during browser idle time.
254
+ *
255
+ * Uses `requestIdleCallback` when available for non-blocking execution.
256
+ * Eager prefetches execute immediately.
257
+ *
258
+ * @param href - The URL to prefetch
259
+ * @param eager - If true, prefetch immediately without waiting for idle
260
+ */
261
+ scheduleIdlePrefetch(href, eager = false) {
262
+ if (this.prefetched.has(href)) return;
263
+ const prefetch = () => this.prefetch(href);
264
+ if (eager) {
265
+ prefetch();
266
+ return;
267
+ }
268
+ if ("requestIdleCallback" in window) {
269
+ requestIdleCallback(prefetch, { timeout: 2e3 });
270
+ } else {
271
+ setTimeout(prefetch, 0);
272
+ }
273
+ }
274
+ /**
275
+ * Schedules a prefetch after a hover delay.
276
+ *
277
+ * The delay prevents prefetching when users briefly pass over links
278
+ * without intent to navigate.
279
+ *
280
+ * @param href - The URL to prefetch
281
+ * @param delay - Milliseconds to wait before prefetching
282
+ */
283
+ scheduleHoverPrefetch(href, delay = this.options.delay) {
284
+ if (this.prefetched.has(href)) return;
285
+ if (this.hoverTimeouts.has(href)) return;
286
+ const timeout = setTimeout(() => {
287
+ this.hoverTimeouts.delete(href);
288
+ this.prefetch(href);
289
+ }, delay);
290
+ this.hoverTimeouts.set(href, timeout);
291
+ }
292
+ /**
293
+ * Cancels a pending hover-initiated prefetch.
294
+ * @param href - The URL whose prefetch should be cancelled
295
+ */
296
+ cancelHoverPrefetch(href) {
297
+ const timeout = this.hoverTimeouts.get(href);
298
+ if (timeout) {
299
+ clearTimeout(timeout);
300
+ this.hoverTimeouts.delete(href);
301
+ }
302
+ }
303
+ /**
304
+ * Begins observing all existing links on the page.
305
+ *
306
+ * Called once during initialization. Eager links are prefetched immediately;
307
+ * viewport-strategy links are registered with the IntersectionObserver.
308
+ */
309
+ observeExistingLinks() {
310
+ const links = document.querySelectorAll(this.options.linkSelector);
311
+ for (const link of links) {
312
+ if (link.hasAttribute(this.options.noPrefetchAttribute)) continue;
313
+ const strategy = this.getLinkStrategy(link);
314
+ if (strategy === "eager") {
315
+ this.scheduleIdlePrefetch(link.href, true);
316
+ } else if (this.observer && strategy === "viewport") {
317
+ this.observer.observe(link);
318
+ }
319
+ }
320
+ }
321
+ /**
322
+ * Observes newly added links after DOM mutations.
323
+ *
324
+ * Should be called after client-side navigation or dynamic content updates
325
+ * to ensure new links are tracked for prefetching.
326
+ *
327
+ * @param root - The root element to search for links (defaults to document)
328
+ */
329
+ observeNewLinks(root = document) {
330
+ const links = root.querySelectorAll(this.options.linkSelector);
331
+ for (const link of links) {
332
+ if (link.hasAttribute(this.options.noPrefetchAttribute)) continue;
333
+ const strategy = this.getLinkStrategy(link);
334
+ if (strategy === "eager") {
335
+ this.scheduleIdlePrefetch(link.href, true);
336
+ } else if (this.observer && strategy === "viewport") {
337
+ this.observer.observe(link);
338
+ }
339
+ }
340
+ }
341
+ /**
342
+ * Prefetches stylesheets discovered in HTML content.
343
+ *
344
+ * Parses the HTML to find stylesheet links, then creates preload hints
345
+ * for stylesheets not already present in the current document. This ensures
346
+ * styles are cached before navigation to prevent FOUC.
347
+ *
348
+ * @param html - The raw HTML string to parse
349
+ * @param url - The base URL for resolving relative stylesheet paths
350
+ */
351
+ async prefetchStylesheets(html, url) {
352
+ const parser = new DOMParser();
353
+ const doc = parser.parseFromString(`<base href="${url.href}">${html}`, "text/html");
354
+ const existingHrefs = /* @__PURE__ */ new Set([
355
+ ...Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href),
356
+ ...Array.from(document.querySelectorAll('link[rel="preload"][as="style"]')).map(
357
+ (l) => l.href
358
+ )
359
+ ]);
360
+ const newStylesheets = doc.querySelectorAll('link[rel="stylesheet"]');
361
+ for (const link of newStylesheets) {
362
+ if (!existingHrefs.has(link.href)) {
363
+ const preloadLink = document.createElement("link");
364
+ preloadLink.rel = "preload";
365
+ preloadLink.as = "style";
366
+ preloadLink.href = link.href;
367
+ document.head.appendChild(preloadLink);
368
+ }
369
+ }
370
+ }
371
+ }
372
+ export {
373
+ PrefetchManager
374
+ };