@ecopages/browser-router 0.2.0-alpha.3 → 0.2.0-alpha.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +8 -7
  2. package/README.md +60 -37
  3. package/package.json +4 -2
  4. package/src/client/document-element-sync.d.ts +24 -0
  5. package/src/client/document-element-sync.js +20 -0
  6. package/src/client/eco-router.d.ts +35 -1
  7. package/src/client/eco-router.js +336 -77
  8. package/src/client/services/dom-swapper.d.ts +102 -0
  9. package/src/client/services/dom-swapper.js +316 -39
  10. package/src/client/services/prefetch-manager.d.ts +6 -3
  11. package/src/client/services/prefetch-manager.js +21 -11
  12. package/src/client/services/view-transition-manager.d.ts +7 -1
  13. package/src/client/services/view-transition-manager.js +21 -5
  14. package/src/client/types.d.ts +12 -0
  15. package/src/client/types.js +4 -0
  16. package/src/index.d.ts +2 -1
  17. package/src/index.js +9 -2
  18. package/src/types.d.ts +1 -1
  19. package/src/types.js +2 -1
  20. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-create-router-instance-1.png +0 -0
  21. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Initialization-should-start-and-stop-without-errors-1.png +0 -0
  22. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-dispatch-eco-before-swap--eco-after-swap--and-eco-page-load-events-1.png +0 -0
  23. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-dispatch-eco-page-load-event-after-animation-frame-1.png +0 -0
  24. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Lifecycle-Events-should-provide-event-details-with-url-and-direction-1.png +0 -0
  25. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-Link-Selector-should-work-with-data-attribute-selector-1.png +0 -0
  26. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-Reload-Attribute-should-intercept-links-with-default-reload-attribute-when-custom-is-set-1.png +0 -0
  27. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Custom-link-selector-should-only-intercept-links-matching-custom-selector-1.png +0 -0
  28. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-External-Links--should-NOT-intercept--should-NOT-intercept-external-links-1.png +0 -0
  29. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Internal-Links-should-intercept-clicks-on-relative-path-links-1.png +0 -0
  30. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Internal-Links-should-intercept-clicks-on-same-origin-absolute-URLs-1.png +0 -0
  31. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Link-Attributes--should-NOT-intercept--should-NOT-intercept-links-with-download-attribute-1.png +0 -0
  32. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Link-Attributes--should-NOT-intercept--should-intercept-links-with-target---self--1.png +0 -0
  33. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-alt-click-1.png +0 -0
  34. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-ctrl-click-1.png +0 -0
  35. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-meta-click--cmd-on-Mac--1.png +0 -0
  36. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-middle-mouse-button-click-1.png +0 -0
  37. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-right-mouse-button-click-1.png +0 -0
  38. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Modifier-keys-should-NOT-intercept-shift-click-1.png +0 -0
  39. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-external-links--different-origin--1.png +0 -0
  40. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-hash-only-links-1.png +0 -0
  41. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-javascript--links-1.png +0 -0
  42. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-custom-reload-attribute-1.png +0 -0
  43. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-data-eco-reload-attribute-1.png +0 -0
  44. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-download-attribute-1.png +0 -0
  45. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-empty-href-1.png +0 -0
  46. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---blank--1.png +0 -0
  47. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-with-target---parent--1.png +0 -0
  48. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-NOT-intercept-links-without-href-attribute-1.png +0 -0
  49. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-absolute-same-origin-paths-1.png +0 -0
  50. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-internal-links-with-relative-paths-1.png +0 -0
  51. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-Should-intercept-nested-elements-inside-links-1.png +0 -0
  52. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Link-Interception-should-NOT-intercept-external-links-1.png +0 -0
  53. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Navigation-Abort-should-abort-previous-navigation-when-new-one-starts-1.png +0 -0
  54. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-navigate-and-update-history-with-pushState-1.png +0 -0
  55. package/src/client/__screenshots__/eco-router.test.browser.ts/EcoRouter-Programmatic-Navigation-should-use-replaceState-when-replace-option-is-true-1.png +0 -0
  56. package/src/client/__screenshots__/eco-router.test.ts/EcoRouter-Error-Handling-should-fall-back-to-full-page-navigation-on-fetch-error-1.png +0 -0
  57. package/src/client/__screenshots__/eco-router.test.ts/EcoRouter-Error-Handling-should-log-error-and-attempt-fallback-navigation-on-fetch-error-1.png +0 -0
  58. package/src/client/eco-router.ts +0 -310
  59. package/src/client/services/dom-swapper.ts +0 -325
  60. package/src/client/services/index.ts +0 -9
  61. package/src/client/services/prefetch-manager.ts +0 -457
  62. package/src/client/services/scroll-manager.ts +0 -48
  63. package/src/client/services/view-transition-manager.ts +0 -75
  64. package/src/client/types.ts +0 -109
  65. package/src/client/view-transition-utils.ts +0 -98
  66. package/src/index.ts +0 -19
  67. package/src/types.ts +0 -19
