@ecopages/browser-router 0.2.0-alpha.1 → 0.2.0-alpha.10

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 (67) hide show
  1. package/CHANGELOG.md +12 -6
  2. package/README.md +59 -36
  3. package/package.json +4 -2
  4. package/src/client/document-element-sync.d.ts +24 -0
  5. package/src/client/document-element-sync.js +20 -0
  6. package/src/client/eco-router.d.ts +35 -1
  7. package/src/client/eco-router.js +334 -68
  8. package/src/client/services/dom-swapper.d.ts +23 -0
  9. package/src/client/services/dom-swapper.js +210 -38
  10. package/src/client/services/prefetch-manager.d.ts +1 -0
  11. package/src/client/services/prefetch-manager.js +5 -0
  12. package/src/client/services/view-transition-manager.d.ts +7 -1
  13. package/src/client/services/view-transition-manager.js +10 -5
  14. package/src/client/types.d.ts +12 -0
  15. package/src/client/types.js +4 -0
  16. package/src/index.d.ts +2 -1
  17. package/src/index.js +9 -2
  18. package/src/types.d.ts +1 -1
  19. package/src/types.js +2 -1
  20. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
  21. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
  22. 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
  23. 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
  24. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
  25. 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
  26. 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
  27. 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
  28. 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
  29. 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
  30. 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
  31. 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
  32. 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
  33. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
  34. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
  35. 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
  36. 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
  37. 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
  38. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
  39. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
  40. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
  41. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
  42. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
  43. 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
  44. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
  45. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
  46. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
  47. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
  48. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
  49. 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
  50. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
  51. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
  52. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
  53. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
  54. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
  55. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
  56. 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
  57. 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
  58. package/src/client/eco-router.ts +0 -290
  59. package/src/client/services/dom-swapper.ts +0 -325
  60. package/src/client/services/index.ts +0 -9
  61. package/src/client/services/prefetch-manager.ts +0 -451
  62. package/src/client/services/scroll-manager.ts +0 -48
  63. package/src/client/services/view-transition-manager.ts +0 -75
  64. package/src/client/types.ts +0 -109
  65. package/src/client/view-transition-utils.ts +0 -98
  66. package/src/index.ts +0 -19
  67. package/src/types.ts +0 -19
@@ -1,14 +1,31 @@
1
- import { DEFAULT_OPTIONS } from "./types.js";
1
+ import { getEcoNavigationRuntime } from "@ecopages/core/router/navigation-coordinator";
2
+ import {
3
+ getAnchorFromNavigationEvent,
4
+ recoverPendingNavigationHref
5
+ } from "@ecopages/core/router/link-intent";
6
+ import { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC, DEFAULT_OPTIONS } from "./types.js";
7
+ import { syncDocumentElementAttributes } from "./document-element-sync.js";
2
8
  import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } from "./services/index.js";
3
9
  class EcoRouter {
4
10
  options;
5
- abortController = null;
11
+ unregisterNavigationRuntime = null;
12
+ started = false;
13
+ pendingNavigations = 0;
14
+ pendingPointerNavigation = null;
15
+ pendingHoverNavigation = null;
16
+ queuedNavigationHref = null;
6
17
  domSwapper;
7
18
  scrollManager;
8
19
  viewTransitionManager;
9
20
  prefetchManager = null;
10
21
  constructor(options = {}) {
11
- this.options = { ...DEFAULT_OPTIONS, ...options };
22
+ this.options = {
23
+ ...DEFAULT_OPTIONS,
24
+ ...options,
25
+ documentElementAttributesToSync: [
26
+ ...options.documentElementAttributesToSync ?? DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC
27
+ ]
28
+ };
12
29
  this.domSwapper = new DomSwapper(this.options.persistAttribute);
13
30
  this.scrollManager = new ScrollManager(this.options.scrollBehavior, this.options.smoothScroll);
14
31
  this.viewTransitionManager = new ViewTransitionManager(this.options.viewTransitions);
@@ -19,8 +36,151 @@ class EcoRouter {
19
36
  });
20
37
  }
21
38
  this.handleClick = this.handleClick.bind(this);
39
+ this.handleHoverIntent = this.handleHoverIntent.bind(this);
40
+ this.handlePointerDown = this.handlePointerDown.bind(this);
22
41
  this.handlePopState = this.handlePopState.bind(this);
23
42
  }
