@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 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.
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 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.
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.10",
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.10"
38
+ "@ecopages/core": "0.2.0-alpha.11"
39
39
  },
40
40
  "types": "./src/index.d.ts"
41
41
  }
@@ -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
- replaceCustomElement(fromEl, toEl) {
218
- const newEl = document.createElement(toEl.tagName);
219
- for (const attr of toEl.attributes) {
220
- newEl.setAttribute(attr.name, attr.value);
221
- }
222
- newEl.innerHTML = toEl.innerHTML;
223
- fromEl.replaceWith(newEl);
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.finished.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(() => {
36
47
  clearViewTransitionNames();
37
48
  });
38
49
  await transition.updateCallbackDone;