@ecopages/browser-router 0.2.0-alpha.3 → 0.2.0-alpha.31
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 +8 -7
- package/README.md +60 -37
- package/package.json +4 -2
- package/src/client/document-element-sync.d.ts +24 -0
- package/src/client/document-element-sync.js +20 -0
- package/src/client/eco-router.d.ts +35 -1
- package/src/client/eco-router.js +336 -77
- package/src/client/services/dom-swapper.d.ts +102 -0
- package/src/client/services/dom-swapper.js +316 -39
- package/src/client/services/prefetch-manager.d.ts +6 -3
- package/src/client/services/prefetch-manager.js +21 -11
- package/src/client/services/view-transition-manager.d.ts +7 -1
- package/src/client/services/view-transition-manager.js +21 -5
- package/src/client/types.d.ts +12 -0
- package/src/client/types.js +4 -0
- package/src/index.d.ts +2 -1
- package/src/index.js +9 -2
- package/src/types.d.ts +1 -1
- package/src/types.js +2 -1
- 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.ts +0 -310
- package/src/client/services/dom-swapper.ts +0 -325
- package/src/client/services/index.ts +0 -9
- package/src/client/services/prefetch-manager.ts +0 -457
- package/src/client/services/scroll-manager.ts +0 -48
- package/src/client/services/view-transition-manager.ts +0 -75
- package/src/client/types.ts +0 -109
- package/src/client/view-transition-utils.ts +0 -98
- package/src/index.ts +0 -19
- package/src/types.ts +0 -19
package/src/client/eco-router.js
CHANGED
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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 = {
|
|
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,154 @@ 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
|
+
if (url.hash && url.pathname === window.location.pathname && url.search === window.location.search) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return href;
|
|
63
|
+
}
|
|
64
|
+
getRecoveredPointerHref() {
|
|
65
|
+
const href = recoverPendingNavigationHref(
|
|
66
|
+
this.pendingPointerNavigation,
|
|
67
|
+
this.pendingNavigations > 0,
|
|
68
|
+
performance.now()
|
|
69
|
+
);
|
|
70
|
+
if (!href) {
|
|
71
|
+
this.pendingPointerNavigation = null;
|
|
72
|
+
}
|
|
73
|
+
return href;
|
|
74
|
+
}
|
|
75
|
+
getRecoveredHoverHref() {
|
|
76
|
+
const href = recoverPendingNavigationHref(
|
|
77
|
+
this.pendingHoverNavigation,
|
|
78
|
+
this.pendingNavigations > 0,
|
|
79
|
+
performance.now()
|
|
80
|
+
);
|
|
81
|
+
if (!href) {
|
|
82
|
+
this.pendingHoverNavigation = null;
|
|
83
|
+
}
|
|
84
|
+
return href;
|
|
85
|
+
}
|
|
86
|
+
isAnotherNavigationRuntimeActive() {
|
|
87
|
+
const ownerState = getEcoNavigationRuntime(window).getOwnerState();
|
|
88
|
+
return ownerState.owner !== "none" && ownerState.owner !== "browser-router" && ownerState.canHandleSpaNavigation;
|
|
89
|
+
}
|
|
90
|
+
getDocumentOwner(doc) {
|
|
91
|
+
return getEcoNavigationRuntime(window).resolveDocumentOwner(doc, "browser-router");
|
|
92
|
+
}
|
|
93
|
+
adoptDocumentOwner(doc) {
|
|
94
|
+
getEcoNavigationRuntime(window).adoptDocumentOwner(doc, "browser-router");
|
|
95
|
+
}
|
|
96
|
+
syncDocumentElementAttributes(newDocument) {
|
|
97
|
+
syncDocumentElementAttributes(document, newDocument, this.options.documentElementAttributesToSync);
|
|
98
|
+
}
|
|
99
|
+
reloadDocument(url) {
|
|
100
|
+
window.location.assign(url.href);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Commits a fully fetched document into the live page.
|
|
104
|
+
*
|
|
105
|
+
* When browser-router accepts a handoff from another runtime, it delays source
|
|
106
|
+
* runtime cleanup until the incoming document has been prepared and is ready to
|
|
107
|
+
* commit. That ordering avoids the blank-page window we previously hit when a
|
|
108
|
+
* delegated navigation went stale after the source runtime had already torn
|
|
109
|
+
* itself down.
|
|
110
|
+
*/
|
|
111
|
+
async commitDocumentNavigation(url, direction, newDocument, options = {}) {
|
|
112
|
+
const previousUrl = new URL(window.location.href);
|
|
113
|
+
const navigationRuntime = getEcoNavigationRuntime(window);
|
|
114
|
+
const isStaleNavigation = options.isStaleNavigation ?? (() => false);
|
|
115
|
+
const currentDocumentOwner = navigationRuntime.resolveDocumentOwner(document, "browser-router");
|
|
116
|
+
const newDocumentOwner = navigationRuntime.resolveDocumentOwner(newDocument, "browser-router");
|
|
117
|
+
const activeOwner = navigationRuntime.getOwnerState().owner;
|
|
118
|
+
const shouldCleanupCurrentOwner = currentDocumentOwner !== newDocumentOwner && currentDocumentOwner !== "browser-router" && activeOwner === currentDocumentOwner;
|
|
119
|
+
let shouldReload = false;
|
|
120
|
+
const beforeSwapEvent = {
|
|
121
|
+
url,
|
|
122
|
+
direction,
|
|
123
|
+
newDocument,
|
|
124
|
+
reload: () => {
|
|
125
|
+
shouldReload = true;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
document.dispatchEvent(new CustomEvent("eco:before-swap", { detail: beforeSwapEvent }));
|
|
129
|
+
if (isStaleNavigation()) return;
|
|
130
|
+
if (shouldReload) {
|
|
131
|
+
if (shouldCleanupCurrentOwner) {
|
|
132
|
+
await navigationRuntime.cleanupOwner(currentDocumentOwner);
|
|
133
|
+
}
|
|
134
|
+
if (isStaleNavigation()) return;
|
|
135
|
+
this.reloadDocument(url);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const useViewTransitions = this.options.viewTransitions;
|
|
139
|
+
await this.domSwapper.preloadStylesheets(newDocument);
|
|
140
|
+
if (isStaleNavigation()) return;
|
|
141
|
+
if (shouldCleanupCurrentOwner) {
|
|
142
|
+
await navigationRuntime.cleanupOwner(currentDocumentOwner);
|
|
143
|
+
}
|
|
144
|
+
if (isStaleNavigation()) return;
|
|
145
|
+
const commitSwap = () => {
|
|
146
|
+
if (isStaleNavigation()) return;
|
|
147
|
+
if (this.options.updateHistory && direction === "forward") {
|
|
148
|
+
window.history.pushState({}, "", url.href);
|
|
149
|
+
} else if (direction === "replace") {
|
|
150
|
+
window.history.replaceState({}, "", url.href);
|
|
151
|
+
}
|
|
152
|
+
this.syncDocumentElementAttributes(newDocument);
|
|
153
|
+
this.domSwapper.morphHead(newDocument);
|
|
154
|
+
if (useViewTransitions && !this.domSwapper.shouldReplaceBodyForRerunScripts()) {
|
|
155
|
+
this.domSwapper.morphBody(newDocument);
|
|
156
|
+
} else {
|
|
157
|
+
this.domSwapper.replaceBody(newDocument);
|
|
158
|
+
}
|
|
159
|
+
this.domSwapper.flushRerunScripts();
|
|
160
|
+
this.scrollManager.handleScroll(url, previousUrl);
|
|
161
|
+
};
|
|
162
|
+
if (useViewTransitions) {
|
|
163
|
+
await this.viewTransitionManager.transition(commitSwap);
|
|
164
|
+
} else {
|
|
165
|
+
commitSwap();
|
|
166
|
+
}
|
|
167
|
+
if (isStaleNavigation()) return;
|
|
168
|
+
navigationRuntime.adoptDocumentOwner(newDocument, "browser-router");
|
|
169
|
+
const afterSwapEvent = {
|
|
170
|
+
url,
|
|
171
|
+
direction
|
|
172
|
+
};
|
|
173
|
+
document.dispatchEvent(new CustomEvent("eco:after-swap", { detail: afterSwapEvent }));
|
|
174
|
+
this.prefetchManager?.observeNewLinks();
|
|
175
|
+
if (options.html) {
|
|
176
|
+
this.prefetchManager?.cacheVisitedPage(url.href, options.html);
|
|
177
|
+
}
|
|
178
|
+
requestAnimationFrame(() => {
|
|
179
|
+
if (isStaleNavigation()) return;
|
|
180
|
+
document.dispatchEvent(
|
|
181
|
+
new CustomEvent("eco:page-load", {
|
|
182
|
+
detail: { url, direction }
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
24
187
|
/**
|
|
25
188
|
* Starts the router and begins intercepting navigation.
|
|
26
189
|
*
|
|
@@ -28,30 +191,87 @@ class EcoRouter {
|
|
|
28
191
|
* back/forward buttons. Also starts the prefetch manager if configured.
|
|
29
192
|
*/
|
|
30
193
|
start() {
|
|
31
|
-
|
|
194
|
+
if (this.started) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const navigationRuntime = getEcoNavigationRuntime(window);
|
|
198
|
+
document.addEventListener("mouseover", this.handleHoverIntent, true);
|
|
199
|
+
document.addEventListener("pointerover", this.handleHoverIntent, true);
|
|
200
|
+
document.addEventListener("mousemove", this.handleHoverIntent, true);
|
|
201
|
+
document.addEventListener("pointermove", this.handleHoverIntent, true);
|
|
202
|
+
document.addEventListener("pointerdown", this.handlePointerDown, true);
|
|
203
|
+
document.addEventListener("click", this.handleClick, true);
|
|
32
204
|
window.addEventListener("popstate", this.handlePopState);
|
|
33
205
|
this.prefetchManager?.start();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
this.
|
|
206
|
+
this.unregisterNavigationRuntime?.();
|
|
207
|
+
this.unregisterNavigationRuntime = navigationRuntime.register({
|
|
208
|
+
owner: "browser-router",
|
|
209
|
+
navigate: async (request) => {
|
|
210
|
+
await this.performNavigation(
|
|
211
|
+
new URL(request.href, window.location.origin),
|
|
212
|
+
request.direction ?? "forward"
|
|
213
|
+
);
|
|
214
|
+
return true;
|
|
215
|
+
},
|
|
216
|
+
handoffNavigation: async (request) => {
|
|
217
|
+
const { isStaleNavigation, complete } = this.beginNavigationTransaction();
|
|
218
|
+
if (isStaleNavigation()) return true;
|
|
219
|
+
try {
|
|
220
|
+
await this.commitDocumentNavigation(
|
|
221
|
+
new URL(request.finalHref ?? request.href, window.location.origin),
|
|
222
|
+
request.direction ?? "forward",
|
|
223
|
+
request.document,
|
|
224
|
+
{ html: request.html, isStaleNavigation }
|
|
225
|
+
);
|
|
226
|
+
} finally {
|
|
227
|
+
complete();
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
},
|
|
231
|
+
reloadCurrentPage: async (request) => {
|
|
232
|
+
if (this.pendingNavigations > 0) return;
|
|
233
|
+
const currentUrl = window.location.pathname + window.location.search;
|
|
234
|
+
if (request?.clearCache) {
|
|
235
|
+
this.prefetchManager?.invalidate(currentUrl);
|
|
236
|
+
}
|
|
237
|
+
await this.performNavigation(new URL(currentUrl, window.location.origin), "replace");
|
|
238
|
+
},
|
|
239
|
+
cleanupBeforeHandoff: async () => {
|
|
240
|
+
this.cancelNavigationTransaction();
|
|
39
241
|
}
|
|
40
|
-
|
|
41
|
-
|
|
242
|
+
});
|
|
243
|
+
this.adoptDocumentOwner(document);
|
|
42
244
|
const initialHtml = document.documentElement.outerHTML;
|
|
43
245
|
this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
|
|
246
|
+
this.started = true;
|
|
44
247
|
}
|
|
45
248
|
/**
|
|
46
249
|
* Stops the router and cleans up all event listeners.
|
|
47
250
|
* After calling this, navigation will fall back to full page reloads.
|
|
48
251
|
*/
|
|
49
252
|
stop() {
|
|
50
|
-
|
|
253
|
+
if (!this.started) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this.cancelNavigationTransaction();
|
|
257
|
+
document.removeEventListener("mouseover", this.handleHoverIntent, true);
|
|
258
|
+
document.removeEventListener("pointerover", this.handleHoverIntent, true);
|
|
259
|
+
document.removeEventListener("mousemove", this.handleHoverIntent, true);
|
|
260
|
+
document.removeEventListener("pointermove", this.handleHoverIntent, true);
|
|
261
|
+
document.removeEventListener("pointerdown", this.handlePointerDown, true);
|
|
262
|
+
document.removeEventListener("click", this.handleClick, true);
|
|
51
263
|
window.removeEventListener("popstate", this.handlePopState);
|
|
52
264
|
this.prefetchManager?.stop();
|
|
53
|
-
|
|
54
|
-
|
|
265
|
+
this.unregisterNavigationRuntime?.();
|
|
266
|
+
this.unregisterNavigationRuntime = null;
|
|
267
|
+
this.started = false;
|
|
268
|
+
this.pendingHoverNavigation = null;
|
|
269
|
+
this.pendingPointerNavigation = null;
|
|
270
|
+
this.queuedNavigationHref = null;
|
|
271
|
+
const win = window;
|
|
272
|
+
if (win[ACTIVE_ROUTER_KEY] === this) {
|
|
273
|
+
delete win[ACTIVE_ROUTER_KEY];
|
|
274
|
+
}
|
|
55
275
|
}
|
|
56
276
|
/**
|
|
57
277
|
* Programmatic navigation.
|
|
@@ -88,24 +308,63 @@ class EcoRouter {
|
|
|
88
308
|
* Uses `event.composedPath()` to correctly detect clicks on anchors inside
|
|
89
309
|
* Shadow DOM boundaries (Web Components).
|
|
90
310
|
*/
|
|
311
|
+
handlePointerDown(event) {
|
|
312
|
+
const link = this.getLinkFromEvent(event);
|
|
313
|
+
if (!link) {
|
|
314
|
+
this.pendingPointerNavigation = null;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const href = this.canInterceptLink(event, link);
|
|
318
|
+
this.pendingPointerNavigation = href ? {
|
|
319
|
+
href,
|
|
320
|
+
timestamp: performance.now()
|
|
321
|
+
} : null;
|
|
322
|
+
if (href && this.pendingNavigations > 0) {
|
|
323
|
+
this.queuedNavigationHref = href;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
handleHoverIntent(event) {
|
|
327
|
+
const link = this.getLinkFromEvent(event);
|
|
328
|
+
if (!link) {
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
const href = this.canInterceptLink(event, link);
|
|
332
|
+
if (!href) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
this.pendingHoverNavigation = {
|
|
336
|
+
href,
|
|
337
|
+
timestamp: performance.now()
|
|
338
|
+
};
|
|
339
|
+
if (this.pendingNavigations > 0) {
|
|
340
|
+
this.queuedNavigationHref = href;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
91
343
|
handleClick(event) {
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
);
|
|
95
|
-
|
|
96
|
-
|
|
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");
|
|
344
|
+
const navigationRuntime = getEcoNavigationRuntime(window);
|
|
345
|
+
const link = this.getLinkFromEvent(event);
|
|
346
|
+
const href = link ? this.canInterceptLink(event, link) : this.getRecoveredPointerHref() ?? this.getRecoveredHoverHref();
|
|
347
|
+
this.pendingPointerNavigation = null;
|
|
348
|
+
this.pendingHoverNavigation = null;
|
|
103
349
|
if (!href) return;
|
|
104
|
-
|
|
105
|
-
if (
|
|
350
|
+
this.queuedNavigationHref = null;
|
|
351
|
+
if (this.isAnotherNavigationRuntimeActive()) {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
event.stopImmediatePropagation();
|
|
354
|
+
void navigationRuntime.requestNavigation({
|
|
355
|
+
href,
|
|
356
|
+
direction: "forward",
|
|
357
|
+
source: "browser-router"
|
|
358
|
+
});
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
106
361
|
const url = new URL(href, window.location.origin);
|
|
107
|
-
if (!this.isSameOrigin(url)) return;
|
|
108
362
|
event.preventDefault();
|
|
363
|
+
if (this.pendingNavigations > 0) {
|
|
364
|
+
this.queuedNavigationHref = href;
|
|
365
|
+
this.cancelNavigationTransaction();
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
109
368
|
this.performNavigation(url, "forward");
|
|
110
369
|
}
|
|
111
370
|
/**
|
|
@@ -113,6 +372,7 @@ class EcoRouter {
|
|
|
113
372
|
* Triggered by the History API's popstate event.
|
|
114
373
|
*/
|
|
115
374
|
handlePopState(_event) {
|
|
375
|
+
if (this.isAnotherNavigationRuntimeActive()) return;
|
|
116
376
|
const url = new URL(window.location.href);
|
|
117
377
|
this.performNavigation(url, "back");
|
|
118
378
|
}
|
|
@@ -123,6 +383,17 @@ class EcoRouter {
|
|
|
123
383
|
isSameOrigin(url) {
|
|
124
384
|
return url.origin === window.location.origin;
|
|
125
385
|
}
|
|
386
|
+
cancelNavigationTransaction() {
|
|
387
|
+
getEcoNavigationRuntime(window).cancelCurrentNavigationTransaction();
|
|
388
|
+
}
|
|
389
|
+
beginNavigationTransaction() {
|
|
390
|
+
const transaction = getEcoNavigationRuntime(window).beginNavigationTransaction();
|
|
391
|
+
return {
|
|
392
|
+
isStaleNavigation: () => !transaction.isCurrent(),
|
|
393
|
+
signal: transaction.signal,
|
|
394
|
+
complete: () => transaction.complete()
|
|
395
|
+
};
|
|
396
|
+
}
|
|
126
397
|
/**
|
|
127
398
|
* Executes the core navigation flow.
|
|
128
399
|
*
|
|
@@ -142,64 +413,45 @@ class EcoRouter {
|
|
|
142
413
|
* @param direction - Navigation direction ('forward', 'back', or 'replace')
|
|
143
414
|
*/
|
|
144
415
|
async performNavigation(url, direction) {
|
|
145
|
-
|
|
146
|
-
this.
|
|
147
|
-
|
|
416
|
+
this.pendingNavigations++;
|
|
417
|
+
const { isStaleNavigation, signal, complete } = this.beginNavigationTransaction();
|
|
418
|
+
let queuedNavigationHref = null;
|
|
148
419
|
try {
|
|
149
|
-
const html = await this.fetchPage(url,
|
|
420
|
+
const html = await this.fetchPage(url, signal);
|
|
421
|
+
if (isStaleNavigation()) return;
|
|
150
422
|
const newDocument = this.domSwapper.parseHTML(html, url);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
);
|
|
423
|
+
if (isStaleNavigation()) return;
|
|
424
|
+
await this.commitDocumentNavigation(url, direction, newDocument, {
|
|
425
|
+
html,
|
|
426
|
+
isStaleNavigation
|
|
196
427
|
});
|
|
197
428
|
} catch (error) {
|
|
429
|
+
if (isStaleNavigation()) return;
|
|
198
430
|
if (error instanceof Error && error.name === "AbortError") {
|
|
199
431
|
return;
|
|
200
432
|
}
|
|
201
433
|
console.error("[ecopages] Navigation failed:", error);
|
|
202
434
|
window.location.href = url.href;
|
|
435
|
+
} finally {
|
|
436
|
+
complete();
|
|
437
|
+
this.pendingNavigations--;
|
|
438
|
+
const navigationRuntime = getEcoNavigationRuntime(window);
|
|
439
|
+
if (!navigationRuntime.hasPendingNavigationTransaction()) {
|
|
440
|
+
queuedNavigationHref = this.queuedNavigationHref;
|
|
441
|
+
this.queuedNavigationHref = null;
|
|
442
|
+
}
|
|
443
|
+
if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
|
|
444
|
+
const ownerState = navigationRuntime.getOwnerState();
|
|
445
|
+
if (ownerState.owner !== "none" && ownerState.owner !== "browser-router" && ownerState.canHandleSpaNavigation) {
|
|
446
|
+
void navigationRuntime.requestNavigation({
|
|
447
|
+
href: queuedNavigationHref,
|
|
448
|
+
direction: "forward",
|
|
449
|
+
source: "browser-router"
|
|
450
|
+
});
|
|
451
|
+
} else {
|
|
452
|
+
void this.performNavigation(new URL(queuedNavigationHref, window.location.origin), "forward");
|
|
453
|
+
}
|
|
454
|
+
}
|
|
203
455
|
}
|
|
204
456
|
}
|
|
205
457
|
/**
|
|
@@ -227,8 +479,15 @@ class EcoRouter {
|
|
|
227
479
|
return response.text();
|
|
228
480
|
}
|
|
229
481
|
}
|
|
482
|
+
const ACTIVE_ROUTER_KEY = "__ecopages_browser_router__";
|
|
230
483
|
function createRouter(options) {
|
|
484
|
+
const win = window;
|
|
485
|
+
const existingRouter = win[ACTIVE_ROUTER_KEY];
|
|
486
|
+
if (existingRouter) {
|
|
487
|
+
return existingRouter;
|
|
488
|
+
}
|
|
231
489
|
const router = new EcoRouter(options);
|
|
490
|
+
win[ACTIVE_ROUTER_KEY] = router;
|
|
232
491
|
router.start();
|
|
233
492
|
return router;
|
|
234
493
|
}
|
|
@@ -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,46 @@ 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
|
+
/**
|
|
53
|
+
* Returns whether pending rerun scripts require a full body replacement
|
|
54
|
+
* instead of morphing, so DOM-dependent bootstraps bind against fresh markup.
|
|
55
|
+
*/
|
|
56
|
+
shouldReplaceBodyForRerunScripts(): boolean;
|
|
41
57
|
/**
|
|
42
58
|
* Detects custom elements without shadow DOM (light-DOM custom elements).
|
|
43
59
|
* These need full replacement rather than morphing, because morphdom would
|
|
44
60
|
* strip JS-generated content from their light DOM children.
|
|
45
61
|
*/
|
|
46
62
|
private isLightDomCustomElement;
|
|
63
|
+
/**
|
|
64
|
+
* Queues a custom element for deferred replacement instead of replacing it
|
|
65
|
+
* inline during the morphdom walk.
|
|
66
|
+
*
|
|
67
|
+
* Replacing elements during morphdom traversal mutates the live DOM tree,
|
|
68
|
+
* which can cause morphdom to skip siblings or process stale nodes.
|
|
69
|
+
* Deferring the replacement until after morphdom finishes avoids this.
|
|
70
|
+
*
|
|
71
|
+
* @returns Always `false` to tell morphdom to skip updating this element.
|
|
72
|
+
*/
|
|
73
|
+
private replaceCustomElement;
|
|
74
|
+
private materializeCustomElement;
|
|
75
|
+
/**
|
|
76
|
+
* Replaces all deferred custom elements after morphdom has finished traversing.
|
|
77
|
+
*
|
|
78
|
+
* Each element is recreated via `document.createElement` so the browser fires
|
|
79
|
+
* the full custom element lifecycle (`disconnectedCallback` on the old instance,
|
|
80
|
+
* `connectedCallback` on the new one). Elements that were already removed during
|
|
81
|
+
* the morph pass are skipped via the `isConnected` guard.
|
|
82
|
+
*/
|
|
83
|
+
private flushDeferredCustomElementReplacements;
|
|
47
84
|
/**
|
|
48
85
|
* Morphs document body using morphdom.
|
|
49
86
|
* Preserves persisted elements and hydrated custom elements.
|
|
@@ -57,6 +94,71 @@ export declare class DomSwapper {
|
|
|
57
94
|
* Use when View Transitions are disabled.
|
|
58
95
|
*/
|
|
59
96
|
replaceBody(newDocument: Document): void;
|
|
97
|
+
/**
|
|
98
|
+
* Collects all `data-eco-rerun` scripts from the incoming document.
|
|
99
|
+
*
|
|
100
|
+
* These scripts are re-executed after each navigation so their side-effects
|
|
101
|
+
* (event listeners, DOM bootstraps) bind against the new page content.
|
|
102
|
+
*/
|
|
103
|
+
private collectRerunScripts;
|
|
104
|
+
/**
|
|
105
|
+
* Removes head scripts that are no longer present in the incoming document.
|
|
106
|
+
*
|
|
107
|
+
* Persisted scripts and executable inline scripts with stable identifiers
|
|
108
|
+
* are kept to avoid breaking long-lived runtime state.
|
|
109
|
+
*/
|
|
110
|
+
private removeStaleHeadScripts;
|
|
111
|
+
/**
|
|
112
|
+
* Determines whether an inline head script should survive navigation.
|
|
113
|
+
*
|
|
114
|
+
* Only identified (`data-eco-script-id` or `id`), executable inline scripts
|
|
115
|
+
* are persisted. External scripts and rerun scripts are never persisted here
|
|
116
|
+
* because they have their own lifecycle management.
|
|
117
|
+
*/
|
|
118
|
+
private shouldPersistExecutableInlineHeadScript;
|
|
119
|
+
/**
|
|
120
|
+
* Returns whether a script is non-executable (e.g. `type="application/json"`).
|
|
121
|
+
*
|
|
122
|
+
* Non-executable scripts are data carriers (JSON-LD, page data) that can be
|
|
123
|
+
* safely replaced without side-effects, unlike executable scripts that would
|
|
124
|
+
* re-run their bootstrap logic.
|
|
125
|
+
*/
|
|
126
|
+
private isNonExecutableHeadScript;
|
|
127
|
+
/**
|
|
128
|
+
* Compares two head scripts for structural equality (key, content, attributes).
|
|
129
|
+
*
|
|
130
|
+
* Used to skip replacing non-executable data scripts when their content
|
|
131
|
+
* has not changed between navigations.
|
|
132
|
+
*/
|
|
133
|
+
private areHeadScriptsEquivalent;
|
|
134
|
+
/**
|
|
135
|
+
* Derives a stable identity key for a head script.
|
|
136
|
+
*
|
|
137
|
+
* Priority: `data-eco-script-id` / `id` > `src` > trimmed inline content.
|
|
138
|
+
* Returns `null` for empty anonymous inline scripts that cannot be tracked.
|
|
139
|
+
*/
|
|
140
|
+
private getHeadScriptKey;
|
|
141
|
+
/**
|
|
142
|
+
* Finds an existing head script that matches the given script's identity key.
|
|
143
|
+
*/
|
|
144
|
+
private findExistingHeadScript;
|
|
145
|
+
/**
|
|
146
|
+
* Finds an existing rerun script in the given root by `data-eco-script-id` or
|
|
147
|
+
* by matching `src` and `textContent`.
|
|
148
|
+
*/
|
|
149
|
+
private findExistingRerunScript;
|
|
150
|
+
/**
|
|
151
|
+
* Returns whether a rerun script is an external ES module (`type="module"` with `src`).
|
|
152
|
+
*
|
|
153
|
+
* Module scripts are cached by URL, so re-execution requires cache-busting
|
|
154
|
+
* via a query parameter nonce.
|
|
155
|
+
*/
|
|
156
|
+
private isExternalModuleRerunScript;
|
|
157
|
+
/**
|
|
158
|
+
* Appends a nonce query parameter to a script URL to bust the browser's
|
|
159
|
+
* module cache and force re-execution on navigation.
|
|
160
|
+
*/
|
|
161
|
+
private createRerunScriptUrl;
|
|
60
162
|
/**
|
|
61
163
|
* Manually attaches declarative shadow DOM templates.
|
|
62
164
|
* Browsers only process `<template shadowrootmode>` during initial parse.
|