43
+ getLinkFromEvent(event) {
44
+ return getAnchorFromNavigationEvent(event, this.options.linkSelector);
45
+ }
46
+ canInterceptLink(event, link) {
47
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return null;
48
+ if (event.button !== 0) return null;
49
+ const target = link.getAttribute("target");
50
+ if (target && target !== "_self") return null;
51
+ if (link.hasAttribute(this.options.reloadAttribute)) return null;
52
+ if (link.hasAttribute("download")) return null;
53
+ const href = link.getAttribute("href");
54
+ if (!href) return null;
55
+ if (href.startsWith("#")) return null;
56
+ if (href.startsWith("javascript:")) return null;
57
+ const url = new URL(href, window.location.origin);
58
+ if (!this.isSameOrigin(url)) return null;
59
+ return href;
60
+ }
61
+ getRecoveredPointerHref() {
62
+ const href = recoverPendingNavigationHref(
63
+ this.pendingPointerNavigation,
64
+ this.pendingNavigations > 0,
65
+ performance.now()
66
+ );
67
+ if (!href) {
68
+ this.pendingPointerNavigation = null;
69
+ }
70
+ return href;
71
+ }
72
+ getRecoveredHoverHref() {
73
+ const href = recoverPendingNavigationHref(
74
+ this.pendingHoverNavigation,
75
+ this.pendingNavigations > 0,
76
+ performance.now()
77
+ );
78
+ if (!href) {
79
+ this.pendingHoverNavigation = null;
80
+ }
81
+ return href;
82
+ }
83
+ isAnotherNavigationRuntimeActive() {
84
+ const ownerState = getEcoNavigationRuntime(window).getOwnerState();
85
+ return ownerState.owner !== "none" && ownerState.owner !== "browser-router" && ownerState.canHandleSpaNavigation;
86
+ }
87
+ getDocumentOwner(doc) {
88
+ return getEcoNavigationRuntime(window).resolveDocumentOwner(doc, "browser-router");
89
+ }
90
+ adoptDocumentOwner(doc) {
91
+ getEcoNavigationRuntime(window).adoptDocumentOwner(doc, "browser-router");
92
+ }
93
+ syncDocumentElementAttributes(newDocument) {
94
+ syncDocumentElementAttributes(document, newDocument, this.options.documentElementAttributesToSync);
95
+ }
96
+ reloadDocument(url) {
97
+ window.location.assign(url.href);
98
+ }
99
+ /**
100
+ * Commits a fully fetched document into the live page.
101
+ *
102
+ * When browser-router accepts a handoff from another runtime, it delays source
103
+ * runtime cleanup until the incoming document has been prepared and is ready to
104
+ * commit. That ordering avoids the blank-page window we previously hit when a
105
+ * delegated navigation went stale after the source runtime had already torn
106
+ * itself down.
107
+ */
108
+ async commitDocumentNavigation(url, direction, newDocument, options = {}) {
109
+ const previousUrl = new URL(window.location.href);
110
+ const navigationRuntime = getEcoNavigationRuntime(window);
111
+ const isStaleNavigation = options.isStaleNavigation ?? (() => false);
112
+ const currentDocumentOwner = navigationRuntime.resolveDocumentOwner(document, "browser-router");
113
+ const newDocumentOwner = navigationRuntime.resolveDocumentOwner(newDocument, "browser-router");
114
+ const activeOwner = navigationRuntime.getOwnerState().owner;
115
+ const shouldCleanupCurrentOwner = currentDocumentOwner !== newDocumentOwner && currentDocumentOwner !== "browser-router" && activeOwner === currentDocumentOwner;
116
+ let shouldReload = false;
117
+ const beforeSwapEvent = {
118
+ url,
119
+ direction,
120
+ newDocument,
121
+ reload: () => {
122
+ shouldReload = true;
123
+ }
124
+ };
125
+ document.dispatchEvent(new CustomEvent("eco:before-swap", { detail: beforeSwapEvent }));
126
+ if (isStaleNavigation()) return;
127
+ if (shouldReload) {
128
+ if (shouldCleanupCurrentOwner) {
129
+ await navigationRuntime.cleanupOwner(currentDocumentOwner);
130
+ }
131
+ if (isStaleNavigation()) return;
132
+ this.reloadDocument(url);
133
+ return;
134
+ }
135
+ const useViewTransitions = this.options.viewTransitions;
136
+ await this.domSwapper.preloadStylesheets(newDocument);
137
+ if (isStaleNavigation()) return;
138
+ if (shouldCleanupCurrentOwner) {
139
+ await navigationRuntime.cleanupOwner(currentDocumentOwner);
140
+ }
141
+ if (isStaleNavigation()) return;
142
+ const commitSwap = () => {
143
+ if (isStaleNavigation()) return;
144
+ if (this.options.updateHistory && direction === "forward") {
145
+ window.history.pushState({}, "", url.href);
146
+ } else if (direction === "replace") {
147
+ window.history.replaceState({}, "", url.href);
148
+ }
149
+ this.syncDocumentElementAttributes(newDocument);
150
+ this.domSwapper.morphHead(newDocument);
151
+ if (useViewTransitions && !this.domSwapper.shouldReplaceBodyForRerunScripts()) {
152
+ this.domSwapper.morphBody(newDocument);
153
+ } else {
154
+ this.domSwapper.replaceBody(newDocument);
155
+ }
156
+ this.domSwapper.flushRerunScripts();
157
+ this.scrollManager.handleScroll(url, previousUrl);
158
+ };
159
+ if (useViewTransitions) {
160
+ await this.viewTransitionManager.transition(commitSwap);
161
+ } else {
162
+ commitSwap();
163
+ }
164
+ if (isStaleNavigation()) return;
165
+ navigationRuntime.adoptDocumentOwner(newDocument, "browser-router");
166
+ const afterSwapEvent = {
167
+ url,
168
+ direction
169
+ };
170
+ document.dispatchEvent(new CustomEvent("eco:after-swap", { detail: afterSwapEvent }));
171
+ this.prefetchManager?.observeNewLinks();
172
+ if (options.html) {
173
+ this.prefetchManager?.cacheVisitedPage(url.href, options.html);
174
+ }
175
+ requestAnimationFrame(() => {
176
+ if (isStaleNavigation()) return;
177
+ document.dispatchEvent(
178
+ new CustomEvent("eco:page-load", {
179
+ detail: { url, direction }
180
+ })
181
+ );
182
+ });
183
+ }
24
184
  /**
25
185
  * Starts the router and begins intercepting navigation.
26
186
  *
@@ -28,20 +188,87 @@ class EcoRouter {
28
188
  * back/forward buttons. Also starts the prefetch manager if configured.
29
189
  */
