@ecopages/browser-router 0.2.0-alpha.10 → 0.2.0-alpha.11
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
CHANGED
|
@@ -8,19 +8,13 @@ All notable changes to `@ecopages/browser-router` are documented here.
|
|
|
8
8
|
|
|
9
9
|
### Features
|
|
10
10
|
|
|
11
|
-
- Added cross-router
|
|
12
|
-
- Improved head swaps to execute newly introduced scripts and preserve light-DOM custom elements during page transitions.
|
|
13
|
-
- Added `documentElementAttributesToSync` so apps can explicitly control which root `<html>` attributes browser-router synchronizes during navigation.
|
|
14
|
-
- Added public document sync helpers so advanced setups can reuse browser-router's root `<html>` attribute synchronization logic without overriding the router pipeline.
|
|
11
|
+
- Added cross-router handoff hooks, configurable `<html>` attribute syncing, and public document sync helpers.
|
|
15
12
|
|
|
16
13
|
### Bug Fixes
|
|
17
14
|
|
|
18
|
-
- Fixed
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
- Preserved client-managed `<html>` state such as theme classes and data attributes while still syncing router-owned document metadata during swaps.
|
|
22
|
-
- Prevented duplicate head-script execution, duplicate `/_hmr_runtime.js` injection, and listener accumulation across repeated navigations.
|
|
23
|
-
- Reset hydrated custom elements from incoming HTML and ignored superseded navigation fetch failures so cross-runtime handoffs no longer leave blank or mixed DOM state behind.
|
|
15
|
+
- Fixed navigation races, duplicate script injection, and stale cleanup during repeated page swaps.
|
|
16
|
+
- Fixed mixed-runtime document ownership, script reruns, persisted head scripts, and client-managed `<html>` state during browser-router navigations.
|
|
17
|
+
- Fixed skipped View Transition lifecycle aborts leaking as unhandled browser-router test errors.
|
|
24
18
|
|
|
25
19
|
### Refactoring
|
|
26
20
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/browser-router",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.11",
|
|
4
4
|
"description": "Client-side router for Ecopages with view transitions support",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ecopages",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"morphdom": "^2.7.8"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@ecopages/core": "0.2.0-alpha.
|
|
38
|
+
"@ecopages/core": "0.2.0-alpha.11"
|
|
39
39
|
},
|
|
40
40
|
"types": "./src/index.d.ts"
|
|
41
41
|
}
|
package/src/client/eco-router.js
CHANGED
|
@@ -56,6 +56,9 @@ class EcoRouter {
|
|
|
56
56
|
if (href.startsWith("javascript:")) return null;
|
|
57
57
|
const url = new URL(href, window.location.origin);
|
|
58
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
|
+
}
|
|
59
62
|
return href;
|
|
60
63
|
}
|
|
61
64
|
getRecoveredPointerHref() {
|
|
@@ -49,6 +49,10 @@ export declare class DomSwapper {
|
|
|
49
49
|
* being replaced.
|
|
50
50
|
*/
|
|
51
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
|
+
*/
|
|
52
56
|
shouldReplaceBodyForRerunScripts(): boolean;
|
|
53
57
|
/**
|
|
54
58
|
* Detects custom elements without shadow DOM (light-DOM custom elements).
|
|
@@ -56,7 +60,26 @@ export declare class DomSwapper {
|
|
|
56
60
|
* strip JS-generated content from their light DOM children.
|
|
57
61
|
*/
|
|
58
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
|
+
*/
|
|
59
73
|
private replaceCustomElement;
|
|
74
|
+
/**
|
|
75
|
+
* Replaces all deferred custom elements after morphdom has finished traversing.
|
|
76
|
+
*
|
|
77
|
+
* Each element is recreated via `document.createElement` so the browser fires
|
|
78
|
+
* the full custom element lifecycle (`disconnectedCallback` on the old instance,
|
|
79
|
+
* `connectedCallback` on the new one). Elements that were already removed during
|
|
80
|
+
* the morph pass are skipped via the `isConnected` guard.
|
|
81
|
+
*/
|
|
82
|
+
private flushDeferredCustomElementReplacements;
|
|
60
83
|
/**
|
|
61
84
|
* Morphs document body using morphdom.
|
|
62
85
|
* Preserves persisted elements and hydrated custom elements.
|
|
@@ -70,15 +93,70 @@ export declare class DomSwapper {
|
|
|
70
93
|
* Use when View Transitions are disabled.
|
|
71
94
|
*/
|
|
72
95
|
replaceBody(newDocument: Document): void;
|
|
96
|
+
/**
|
|
97
|
+
* Collects all `data-eco-rerun` scripts from the incoming document.
|
|
98
|
+
*
|
|
99
|
+
* These scripts are re-executed after each navigation so their side-effects
|
|
100
|
+
* (event listeners, DOM bootstraps) bind against the new page content.
|
|
101
|
+
*/
|
|
73
102
|
private collectRerunScripts;
|
|
103
|
+
/**
|
|
104
|
+
* Removes head scripts that are no longer present in the incoming document.
|
|
105
|
+
*
|
|
106
|
+
* Persisted scripts and executable inline scripts with stable identifiers
|
|
107
|
+
* are kept to avoid breaking long-lived runtime state.
|
|
108
|
+
*/
|
|
74
109
|
private removeStaleHeadScripts;
|
|
110
|
+
/**
|
|
111
|
+
* Determines whether an inline head script should survive navigation.
|
|
112
|
+
*
|
|
113
|
+
* Only identified (`data-eco-script-id` or `id`), executable inline scripts
|
|
114
|
+
* are persisted. External scripts and rerun scripts are never persisted here
|
|
115
|
+
* because they have their own lifecycle management.
|
|
116
|
+
*/
|
|
75
117
|
private shouldPersistExecutableInlineHeadScript;
|
|
118
|
+
/**
|
|
119
|
+
* Returns whether a script is non-executable (e.g. `type="application/json"`).
|
|
120
|
+
*
|
|
121
|
+
* Non-executable scripts are data carriers (JSON-LD, page data) that can be
|
|
122
|
+
* safely replaced without side-effects, unlike executable scripts that would
|
|
123
|
+
* re-run their bootstrap logic.
|
|
124
|
+
*/
|
|
76
125
|
private isNonExecutableHeadScript;
|
|
126
|
+
/**
|
|
127
|
+
* Compares two head scripts for structural equality (key, content, attributes).
|
|
128
|
+
*
|
|
129
|
+
* Used to skip replacing non-executable data scripts when their content
|
|
130
|
+
* has not changed between navigations.
|
|
131
|
+
*/
|
|
77
132
|
private areHeadScriptsEquivalent;
|
|
133
|
+
/**
|
|
134
|
+
* Derives a stable identity key for a head script.
|
|
135
|
+
*
|
|
136
|
+
* Priority: `data-eco-script-id` / `id` > `src` > trimmed inline content.
|
|
137
|
+
* Returns `null` for empty anonymous inline scripts that cannot be tracked.
|
|
138
|
+
*/
|
|
78
139
|
private getHeadScriptKey;
|
|
140
|
+
/**
|
|
141
|
+
* Finds an existing head script that matches the given script's identity key.
|
|
142
|
+
*/
|
|
79
143
|
private findExistingHeadScript;
|
|
144
|
+
/**
|
|
145
|
+
* Finds an existing rerun script in the given root by `data-eco-script-id` or
|
|
146
|
+
* by matching `src` and `textContent`.
|
|
147
|
+
*/
|
|
80
148
|
private findExistingRerunScript;
|
|
149
|
+
/**
|
|
150
|
+
* Returns whether a rerun script is an external ES module (`type="module"` with `src`).
|
|
151
|
+
*
|
|
152
|
+
* Module scripts are cached by URL, so re-execution requires cache-busting
|
|
153
|
+
* via a query parameter nonce.
|
|
154
|
+
*/
|
|
81
155
|
private isExternalModuleRerunScript;
|
|
156
|
+
/**
|
|
157
|
+
* Appends a nonce query parameter to a script URL to bust the browser's
|
|
158
|
+
* module cache and force re-execution on navigation.
|
|
159
|
+
*/
|
|
82
160
|
private createRerunScriptUrl;
|
|
83
161
|
/**
|
|
84
162
|
* Manually attaches declarative shadow DOM templates.
|
|
@@ -203,6 +203,10 @@ class DomSwapper {
|
|
|
203
203
|
}
|
|
204
204
|
this.pendingRerunScripts = [];
|
|
205
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Returns whether pending rerun scripts require a full body replacement
|
|
208
|
+
* instead of morphing, so DOM-dependent bootstraps bind against fresh markup.
|
|
209
|
+
*/
|
|
206
210
|
shouldReplaceBodyForRerunScripts() {
|
|
207
211
|
return this.pendingRerunScripts.length > 0;
|
|
208
212
|
}
|
|
@@ -214,15 +218,39 @@ class DomSwapper {
|
|
|
214
218
|
isLightDomCustomElement(element) {
|
|
215
219
|
return element.localName.includes("-") && element.shadowRoot === null;
|
|
216
220
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Queues a custom element for deferred replacement instead of replacing it
|
|
223
|
+
* inline during the morphdom walk.
|
|
224
|
+
*
|
|
225
|
+
* Replacing elements during morphdom traversal mutates the live DOM tree,
|
|
226
|
+
* which can cause morphdom to skip siblings or process stale nodes.
|
|
227
|
+
* Deferring the replacement until after morphdom finishes avoids this.
|
|
228
|
+
*
|
|
229
|
+
* @returns Always `false` to tell morphdom to skip updating this element.
|
|
230
|
+
*/
|
|
231
|
+
replaceCustomElement(fromEl, toEl, deferred) {
|
|
232
|
+
deferred.push({ from: fromEl, to: toEl });
|
|
224
233
|
return false;
|
|
225
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* Replaces all deferred custom elements after morphdom has finished traversing.
|
|
237
|
+
*
|
|
238
|
+
* Each element is recreated via `document.createElement` so the browser fires
|
|
239
|
+
* the full custom element lifecycle (`disconnectedCallback` on the old instance,
|
|
240
|
+
* `connectedCallback` on the new one). Elements that were already removed during
|
|
241
|
+
* the morph pass are skipped via the `isConnected` guard.
|
|
242
|
+
*/
|
|
243
|
+
flushDeferredCustomElementReplacements(deferred) {
|
|
244
|
+
for (const { from, to } of deferred) {
|
|
245
|
+
if (!from.isConnected) continue;
|
|
246
|
+
const newEl = document.createElement(to.tagName);
|
|
247
|
+
for (const attr of to.attributes) {
|
|
248
|
+
newEl.setAttribute(attr.name, attr.value);
|
|
249
|
+
}
|
|
250
|
+
newEl.innerHTML = to.innerHTML;
|
|
251
|
+
from.replaceWith(newEl);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
226
254
|
/**
|
|
227
255
|
* Morphs document body using morphdom.
|
|
228
256
|
* Preserves persisted elements and hydrated custom elements.
|
|
@@ -231,16 +259,17 @@ class DomSwapper {
|
|
|
231
259
|
*/
|
|
232
260
|
morphBody(newDocument) {
|
|
233
261
|
const persistAttr = this.persistAttribute;
|
|
262
|
+
const deferredReplacements = [];
|
|
234
263
|
morphdom(document.body, newDocument.body, {
|
|
235
264
|
onBeforeElUpdated: (fromEl, toEl) => {
|
|
236
265
|
if (isPersisted(fromEl, persistAttr)) {
|
|
237
266
|
return false;
|
|
238
267
|
}
|
|
239
268
|
if (isHydratedCustomElement(fromEl)) {
|
|
240
|
-
return this.replaceCustomElement(fromEl, toEl);
|
|
269
|
+
return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
|
|
241
270
|
}
|
|
242
271
|
if (this.isLightDomCustomElement(fromEl)) {
|
|
243
|
-
return this.replaceCustomElement(fromEl, toEl);
|
|
272
|
+
return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
|
|
244
273
|
}
|
|
245
274
|
if (fromEl.isEqualNode(toEl)) {
|
|
246
275
|
return false;
|
|
@@ -248,6 +277,7 @@ class DomSwapper {
|
|
|
248
277
|
return true;
|
|
249
278
|
}
|
|
250
279
|
});
|
|
280
|
+
this.flushDeferredCustomElementReplacements(deferredReplacements);
|
|
251
281
|
this.processDeclarativeShadowDOM(document.body);
|
|
252
282
|
}
|
|
253
283
|
/**
|
|
@@ -273,9 +303,15 @@ class DomSwapper {
|
|
|
273
303
|
placeholder.replaceWith(oldEl);
|
|
274
304
|
}
|
|
275
305
|
}
|
|
276
|
-
document.body.replaceChildren(...newDocument.body.childNodes);
|
|
306
|
+
document.body.replaceChildren(...Array.from(newDocument.body.childNodes));
|
|
277
307
|
this.processDeclarativeShadowDOM(document.body);
|
|
278
308
|
}
|
|
309
|
+
/**
|
|
310
|
+
* Collects all `data-eco-rerun` scripts from the incoming document.
|
|
311
|
+
*
|
|
312
|
+
* These scripts are re-executed after each navigation so their side-effects
|
|
313
|
+
* (event listeners, DOM bootstraps) bind against the new page content.
|
|
314
|
+
*/
|
|
279
315
|
collectRerunScripts(newDocument) {
|
|
280
316
|
return Array.from(newDocument.querySelectorAll("script[data-eco-rerun]")).map((script) => ({
|
|
281
317
|
parent: script.closest("body") ? "body" : "head",
|
|
@@ -285,6 +321,12 @@ class DomSwapper {
|
|
|
285
321
|
scriptId: script.getAttribute("data-eco-script-id")
|
|
286
322
|
}));
|
|
287
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Removes head scripts that are no longer present in the incoming document.
|
|
326
|
+
*
|
|
327
|
+
* Persisted scripts and executable inline scripts with stable identifiers
|
|
328
|
+
* are kept to avoid breaking long-lived runtime state.
|
|
329
|
+
*/
|
|
288
330
|
removeStaleHeadScripts(newDocument) {
|
|
289
331
|
const nextScriptKeys = new Set(
|
|
290
332
|
Array.from(newDocument.head.querySelectorAll("script")).map((script) => this.getHeadScriptKey(script)).filter((key) => key !== null)
|
|
@@ -294,12 +336,22 @@ class DomSwapper {
|
|
|
294
336
|
if (!key || nextScriptKeys.has(key)) {
|
|
295
337
|
continue;
|
|
296
338
|
}
|
|
339
|
+
if (isPersisted(script, this.persistAttribute)) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
297
342
|
if (this.shouldPersistExecutableInlineHeadScript(script)) {
|
|
298
343
|
continue;
|
|
299
344
|
}
|
|
300
345
|
script.remove();
|
|
301
346
|
}
|
|
302
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* Determines whether an inline head script should survive navigation.
|
|
350
|
+
*
|
|
351
|
+
* Only identified (`data-eco-script-id` or `id`), executable inline scripts
|
|
352
|
+
* are persisted. External scripts and rerun scripts are never persisted here
|
|
353
|
+
* because they have their own lifecycle management.
|
|
354
|
+
*/
|
|
303
355
|
shouldPersistExecutableInlineHeadScript(script) {
|
|
304
356
|
const scriptId = script.getAttribute("data-eco-script-id") || script.getAttribute("id");
|
|
305
357
|
if (!scriptId) {
|
|
@@ -313,6 +365,13 @@ class DomSwapper {
|
|
|
313
365
|
}
|
|
314
366
|
return !this.isNonExecutableHeadScript(script);
|
|
315
367
|
}
|
|
368
|
+
/**
|
|
369
|
+
* Returns whether a script is non-executable (e.g. `type="application/json"`).
|
|
370
|
+
*
|
|
371
|
+
* Non-executable scripts are data carriers (JSON-LD, page data) that can be
|
|
372
|
+
* safely replaced without side-effects, unlike executable scripts that would
|
|
373
|
+
* re-run their bootstrap logic.
|
|
374
|
+
*/
|
|
316
375
|
isNonExecutableHeadScript(script) {
|
|
317
376
|
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
318
377
|
if (!type) {
|
|
@@ -326,6 +385,12 @@ class DomSwapper {
|
|
|
326
385
|
"text/javascript"
|
|
327
386
|
].includes(type);
|
|
328
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Compares two head scripts for structural equality (key, content, attributes).
|
|
390
|
+
*
|
|
391
|
+
* Used to skip replacing non-executable data scripts when their content
|
|
392
|
+
* has not changed between navigations.
|
|
393
|
+
*/
|
|
329
394
|
areHeadScriptsEquivalent(nextScript, currentScript) {
|
|
330
395
|
if (this.getHeadScriptKey(nextScript) !== this.getHeadScriptKey(currentScript)) {
|
|
331
396
|
return false;
|
|
@@ -345,6 +410,12 @@ class DomSwapper {
|
|
|
345
410
|
([name, value], index) => currentAttributes[index]?.[0] === name && currentAttributes[index]?.[1] === value
|
|
346
411
|
);
|
|
347
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Derives a stable identity key for a head script.
|
|
415
|
+
*
|
|
416
|
+
* Priority: `data-eco-script-id` / `id` > `src` > trimmed inline content.
|
|
417
|
+
* Returns `null` for empty anonymous inline scripts that cannot be tracked.
|
|
418
|
+
*/
|
|
348
419
|
getHeadScriptKey(script) {
|
|
349
420
|
const scriptId = script instanceof HTMLScriptElement ? script.getAttribute("data-eco-script-id") || script.getAttribute("id") : script.scriptId;
|
|
350
421
|
if (scriptId) {
|
|
@@ -357,6 +428,9 @@ class DomSwapper {
|
|
|
357
428
|
const textContent = (script.textContent ?? "").trim();
|
|
358
429
|
return textContent ? `inline:${textContent}` : null;
|
|
359
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Finds an existing head script that matches the given script's identity key.
|
|
433
|
+
*/
|
|
360
434
|
findExistingHeadScript(script) {
|
|
361
435
|
const scriptKey = this.getHeadScriptKey(script);
|
|
362
436
|
if (!scriptKey) {
|
|
@@ -366,6 +440,10 @@ class DomSwapper {
|
|
|
366
440
|
(candidate) => this.getHeadScriptKey(candidate) === scriptKey
|
|
367
441
|
) ?? null;
|
|
368
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Finds an existing rerun script in the given root by `data-eco-script-id` or
|
|
445
|
+
* by matching `src` and `textContent`.
|
|
446
|
+
*/
|
|
369
447
|
findExistingRerunScript(root, script) {
|
|
370
448
|
const scripts = Array.from(root.querySelectorAll("script"));
|
|
371
449
|
if (script.scriptId) {
|
|
@@ -375,12 +453,22 @@ class DomSwapper {
|
|
|
375
453
|
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
376
454
|
) ?? null;
|
|
377
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Returns whether a rerun script is an external ES module (`type="module"` with `src`).
|
|
458
|
+
*
|
|
459
|
+
* Module scripts are cached by URL, so re-execution requires cache-busting
|
|
460
|
+
* via a query parameter nonce.
|
|
461
|
+
*/
|
|
378
462
|
isExternalModuleRerunScript(script) {
|
|
379
463
|
if (!script.src) {
|
|
380
464
|
return false;
|
|
381
465
|
}
|
|
382
466
|
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
383
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Appends a nonce query parameter to a script URL to bust the browser's
|
|
470
|
+
* module cache and force re-execution on navigation.
|
|
471
|
+
*/
|
|
384
472
|
createRerunScriptUrl(src) {
|
|
385
473
|
const url = new URL(src, document.baseURI);
|
|
386
474
|
url.searchParams.set("__eco_rerun", String(++this.rerunNonce));
|
|
@@ -32,7 +32,18 @@ class ViewTransitionManager {
|
|
|
32
32
|
await callback();
|
|
33
33
|
applyViewTransitionNames();
|
|
34
34
|
});
|
|
35
|
-
void transition.
|
|
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(() => {
|
|
36
47
|
clearViewTransitionNames();
|
|
37
48
|
});
|
|
38
49
|
await transition.updateCallbackDone;
|