@ecopages/browser-router 0.2.0-alpha.5 → 0.2.0-alpha.6

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.
@@ -1,8 +1,18 @@
1
+ import { getEcoNavigationRuntime } from "@ecopages/core/router/navigation-coordinator";
2
+ import {
3
+ getAnchorFromNavigationEvent,
4
+ recoverPendingNavigationHref
5
+ } from "@ecopages/core/router/link-intent";
1
6
  import { DEFAULT_OPTIONS } from "./types.js";
2
7
  import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } from "./services/index.js";
3
8
  class EcoRouter {
4
9
  options;
5
- abortController = null;
10
+ unregisterNavigationRuntime = null;
11
+ started = false;
12
+ pendingNavigations = 0;
13
+ pendingPointerNavigation = null;
14
+ pendingHoverNavigation = null;
15
+ queuedNavigationHref = null;
6
16
  domSwapper;
7
17
  scrollManager;
8
18
  viewTransitionManager;
@@ -19,8 +29,162 @@ class EcoRouter {
19
29
  });
20
30
  }
21
31
  this.handleClick = this.handleClick.bind(this);
32
+ this.handleHoverIntent = this.handleHoverIntent.bind(this);
33
+ this.handlePointerDown = this.handlePointerDown.bind(this);
22
34
  this.handlePopState = this.handlePopState.bind(this);
23
35
  }