@@ -1,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
- const newScript = document.createElement("script");
132
- for (const attr of script.attributes) {
133
- newScript.setAttribute(attr.name, attr.value);
134
- }
135
- document.head.appendChild(newScript);
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
- const newScript = document.createElement("script");
141
- for (const attr of script.attributes) {
142
- newScript.setAttribute(attr.name, attr.value);
143
- }
144
- newScript.textContent = script.textContent;
145
- document.head.appendChild(newScript);
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,43 @@ 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.cloneNode(true) });
239
+ return false;
240
+ }
241
+ materializeCustomElement(element) {
242
+ const materializedElement = document.createElement(element.localName);
243
+ for (const attr of element.attributes) {
244
+ materializedElement.setAttribute(attr.name, attr.value);
245
+ }
246
+ materializedElement.innerHTML = element.innerHTML;
247
+ return materializedElement;
248
+ }
249
+ /**
250
+ * Replaces all deferred custom elements after morphdom has finished traversing.
251
+ *
252
+ * Each element is recreated via `document.createElement` so the browser fires
253
+ * the full custom element lifecycle (`disconnectedCallback` on the old instance,
254
+ * `connectedCallback` on the new one). Elements that were already removed during
255
+ * the morph pass are skipped via the `isConnected` guard.
256
+ */
257
+ flushDeferredCustomElementReplacements(deferred) {
258
+ for (const { from, to } of deferred) {
259
+ if (!from.isConnected) continue;
260
+ const newEl = this.materializeCustomElement(to);
261
+ from.replaceWith(newEl);
262
+ }
263
+ }
158
264
  /**
159
265
  * Morphs document body using morphdom.
160
266
  * Preserves persisted elements and hydrated custom elements.
@@ -163,22 +269,24 @@ class DomSwapper {
163
269
  */