30
190
  start() {
31
- document.addEventListener("click", this.handleClick);
191
+ if (this.started) {
192
+ return;
193
+ }
194
+ const navigationRuntime = getEcoNavigationRuntime(window);
195
+ document.addEventListener("mouseover", this.handleHoverIntent, true);
196
+ document.addEventListener("pointerover", this.handleHoverIntent, true);
197
+ document.addEventListener("mousemove", this.handleHoverIntent, true);
198
+ document.addEventListener("pointermove", this.handleHoverIntent, true);
199
+ document.addEventListener("pointerdown", this.handlePointerDown, true);
200
+ document.addEventListener("click", this.handleClick, true);
32
201
  window.addEventListener("popstate", this.handlePopState);
33
202
  this.prefetchManager?.start();
203
+ this.unregisterNavigationRuntime?.();
204
+ this.unregisterNavigationRuntime = navigationRuntime.register({
205
+ owner: "browser-router",
206
+ navigate: async (request) => {
207
+ await this.performNavigation(
208
+ new URL(request.href, window.location.origin),
209
+ request.direction ?? "forward"
210
+ );
211
+ return true;
212
+ },
213
+ handoffNavigation: async (request) => {
214
+ const { isStaleNavigation, complete } = this.beginNavigationTransaction();
215
+ if (isStaleNavigation()) return true;
216
+ try {
217
+ await this.commitDocumentNavigation(
218
+ new URL(request.finalHref ?? request.href, window.location.origin),
219
+ request.direction ?? "forward",
220
+ request.document,
221
+ { html: request.html, isStaleNavigation }
222
+ );
223
+ } finally {
224
+ complete();
225
+ }
226
+ return true;
227
+ },
228
+ reloadCurrentPage: async (request) => {
229
+ if (this.pendingNavigations > 0) return;
230
+ const currentUrl = window.location.pathname + window.location.search;
231
+ if (request?.clearCache) {
232
+ this.prefetchManager?.invalidate(currentUrl);
233
+ }
234
+ await this.performNavigation(new URL(currentUrl, window.location.origin), "replace");
235
+ },
236
+ cleanupBeforeHandoff: async () => {
237
+ this.cancelNavigationTransaction();
238
+ }
239
+ });
240
+ this.adoptDocumentOwner(document);
34
241
  const initialHtml = document.documentElement.outerHTML;
35
242
  this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
243
+ this.started = true;
36
244
  }
