@ecopages/browser-router 0.2.0-alpha.4 → 0.2.0-alpha.6

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.
@@ -8,6 +8,24 @@ import morphdom from 'morphdom';
8
8
 
9
9
  const DEFAULT_PERSIST_ATTR = 'data-eco-persist';
10
10
 
11
+ type PendingRerunScript = {
12
+ parent: 'head' | 'body';
13
+ attributes: Array<[string, string]>;
14
+ textContent: string;
15
+ src: string | null;
16
+ scriptId: string | null;
17
+ };
18
+
19
+ type PendingHeadScript = {
20
+ attributes: Array<[string, string]>;
21
+ textContent: string;
22
+ src: string | null;
23
+ scriptId: string | null;
24
+ replaceExisting: boolean;
25
+ };
26
+
27
+ const RERUN_SRC_ATTR = 'data-eco-rerun-src';
28
+
11
29
  /**
12
30
  * Checks if element has a persist attribute (custom or default).
13
31
  */
@@ -31,6 +49,9 @@ function isHydratedCustomElement(element: Element): boolean {
31
49
  */
32
50
  export class DomSwapper {
33
51
  private persistAttribute: string;
52
+ private pendingHeadScripts: PendingHeadScript[] = [];
53
+ private pendingRerunScripts: PendingRerunScript[] = [];
54
+ private rerunNonce = 0;
34
55
 
35
56
  constructor(persistAttribute: string) {
36
57
  this.persistAttribute = persistAttribute;
@@ -114,6 +135,10 @@ export class DomSwapper {
114
135
  * - Injects new scripts from the incoming page that are absent from the current head
115
136
  */
116
137
  morphHead(newDocument: Document): void {
138
+ this.pendingHeadScripts = [];
139
+ this.pendingRerunScripts = this.collectRerunScripts(newDocument);
140
+ this.removeStaleHeadScripts(newDocument);
141
+
117
142
  /** Update the document title if it has changed. */
118
143
  const newTitle = newDocument.head.querySelector('title');
119
144
  if (newTitle && document.title !== newTitle.textContent) {
@@ -139,31 +164,6 @@ export class DomSwapper {
139
164
  }
140
165
  }
141
166
 
142
- /**
143
- * Re-execute scripts that are explicitly marked with `data-eco-rerun`.
144
- * Deduplication is performed via `data-eco-script-id` to prevent double execution.
145
- */
146
- const existingScriptIds = new Set(
147
- Array.from(document.head.querySelectorAll('script[data-eco-script-id]')).map((s) =>
148
- s.getAttribute('data-eco-script-id'),
149
- ),
150
- );
151
-
152
- const rerunScripts = newDocument.head.querySelectorAll('script[data-eco-rerun]');
153
- for (const script of rerunScripts) {
154
- const scriptId = script.getAttribute('data-eco-script-id');
155
- if (scriptId && !existingScriptIds.has(scriptId)) {
156
- const newScript = document.createElement('script');
157
- for (const attr of script.attributes) {
158
- if (attr.name !== 'data-eco-rerun') {
159
- newScript.setAttribute(attr.name, attr.value);
160
- }
161
- }
162
- newScript.textContent = script.textContent;
163
- document.head.appendChild(newScript);
164
- }
165
- }
166
-
167
167
  /**
168
168
  * Inject new scripts from the incoming page that are not already loaded.
169
169
  *
@@ -189,31 +189,120 @@ export class DomSwapper {
189
189
  if (script.hasAttribute('data-eco-rerun')) continue;
190
190
 
191
191
  const src = script.getAttribute('src');
192
+ const scriptId = script.getAttribute('data-eco-script-id') || script.getAttribute('id');
193
+ const existingScript = this.findExistingHeadScript(script);
194
+
195
+ if (scriptId && existingScript) {
196
+ if (!this.isNonExecutableHeadScript(script)) {
197
+ continue;
198
+ }
199
+
200
+ if (this.areHeadScriptsEquivalent(script, existingScript)) {
201
+ continue;
202
+ }
203
+
204
+ this.pendingHeadScripts.push({
205
+ attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
206
+ textContent: script.textContent ?? '',
207
+ src,
208
+ scriptId,
209
+ replaceExisting: true,
210
+ });
211
+ continue;
212
+ }
192
213
 
193
214
  if (src) {
194
215
  if (existingScriptSrcs.has(src)) continue;
195
- /** New external script — append a freshly created element so the browser fetches and executes it. */
196
- const newScript = document.createElement('script');
197
- for (const attr of script.attributes) {
198
- newScript.setAttribute(attr.name, attr.value);
199
- }
200
- document.head.appendChild(newScript);
216
+ this.pendingHeadScripts.push({
217
+ attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
218
+ textContent: script.textContent ?? '',
219
+ src,
220
+ scriptId,
221
+ replaceExisting: false,
222
+ });
201
223
  existingScriptSrcs.add(src);
202
224
  } else {
203
225
  /** Inline script — skip if identical content is already present to avoid re-running on every navigation. */
204
226
  const content = (script.textContent ?? '').trim();
205
227
  if (!content || existingInlineContents.has(content)) continue;
206
- const newScript = document.createElement('script');
207
- for (const attr of script.attributes) {
208
- newScript.setAttribute(attr.name, attr.value);
209
- }
210
- newScript.textContent = script.textContent;
211
- document.head.appendChild(newScript);
228
+ this.pendingHeadScripts.push({
229
+ attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
230
+ textContent: script.textContent ?? '',
231
+ src: null,
232
+ scriptId,
233
+ replaceExisting: false,
234
+ });
212
235
  existingInlineContents.add(content);
213
236
  }
214
237
  }
215
238
  }
216
239
 
240
+ /**
241
+ * Replays queued `data-eco-rerun` scripts after the body swap completes.
242
+ *
243
+ * Scripts are intentionally flushed after the new body is in place so DOM-
244
+ * dependent bootstraps bind against the incoming page rather than the page
245
+ * being replaced.
246
+ */
247
+ flushRerunScripts(): void {
248
+ for (const script of this.pendingHeadScripts) {
249
+ const replacement = document.createElement('script');
250
+
251
+ for (const [name, value] of script.attributes) {
252
+ replacement.setAttribute(name, value);
253
+ }
254
+
255
+ replacement.textContent = script.textContent;
256
+
257
+ const existingScript = this.findExistingHeadScript(script);
258
+ if (script.replaceExisting && existingScript) {
259
+ existingScript.replaceWith(replacement);
260
+ continue;
261
+ }
262
+
263
+ document.head.appendChild(replacement);
264
+ }
265
+
266
+ this.pendingHeadScripts = [];
267
+
268
+ for (const script of this.pendingRerunScripts) {
269
+ const targetParent = script.parent === 'body' ? document.body : document.head;
270
+ const replacement = document.createElement('script');
271
+ const shouldBustModuleSrc = this.isExternalModuleRerunScript(script);
272
+
273
+ for (const [name, value] of script.attributes) {
274
+ if (name === 'data-eco-rerun') {
275
+ continue;
276
+ }
277
+
278
+ if (name === 'src' && shouldBustModuleSrc) {
279
+ replacement.setAttribute(RERUN_SRC_ATTR, value);
280
+ replacement.setAttribute('src', this.createRerunScriptUrl(value));
281
+ continue;
282
+ }
283
+
284
+ replacement.setAttribute(name, value);
285
+ }
286
+
287
+ replacement.textContent = script.textContent;
288
+
289
+ const existingScript = this.findExistingRerunScript(targetParent, script);
290
+
291
+ if (existingScript) {
292
+ existingScript.replaceWith(replacement);
293
+ continue;
294
+ }
295
+
296
+ targetParent.appendChild(replacement);
297
+ }
298
+
299
+ this.pendingRerunScripts = [];
300
+ }
301
+
302
+ shouldReplaceBodyForRerunScripts(): boolean {
303
+ return this.pendingRerunScripts.length > 0;
304
+ }
305
+
217
306
  /**
218
307
  * Detects custom elements without shadow DOM (light-DOM custom elements).
219
308
  * These need full replacement rather than morphing, because morphdom would
@@ -223,6 +312,16 @@ export class DomSwapper {
223
312
  return element.localName.includes('-') && element.shadowRoot === null;
224
313
  }
225
314
 
315
+ private replaceCustomElement(fromEl: Element, toEl: Element): false {
316
+ const newEl = document.createElement(toEl.tagName);
317
+ for (const attr of toEl.attributes) {
318
+ newEl.setAttribute(attr.name, attr.value);
319
+ }
320
+ newEl.innerHTML = toEl.innerHTML;
321
+ fromEl.replaceWith(newEl);
322
+ return false;
323
+ }
324
+
226
325
  /**
227
326
  * Morphs document body using morphdom.
228
327
  * Preserves persisted elements and hydrated custom elements.
@@ -238,7 +337,7 @@ export class DomSwapper {
238
337
  return false;
239
338
  }
240
339
  if (isHydratedCustomElement(fromEl)) {
241
- return false;
340
+ return this.replaceCustomElement(fromEl, toEl);
242
341
  }
243
342
 
244
343
  /**
@@ -250,13 +349,7 @@ export class DomSwapper {
250
349
  * on the fresh one.
251
350
  */
252
351
  if (this.isLightDomCustomElement(fromEl)) {
253
- const newEl = document.createElement(toEl.tagName);
254
- for (const attr of toEl.attributes) {
255
- newEl.setAttribute(attr.name, attr.value);
256
- }
257
- newEl.innerHTML = toEl.innerHTML;
258
- fromEl.replaceWith(newEl);
259
- return false;
352
+ return this.replaceCustomElement(fromEl, toEl);
260
353
  }
261
354
 
262
355
  if (fromEl.isEqualNode(toEl)) {
@@ -301,6 +394,164 @@ export class DomSwapper {
301
394
  this.processDeclarativeShadowDOM(document.body);
302
395
  }
303
396
 
397
+ private collectRerunScripts(newDocument: Document): PendingRerunScript[] {
398
+ return Array.from(newDocument.querySelectorAll<HTMLScriptElement>('script[data-eco-rerun]')).map((script) => ({
399
+ parent: script.closest('body') ? 'body' : 'head',
400
+ attributes: Array.from(script.attributes).map((attr) => [attr.name, attr.value]),
401
+ textContent: script.textContent ?? '',
402
+ src: script.getAttribute('src'),
403
+ scriptId: script.getAttribute('data-eco-script-id'),
404
+ }));
405
+ }
406
+
407
+ private removeStaleHeadScripts(newDocument: Document): void {
408
+ const nextScriptKeys = new Set(
409
+ Array.from(newDocument.head.querySelectorAll<HTMLScriptElement>('script'))
410
+ .map((script) => this.getHeadScriptKey(script))
411
+ .filter((key): key is string => key !== null),
412
+ );
413
+
414
+ for (const script of Array.from(document.head.querySelectorAll<HTMLScriptElement>('script'))) {
415
+ const key = this.getHeadScriptKey(script);
416
+ if (!key || nextScriptKeys.has(key)) {
417
+ continue;
418
+ }
419
+
420
+ if (this.shouldPersistExecutableInlineHeadScript(script)) {
421
+ continue;
422
+ }
423
+
424
+ script.remove();
425
+ }
426
+ }
427
+
428
+ private shouldPersistExecutableInlineHeadScript(script: HTMLScriptElement): boolean {
429
+ const scriptId = script.getAttribute('data-eco-script-id') || script.getAttribute('id');
430
+ if (!scriptId) {
431
+ return false;
432
+ }
433
+
434
+ if (script.hasAttribute('data-eco-rerun')) {
435
+ return false;
436
+ }
437
+
438
+ if (script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute('src')) {
439
+ return false;
440
+ }
441
+
442
+ return !this.isNonExecutableHeadScript(script);
443
+ }
444
+
445
+ private isNonExecutableHeadScript(script: HTMLScriptElement): boolean {
446
+ const type = (script.getAttribute('type') ?? '').trim().toLowerCase();
447
+
448
+ if (!type) {
449
+ return false;
450
+ }
451
+
452
+ return ![
453
+ 'application/javascript',
454
+ 'application/ecmascript',
455
+ 'module',
456
+ 'text/ecmascript',
457
+ 'text/javascript',
458
+ ].includes(type);
459
+ }
460
+
461
+ private areHeadScriptsEquivalent(nextScript: HTMLScriptElement, currentScript: HTMLScriptElement): boolean {
462
+ if (this.getHeadScriptKey(nextScript) !== this.getHeadScriptKey(currentScript)) {
463
+ return false;
464
+ }
465
+
466
+ if ((nextScript.textContent ?? '') !== (currentScript.textContent ?? '')) {
467
+ return false;
468
+ }
469
+
470
+ const nextAttributes = Array.from(nextScript.attributes).map((attribute) => [attribute.name, attribute.value]);
471
+ const currentAttributes = Array.from(currentScript.attributes).map((attribute) => [
472
+ attribute.name,
473
+ attribute.value,
474
+ ]);
475
+
476
+ if (nextAttributes.length !== currentAttributes.length) {
477
+ return false;
478
+ }
479
+
480
+ return nextAttributes.every(
481
+ ([name, value], index) => currentAttributes[index]?.[0] === name && currentAttributes[index]?.[1] === value,
482
+ );
483
+ }
484
+
485
+ private getHeadScriptKey(
486
+ script: HTMLScriptElement | Pick<PendingHeadScript | PendingRerunScript, 'scriptId' | 'src' | 'textContent'>,
487
+ ): string | null {
488
+ const scriptId =
489
+ script instanceof HTMLScriptElement
490
+ ? script.getAttribute('data-eco-script-id') || script.getAttribute('id')
491
+ : script.scriptId;
492
+ if (scriptId) {
493
+ return `id:${scriptId}`;
494
+ }
495
+
496
+ const src =
497
+ script instanceof HTMLScriptElement
498
+ ? script.getAttribute(RERUN_SRC_ATTR) || script.getAttribute('src')
499
+ : script.src;
500
+ if (src) {
501
+ return `src:${src}`;
502
+ }
503
+
504
+ const textContent = (script.textContent ?? '').trim();
505
+ return textContent ? `inline:${textContent}` : null;
506
+ }
507
+
508
+ private findExistingHeadScript(
509
+ script: HTMLScriptElement | Pick<PendingHeadScript | PendingRerunScript, 'scriptId' | 'src' | 'textContent'>,
510
+ ): HTMLScriptElement | null {
511
+ const scriptKey = this.getHeadScriptKey(script);
512
+ if (!scriptKey) {
513
+ return null;
514
+ }
515
+
516
+ return (
517
+ Array.from(document.head.querySelectorAll<HTMLScriptElement>('script')).find(
518
+ (candidate) => this.getHeadScriptKey(candidate) === scriptKey,
519
+ ) ?? null
520
+ );
521
+ }
522
+
523
+ private findExistingRerunScript(root: HTMLElement, script: PendingRerunScript): HTMLScriptElement | null {
524
+ const scripts = Array.from(root.querySelectorAll<HTMLScriptElement>('script'));
525
+
526
+ if (script.scriptId) {
527
+ return (
528
+ scripts.find((candidate) => candidate.getAttribute('data-eco-script-id') === script.scriptId) ?? null
529
+ );
530
+ }
531
+
532
+ return (
533
+ scripts.find(
534
+ (candidate) =>
535
+ (candidate.getAttribute(RERUN_SRC_ATTR) ?? candidate.getAttribute('src')) === script.src &&
536
+ (candidate.textContent ?? '') === script.textContent,
537
+ ) ?? null
538
+ );
539
+ }
540
+
541
+ private isExternalModuleRerunScript(script: PendingRerunScript): boolean {
542
+ if (!script.src) {
543
+ return false;
544
+ }
545
+
546
+ return script.attributes.some(([name, value]) => name === 'type' && value === 'module');
547
+ }
548
+
549
+ private createRerunScriptUrl(src: string): string {
550
+ const url = new URL(src, document.baseURI);
551
+ url.searchParams.set('__eco_rerun', String(++this.rerunNonce));
552
+ return url.toString();
553
+ }
554
+
304
555
  /**
305
556
  * Manually attaches declarative shadow DOM templates.
306
557
  * Browsers only process `<template shadowrootmode>` during initial parse.
@@ -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,10 @@ class ViewTransitionManager {
26
32
  await callback();
27
33
  applyViewTransitionNames();
28
34
  });
29
- try {
30
- await transition.finished;
31
- } finally {
35
+ void transition.finished.finally(() => {
32
36
  clearViewTransitionNames();
33
- }
37
+ });
38
+ await transition.updateCallbackDone;
34
39
  }
35
40
  }
36
41
  export {
@@ -26,8 +26,14 @@ export class ViewTransitionManager {
26
26
  /**
27
27
  * Execute a callback with view transition if available and enabled.
28
28
  * Falls back to direct execution if not supported.
29
+ *
30
+ * Navigation correctness depends on the DOM update callback completing, not on
31
+ * the browser finishing the visual transition animation. Awaiting
32
+ * `finished` here can leave router transactions artificially in-flight and
33
+ * block later navigations during rapid repeated clicks, so the router waits
34
+ * for `updateCallbackDone` and lets the animation finish in the background.
29
35
  * @param callback - The DOM update callback to execute
30
- * @returns Promise that resolves when the transition completes
36
+ * @returns Promise that resolves when the DOM update has committed
31
37
  */
32
38
  async transition(callback: () => void | Promise<void>): Promise<void> {
33
39
  if (!this.enabled || !this.isSupported()) {
@@ -52,15 +58,15 @@ export class ViewTransitionManager {
52
58
  applyViewTransitionNames();
53
59
  });
54
60
 
55
- try {
56
- await transition.finished;
57
- } finally {
61
+ void transition.finished.finally(() => {
58
62
  /**
59
- * Cleanup view transition names and dynamic styles after transition completes.
60
- * This prevents style pollution.
63
+ * Cleanup view transition names and dynamic styles after the browser's
64
+ * animation lifecycle completes.
61
65
  */
62
66
  clearViewTransitionNames();
63
- }
67
+ });
68
+
69
+ await transition.updateCallbackDone;
64
70
  }
65
71
  }
66
72
 
@@ -67,6 +67,9 @@ export interface EcoRouterOptions {
67
67
  */
68
68
  prefetch?: PrefetchConfig | false;
69
69
  }
70
+ export type BrowserRouterNavigateOptions = {
71
+ direction?: 'forward' | 'back' | 'replace';
72
+ };
70
73
  /** Events emitted during the navigation lifecycle */
71
74
  export interface EcoNavigationEvent {
72
75
  url: URL;
@@ -73,6 +73,10 @@ export interface EcoRouterOptions {
73
73
  prefetch?: PrefetchConfig | false;
74
74
  }
75
75
 
76
+ export type BrowserRouterNavigateOptions = {
77
+ direction?: 'forward' | 'back' | 'replace';
78
+ };
79
+
76
80
  /** Events emitted during the navigation lifecycle */
77
81
  export interface EcoNavigationEvent {
78
82
  url: URL;