164
270
  morphBody(newDocument) {
165
271
  const persistAttr = this.persistAttribute;
272
+ const deferredReplacements = [];
166
273
  morphdom(document.body, newDocument.body, {
274
+ getNodeKey: (node) => getBodyMorphKey(node, persistAttr),
275
+ onBeforeNodeAdded: (node) => {
276
+ if (node instanceof Element && this.isLightDomCustomElement(node)) {
277
+ return this.materializeCustomElement(node);
278
+ }
279
+ return node;
280
+ },
167
281
  onBeforeElUpdated: (fromEl, toEl) => {
168
282
  if (isPersisted(fromEl, persistAttr)) {
169
283
  return false;
170
284
  }
171
285
  if (isHydratedCustomElement(fromEl)) {
172
- return false;
286
+ return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
173
287
  }
174
288
  if (this.isLightDomCustomElement(fromEl)) {
175
- const newEl = document.createElement(toEl.tagName);
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;
289
+ return this.replaceCustomElement(fromEl, toEl, deferredReplacements);
182
290
  }
183
291
  if (fromEl.isEqualNode(toEl)) {
184
292
  return false;
@@ -186,6 +294,7 @@ class DomSwapper {
186
294
  return true;
187
295
  }
188
296
  });
297
+ this.flushDeferredCustomElementReplacements(deferredReplacements);
189
298
  this.processDeclarativeShadowDOM(document.body);
190
299
  }
191
300
  /**
@@ -211,9 +320,177 @@ class DomSwapper {
211
320
  placeholder.replaceWith(oldEl);
212
321
  }
213
322
  }
214
- document.body.replaceChildren(...newDocument.body.childNodes);
323
+ document.body.replaceChildren(...Array.from(newDocument.body.childNodes));
215
324
  this.processDeclarativeShadowDOM(document.body);
216
325
  }
326
+ /**
327
+ * Collects all `data-eco-rerun` scripts from the incoming document.
328
+ *
329
+ * These scripts are re-executed after each navigation so their side-effects
330
+ * (event listeners, DOM bootstraps) bind against the new page content.
331
+ */
332
+ collectRerunScripts(newDocument) {
333
+ return Array.from(newDocument.querySelectorAll("script[data-eco-rerun]")).map((script) => ({
334
+ parent: script.closest("body") ? "body" : "head",
335
+ attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
336
+ textContent: script.textContent ?? "",
337
+ src: script.getAttribute("src"),
338
+ scriptId: script.getAttribute("data-eco-script-id")
339
+ }));
340
+ }
341
+ /**
342
+ * Removes head scripts that are no longer present in the incoming document.
343
+ *
344
+ * Persisted scripts and executable inline scripts with stable identifiers
345
+ * are kept to avoid breaking long-lived runtime state.
346
+ */
347
+ removeStaleHeadScripts(newDocument) {
348
+ const nextScriptKeys = new Set(
349
+ Array.from(newDocument.head.querySelectorAll("script")).map((script) => this.getHeadScriptKey(script)).filter((key) => key !== null)
350
+ );
351
+ for (const script of Array.from(document.head.querySelectorAll("script"))) {
352
+ const key = this.getHeadScriptKey(script);
353
+ if (!key || nextScriptKeys.has(key)) {
354
+ continue;
355
+ }
356
+ if (isPersisted(script, this.persistAttribute)) {
357
+ continue;
358
+ }
359
+ if (this.shouldPersistExecutableInlineHeadScript(script)) {
360
+ continue;
361
+ }
362
+ script.remove();
363
+ }
364
+ }
365
+ /**
366
+ * Determines whether an inline head script should survive navigation.
367
+ *
368
+ * Only identified (`data-eco-script-id` or `id`), executable inline scripts
369
+ * are persisted. External scripts and rerun scripts are never persisted here
370
+ * because they have their own lifecycle management.
371
+ */
372
+ shouldPersistExecutableInlineHeadScript(script) {
373
+ const scriptId = script.getAttribute("data-eco-script-id") || script.getAttribute("id");
374
+ if (!scriptId) {
375
+ return false;
376
+ }
377
+ if (script.hasAttribute("data-eco-rerun")) {
378
+ return false;
379
+ }
380
+ if (script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute("src")) {
381
+ return false;
382
+ }
383
+ return !this.isNonExecutableHeadScript(script);
384
+ }
385
+ /**
386
+ * Returns whether a script is non-executable (e.g. `type="application/json"`).
387
+ *
388
+ * Non-executable scripts are data carriers (JSON-LD, page data) that can be
389
+ * safely replaced without side-effects, unlike executable scripts that would
390
+ * re-run their bootstrap logic.
391
+ */
392
+ isNonExecutableHeadScript(script) {
393
+ const type = (script.getAttribute("type") ?? "").trim().toLowerCase();
394
+ if (!type) {
395
+ return false;
396
+ }
397
+ return ![
398
+ "application/javascript",
399
+ "application/ecmascript",
400
+ "module",
401
+ "text/ecmascript",
402
+ "text/javascript"
403
+ ].includes(type);
404
+ }
405
+ /**
406
+ * Compares two head scripts for structural equality (key, content, attributes).
407
+ *
408
+ * Used to skip replacing non-executable data scripts when their content
409
+ * has not changed between navigations.
410
+ */
411
+ areHeadScriptsEquivalent(nextScript, currentScript) {
412
+ if (this.getHeadScriptKey(nextScript) !== this.getHeadScriptKey(currentScript)) {
413
+ return false;
414
+ }
415
+ if ((nextScript.textContent ?? "") !== (currentScript.textContent ?? "")) {
416
+ return false;
417
+ }
418
+ const nextAttributes = Array.from(nextScript.attributes).map((attribute) => [attribute.name, attribute.value]);
419
+ const currentAttributes = Array.from(currentScript.attributes).map((attribute) => [
420
+ attribute.name,
421
+ attribute.value
422
+ ]);
423
+ if (nextAttributes.length !== currentAttributes.length) {
424
+ return false;
425
+ }
426
+ return nextAttributes.every(
427
+ ([name, value], index) => currentAttributes[index]?.[0] === name && currentAttributes[index]?.[1] === value
428
+ );
429
+ }
430
+ /**
431
+ * Derives a stable identity key for a head script.
432
+ *
433
+ * Priority: `data-eco-script-id` / `id` > `src` > trimmed inline content.
434
+ * Returns `null` for empty anonymous inline scripts that cannot be tracked.
435
+ */
436
+ getHeadScriptKey(script) {
437
+ const scriptId = script instanceof HTMLScriptElement ? script.getAttribute("data-eco-script-id") || script.getAttribute("id") : script.scriptId;
438
+ if (scriptId) {
439
+ return `id:${scriptId}`;
440
+ }
441
+ const src = script instanceof HTMLScriptElement ? script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute("src") : script.src;
442
+ if (src) {
443
+ return `src:${src}`;
444
+ }
445
+ const textContent = (script.textContent ?? "").trim();
446
+ return textContent ? `inline:${textContent}` : null;
447
+ }
448
+ /**
449
+ * Finds an existing head script that matches the given script's identity key.
450
+ */
451
+ findExistingHeadScript(script) {
452
+ const scriptKey = this.getHeadScriptKey(script);
453
+ if (!scriptKey) {
454
+ return null;
455
+ }
456
+ return Array.from(document.head.querySelectorAll("script")).find(
457
+ (candidate) => this.getHeadScriptKey(candidate) === scriptKey
458
+ ) ?? null;
459
+ }
460
+ /**
461
+ * Finds an existing rerun script in the given root by `data-eco-script-id` or
462
+ * by matching `src` and `textContent`.
463
+ */
464
+ findExistingRerunScript(root, script) {
465
+ const scripts = Array.from(root.querySelectorAll("script"));
466
+ if (script.scriptId) {
467
+ return scripts.find((candidate) => candidate.getAttribute("data-eco-script-id") === script.scriptId) ?? null;
468
+ }
469
+ return scripts.find(
470
+ (candidate) => (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute("src")) === script.src && (candidate.textContent ?? "") === script.textContent
471
+ ) ?? null;
472
+ }
473
+ /**
474
+ * Returns whether a rerun script is an external ES module (`type="module"` with `src`).
475
+ *
476
+ * Module scripts are cached by URL, so re-execution requires cache-busting
477
+ * via a query parameter nonce.
478
+ */
479
+ isExternalModuleRerunScript(script) {
480
+ if (!script.src) {
481
+ return false;
482
+ }
483
+ return script.attributes.some(([name, value]) => name === "type" && value === "module");
484
+ }
485
+ /**
486
+ * Appends a nonce query parameter to a script URL to bust the browser's
487
+ * module cache and force re-execution on navigation.
488
+ */
489
+ createRerunScriptUrl(src) {
490
+ const url = new URL(src, document.baseURI);
491
+ url.searchParams.set("__eco_rerun", String(++this.rerunNonce));
492
+ return url.toString();
493
+ }
217
494
  /**
218
495
  * Manually attaches declarative shadow DOM templates.
219
496
  * Browsers only process `<template shadowrootmode>` during initial parse.
@@ -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 creates preload hints
163
- * for stylesheets not already present in the current document. This ensures
164
- * styles are cached before navigation to prevent FOUC.
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 creates preload hints
350
- * for stylesheets not already present in the current document. This ensures
351
- * styles are cached before navigation to prevent FOUC.
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
- ...Array.from(document.querySelectorAll('link[rel="preload"][as="style"]')).map(
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
- const preloadLink = document.createElement("link");
369
- preloadLink.rel = "preload";
370
- preloadLink.as = "style";
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 {
@@ -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 transition completes
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 transition completes
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
- try {
30
- await transition.finished;
31
- } finally {
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 {
@@ -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>;
@@ -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
  */