37
245
  /**
38
246
  * Stops the router and cleans up all event listeners.
39
247
  * After calling this, navigation will fall back to full page reloads.
40
248
  */
41
249
  stop() {
42
- document.removeEventListener("click", this.handleClick);
250
+ if (!this.started) {
251
+ return;
252
+ }
253
+ this.cancelNavigationTransaction();
254
+ document.removeEventListener("mouseover", this.handleHoverIntent, true);
255
+ document.removeEventListener("pointerover", this.handleHoverIntent, true);
256
+ document.removeEventListener("mousemove", this.handleHoverIntent, true);
257
+ document.removeEventListener("pointermove", this.handleHoverIntent, true);
258
+ document.removeEventListener("pointerdown", this.handlePointerDown, true);
259
+ document.removeEventListener("click", this.handleClick, true);
43
260
  window.removeEventListener("popstate", this.handlePopState);
44
261
  this.prefetchManager?.stop();
262
+ this.unregisterNavigationRuntime?.();
263
+ this.unregisterNavigationRuntime = null;
264
+ this.started = false;
265
+ this.pendingHoverNavigation = null;
266
+ this.pendingPointerNavigation = null;
267
+ this.queuedNavigationHref = null;
268
+ const win = window;
269
+ if (win[ACTIVE_ROUTER_KEY] === this) {
270
+ delete win[ACTIVE_ROUTER_KEY];
271
+ }
45
272
  }
46
273
  /**
47
274
  * Programmatic navigation.
@@ -78,24 +305,63 @@ class EcoRouter {
78
305
  * Uses `event.composedPath()` to correctly detect clicks on anchors inside
79
306
  * Shadow DOM boundaries (Web Components).
80
307
  */
308
+ handlePointerDown(event) {
309
+ const link = this.getLinkFromEvent(event);
310
+ if (!link) {
311
+ this.pendingPointerNavigation = null;
312
+ return;
313
+ }
314
+ const href = this.canInterceptLink(event, link);
315
+ this.pendingPointerNavigation = href ? {
316
+ href,
317
+ timestamp: performance.now()
318
+ } : null;
319
+ if (href && this.pendingNavigations > 0) {
320
+ this.queuedNavigationHref = href;
321
+ }
322
+ }
323
+ handleHoverIntent(event) {
324
+ const link = this.getLinkFromEvent(event);
325
+ if (!link) {
326
+ return;
327
+ }
328
+ const href = this.canInterceptLink(event, link);
329
+ if (!href) {
330
+ return;
331
+ }
332
+ this.pendingHoverNavigation = {
333
+ href,
334
+ timestamp: performance.now()
335
+ };
336
+ if (this.pendingNavigations > 0) {
337
+ this.queuedNavigationHref = href;
338
+ }
339
+ }
81
340
  handleClick(event) {
82
- const link = event.composedPath().find(
83
- (el) => el instanceof HTMLAnchorElement && el.matches(this.options.linkSelector)
84
- );
85
- if (!link) return;
86
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
87
- if (event.button !== 0) return;
88
- const target = link.getAttribute("target");
89
- if (target && target !== "_self") return;
90
- if (link.hasAttribute(this.options.reloadAttribute)) return;
91
- if (link.hasAttribute("download")) return;
92
- const href = link.getAttribute("href");
341
+ const navigationRuntime = getEcoNavigationRuntime(window);
342
+ const link = this.getLinkFromEvent(event);
343
+ const href = link ? this.canInterceptLink(event, link) : this.getRecoveredPointerHref() ?? this.getRecoveredHoverHref();
344
+ this.pendingPointerNavigation = null;
345
+ this.pendingHoverNavigation = null;
93
346
  if (!href) return;
94
- if (href.startsWith("#")) return;
95
- if (href.startsWith("javascript:")) return;
347
+ this.queuedNavigationHref = null;
348
+ if (this.isAnotherNavigationRuntimeActive()) {
349
+ event.preventDefault();
350
+ event.stopImmediatePropagation();
351
+ void navigationRuntime.requestNavigation({
352
+ href,
353
+ direction: "forward",
354
+ source: "browser-router"
355
+ });
356
+ return;
357
+ }
96
358
  const url = new URL(href, window.location.origin);
97
- if (!this.isSameOrigin(url)) return;
98
359
  event.preventDefault();
360
+ if (this.pendingNavigations > 0) {
361
+ this.queuedNavigationHref = href;
362
+ this.cancelNavigationTransaction();
363
+ return;
364
+ }
99
365
  this.performNavigation(url, "forward");
100
366
  }
