@ecopages/browser-router 0.2.0-alpha.9 → 0.2.0-beta.1

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 CHANGED
@@ -24,7 +24,7 @@ This package is designed for MPA-style rendering where the server returns full H
24
24
  ## Installation
25
25
 
26
26
  ```bash
27
- bunx jsr add @ecopages/browser-router
27
+ bun add @ecopages/browser-router
28
28
  ```
29
29
 
30
30
  ## Usage
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecopages/browser-router",
3
- "version": "0.2.0-alpha.9",
3
+ "version": "0.2.0-beta.1",
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.9"
38
+ "@ecopages/core": "0.2.0-beta.1"
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,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
- 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);
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 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 {
@@ -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;
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
- }