36
+ getLinkFromEvent(event) {
37
+ return getAnchorFromNavigationEvent(event, this.options.linkSelector);
38
+ }
39
+ canInterceptLink(event, link) {
40
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return null;
41
+ if (event.button !== 0) return null;
42
+ const target = link.getAttribute("target");
43
+ if (target && target !== "_self") return null;
44
+ if (link.hasAttribute(this.options.reloadAttribute)) return null;
45
+ if (link.hasAttribute("download")) return null;
46
+ const href = link.getAttribute("href");
47
+ if (!href) return null;
48
+ if (href.startsWith("#")) return null;
49
+ if (href.startsWith("javascript:")) return null;
50
+ const url = new URL(href, window.location.origin);
51
+ if (!this.isSameOrigin(url)) return null;
52
+ return href;
53
+ }
54
+ getRecoveredPointerHref() {
55
+ const href = recoverPendingNavigationHref(
56
+ this.pendingPointerNavigation,
57
+ this.pendingNavigations > 0,
58
+ performance.now()
59
+ );
60
+ if (!href) {
61
+ this.pendingPointerNavigation = null;
62
+ }
63
+ return href;
64
+ }
65
+ getRecoveredHoverHref() {
66
+ const href = recoverPendingNavigationHref(
67
+ this.pendingHoverNavigation,
68
+ this.pendingNavigations > 0,
69
+ performance.now()
70
+ );
71
+ if (!href) {
72
+ this.pendingHoverNavigation = null;
73
+ }
74
+ return href;
75
+ }
76
+ isAnotherNavigationRuntimeActive() {
77
+ const ownerState = getEcoNavigationRuntime(window).getOwnerState();
78
+ return ownerState.owner !== "none" && ownerState.owner !== "browser-router" && ownerState.canHandleSpaNavigation;
79
+ }
80
+ getDocumentOwner(doc) {
81
+ return getEcoNavigationRuntime(window).resolveDocumentOwner(doc, "browser-router");
82
+ }
83
+ adoptDocumentOwner(doc) {
84
+ getEcoNavigationRuntime(window).adoptDocumentOwner(doc, "browser-router");
85
+ }
86
+ syncDocumentElementAttributes(newDocument) {
87
+ const currentHtml = document.documentElement;
88
+ const nextHtml = newDocument.documentElement;
89
+ for (const attribute of Array.from(currentHtml.attributes)) {
90
+ if (!nextHtml.hasAttribute(attribute.name)) {
91
+ currentHtml.removeAttribute(attribute.name);
92
+ }
93
+ }
94
+ for (const attribute of Array.from(nextHtml.attributes)) {
95
+ if (currentHtml.getAttribute(attribute.name) !== attribute.value) {
96
+ currentHtml.setAttribute(attribute.name, attribute.value);
97
+ }
98
+ }
99
+ }
100
+ reloadDocument(url) {
101
+ window.location.assign(url.href);
102
+ }
103
+ /**
104
+ * Commits a fully fetched document into the live page.
105
+ *
106
+ * When browser-router accepts a handoff from another runtime, it delays source
107
+ * runtime cleanup until the incoming document has been prepared and is ready to
108
+ * commit. That ordering avoids the blank-page window we previously hit when a
109
+ * delegated navigation went stale after the source runtime had already torn
110
+ * itself down.
111
+ */
112
+ async commitDocumentNavigation(url, direction, newDocument, options = {}) {
113
+ const previousUrl = new URL(window.location.href);
114
+ const navigationRuntime = getEcoNavigationRuntime(window);
115
+ const isStaleNavigation = options.isStaleNavigation ?? (() => false);
116
+ const currentDocumentOwner = navigationRuntime.resolveDocumentOwner(document, "browser-router");
117
+ const newDocumentOwner = navigationRuntime.resolveDocumentOwner(newDocument, "browser-router");
118
+ const activeOwner = navigationRuntime.getOwnerState().owner;
119
+ const shouldCleanupCurrentOwner = currentDocumentOwner !== newDocumentOwner && currentDocumentOwner !== "browser-router" && activeOwner === currentDocumentOwner;
120
+ let shouldReload = false;
121
+ const beforeSwapEvent = {
122
+ url,
123
+ direction,
124
+ newDocument,
125
+ reload: () => {
126
+ shouldReload = true;
127
+ }
128
+ };
129
+ document.dispatchEvent(new CustomEvent("eco:before-swap", { detail: beforeSwapEvent }));
130
+ if (isStaleNavigation()) return;
131
+ if (shouldReload) {
132
+ if (shouldCleanupCurrentOwner) {
133
+ await navigationRuntime.cleanupOwner(currentDocumentOwner);
134
+ }
135
+ if (isStaleNavigation()) return;
136
+ this.reloadDocument(url);
137
+ return;
138
+ }
139
+ const useViewTransitions = this.options.viewTransitions;
140
+ await this.domSwapper.preloadStylesheets(newDocument);
141
+ if (isStaleNavigation()) return;
142
+ if (shouldCleanupCurrentOwner) {
143
+ await navigationRuntime.cleanupOwner(currentDocumentOwner);
144
+ }
145
+ if (isStaleNavigation()) return;
146
+ const commitSwap = () => {
147
+ if (isStaleNavigation()) return;
148
+ if (this.options.updateHistory && direction === "forward") {
149
+ window.history.pushState({}, "", url.href);
150
+ } else if (direction === "replace") {
151
+ window.history.replaceState({}, "", url.href);
152
+ }
153
+ this.syncDocumentElementAttributes(newDocument);
154
+ this.domSwapper.morphHead(newDocument);
155
+ if (useViewTransitions && !this.domSwapper.shouldReplaceBodyForRerunScripts()) {
156
+ this.domSwapper.morphBody(newDocument);
157
+ } else {
158
+ this.domSwapper.replaceBody(newDocument);
159
+ }
160
+ this.domSwapper.flushRerunScripts();
161
+ this.scrollManager.handleScroll(url, previousUrl);
162
+ };
163
+ if (useViewTransitions) {
164
+ await this.viewTransitionManager.transition(commitSwap);
165
+ } else {
166
+ commitSwap();
167
+ }
168
+ if (isStaleNavigation()) return;
169
+ navigationRuntime.adoptDocumentOwner(newDocument, "browser-router");
170
+ const afterSwapEvent = {
171
+ url,
172
+ direction
173
+ };
174
+ document.dispatchEvent(new CustomEvent("eco:after-swap", { detail: afterSwapEvent }));
175
+ this.prefetchManager?.observeNewLinks();
176
+ if (options.html) {
177
+ this.prefetchManager?.cacheVisitedPage(url.href, options.html);
178
+ }
179
+ requestAnimationFrame(() => {
180
+ if (isStaleNavigation()) return;
181
+ document.dispatchEvent(
182
+ new CustomEvent("eco:page-load", {
183
+ detail: { url, direction }
184
+ })
185
+ );
186
+ });
187
+ }
24
188
  /**
25
189
  * Starts the router and begins intercepting navigation.
26
190
  *
@@ -28,30 +192,87 @@ class EcoRouter {
28
192
  * back/forward buttons. Also starts the prefetch manager if configured.
29
193
  */