101
367
  /**
@@ -103,6 +369,7 @@ class EcoRouter {
103
369
  * Triggered by the History API's popstate event.
104
370
  */
105
371
  handlePopState(_event) {
372
+ if (this.isAnotherNavigationRuntimeActive()) return;
106
373
  const url = new URL(window.location.href);
107
374
  this.performNavigation(url, "back");
108
375
  }
@@ -113,6 +380,17 @@ class EcoRouter {
113
380
  isSameOrigin(url) {
114
381
  return url.origin === window.location.origin;
115
382
  }
383
+ cancelNavigationTransaction() {
384
+ getEcoNavigationRuntime(window).cancelCurrentNavigationTransaction();
385
+ }
386
+ beginNavigationTransaction() {
387
+ const transaction = getEcoNavigationRuntime(window).beginNavigationTransaction();
388
+ return {
389
+ isStaleNavigation: () => !transaction.isCurrent(),
390
+ signal: transaction.signal,
391
+ complete: () => transaction.complete()
392
+ };
393
+ }
116
394
  /**
117
395
  * Executes the core navigation flow.
118
396
  *
@@ -132,64 +410,45 @@ class EcoRouter {
132
410
  * @param direction - Navigation direction ('forward', 'back', or 'replace')
133
411
  */
134
412
  async performNavigation(url, direction) {
135
- const previousUrl = new URL(window.location.href);
136
- this.abortController?.abort();
137
- this.abortController = new AbortController();
413
+ this.pendingNavigations++;
414
+ const { isStaleNavigation, signal, complete } = this.beginNavigationTransaction();
415
+ let queuedNavigationHref = null;
138
416
  try {
139
- const html = await this.fetchPage(url, this.abortController.signal);
417
+ const html = await this.fetchPage(url, signal);
418
+ if (isStaleNavigation()) return;
140
419
  const newDocument = this.domSwapper.parseHTML(html, url);
141
- let shouldReload = false;
142
- const beforeSwapEvent = {
143
- url,
144
- direction,
145
- newDocument,
146
- reload: () => {
147
- shouldReload = true;
148
- }
149
- };
150
- document.dispatchEvent(new CustomEvent("eco:before-swap", { detail: beforeSwapEvent }));
151
- if (shouldReload) {
152
- window.location.href = url.href;
153
- return;
154
- }
155
- if (this.options.updateHistory && direction === "forward") {
156
- window.history.pushState({}, "", url.href);
157
- } else if (direction === "replace") {
158
- window.history.replaceState({}, "", url.href);
159
- }
160
- const useViewTransitions = this.options.viewTransitions;
161
- await this.domSwapper.preloadStylesheets(newDocument);
162
- if (useViewTransitions) {
163
- await this.viewTransitionManager.transition(() => {
164
- this.domSwapper.morphHead(newDocument);
165
- this.domSwapper.morphBody(newDocument);
166
- this.scrollManager.handleScroll(url, previousUrl);
167
- });
168
- } else {
169
- this.domSwapper.morphHead(newDocument);
170
- this.domSwapper.replaceBody(newDocument);
171
- this.scrollManager.handleScroll(url, previousUrl);
172
- }
173
- const afterSwapEvent = {
174
- url,
175
- direction
176
- };
177
- document.dispatchEvent(new CustomEvent("eco:after-swap", { detail: afterSwapEvent }));
178
- this.prefetchManager?.observeNewLinks();
179
- this.prefetchManager?.cacheVisitedPage(url.href, html);
180
- requestAnimationFrame(() => {
181
- document.dispatchEvent(
182
- new CustomEvent("eco:page-load", {
183
- detail: { url, direction }
184
- })
185
- );
420
+ if (isStaleNavigation()) return;
421
+ await this.commitDocumentNavigation(url, direction, newDocument, {
422
+ html,
423
+ isStaleNavigation
186
424
  });
187
425
  } catch (error) {
426
+ if (isStaleNavigation()) return;
188
427
  if (error instanceof Error && error.name === "AbortError") {
189
428
  return;
190
429
  }
191
430
  console.error("[ecopages] Navigation failed:", error);
192
431
  window.location.href = url.href;
432
+ } finally {
433
+ complete();
434
+ this.pendingNavigations--;
435
+ const navigationRuntime = getEcoNavigationRuntime(window);
436
+ if (!navigationRuntime.hasPendingNavigationTransaction()) {
437
+ queuedNavigationHref = this.queuedNavigationHref;
438
+ this.queuedNavigationHref = null;
439
+ }
440
+ if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
441
+ const ownerState = navigationRuntime.getOwnerState();
442
+ if (ownerState.owner !== "none" && ownerState.owner !== "browser-router" && ownerState.canHandleSpaNavigation) {
443
+ void navigationRuntime.requestNavigation({
444
+ href: queuedNavigationHref,
445
+ direction: "forward",
446
+ source: "browser-router"
447
+ });
448
+ } else {
449
+ void this.performNavigation(new URL(queuedNavigationHref, window.location.origin), "forward");
450
+ }
451
+ }
193
452
  }
194
453
  }
195
454
  /**
@@ -217,8 +476,15 @@ class EcoRouter {
217
476
  return response.text();
218
477
  }
219
478
  }
479
+ const ACTIVE_ROUTER_KEY = "__ecopages_browser_router__";
220
480
  function createRouter(options) {
481
+ const win = window;
482
+ const existingRouter = win[ACTIVE_ROUTER_KEY];
483
+ if (existingRouter) {
484
+ return existingRouter;
485
+ }
221
486
  const router = new EcoRouter(options);
487
+ win[ACTIVE_ROUTER_KEY] = router;
222
488
  router.start();
223
489
  return router;
224
490
  }
@@ -12,6 +12,9 @@
12
12
  */
13
13
  export declare class DomSwapper {
14
14
  private persistAttribute;
15
+ private pendingHeadScripts;
16
+ private pendingRerunScripts;
17
+ private rerunNonce;
15
18
  constructor(persistAttribute: string);
16
19
  /**
17
20
  * Parses HTML string into a Document, injecting a temporary base tag for URL resolution.
@@ -38,12 +41,22 @@ export declare class DomSwapper {
38
41
  * - Injects new scripts from the incoming page that are absent from the current head
39
42
  */
40
43
  morphHead(newDocument: Document): void;
44
+ /**
45
+ * Replays queued `data-eco-rerun` scripts after the body swap completes.
46
+ *
47
+ * Scripts are intentionally flushed after the new body is in place so DOM-
48
+ * dependent bootstraps bind against the incoming page rather than the page
49
+ * being replaced.
50
+ */
51
+ flushRerunScripts(): void;
52
+ shouldReplaceBodyForRerunScripts(): boolean;
41
53
  /**
42
54
  * Detects custom elements without shadow DOM (light-DOM custom elements).
43
55
  * These need full replacement rather than morphing, because morphdom would
44
56
  * strip JS-generated content from their light DOM children.
45
57
  */
46
58
  private isLightDomCustomElement;
59
+ private replaceCustomElement;
47
60
  /**
48
61
  * Morphs document body using morphdom.
49
62
  * Preserves persisted elements and hydrated custom elements.
@@ -57,6 +70,16 @@ export declare class DomSwapper {
57
70
  * Use when View Transitions are disabled.
58
71
  */
59
72
  replaceBody(newDocument: Document): void;
73
+ private collectRerunScripts;
74
+ private removeStaleHeadScripts;
75
+ private shouldPersistExecutableInlineHeadScript;
76
+ private isNonExecutableHeadScript;
77
+ private areHeadScriptsEquivalent;
78
+ private getHeadScriptKey;
79
+ private findExistingHeadScript;
80
+ private findExistingRerunScript;
81
+ private isExternalModuleRerunScript;
82
+ private createRerunScriptUrl;
60
83
  /**
61
84
  * Manually attaches declarative shadow DOM templates.
62
85
  * Browsers only process `<template shadowrootmode>` during initial parse.