@ecopages/browser-router 0.2.0-alpha.9 → 0.2.1-beta.0
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/README.md +1 -1
- package/package.json +2 -2
- package/src/client/eco-router.js +3 -0
- package/src/client/services/dom-swapper.d.ts +80 -0
- package/src/client/services/dom-swapper.js +131 -11
- 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.js +12 -1
- package/CHANGELOG.md +0 -31
- package/src/client/document-element-sync.ts +0 -48
- package/src/client/eco-router.ts +0 -634
- package/src/client/services/dom-swapper.ts +0 -576
- 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 -81
- package/src/client/types.ts +0 -126
- package/src/client/view-transition-utils.ts +0 -98
- package/src/index.ts +0 -23
- package/src/types.ts +0 -19
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ecopages/browser-router",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1-beta.0",
|
|
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.
|
|
38
|
+
"@ecopages/core": "0.2.1-beta.0"
|
|
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,27 @@ 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
|
+
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;
|
|
60
84
|
/**
|
|
61
85
|
* Morphs document body using morphdom.
|
|
62
86
|
* Preserves persisted elements and hydrated custom elements.
|
|
@@ -70,15 +94,71 @@ export declare class DomSwapper {
|
|
|
70
94
|
* Use when View Transitions are disabled.
|
|
71
95
|
*/
|
|
72
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
|
+
*/
|
|
73
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
|
+
*/
|
|
74
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
|
+
*/
|
|
75
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
|
+
*/
|
|
76
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
|
+
*/
|
|
77
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
|
+
*/
|
|
78
140
|
private getHeadScriptKey;
|
|
141
|
+
/**
|
|
142
|
+
* Finds an existing head script that matches the given script's identity key.
|
|
143
|
+
*/
|
|
79
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
|
+
*/
|
|
80
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
|
+
*/
|
|
81
156
|
private isExternalModuleRerunScript;
|
|
157
|
+
private getRegisteredRerunScript;
|
|
158
|
+
/**
|
|
159
|
+
* Appends a nonce query parameter to a script URL to bust the browser's
|
|
160
|
+
* module cache and force re-execution on navigation.
|
|
161
|
+
*/
|
|
82
162
|
private createRerunScriptUrl;
|
|
83
163
|
/**
|
|
84
164
|
* Manually attaches declarative shadow DOM templates.
|
|
@@ -7,6 +7,12 @@ function isPersisted(element, persistAttribute) {
|
|
|
7
7
|
function isHydratedCustomElement(element) {
|
|
8
8
|
return element.localName.includes("-") && element.shadowRoot !== null;
|
|
9
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
|
+
}
|
|
10
16
|
class DomSwapper {
|
|
11
17
|
persistAttribute;
|
|
12
18
|
pendingHeadScripts = [];
|
|
@@ -180,8 +186,9 @@ class DomSwapper {
|
|
|
180
186
|
this.pendingHeadScripts = [];
|
|
181
187
|
for (const script of this.pendingRerunScripts) {
|
|
182
188
|
const targetParent = script.parent === "body" ? document.body : document.head;
|
|
189
|
+
const registeredRerun = this.getRegisteredRerunScript(script.scriptId);
|
|
183
190
|
const replacement = document.createElement("script");
|
|
184
|
-
const shouldBustModuleSrc = this.isExternalModuleRerunScript(script);
|
|
191
|
+
const shouldBustModuleSrc = this.isExternalModuleRerunScript(script) && !registeredRerun;
|
|
185
192
|
for (const [name, value] of script.attributes) {
|
|
186
193
|
if (name === "data-eco-rerun") {
|
|
187
194
|
continue;
|
|
@@ -195,6 +202,13 @@ class DomSwapper {
|
|
|
195
202
|
}
|
|
196
203
|
replacement.textContent = script.textContent;
|
|
197
204
|
const existingScript = this.findExistingRerunScript(targetParent, script);
|
|
205
|
+
if (registeredRerun) {
|
|
206
|
+
if (!existingScript) {
|
|
207
|
+
targetParent.appendChild(replacement);
|
|
208
|
+
}
|
|
209
|
+
registeredRerun();
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
198
212
|
if (existingScript) {
|
|
199
213
|
existingScript.replaceWith(replacement);
|
|
200
214
|
continue;
|
|
@@ -203,6 +217,10 @@ class DomSwapper {
|
|
|
203
217
|
}
|
|
204
218
|
this.pendingRerunScripts = [];
|
|
205
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Returns whether pending rerun scripts require a full body replacement
|
|
222
|
+
* instead of morphing, so DOM-dependent bootstraps bind against fresh markup.
|
|
223
|
+
*/
|
|
206
224
|
shouldReplaceBodyForRerunScripts() {
|
|
207
225
|
return this.pendingRerunScripts.length > 0;
|
|
208
226
|
}
|
|
@@ -214,15 +232,43 @@ class DomSwapper {
|
|
|
214
232
|
isLightDomCustomElement(element) {
|
|
215
233
|
return element.localName.includes("-") && element.shadowRoot === null;
|
|
216
234
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Queues a custom element for deferred replacement instead of replacing it
|
|
237
|
+
* inline during the morphdom walk.
|
|
238
|
+
*
|
|
239
|
+
* Replacing elements during morphdom traversal mutates the live DOM tree,
|
|
240
|
+
* which can cause morphdom to skip siblings or process stale nodes.
|
|
241
|
+
* Deferring the replacement until after morphdom finishes avoids this.
|
|
242
|
+
*
|
|
243
|
+
* @returns Always `false` to tell morphdom to skip updating this element.
|
|
244
|
+
*/
|
|
245
|
+
replaceCustomElement(fromEl, toEl, deferred) {
|
|
246
|
+
deferred.push({ from: fromEl, to: toEl.cloneNode(true) });
|
|
224
247
|
return false;
|
|
225
248
|
}
|
|
249
|
+
materializeCustomElement(element) {
|
|
250
|
+
const materializedElement = document.createElement(element.localName);
|
|
251
|
+
for (const attr of element.attributes) {
|
|
252
|
+
materializedElement.setAttribute(attr.name, attr.value);
|
|
253
|
+
}
|
|
254
|
+
materializedElement.innerHTML = element.innerHTML;
|
|
255
|
+
return materializedElement;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Replaces all deferred custom elements after morphdom has finished traversing.
|
|
259
|
+
*
|
|
260
|
+
* Each element is recreated via `document.createElement` so the browser fires
|
|
261
|
+
* the full custom element lifecycle (`disconnectedCallback` on the old instance,
|
|
262
|
+
* `connectedCallback` on the new one). Elements that were already removed during
|
|
263
|
+
* the morph pass are skipped via the `isConnected` guard.
|
|
264
|
+
*/
|
|
265
|
+
flushDeferredCustomElementReplacements(deferred) {
|
|
266
|
+
for (const { from, to } of deferred) {
|
|
267
|
+
if (!from.isConnected) continue;
|
|
268
|
+
const newEl = this.materializeCustomElement(to);
|
|
269
|
+
from.replaceWith(newEl);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
226
272
|
/**
|
|
227
273
|
* Morphs document body using morphdom.
|
|
228
274
|
* Preserves persisted elements and hydrated custom elements.
|
|
@@ -231,16 +277,24 @@ class DomSwapper {
|
|
|
231
277
|
*/
|
|
232
278
|
morphBody(newDocument) {
|
|
233
279
|
const persistAttr = this.persistAttribute;
|
|
280
|
+
const deferredReplacements = [];
|
|
234
281
|
morphdom(document.body, newDocument.body, {
|
|
282
|
+
getNodeKey: (node) => getBodyMorphKey(node, persistAttr),
|
|
283
|
+
onBeforeNodeAdded: (node) => {
|
|
284
|
+
if (node instanceof Element && this.isLightDomCustomElement(node)) {
|
|
285
|
+
return this.materializeCustomElement(node);
|
|
286
|
+
}
|
|
287
|
+
return node;
|
|
288
|
+
},
|
|
235
289
|
onBeforeElUpdated: (fromEl, toEl) => {
|
|
236
290
|
if (isPersisted(fromEl, persistAttr)) {
|
|
237
291
|
return false;
|
|
238
292
|
}
|
|
239
293
|
if (isHydratedCustomElement(fromEl)) {
|
|
240
|
-
return this.replaceCustomElement(fromEl, toEl);
|
|
294
|
+
return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
|
|
241
295
|
}
|
|
242
296
|
if (this.isLightDomCustomElement(fromEl)) {
|
|
243
|
-
return this.replaceCustomElement(fromEl, toEl);
|
|
297
|
+
return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
|
|
244
298
|
}
|
|
245
299
|
if (fromEl.isEqualNode(toEl)) {
|
|
246
300
|
return false;
|
|
@@ -248,6 +302,7 @@ class DomSwapper {
|
|
|
248
302
|
return true;
|
|
249
303
|
}
|
|
250
304
|
});
|
|
305
|
+
this.flushDeferredCustomElementReplacements(deferredReplacements);
|
|
251
306
|
this.processDeclarativeShadowDOM(document.body);
|
|
252
307
|
}
|
|
253
308
|
/**
|
|
@@ -273,9 +328,15 @@ class DomSwapper {
|
|
|
273
328
|
placeholder.replaceWith(oldEl);
|
|
274
329
|
}
|
|
275
330
|
}
|
|
276
|
-
document.body.replaceChildren(...newDocument.body.childNodes);
|
|
331
|
+
document.body.replaceChildren(...Array.from(newDocument.body.childNodes));
|
|
277
332
|
this.processDeclarativeShadowDOM(document.body);
|
|
278
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* Collects all `data-eco-rerun` scripts from the incoming document.
|
|
336
|
+
*
|
|
337
|
+
* These scripts are re-executed after each navigation so their side-effects
|
|
338
|
+
* (event listeners, DOM bootstraps) bind against the new page content.
|
|
339
|
+
*/
|
|
279
340
|
collectRerunScripts(newDocument) {
|
|
280
341
|
return Array.from(newDocument.querySelectorAll("script[data-eco-rerun]")).map((script) => ({
|
|
281
342
|
parent: script.closest("body") ? "body" : "head",
|
|
@@ -285,6 +346,12 @@ class DomSwapper {
|
|
|
285
346
|
scriptId: script.getAttribute("data-eco-script-id")
|
|
286
347
|
}));
|
|
287
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* Removes head scripts that are no longer present in the incoming document.
|
|
351
|
+
*
|
|
352
|
+
* Persisted scripts and executable inline scripts with stable identifiers
|
|
353
|
+
* are kept to avoid breaking long-lived runtime state.
|
|
354
|
+
*/
|
|
288
355
|
removeStaleHeadScripts(newDocument) {
|
|
289
356
|
const nextScriptKeys = new Set(
|
|
290
357
|
Array.from(newDocument.head.querySelectorAll("script")).map((script) => this.getHeadScriptKey(script)).filter((key) => key !== null)
|
|
@@ -294,12 +361,22 @@ class DomSwapper {
|
|
|
294
361
|
if (!key || nextScriptKeys.has(key)) {
|
|
295
362
|
continue;
|
|
296
363
|
}
|
|
364
|
+
if (isPersisted(script, this.persistAttribute)) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
297
367
|
if (this.shouldPersistExecutableInlineHeadScript(script)) {
|
|
298
368
|
continue;
|
|
299
369
|
}
|
|
300
370
|
script.remove();
|
|
301
371
|
}
|
|
302
372
|
}
|
|
373
|
+
/**
|
|
374
|
+
* Determines whether an inline head script should survive navigation.
|
|
375
|
+
*
|
|
376
|
+
* Only identified (`data-eco-script-id` or `id`), executable inline scripts
|
|
377
|
+
* are persisted. External scripts and rerun scripts are never persisted here
|
|
378
|
+
* because they have their own lifecycle management.
|
|
379
|
+
*/
|
|
303
380
|
shouldPersistExecutableInlineHeadScript(script) {
|
|
304
381
|
const scriptId = script.getAttribute("data-eco-script-id") || script.getAttribute("id");
|
|
305
382
|
if (!scriptId) {
|
|
@@ -313,6 +390,13 @@ class DomSwapper {
|
|
|
313
390
|
}
|
|
314
391
|
return !this.isNonExecutableHeadScript(script);
|
|
315
392
|
}
|
|
393
|
+
/**
|
|
394
|
+
* Returns whether a script is non-executable (e.g. `type="application/json"`).
|
|
395
|
+
*
|
|
396
|
+
* Non-executable scripts are data carriers (JSON-LD, page data) that can be
|
|
397
|
+
* safely replaced without side-effects, unlike executable scripts that would
|
|
398
|
+
* re-run their bootstrap logic.
|
|
399
|
+
*/
|
|
316
400
|
isNonExecutableHeadScript(script) {
|
|
317
401
|
const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
|
|
318
402
|
if (!type) {
|
|
@@ -326,6 +410,12 @@ class DomSwapper {
|
|
|
326
410
|
"text/javascript"
|
|
327
411
|
].includes(type);
|
|
328
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* Compares two head scripts for structural equality (key, content, attributes).
|
|
415
|
+
*
|
|
416
|
+
* Used to skip replacing non-executable data scripts when their content
|
|
417
|
+
* has not changed between navigations.
|
|
418
|
+
*/
|
|
329
419
|
areHeadScriptsEquivalent(nextScript, currentScript) {
|
|
330
420
|
if (this.getHeadScriptKey(nextScript) !== this.getHeadScriptKey(currentScript)) {
|
|
331
421
|
return false;
|
|
@@ -345,6 +435,12 @@ class DomSwapper {
|
|
|
345
435
|
([name, value], index) => currentAttributes[index]?.[0] === name && currentAttributes[index]?.[1] === value
|
|
346
436
|
);
|
|
347
437
|
}
|
|
438
|
+
/**
|
|
439
|
+
* Derives a stable identity key for a head script.
|
|
440
|
+
*
|
|
441
|
+
* Priority: `data-eco-script-id` / `id` > `src` > trimmed inline content.
|
|
442
|
+
* Returns `null` for empty anonymous inline scripts that cannot be tracked.
|
|
443
|
+
*/
|
|
348
444
|
getHeadScriptKey(script) {
|
|
349
445
|
const scriptId = script instanceof HTMLScriptElement ? script.getAttribute("data-eco-script-id") || script.getAttribute("id") : script.scriptId;
|
|
350
446
|
if (scriptId) {
|
|
@@ -357,6 +453,9 @@ class DomSwapper {
|
|
|
357
453
|
const textContent = (script.textContent ?? "").trim();
|
|
358
454
|
return textContent ? `inline:${textContent}` : null;
|
|
359
455
|
}
|
|
456
|
+
/**
|
|
457
|
+
* Finds an existing head script that matches the given script's identity key.
|
|
458
|
+
*/
|
|
360
459
|
findExistingHeadScript(script) {
|
|
361
460
|
const scriptKey = this.getHeadScriptKey(script);
|
|
362
461
|
if (!scriptKey) {
|
|
@@ -366,6 +465,10 @@ class DomSwapper {
|
|
|
366
465
|
(candidate) => this.getHeadScriptKey(candidate) === scriptKey
|
|
367
466
|
) ?? null;
|
|
368
467
|
}
|
|
468
|
+
/**
|
|
469
|
+
* Finds an existing rerun script in the given root by `data-eco-script-id` or
|
|
470
|
+
* by matching `src` and `textContent`.
|
|
471
|
+
*/
|
|
369
472
|
findExistingRerunScript(root, script) {
|
|
370
473
|
const scripts = Array.from(root.querySelectorAll("script"));
|
|
371
474
|
if (script.scriptId) {
|
|
@@ -375,12 +478,29 @@ class DomSwapper {
|
|
|
375
478
|
(candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
|
|
376
479
|
) ?? null;
|
|
377
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* Returns whether a rerun script is an external ES module (`type="module"` with `src`).
|
|
483
|
+
*
|
|
484
|
+
* Module scripts are cached by URL, so re-execution requires cache-busting
|
|
485
|
+
* via a query parameter nonce.
|
|
486
|
+
*/
|
|
378
487
|
isExternalModuleRerunScript(script) {
|
|
379
488
|
if (!script.src) {
|
|
380
489
|
return false;
|
|
381
490
|
}
|
|
382
491
|
return script.attributes.some(([name, value]) => name === "type" && value === "module");
|
|
383
492
|
}
|
|
493
|
+
getRegisteredRerunScript(scriptId) {
|
|
494
|
+
if (!scriptId) {
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
const runtimeWindow = window;
|
|
498
|
+
return runtimeWindow.__ECO_PAGES__?.rerunScripts?.[scriptId] ?? null;
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Appends a nonce query parameter to a script URL to bust the browser's
|
|
502
|
+
* module cache and force re-execution on navigation.
|
|
503
|
+
*/
|
|
384
504
|
createRerunScriptUrl(src) {
|
|
385
505
|
const url = new URL(src, document.baseURI);
|
|
386
506
|
url.searchParams.set("__eco_rerun", String(++this.rerunNonce));
|
|
@@ -14,6 +14,7 @@ export interface PrefetchOptions {
|
|
|
14
14
|
export declare class PrefetchManager {
|
|
15
15
|
private options;
|
|
16
16
|
private prefetched;
|
|
17
|
+
private prefetchedStylesheets;
|
|
17
18
|
private htmlCache;
|
|
18
19
|
private observer;
|
|
19
20
|
private hoverTimeouts;
|
|
@@ -159,12 +160,14 @@ export declare class PrefetchManager {
|
|
|
159
160
|
/**
|
|
160
161
|
* Prefetches stylesheets discovered in HTML content.
|
|
161
162
|
*
|
|
162
|
-
* Parses the HTML to find stylesheet links, then
|
|
163
|
-
*
|
|
164
|
-
*
|
|
163
|
+
* Parses the HTML to find stylesheet links, then warms the HTTP cache for
|
|
164
|
+
* stylesheets not already present in the current document. This keeps future
|
|
165
|
+
* navigation CSS warm without injecting `preload` hints that browsers expect
|
|
166
|
+
* the current page to consume immediately.
|
|
165
167
|
*
|
|
166
168
|
* @param html - The raw HTML string to parse
|
|
167
169
|
* @param url - The base URL for resolving relative stylesheet paths
|
|
168
170
|
*/
|
|
169
171
|
private prefetchStylesheets;
|
|
172
|
+
private prefetchStylesheet;
|
|
170
173
|
}
|
|
@@ -8,6 +8,7 @@ const DEFAULT_PREFETCH_OPTIONS = {
|
|
|
8
8
|
class PrefetchManager {
|
|
9
9
|
options;
|
|
10
10
|
prefetched = /* @__PURE__ */ new Set();
|
|
11
|
+
prefetchedStylesheets = /* @__PURE__ */ new Set();
|
|
11
12
|
htmlCache = /* @__PURE__ */ new Map();
|
|
12
13
|
observer = null;
|
|
13
14
|
hoverTimeouts = /* @__PURE__ */ new Map();
|
|
@@ -346,9 +347,10 @@ class PrefetchManager {
|
|
|
346
347
|
/**
|
|
347
348
|
* Prefetches stylesheets discovered in HTML content.
|
|
348
349
|
*
|
|
349
|
-
* Parses the HTML to find stylesheet links, then
|
|
350
|
-
*
|
|
351
|
-
*
|
|
350
|
+
* Parses the HTML to find stylesheet links, then warms the HTTP cache for
|
|
351
|
+
* stylesheets not already present in the current document. This keeps future
|
|
352
|
+
* navigation CSS warm without injecting `preload` hints that browsers expect
|
|
353
|
+
* the current page to consume immediately.
|
|
352
354
|
*
|
|
353
355
|
* @param html - The raw HTML string to parse
|
|
354
356
|
* @param url - The base URL for resolving relative stylesheet paths
|
|
@@ -358,20 +360,28 @@ class PrefetchManager {
|
|
|
358
360
|
const doc = parser.parseFromString(`<base href="${url.href}">${html}`, "text/html");
|
|
359
361
|
const existingHrefs = /* @__PURE__ */ new Set([
|
|
360
362
|
...Array.from(document.querySelectorAll('link[rel="stylesheet"]')).map((l) => l.href),
|
|
361
|
-
...
|
|
362
|
-
(l) => l.href
|
|
363
|
-
)
|
|
363
|
+
...this.prefetchedStylesheets
|
|
364
364
|
]);
|
|
365
365
|
const newStylesheets = doc.querySelectorAll('link[rel="stylesheet"]');
|
|
366
|
+
const stylesheetFetches = [];
|
|
366
367
|
for (const link of newStylesheets) {
|
|
367
368
|
if (!existingHrefs.has(link.href)) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
preloadLink.href = link.href;
|
|
372
|
-
document.head.appendChild(preloadLink);
|
|
369
|
+
existingHrefs.add(link.href);
|
|
370
|
+
this.prefetchedStylesheets.add(link.href);
|
|
371
|
+
stylesheetFetches.push(this.prefetchStylesheet(link.href));
|
|
373
372
|
}
|
|
374
373
|
}
|
|
374
|
+
await Promise.allSettled(stylesheetFetches);
|
|
375
|
+
}
|
|
376
|
+
async prefetchStylesheet(href) {
|
|
377
|
+
try {
|
|
378
|
+
await fetch(href, {
|
|
379
|
+
credentials: "same-origin",
|
|
380
|
+
priority: "low"
|
|
381
|
+
});
|
|
382
|
+
} catch {
|
|
383
|
+
this.prefetchedStylesheets.delete(href);
|
|
384
|
+
}
|
|
375
385
|
}
|
|
376
386
|
}
|
|
377
387
|
export {
|
|
@@ -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;
|
package/CHANGELOG.md
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
All notable changes to `@ecopages/browser-router` are documented here.
|
|
4
|
-
|
|
5
|
-
> **Note:** Changelog tracking begins at version `0.2.0`. Changes prior to this release are not recorded here but are available in the git history.
|
|
6
|
-
|
|
7
|
-
## [UNRELEASED] — TBD
|
|
8
|
-
|
|
9
|
-
### Features
|
|
10
|
-
|
|
11
|
-
- Added cross-router navigation handoff and document adoption hooks so browser-router can exchange control with other runtimes without forcing a reload.
|
|
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.
|
|
15
|
-
|
|
16
|
-
### Bug Fixes
|
|
17
|
-
|
|
18
|
-
- Fixed stale delegated navigations and rapid-click races so only the latest browser-router navigation can commit a swap.
|
|
19
|
-
- Re-executed `data-eco-rerun` scripts after body replacement and forced fresh module URLs for external rerun scripts so page bootstraps rebind correctly on every navigation.
|
|
20
|
-
- Synced rendered document ownership markers and live `<html>` attributes during swaps so mixed browser-router and React-router pages preserve the correct hydration owner.
|
|
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.
|
|
24
|
-
|
|
25
|
-
### Refactoring
|
|
26
|
-
|
|
27
|
-
- Routed handoff and current-page reload behavior through the shared navigation coordinator.
|
|
28
|
-
|
|
29
|
-
### Documentation
|
|
30
|
-
|
|
31
|
-
- Updated the README examples for the current router API.
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Helpers for synchronizing selected root `<html>` attributes during client-side navigation.
|
|
3
|
-
* @module
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC } from './types.ts';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Default root `<html>` attributes that browser-router treats as document-owned.
|
|
10
|
-
*
|
|
11
|
-
* These attributes are synchronized from the incoming document during navigation.
|
|
12
|
-
* Other root attributes are preserved unless explicitly included.
|
|
13
|
-
*/
|
|
14
|
-
export const defaultDocumentElementAttributesToSync = DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Synchronizes a selected set of root `<html>` attributes from an incoming document
|
|
18
|
-
* onto the current live document.
|
|
19
|
-
*
|
|
20
|
-
* Attributes listed here are treated as document-owned metadata. Attributes not
|
|
21
|
-
* listed remain untouched on the live document so client-managed state can survive
|
|
22
|
-
* across navigation swaps.
|
|
23
|
-
*
|
|
24
|
-
* @param currentDocument - The live document being updated
|
|
25
|
-
* @param newDocument - The parsed incoming document for the next page
|
|
26
|
-
* @param attributes - Root `<html>` attributes to synchronize
|
|
27
|
-
*/
|
|
28
|
-
export function syncDocumentElementAttributes(
|
|
29
|
-
currentDocument: Document,
|
|
30
|
-
newDocument: Document,
|
|
31
|
-
attributes: readonly string[],
|
|
32
|
-
): void {
|
|
33
|
-
const currentHtml = currentDocument.documentElement;
|
|
34
|
-
const nextHtml = newDocument.documentElement;
|
|
35
|
-
|
|
36
|
-
for (const attributeName of attributes) {
|
|
37
|
-
const nextValue = nextHtml.getAttribute(attributeName);
|
|
38
|
-
|
|
39
|
-
if (nextValue === null) {
|
|
40
|
-
currentHtml.removeAttribute(attributeName);
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (currentHtml.getAttribute(attributeName) !== nextValue) {
|
|
45
|
-
currentHtml.setAttribute(attributeName, nextValue);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|