30
194
  start() {
31
- document.addEventListener("click", this.handleClick);
195
+ if (this.started) {
196
+ return;
197
+ }
198
+ const navigationRuntime = getEcoNavigationRuntime(window);
199
+ document.addEventListener("mouseover", this.handleHoverIntent, true);
200
+ document.addEventListener("pointerover", this.handleHoverIntent, true);
201
+ document.addEventListener("mousemove", this.handleHoverIntent, true);
202
+ document.addEventListener("pointermove", this.handleHoverIntent, true);
203
+ document.addEventListener("pointerdown", this.handlePointerDown, true);
204
+ document.addEventListener("click", this.handleClick, true);
32
205
  window.addEventListener("popstate", this.handlePopState);
33
206
  this.prefetchManager?.start();
34
- const windowWithHmr = window;
35
- windowWithHmr.__ecopages_reload_current_page__ = async (options) => {
36
- const currentUrl = window.location.pathname + window.location.search;
37
- if (options.clearCache) {
38
- this.prefetchManager?.invalidate(currentUrl);
207
+ this.unregisterNavigationRuntime?.();
208
+ this.unregisterNavigationRuntime = navigationRuntime.register({
209
+ owner: "browser-router",
210
+ navigate: async (request) => {
211
+ await this.performNavigation(
212
+ new URL(request.href, window.location.origin),
213
+ request.direction ?? "forward"
214
+ );
215
+ return true;
216
+ },
217
+ handoffNavigation: async (request) => {
218
+ const { isStaleNavigation, complete } = this.beginNavigationTransaction();
219
+ if (isStaleNavigation()) return true;
220
+ try {
221
+ await this.commitDocumentNavigation(
222
+ new URL(request.finalHref ?? request.href, window.location.origin),
223
+ request.direction ?? "forward",
224
+ request.document,
225
+ { html: request.html, isStaleNavigation }
226
+ );
227
+ } finally {
228
+ complete();
229
+ }
230
+ return true;
231
+ },
232
+ reloadCurrentPage: async (request) => {
233
+ if (this.pendingNavigations > 0) return;
234
+ const currentUrl = window.location.pathname + window.location.search;
235
+ if (request?.clearCache) {
236
+ this.prefetchManager?.invalidate(currentUrl);
237
+ }
238
+ await this.performNavigation(new URL(currentUrl, window.location.origin), "replace");
239
+ },
240
+ cleanupBeforeHandoff: async () => {
241
+ this.cancelNavigationTransaction();
39
242
  }
40
- await this.performNavigation(new URL(currentUrl, window.location.origin), "replace");
41
- };
243
+ });
244
+ this.adoptDocumentOwner(document);
42
245
  const initialHtml = document.documentElement.outerHTML;
43
246
  this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
247
+ this.started = true;
44
248
  }
45
249
  /**
46
250
  * Stops the router and cleans up all event listeners.
47
251
  * After calling this, navigation will fall back to full page reloads.
48
252
  */
49
253
  stop() {
50
- document.removeEventListener("click", this.handleClick);
254
+ if (!this.started) {
255
+ return;
256
+ }
257
+ this.cancelNavigationTransaction();
258
+ document.removeEventListener("mouseover", this.handleHoverIntent, true);
259
+ document.removeEventListener("pointerover", this.handleHoverIntent, true);
260
+ document.removeEventListener("mousemove", this.handleHoverIntent, true);
261
+ document.removeEventListener("pointermove", this.handleHoverIntent, true);
262
+ document.removeEventListener("pointerdown", this.handlePointerDown, true);
263
+ document.removeEventListener("click", this.handleClick, true);
51
264
  window.removeEventListener("popstate", this.handlePopState);
52
265
  this.prefetchManager?.stop();
53
- const windowWithHmr = window;
54
- windowWithHmr.__ecopages_reload_current_page__ = void 0;
266
+ this.unregisterNavigationRuntime?.();
267
+ this.unregisterNavigationRuntime = null;
268
+ this.started = false;
269
+ this.pendingHoverNavigation = null;
270
+ this.pendingPointerNavigation = null;
271
+ this.queuedNavigationHref = null;
272
+ const win = window;
273
+ if (win[ACTIVE_ROUTER_KEY] === this) {
274
+ delete win[ACTIVE_ROUTER_KEY];
275
+ }
55
276
  }
56
277
  /**
57
278
  * Programmatic navigation.
@@ -88,24 +309,63 @@ class EcoRouter {
88
309
  * Uses `event.composedPath()` to correctly detect clicks on anchors inside
89
310
  * Shadow DOM boundaries (Web Components).
90
311
  */
312
+ handlePointerDown(event) {
313
+ const link = this.getLinkFromEvent(event);
314
+ if (!link) {
315
+ this.pendingPointerNavigation = null;
316
+ return;
317
+ }
318
+ const href = this.canInterceptLink(event, link);
319
+ this.pendingPointerNavigation = href ? {
320
+ href,
321
+ timestamp: performance.now()
322
+ } : null;
323
+ if (href && this.pendingNavigations > 0) {
324
+ this.queuedNavigationHref = href;
325
+ }
326
+ }
327
+ handleHoverIntent(event) {
328
+ const link = this.getLinkFromEvent(event);
329
+ if (!link) {
330
+ return;
331
+ }
332
+ const href = this.canInterceptLink(event, link);
333
+ if (!href) {
334
+ return;
335
+ }
336
+ this.pendingHoverNavigation = {
337
+ href,
338
+ timestamp: performance.now()
339
+ };
340
+ if (this.pendingNavigations > 0) {
341
+ this.queuedNavigationHref = href;
342
+ }
343
+ }
91
344
  handleClick(event) {
92
- const link = event.composedPath().find(
93
- (el) => el instanceof HTMLAnchorElement && el.matches(this.options.linkSelector)
94
- );
95
- if (!link) return;
96
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
97
- if (event.button !== 0) return;
98
- const target = link.getAttribute("target");
99
- if (target && target !== "_self") return;
100
- if (link.hasAttribute(this.options.reloadAttribute)) return;
101
- if (link.hasAttribute("download")) return;
102
- const href = link.getAttribute("href");
345
+ const navigationRuntime = getEcoNavigationRuntime(window);
346
+ const link = this.getLinkFromEvent(event);
347
+ const href = link ? this.canInterceptLink(event, link) : this.getRecoveredPointerHref() ?? this.getRecoveredHoverHref();
348
+ this.pendingPointerNavigation = null;
349
+ this.pendingHoverNavigation = null;
103
350
  if (!href) return;
104
- if (href.startsWith("#")) return;
105
- if (href.startsWith("javascript:")) return;
351
+ this.queuedNavigationHref = null;
352
+ if (this.isAnotherNavigationRuntimeActive()) {
353
+ event.preventDefault();
354
+ event.stopImmediatePropagation();
355
+ void navigationRuntime.requestNavigation({
356
+ href,
357
+ direction: "forward",
358
+ source: "browser-router"
359
+ });
360
+ return;
361
+ }
106
362
  const url = new URL(href, window.location.origin);
107
- if (!this.isSameOrigin(url)) return;
108
363
  event.preventDefault();
364
+ if (this.pendingNavigations > 0) {
365
+ this.queuedNavigationHref = href;
366
+ this.cancelNavigationTransaction();
367
+ return;
368
+ }
109
369
  this.performNavigation(url, "forward");
110
370
  }
111
371
  /**
@@ -113,6 +373,7 @@ class EcoRouter {
113
373
  * Triggered by the History API's popstate event.
114
374
  */
115
375
  handlePopState(_event) {
376
+ if (this.isAnotherNavigationRuntimeActive()) return;
116
377
  const url = new URL(window.location.href);
117
378
  this.performNavigation(url, "back");
118
379
  }
@@ -123,6 +384,17 @@ class EcoRouter {
123
384
  isSameOrigin(url) {
124
385
  return url.origin === window.location.origin;
125
386
  }
387
+ cancelNavigationTransaction() {
388
+ getEcoNavigationRuntime(window).cancelCurrentNavigationTransaction();
389
+ }
390
+ beginNavigationTransaction() {
391
+ const transaction = getEcoNavigationRuntime(window).beginNavigationTransaction();
392
+ return {
393
+ isStaleNavigation: () => !transaction.isCurrent(),
394
+ signal: transaction.signal,
395
+ complete: () => transaction.complete()
396
+ };
397
+ }
126
398
  /**
127
399
  * Executes the core navigation flow.
128
400
  *
@@ -142,64 +414,45 @@ class EcoRouter {
142
414
  * @param direction - Navigation direction ('forward', 'back', or 'replace')
143
415
  */
144
416
  async performNavigation(url, direction) {
145
- const previousUrl = new URL(window.location.href);
146
- this.abortController?.abort();
147
- this.abortController = new AbortController();
417
+ this.pendingNavigations++;
418
+ const { isStaleNavigation, signal, complete } = this.beginNavigationTransaction();
419
+ let queuedNavigationHref = null;
148
420
  try {
149
- const html = await this.fetchPage(url, this.abortController.signal);
421
+ const html = await this.fetchPage(url, signal);
422
+ if (isStaleNavigation()) return;
150
423
  const newDocument = this.domSwapper.parseHTML(html, url);
151
- let shouldReload = false;
152
- const beforeSwapEvent = {
153
- url,
154
- direction,
155
- newDocument,
156
- reload: () => {
157
- shouldReload = true;
158
- }
159
- };
160
- document.dispatchEvent(new CustomEvent("eco:before-swap", { detail: beforeSwapEvent }));
161
- if (shouldReload) {
162
- window.location.href = url.href;
163
- return;
164
- }
165
- if (this.options.updateHistory && direction === "forward") {
166
- window.history.pushState({}, "", url.href);
167
- } else if (direction === "replace") {
168
- window.history.replaceState({}, "", url.href);
169
- }
170
- const useViewTransitions = this.options.viewTransitions;
171
- await this.domSwapper.preloadStylesheets(newDocument);
172
- if (useViewTransitions) {
173
- await this.viewTransitionManager.transition(() => {
174
- this.domSwapper.morphHead(newDocument);
175
- this.domSwapper.morphBody(newDocument);
176
- this.scrollManager.handleScroll(url, previousUrl);
177
- });
178
- } else {
179
- this.domSwapper.morphHead(newDocument);
180
- this.domSwapper.replaceBody(newDocument);
181
- this.scrollManager.handleScroll(url, previousUrl);
182
- }
183
- const afterSwapEvent = {
184
- url,
185
- direction
186
- };
187
- document.dispatchEvent(new CustomEvent("eco:after-swap", { detail: afterSwapEvent }));
188
- this.prefetchManager?.observeNewLinks();
189
- this.prefetchManager?.cacheVisitedPage(url.href, html);
190
- requestAnimationFrame(() => {
191
- document.dispatchEvent(
192
- new CustomEvent("eco:page-load", {
193
- detail: { url, direction }
194
- })
195
- );
424
+ if (isStaleNavigation()) return;
425
+ await this.commitDocumentNavigation(url, direction, newDocument, {
426
+ html,
427
+ isStaleNavigation
196
428
  });
197
429
  } catch (error) {
430
+ if (isStaleNavigation()) return;
198
431
  if (error instanceof Error && error.name === "AbortError") {
199
432
  return;
200
433
  }
201
434
  console.error("[ecopages] Navigation failed:", error);
202
435
  window.location.href = url.href;
436
+ } finally {
437
+ complete();
438
+ this.pendingNavigations--;
439
+ const navigationRuntime = getEcoNavigationRuntime(window);
440
+ if (!navigationRuntime.hasPendingNavigationTransaction()) {
441
+ queuedNavigationHref = this.queuedNavigationHref;
442
+ this.queuedNavigationHref = null;
443
+ }
444
+ if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
445
+ const ownerState = navigationRuntime.getOwnerState();
446
+ if (ownerState.owner !== "none" && ownerState.owner !== "browser-router" && ownerState.canHandleSpaNavigation) {
447
+ void navigationRuntime.requestNavigation({
448
+ href: queuedNavigationHref,
449
+ direction: "forward",
450
+ source: "browser-router"
451
+ });
452
+ } else {
453
+ void this.performNavigation(new URL(queuedNavigationHref, window.location.origin), "forward");
454
+ }
455
+ }
203
456
  }
204
457
  }
205
458
  /**
@@ -227,8 +480,15 @@ class EcoRouter {
227
480
  return response.text();
228
481
  }
229
482
  }
483
+ const ACTIVE_ROUTER_KEY = "__ecopages_browser_router__";
230
484
  function createRouter(options) {
485
+ const win = window;
486
+ const existingRouter = win[ACTIVE_ROUTER_KEY];
487
+ if (existingRouter) {
488
+ return existingRouter;
489
+ }
231
490
  const router = new EcoRouter(options);
491
+ win[ACTIVE_ROUTER_KEY] = router;
232
492
  router.start();
233
493
  return router;
234
494
  }