@ecopages/browser-router 0.2.0-alpha.2 → 0.2.0-alpha.20
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 +7 -6
- 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 +337 -68
- package/src/client/services/dom-swapper.d.ts +101 -0
- package/src/client/services/dom-swapper.js +306 -39
- package/src/client/services/prefetch-manager.d.ts +1 -0
- package/src/client/services/prefetch-manager.js +5 -0
- 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 -290
- 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 -451
- 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
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import morphdom from "morphdom";
|
|
2
2
|
const DEFAULT_PERSIST_ATTR = "data-eco-persist";
|
|
3
|
+
const RERUN_SRC_ATTR = "data-eco-rerun-src";
|
|
3
4
|
function isPersisted(element, persistAttribute) {
|
|
4
5
|
return element.hasAttribute(persistAttribute) || element.hasAttribute(DEFAULT_PERSIST_ATTR);
|
|
5
6
|
}
|
|
6
7
|
function isHydratedCustomElement(element) {
|
|
7
8
|
return element.localName.includes("-") && element.shadowRoot !== null;
|
|
8
9
|
}
|
|
10
|
+
function getBodyMorphKey(element, persistAttribute) {
|
|
11
|
+
if (!(element instanceof Element)) {
|
|
12
|
+
return void 0;
|
|
13
|
+
}
|
|
14
|
+
return element.getAttribute(persistAttribute) || element.getAttribute(DEFAULT_PERSIST_ATTR) || void 0;
|
|
15
|
+
}
|
|
9
16
|
class DomSwapper {
|
|
10
17
|
persistAttribute;
|
|
18
|
+
pendingHeadScripts = [];
|
|
19
|
+
pendingRerunScripts = [];
|
|
20
|
+
rerunNonce = 0;
|
|
11
21
|
constructor(persistAttribute) {
|
|
12
22
|
this.persistAttribute = persistAttribute;
|
|
13
23
|
}
|
|
@@ -78,6 +88,9 @@ class DomSwapper {
|
|
|
78
88
|
* - Injects new scripts from the incoming page that are absent from the current head
|
|
79
89
|
*/
|
|
80
90
|
morphHead(newDocument) {
|
|
91
|
+
this.pendingHeadScripts = [];
|
|
92
|
+
this.pendingRerunScripts = this.collectRerunScripts(newDocument);
|
|
93
|
+
this.removeStaleHeadScripts(newDocument);
|
|
81
94
|
const newTitle = newDocument.head.querySelector("title");
|
|
82
95
|
if (newTitle && document.title !== newTitle.textContent) {
|
|
83
96
|
document.title = newTitle.textContent || "";
|
|
@@ -97,25 +110,6 @@ class DomSwapper {
|
|
|
97
110
|
document.head.appendChild(newMeta.cloneNode(true));
|
|
98
111
|
}
|
|
99
112
|
}
|
|
100
|
-
const existingScriptIds = new Set(
|
|
101
|
-
Array.from(document.head.querySelectorAll("script[data-eco-script-id]")).map(
|
|
102
|
-
(s) => s.getAttribute("data-eco-script-id")
|
|
103
|
-
)
|
|
104
|
-
);
|
|
105
|
-
const rerunScripts = newDocument.head.querySelectorAll("script[data-eco-rerun]");
|
|
106
|
-
for (const script of rerunScripts) {
|
|
107
|
-
const scriptId = script.getAttribute("data-eco-script-id");
|
|
108
|
-
if (scriptId && !existingScriptIds.has(scriptId)) {
|
|
109
|
-
const newScript = document.createElement("script");
|
|
110
|
-
for (const attr of script.attributes) {
|
|
111
|
-
if (attr.name !== "data-eco-rerun") {
|
|
112
|
-
newScript.setAttribute(attr.name, attr.value);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
newScript.textContent = script.textContent;
|
|
116
|
-
document.head.appendChild(newScript);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
113
|
const existingScriptSrcs = new Set(
|
|
120
114
|
Array.from(document.head.querySelectorAll("script[src]")).map((s) => s.getAttribute("src"))
|
|
121
115
|
);
|
|
@@ -126,27 +120,102 @@ class DomSwapper {
|
|
|
126
120
|
for (const script of allNewHeadScripts) {
|
|
127
121
|
if (script.hasAttribute("data-eco-rerun")) continue;
|
|
128
122
|
const src = script.getAttribute("src");
|
|
123
|
+
const scriptId = script.getAttribute("data-eco-script-id") || script.getAttribute("id");
|
|
124
|
+
const existingScript = this.findExistingHeadScript(script);
|
|
125
|
+
if (scriptId && existingScript) {
|
|
126
|
+
if (!this.isNonExecutableHeadScript(script)) {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (this.areHeadScriptsEquivalent(script, existingScript)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
this.pendingHeadScripts.push({
|
|
133
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
134
|
+
textContent: script.textContent ?? "",
|
|
135
|
+
src,
|
|
136
|
+
scriptId,
|
|
137
|
+
replaceExisting: true
|
|
138
|
+
});
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
129
141
|
if (src) {
|
|
130
142
|
if (existingScriptSrcs.has(src)) continue;
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
143
|
+
this.pendingHeadScripts.push({
|
|
144
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
145
|
+
textContent: script.textContent ?? "",
|
|
146
|
+
src,
|
|
147
|
+
scriptId,
|
|
148
|
+
replaceExisting: false
|
|
149
|
+
});
|
|
136
150
|
existingScriptSrcs.add(src);
|
|
137
151
|
} else {
|
|
138
152
|
const content = (script.textContent ?? "").trim();
|
|
139
153
|
if (!content || existingInlineContents.has(content)) continue;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
this.pendingHeadScripts.push({
|
|
155
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
156
|
+
textContent: script.textContent ?? "",
|
|
157
|
+
src: null,
|
|
158
|
+
scriptId,
|
|
159
|
+
replaceExisting: false
|
|
160
|
+
});
|
|
146
161
|
existingInlineContents.add(content);
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Replays queued `data-eco-rerun` scripts after the body swap completes.
|
|
167
|
+
*
|
|
168
|
+
* Scripts are intentionally flushed after the new body is in place so DOM-
|
|
169
|
+
* dependent bootstraps bind against the incoming page rather than the page
|
|
170
|
+
* being replaced.
|
|
171
|
+
*/
|
|
172
|
+
flushRerunScripts() {
|
|
173
|
+
for (const script of this.pendingHeadScripts) {
|
|
174
|
+
const replacement = document.createElement("script");
|
|
175
|
+
for (const [name, value] of script.attributes) {
|
|
176
|
+
replacement.setAttribute(name, value);
|
|
177
|
+
}
|
|
178
|
+
replacement.textContent = script.textContent;
|
|
179
|
+
const existingScript = this.findExistingHeadScript(script);
|
|
180
|
+
if (script.replaceExisting && existingScript) {
|
|
181
|
+
existingScript.replaceWith(replacement);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
document.head.appendChild(replacement);
|
|
185
|
+
}
|
|
186
|
+
this.pendingHeadScripts = [];
|
|
187
|
+
for (const script of this.pendingRerunScripts) {
|
|
188
|
+
const targetParent = script.parent === "body" ? document.body : document.head;
|
|
189
|
+
const replacement = document.createElement("script");
|
|
190
|
+
const shouldBustModuleSrc = this.isExternalModuleRerunScript(script);
|
|
191
|
+
for (const [name, value] of script.attributes) {
|
|
192
|
+
if (name === "data-eco-rerun") {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (name === "src" && shouldBustModuleSrc) {
|
|
196
|
+
replacement.setAttribute(RERUN_SRC_ATTR, value);
|
|
197
|
+
replacement.setAttribute("src", this.createRerunScriptUrl(value));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
replacement.setAttribute(name, value);
|
|
201
|
+
}
|
|
202
|
+
replacement.textContent = script.textContent;
|
|
203
|
+
const existingScript = this.findExistingRerunScript(targetParent, script);
|
|
204
|
+
if (existingScript) {
|
|
205
|
+
existingScript.replaceWith(replacement);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
targetParent.appendChild(replacement);
|
|
209
|
+
}
|
|
210
|
+
this.pendingRerunScripts = [];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Returns whether pending rerun scripts require a full body replacement
|
|
214
|
+
* instead of morphing, so DOM-dependent bootstraps bind against fresh markup.
|
|
215
|
+
*/
|
|
216
|
+
shouldReplaceBodyForRerunScripts() {
|
|
217
|
+
return this.pendingRerunScripts.length > 0;
|
|
218
|
+
}
|
|
150
219
|
/**
|
|
151
220
|
* Detects custom elements without shadow DOM (light-DOM custom elements).
|
|
152
221
|
* These need full replacement rather than morphing, because morphdom would
|
|
@@ -155,6 +224,39 @@ class DomSwapper {
|
|
|
155
224
|
isLightDomCustomElement(element) {
|
|
156
225
|
return element.localName.includes("-") && element.shadowRoot === null;
|
|
157
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Queues a custom element for deferred replacement instead of replacing it
|
|
229
|
+
* inline during the morphdom walk.
|
|
230
|
+
*
|
|
231
|
+
* Replacing elements during morphdom traversal mutates the live DOM tree,
|
|
232
|
+
* which can cause morphdom to skip siblings or process stale nodes.
|
|
233
|
+
* Deferring the replacement until after morphdom finishes avoids this.
|
|
234
|
+
*
|
|
235
|
+
* @returns Always `false` to tell morphdom to skip updating this element.
|
|
236
|
+
*/
|
|
237
|
+
replaceCustomElement(fromEl, toEl, deferred) {
|
|
238
|
+
deferred.push({ from: fromEl, to: toEl });
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Replaces all deferred custom elements after morphdom has finished traversing.
|
|
243
|
+
*
|
|
244
|
+
* Each element is recreated via `document.createElement` so the browser fires
|
|
245
|
+
* the full custom element lifecycle (`disconnectedCallback` on the old instance,
|
|
246
|
+
* `connectedCallback` on the new one). Elements that were already removed during
|
|
247
|
+
* the morph pass are skipped via the `isConnected` guard.
|
|
248
|
+
*/
|
|
249
|
+
flushDeferredCustomElementReplacements(deferred) {
|
|
250
|
+
for (const { from, to } of deferred) {
|
|
251
|
+
if (!from.isConnected) continue;
|
|
252
|
+
const newEl = document.createElement(to.tagName);
|
|
253
|
+
for (const attr of to.attributes) {
|
|
254
|
+
newEl.setAttribute(attr.name, attr.value);
|
|
255
|
+
}
|
|
256
|
+
newEl.innerHTML = to.innerHTML;
|
|
257
|
+
from.replaceWith(newEl);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
158
260
|
/**
|
|
159
261
|
* Morphs document body using morphdom.
|
|
160
262
|
* Preserves persisted elements and hydrated custom elements.
|
|
@@ -163,22 +265,18 @@ class DomSwapper {
|
|
|
163
265
|
*/
|
|
164
266
|
morphBody(newDocument) {
|
|
165
267
|
const persistAttr = this.persistAttribute;
|
|
268
|
+
const deferredReplacements = [];
|
|
166
269
|
morphdom(document.body, newDocument.body, {
|
|
270
|
+
getNodeKey: (node) => getBodyMorphKey(node, persistAttr),
|
|
167
271
|
onBeforeElUpdated: (fromEl, toEl) => {
|
|
168
272
|
if (isPersisted(fromEl, persistAttr)) {
|
|
169
273
|
return false;
|
|
170
274
|
}
|
|
171
275
|
if (isHydratedCustomElement(fromEl)) {
|
|
172
|
-
return
|
|
276
|
+
return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
|
|
173
277
|
}
|
|
174
278
|
if (this.isLightDomCustomElement(fromEl)) {
|
|
175
|
-
|
|
176
|
-
for (const attr of toEl.attributes) {
|
|
177
|
-
newEl.setAttribute(attr.name, attr.value);
|
|
178
|
-
}
|
|
179
|
-
newEl.innerHTML = toEl.innerHTML;
|
|
180
|
-
fromEl.replaceWith(newEl);
|
|
181
|
-
return false;
|
|
279
|
+
return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
|
|
182
280
|
}
|
|
183
281
|
if (fromEl.isEqualNode(toEl)) {
|
|
184
282
|
return false;
|
|
@@ -186,6 +284,7 @@ class DomSwapper {
|
|
|
186
284
|
return true;
|
|
187
285
|
}
|
|
188
286
|
});
|
|
287
|
+
this.flushDeferredCustomElementReplacements(deferredReplacements);
|
|
189
288
|
this.processDeclarativeShadowDOM(document.body);
|
|
190
289
|
}
|
|
191
290
|
/**
|
|
@@ -211,9 +310,177 @@ class DomSwapper {
|
|
|
211
310
|
placeholder.replaceWith(oldEl);
|
|
212
311
|
}
|
|
213
312
|
}
|
|
214
|
-
document.body.replaceChildren(...newDocument.body.childNodes);
|
|
313
|
+
document.body.replaceChildren(...Array.from(newDocument.body.childNodes));
|
|
215
314
|
this.processDeclarativeShadowDOM(document.body);
|
|
216
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* Collects all `data-eco-rerun` scripts from the incoming document.
|
|
318
|
+
*
|
|
319
|
+
* These scripts are re-executed after each navigation so their side-effects
|
|
320
|
+
* (event listeners, DOM bootstraps) bind against the new page content.
|
|
321
|
+
*/
|
|
322
|
+
collectRerunScripts(newDocument) {
|
|
323
|
+
return Array.from(newDocument.querySelectorAll("script[data-eco-rerun]")).map((script) => ({
|
|
324
|
+
parent: script.closest("body") ? "body" : "head",
|
|
325
|
+
attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
|
|
326
|
+
textContent: script.textContent ?? "",
|
|
327
|
+
src: script.getAttribute("src"),
|
|
328
|
+
scriptId: script.getAttribute("data-eco-script-id")
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Removes head scripts that are no longer present in the incoming document.
|
|
333
|
+
*
|
|
334
|
+
* Persisted scripts and executable inline scripts with stable identifiers
|
|
335
|
+
* are kept to avoid breaking long-lived runtime state.
|
|
336
|
+
*/
|
|
337
|
+
removeStaleHeadScripts(newDocument) {
|
|
338
|
+
const nextScriptKeys = new Set(
|
|
339
|
+
Array.from(newDocument.head.querySelectorAll("script")).map((script) => this.getHeadScriptKey(script)).filter((key) => key !== null)
|
|
340
|
+
);
|
|
341
|
+
for (const script of Array.from(document.head.querySelectorAll("script"))) {
|
|
342
|
+
const key = this.getHeadScriptKey(script);
|
|
343
|
+
if (!key || nextScriptKeys.has(key)) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (isPersisted(script, this.persistAttribute)) {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
if (this.shouldPersistExecutableInlineHeadScript(script)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
script.remove();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Determines whether an inline head script should survive navigation.
|
|
357
|
+
*
|
|
358
|
+
* Only identified (`data-eco-script-id` or `id`), executable inline scripts
|
|
359
|
+
* are persisted. External scripts and rerun scripts are never persisted here
|
|
360
|
+
* because they have their own lifecycle management.
|
|
361
|
+
*/
|
|
362
|
+
shouldPersistExecutableInlineHeadScript(script) {
|
|
363
|
+
const scriptId = script.getAttribute("data-eco-script-id") || script.getAttribute("id");
|
|
364
|
+
if (!scriptId) {
|
|
365
|
+
return false;
|
|
366
|
+
}
|
|
367
|
+
if (script.hasAttribute("data-eco-rerun")) {
|
|
368
|
+
return false;
|
|
369
|
+
}
|
|
370
|
+
if (script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute("src")) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
return !this.isNonExecutableHeadScript(script);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Returns whether a script is non-executable (e.g. `type="application/json"`).
|
|
377
|
+
*
|
|
378
|
+
* Non-executable scripts are data carriers (JSON-LD, page data) that can be
|
|
379
|
+
* safely replaced without side-effects, unlike executable scripts that would
|
|
380
|
+
* re-run their bootstrap logic.
|
|
381
|
+
*/
|
|
382
|
+
isNonExecutableHeadScript(script) {
|
|
383
|
+
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
384
|
+
if (!type) {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
return ![
|
|
388
|
+
"application/javascript",
|
|
389
|
+
"application/ecmascript",
|
|
390
|
+
"module",
|
|
391
|
+
"text/ecmascript",
|
|
392
|
+
"text/javascript"
|
|
393
|
+
].includes(type);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Compares two head scripts for structural equality (key, content, attributes).
|
|
397
|
+
*
|
|
398
|
+
* Used to skip replacing non-executable data scripts when their content
|
|
399
|
+
* has not changed between navigations.
|
|
400
|
+
*/
|
|
401
|
+
areHeadScriptsEquivalent(nextScript, currentScript) {
|
|
402
|
+
if (this.getHeadScriptKey(nextScript) !== this.getHeadScriptKey(currentScript)) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
if ((nextScript.textContent ?? "") !== (currentScript.textContent ?? "")) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
const nextAttributes = Array.from(nextScript.attributes).map((attribute) => [attribute.name, attribute.value]);
|
|
409
|
+
const currentAttributes = Array.from(currentScript.attributes).map((attribute) => [
|
|
410
|
+
attribute.name,
|
|
411
|
+
attribute.value
|
|
412
|
+
]);
|
|
413
|
+
if (nextAttributes.length !== currentAttributes.length) {
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
return nextAttributes.every(
|
|
417
|
+
([name, value], index) => currentAttributes[index]?.[0] === name && currentAttributes[index]?.[1] === value
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Derives a stable identity key for a head script.
|
|
422
|
+
*
|
|
423
|
+
* Priority: `data-eco-script-id` / `id` > `src` > trimmed inline content.
|
|
424
|
+
* Returns `null` for empty anonymous inline scripts that cannot be tracked.
|
|
425
|
+
*/
|
|
426
|
+
getHeadScriptKey(script) {
|
|
427
|
+
const scriptId = script instanceof HTMLScriptElement ? script.getAttribute("data-eco-script-id") || script.getAttribute("id") : script.scriptId;
|
|
428
|
+
if (scriptId) {
|
|
429
|
+
return `id:${scriptId}`;
|
|
430
|
+
}
|
|
431
|
+
const src = script instanceof HTMLScriptElement ? script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute("src") : script.src;
|
|
432
|
+
if (src) {
|
|
433
|
+
return `src:${src}`;
|
|
434
|
+
}
|
|
435
|
+
const textContent = (script.textContent ?? "").trim();
|
|
436
|
+
return textContent ? `inline:${textContent}` : null;
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Finds an existing head script that matches the given script's identity key.
|
|
440
|
+
*/
|
|
441
|
+
findExistingHeadScript(script) {
|
|
442
|
+
const scriptKey = this.getHeadScriptKey(script);
|
|
443
|
+
if (!scriptKey) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
return Array.from(document.head.querySelectorAll("script")).find(
|
|
447
|
+
(candidate) => this.getHeadScriptKey(candidate) === scriptKey
|
|
448
|
+
) ?? null;
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Finds an existing rerun script in the given root by `data-eco-script-id` or
|
|
452
|
+
* by matching `src` and `textContent`.
|
|
453
|
+
*/
|
|
454
|
+
findExistingRerunScript(root, script) {
|
|
455
|
+
const scripts = Array.from(root.querySelectorAll("script"));
|
|
456
|
+
if (script.scriptId) {
|
|
457
|
+
return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
|
|
458
|
+
}
|
|
459
|
+
return scripts.find(
|
|
460
|
+
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
461
|
+
) ?? null;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Returns whether a rerun script is an external ES module (`type="module"` with `src`).
|
|
465
|
+
*
|
|
466
|
+
* Module scripts are cached by URL, so re-execution requires cache-busting
|
|
467
|
+
* via a query parameter nonce.
|
|
468
|
+
*/
|
|
469
|
+
isExternalModuleRerunScript(script) {
|
|
470
|
+
if (!script.src) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Appends a nonce query parameter to a script URL to bust the browser's
|
|
477
|
+
* module cache and force re-execution on navigation.
|
|
478
|
+
*/
|
|
479
|
+
createRerunScriptUrl(src) {
|
|
480
|
+
const url = new URL(src, document.baseURI);
|
|
481
|
+
url.searchParams.set("__eco_rerun", String(++this.rerunNonce));
|
|
482
|
+
return url.toString();
|
|
483
|
+
}
|
|
217
484
|
/**
|
|
218
485
|
* Manually attaches declarative shadow DOM templates.
|
|
219
486
|
* Browsers only process `<template shadowrootmode>` during initial parse.
|
|
@@ -52,6 +52,7 @@ export declare class PrefetchManager {
|
|
|
52
52
|
* @returns The cached HTML string, or null if not cached
|
|
53
53
|
*/
|
|
54
54
|
getCachedHtml(href: string): string | null;
|
|
55
|
+
invalidate(href: string): void;
|
|
55
56
|
/**
|
|
56
57
|
* Caches HTML content for a visited page and triggers background revalidation.
|
|
57
58
|
*
|
|
@@ -89,6 +89,11 @@ class PrefetchManager {
|
|
|
89
89
|
const url = new URL(href, window.location.origin);
|
|
90
90
|
return this.htmlCache.get(url.href) ?? null;
|
|
91
91
|
}
|
|
92
|
+
invalidate(href) {
|
|
93
|
+
const url = new URL(href, window.location.origin);
|
|
94
|
+
this.htmlCache.delete(url.href);
|
|
95
|
+
this.prefetched.delete(url.href);
|
|
96
|
+
}
|
|
92
97
|
/**
|
|
93
98
|
* Caches HTML content for a visited page and triggers background revalidation.
|
|
94
99
|
*
|
|
@@ -16,8 +16,14 @@ export declare class ViewTransitionManager {
|
|
|
16
16
|
/**
|
|
17
17
|
* Execute a callback with view transition if available and enabled.
|
|
18
18
|
* Falls back to direct execution if not supported.
|
|
19
|
+
*
|
|
20
|
+
* Navigation correctness depends on the DOM update callback completing, not on
|
|
21
|
+
* the browser finishing the visual transition animation. Awaiting
|
|
22
|
+
* `finished` here can leave router transactions artificially in-flight and
|
|
23
|
+
* block later navigations during rapid repeated clicks, so the router waits
|
|
24
|
+
* for `updateCallbackDone` and lets the animation finish in the background.
|
|
19
25
|
* @param callback - The DOM update callback to execute
|
|
20
|
-
* @returns Promise that resolves when the
|
|
26
|
+
* @returns Promise that resolves when the DOM update has committed
|
|
21
27
|
*/
|
|
22
28
|
transition(callback: () => void | Promise<void>): Promise<void>;
|
|
23
29
|
}
|
|
@@ -13,8 +13,14 @@ class ViewTransitionManager {
|
|
|
13
13
|
/**
|
|
14
14
|
* Execute a callback with view transition if available and enabled.
|
|
15
15
|
* Falls back to direct execution if not supported.
|
|
16
|
+
*
|
|
17
|
+
* Navigation correctness depends on the DOM update callback completing, not on
|
|
18
|
+
* the browser finishing the visual transition animation. Awaiting
|
|
19
|
+
* `finished` here can leave router transactions artificially in-flight and
|
|
20
|
+
* block later navigations during rapid repeated clicks, so the router waits
|
|
21
|
+
* for `updateCallbackDone` and lets the animation finish in the background.
|
|
16
22
|
* @param callback - The DOM update callback to execute
|
|
17
|
-
* @returns Promise that resolves when the
|
|
23
|
+
* @returns Promise that resolves when the DOM update has committed
|
|
18
24
|
*/
|
|
19
25
|
async transition(callback) {
|
|
20
26
|
if (!this.enabled || !this.isSupported()) {
|
|
@@ -26,11 +32,21 @@ class ViewTransitionManager {
|
|
|
26
32
|
await callback();
|
|
27
33
|
applyViewTransitionNames();
|
|
28
34
|
});
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
void transition.ready.catch((error) => {
|
|
36
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
console.error("[ecopages] View transition failed to start:", error);
|
|
40
|
+
});
|
|
41
|
+
void transition.finished.catch((error) => {
|
|
42
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
console.error("[ecopages] View transition lifecycle failed:", error);
|
|
46
|
+
}).finally(() => {
|
|
32
47
|
clearViewTransitionNames();
|
|
33
|
-
}
|
|
48
|
+
});
|
|
49
|
+
await transition.updateCallbackDone;
|
|
34
50
|
}
|
|
35
51
|
}
|
|
36
52
|
export {
|
package/src/client/types.d.ts
CHANGED
|
@@ -36,6 +36,13 @@ export interface PrefetchConfig {
|
|
|
36
36
|
export interface EcoRouterOptions {
|
|
37
37
|
/** Selector for links to intercept. @default 'a[href]' */
|
|
38
38
|
linkSelector?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Document-level `<html>` attributes to sync from the incoming document during navigation.
|
|
41
|
+
* Attributes not listed here are preserved on the live document element so client-managed
|
|
42
|
+
* state such as theme classes or data attributes is not clobbered during swaps.
|
|
43
|
+
* @default ['lang', 'dir', 'data-eco-document-owner']
|
|
44
|
+
*/
|
|
45
|
+
documentElementAttributesToSync?: string[];
|
|
39
46
|
/** Attribute to mark elements for DOM persistence. @default 'data-eco-persist' */
|
|
40
47
|
persistAttribute?: string;
|
|
41
48
|
/** Attribute to force full page reload. @default 'data-eco-reload' */
|
|
@@ -67,6 +74,9 @@ export interface EcoRouterOptions {
|
|
|
67
74
|
*/
|
|
68
75
|
prefetch?: PrefetchConfig | false;
|
|
69
76
|
}
|
|
77
|
+
export type BrowserRouterNavigateOptions = {
|
|
78
|
+
direction?: 'forward' | 'back' | 'replace';
|
|
79
|
+
};
|
|
70
80
|
/** Events emitted during the navigation lifecycle */
|
|
71
81
|
export interface EcoNavigationEvent {
|
|
72
82
|
url: URL;
|
|
@@ -80,5 +90,7 @@ export interface EcoBeforeSwapEvent extends EcoNavigationEvent {
|
|
|
80
90
|
/** Event fired after the DOM swap completes */
|
|
81
91
|
export interface EcoAfterSwapEvent extends EcoNavigationEvent {
|
|
82
92
|
}
|
|
93
|
+
/** Default document-level `<html>` attributes synchronized during navigation swaps. */
|
|
94
|
+
export declare const DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC: string[];
|
|
83
95
|
/** Default configuration options */
|
|
84
96
|
export declare const DEFAULT_OPTIONS: Required<EcoRouterOptions>;
|
package/src/client/types.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
+
import { ECO_DOCUMENT_OWNER_ATTRIBUTE } from "@ecopages/core/router/navigation-coordinator";
|
|
1
2
|
const DEFAULT_PREFETCH_CONFIG = {
|
|
2
3
|
strategy: "intent",
|
|
3
4
|
delay: 65,
|
|
4
5
|
noPrefetchAttribute: "data-eco-no-prefetch",
|
|
5
6
|
respectDataSaver: true
|
|
6
7
|
};
|
|
8
|
+
const DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC = ["lang", "dir", ECO_DOCUMENT_OWNER_ATTRIBUTE];
|
|
7
9
|
const DEFAULT_OPTIONS = {
|
|
8
10
|
linkSelector: "a[href]",
|
|
11
|
+
documentElementAttributesToSync: DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
|
|
9
12
|
persistAttribute: "data-eco-persist",
|
|
10
13
|
reloadAttribute: "data-eco-reload",
|
|
11
14
|
updateHistory: true,
|
|
@@ -15,5 +18,6 @@ const DEFAULT_OPTIONS = {
|
|
|
15
18
|
prefetch: DEFAULT_PREFETCH_CONFIG
|
|
16
19
|
};
|
|
17
20
|
export {
|
|
21
|
+
DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
|
|
18
22
|
DEFAULT_OPTIONS
|
|
19
23
|
};
|
package/src/index.d.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
export type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent, EcoRouterEventMap, } from './types.js';
|
|
7
|
-
export { DEFAULT_OPTIONS } from './types.js';
|
|
7
|
+
export { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC, DEFAULT_OPTIONS } from './types.js';
|
|
8
|
+
export { defaultDocumentElementAttributesToSync, syncDocumentElementAttributes, } from './client/document-element-sync.js';
|
|
8
9
|
export { EcoRouter, createRouter } from './client/eco-router.js';
|
|
9
10
|
export { DomSwapper, ScrollManager, ViewTransitionManager } from './client/services/index.js';
|
package/src/index.js
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
|
-
import { DEFAULT_OPTIONS } from "./types.js";
|
|
1
|
+
import { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC, DEFAULT_OPTIONS } from "./types.js";
|
|
2
|
+
import {
|
|
3
|
+
defaultDocumentElementAttributesToSync,
|
|
4
|
+
syncDocumentElementAttributes
|
|
5
|
+
} from "./client/document-element-sync.js";
|
|
2
6
|
import { EcoRouter, createRouter } from "./client/eco-router.js";
|
|
3
7
|
import { DomSwapper, ScrollManager, ViewTransitionManager } from "./client/services/index.js";
|
|
4
8
|
export {
|
|
9
|
+
DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC,
|
|
5
10
|
DEFAULT_OPTIONS,
|
|
6
11
|
DomSwapper,
|
|
7
12
|
EcoRouter,
|
|
8
13
|
ScrollManager,
|
|
9
14
|
ViewTransitionManager,
|
|
10
|
-
createRouter
|
|
15
|
+
createRouter,
|
|
16
|
+
defaultDocumentElementAttributesToSync,
|
|
17
|
+
syncDocumentElementAttributes
|
|
11
18
|
};
|
package/src/types.d.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './client/types';
|
|
6
6
|
export type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './client/types';
|
|
7
|
-
export { DEFAULT_OPTIONS } from './client/types';
|
|
7
|
+
export { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC, DEFAULT_OPTIONS } from './client/types';
|
|
8
8
|
/**
|
|
9
9
|
* Custom event map for navigation lifecycle
|
|
10
10
|
*/
|
package/src/types.js
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|