@ecopages/browser-router 0.2.0-alpha.5 → 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.
@@ -4,6 +4,12 @@
4
4
  */
5
5
 
6
6
  import type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types.ts';
7
+ import { getEcoNavigationRuntime } from '@ecopages/core/router/navigation-coordinator';
8
+ import {
9
+ getAnchorFromNavigationEvent,
10
+ recoverPendingNavigationHref,
11
+ type EcoPendingNavigationIntent,
12
+ } from '@ecopages/core/router/link-intent';
7
13
  import { DEFAULT_OPTIONS } from './types.ts';
8
14
  import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } from './services/index.ts';
9
15
 
@@ -13,7 +19,12 @@ import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } fro
13
19
  */
14
20
  export class EcoRouter {
15
21
  private options: Required<EcoRouterOptions>;
16
- private abortController: AbortController | null = null;
22
+ private unregisterNavigationRuntime: (() => void) | null = null;
23
+ private started = false;
24
+ private pendingNavigations = 0;
25
+ private pendingPointerNavigation: EcoPendingNavigationIntent | null = null;
26
+ private pendingHoverNavigation: EcoPendingNavigationIntent | null = null;
27
+ private queuedNavigationHref: string | null = null;
17
28
 
18
29
  private domSwapper: DomSwapper;
19
30
  private scrollManager: ScrollManager;
@@ -35,9 +46,216 @@ export class EcoRouter {
35
46
  }
36
47
 
37
48
  this.handleClick = this.handleClick.bind(this);
49
+ this.handleHoverIntent = this.handleHoverIntent.bind(this);
50
+ this.handlePointerDown = this.handlePointerDown.bind(this);
38
51
  this.handlePopState = this.handlePopState.bind(this);
39
52
  }
40
53
 
54
+ private getLinkFromEvent(event: MouseEvent | PointerEvent): HTMLAnchorElement | null {
55
+ return getAnchorFromNavigationEvent(event, this.options.linkSelector);
56
+ }
57
+
58
+ private canInterceptLink(event: MouseEvent | PointerEvent, link: HTMLAnchorElement): string | null {
59
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return null;
60
+ if (event.button !== 0) return null;
61
+
62
+ const target = link.getAttribute('target');
63
+ if (target && target !== '_self') return null;
64
+
65
+ if (link.hasAttribute(this.options.reloadAttribute)) return null;
66
+ if (link.hasAttribute('download')) return null;
67
+
68
+ const href = link.getAttribute('href');
69
+ if (!href) return null;
70
+
71
+ if (href.startsWith('#')) return null;
72
+ if (href.startsWith('javascript:')) return null;
73
+
74
+ const url = new URL(href, window.location.origin);
75
+ if (!this.isSameOrigin(url)) return null;
76
+
77
+ return href;
78
+ }
79
+
80
+ private getRecoveredPointerHref(): string | null {
81
+ const href = recoverPendingNavigationHref(
82
+ this.pendingPointerNavigation,
83
+ this.pendingNavigations > 0,
84
+ performance.now(),
85
+ );
86
+
87
+ if (!href) {
88
+ this.pendingPointerNavigation = null;
89
+ }
90
+
91
+ return href;
92
+ }
93
+
94
+ private getRecoveredHoverHref(): string | null {
95
+ const href = recoverPendingNavigationHref(
96
+ this.pendingHoverNavigation,
97
+ this.pendingNavigations > 0,
98
+ performance.now(),
99
+ );
100
+
101
+ if (!href) {
102
+ this.pendingHoverNavigation = null;
103
+ }
104
+
105
+ return href;
106
+ }
107
+
108
+ private isAnotherNavigationRuntimeActive(): boolean {
109
+ const ownerState = getEcoNavigationRuntime(window).getOwnerState();
110
+ return (
111
+ ownerState.owner !== 'none' && ownerState.owner !== 'browser-router' && ownerState.canHandleSpaNavigation
112
+ );
113
+ }
114
+
115
+ private getDocumentOwner(doc: Document) {
116
+ return getEcoNavigationRuntime(window).resolveDocumentOwner(doc, 'browser-router');
117
+ }
118
+
119
+ private adoptDocumentOwner(doc: Document): void {
120
+ getEcoNavigationRuntime(window).adoptDocumentOwner(doc, 'browser-router');
121
+ }
122
+
123
+ private syncDocumentElementAttributes(newDocument: Document): void {
124
+ const currentHtml = document.documentElement;
125
+ const nextHtml = newDocument.documentElement;
126
+
127
+ for (const attribute of Array.from(currentHtml.attributes)) {
128
+ if (!nextHtml.hasAttribute(attribute.name)) {
129
+ currentHtml.removeAttribute(attribute.name);
130
+ }
131
+ }
132
+
133
+ for (const attribute of Array.from(nextHtml.attributes)) {
134
+ if (currentHtml.getAttribute(attribute.name) !== attribute.value) {
135
+ currentHtml.setAttribute(attribute.name, attribute.value);
136
+ }
137
+ }
138
+ }
139
+
140
+ private reloadDocument(url: URL): void {
141
+ window.location.assign(url.href);
142
+ }
143
+
144
+ /**
145
+ * Commits a fully fetched document into the live page.
146
+ *
147
+ * When browser-router accepts a handoff from another runtime, it delays source
148
+ * runtime cleanup until the incoming document has been prepared and is ready to
149
+ * commit. That ordering avoids the blank-page window we previously hit when a
150
+ * delegated navigation went stale after the source runtime had already torn
151
+ * itself down.
152
+ */
153
+ private async commitDocumentNavigation(
154
+ url: URL,
155
+ direction: EcoNavigationEvent['direction'],
156
+ newDocument: Document,
157
+ options: {
158
+ html?: string;
159
+ isStaleNavigation?: () => boolean;
160
+ } = {},
161
+ ): Promise<void> {
162
+ const previousUrl = new URL(window.location.href);
163
+ const navigationRuntime = getEcoNavigationRuntime(window);
164
+ const isStaleNavigation = options.isStaleNavigation ?? (() => false);
165
+ const currentDocumentOwner = navigationRuntime.resolveDocumentOwner(document, 'browser-router');
166
+ const newDocumentOwner = navigationRuntime.resolveDocumentOwner(newDocument, 'browser-router');
167
+ const activeOwner = navigationRuntime.getOwnerState().owner;
168
+ const shouldCleanupCurrentOwner =
169
+ currentDocumentOwner !== newDocumentOwner &&
170
+ currentDocumentOwner !== 'browser-router' &&
171
+ activeOwner === currentDocumentOwner;
172
+ let shouldReload = false;
173
+ const beforeSwapEvent: EcoBeforeSwapEvent = {
174
+ url,
175
+ direction,
176
+ newDocument,
177
+ reload: () => {
178
+ shouldReload = true;
179
+ },
180
+ };
181
+
182
+ document.dispatchEvent(new CustomEvent('eco:before-swap', { detail: beforeSwapEvent }));
183
+ if (isStaleNavigation()) return;
184
+
185
+ if (shouldReload) {
186
+ if (shouldCleanupCurrentOwner) {
187
+ await navigationRuntime.cleanupOwner(currentDocumentOwner);
188
+ }
189
+ if (isStaleNavigation()) return;
190
+ this.reloadDocument(url);
191
+ return;
192
+ }
193
+
194
+ const useViewTransitions = this.options.viewTransitions;
195
+ await this.domSwapper.preloadStylesheets(newDocument);
196
+ if (isStaleNavigation()) return;
197
+
198
+ // Defer source-runtime cleanup until the incoming document is ready to win.
199
+ if (shouldCleanupCurrentOwner) {
200
+ await navigationRuntime.cleanupOwner(currentDocumentOwner);
201
+ }
202
+
203
+ if (isStaleNavigation()) return;
204
+
205
+ const commitSwap = () => {
206
+ if (isStaleNavigation()) return;
207
+
208
+ if (this.options.updateHistory && direction === 'forward') {
209
+ window.history.pushState({}, '', url.href);
210
+ } else if (direction === 'replace') {
211
+ window.history.replaceState({}, '', url.href);
212
+ }
213
+
214
+ this.syncDocumentElementAttributes(newDocument);
215
+ this.domSwapper.morphHead(newDocument);
216
+ if (useViewTransitions && !this.domSwapper.shouldReplaceBodyForRerunScripts()) {
217
+ this.domSwapper.morphBody(newDocument);
218
+ } else {
219
+ this.domSwapper.replaceBody(newDocument);
220
+ }
221
+ this.domSwapper.flushRerunScripts();
222
+ this.scrollManager.handleScroll(url, previousUrl);
223
+ };
224
+
225
+ if (useViewTransitions) {
226
+ await this.viewTransitionManager.transition(commitSwap);
227
+ } else {
228
+ commitSwap();
229
+ }
230
+
231
+ if (isStaleNavigation()) return;
232
+
233
+ navigationRuntime.adoptDocumentOwner(newDocument, 'browser-router');
234
+
235
+ const afterSwapEvent: EcoAfterSwapEvent = {
236
+ url,
237
+ direction,
238
+ };
239
+
240
+ document.dispatchEvent(new CustomEvent('eco:after-swap', { detail: afterSwapEvent }));
241
+
242
+ this.prefetchManager?.observeNewLinks();
243
+
244
+ if (options.html) {
245
+ this.prefetchManager?.cacheVisitedPage(url.href, options.html);
246
+ }
247
+
248
+ requestAnimationFrame(() => {
249
+ if (isStaleNavigation()) return;
250
+
251
+ document.dispatchEvent(
252
+ new CustomEvent('eco:page-load', {
253
+ detail: { url, direction } as EcoNavigationEvent,
254
+ }),
255
+ );
256
+ });
257
+ }
258
+
41
259
  /**
42
260
  * Starts the router and begins intercepting navigation.
43
261
  *
@@ -45,27 +263,66 @@ export class EcoRouter {
45
263
  * back/forward buttons. Also starts the prefetch manager if configured.
46
264
  */
47
265
  public start(): void {
48
- document.addEventListener('click', this.handleClick);
266
+ if (this.started) {
267
+ return;
268
+ }
269
+
270
+ const navigationRuntime = getEcoNavigationRuntime(window);
271
+
272
+ document.addEventListener('mouseover', this.handleHoverIntent, true);
273
+ document.addEventListener('pointerover', this.handleHoverIntent, true);
274
+ document.addEventListener('mousemove', this.handleHoverIntent, true);
275
+ document.addEventListener('pointermove', this.handleHoverIntent, true);
276
+ document.addEventListener('pointerdown', this.handlePointerDown, true);
277
+ document.addEventListener('click', this.handleClick, true);
49
278
  window.addEventListener('popstate', this.handlePopState);
50
279
  this.prefetchManager?.start();
280
+ this.unregisterNavigationRuntime?.();
281
+ this.unregisterNavigationRuntime = navigationRuntime.register({
282
+ owner: 'browser-router',
283
+ navigate: async (request) => {
284
+ await this.performNavigation(
285
+ new URL(request.href, window.location.origin),
286
+ request.direction ?? 'forward',
287
+ );
288
+ return true;
289
+ },
290
+ handoffNavigation: async (request) => {
291
+ const { isStaleNavigation, complete } = this.beginNavigationTransaction();
292
+ if (isStaleNavigation()) return true;
293
+ try {
294
+ await this.commitDocumentNavigation(
295
+ new URL(request.finalHref ?? request.href, window.location.origin),
296
+ request.direction ?? 'forward',
297
+ request.document,
298
+ { html: request.html, isStaleNavigation },
299
+ );
300
+ } finally {
301
+ complete();
302
+ }
303
+ return true;
304
+ },
305
+ reloadCurrentPage: async (request) => {
306
+ if (this.pendingNavigations > 0) return;
51
307
 
52
- const windowWithHmr = window as typeof window & {
53
- __ecopages_reload_current_page__?: (options: { clearCache: boolean }) => Promise<void>;
54
- };
55
-
56
- windowWithHmr.__ecopages_reload_current_page__ = async (options: { clearCache: boolean }) => {
57
- const currentUrl = window.location.pathname + window.location.search;
308
+ const currentUrl = window.location.pathname + window.location.search;
58
309
 
59
- if (options.clearCache) {
60
- this.prefetchManager?.invalidate(currentUrl);
61
- }
310
+ if (request?.clearCache) {
311
+ this.prefetchManager?.invalidate(currentUrl);
312
+ }
62
313
 
63
- await this.performNavigation(new URL(currentUrl, window.location.origin), 'replace');
64
- };
314
+ await this.performNavigation(new URL(currentUrl, window.location.origin), 'replace');
315
+ },
316
+ cleanupBeforeHandoff: async () => {
317
+ this.cancelNavigationTransaction();
318
+ },
319
+ });
320
+ this.adoptDocumentOwner(document);
65
321
 
66
322
  // Cache the initial page for instant back-navigation
67
323
  const initialHtml = document.documentElement.outerHTML;
68
324
  this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
325
+ this.started = true;
69
326
  }
70
327
 
71
328
  /**
@@ -73,15 +330,30 @@ export class EcoRouter {
73
330
  * After calling this, navigation will fall back to full page reloads.
74
331
  */
75
332
  public stop(): void {
76
- document.removeEventListener('click', this.handleClick);
333
+ if (!this.started) {
334
+ return;
335
+ }
336
+
337
+ this.cancelNavigationTransaction();
338
+ document.removeEventListener('mouseover', this.handleHoverIntent, true);
339
+ document.removeEventListener('pointerover', this.handleHoverIntent, true);
340
+ document.removeEventListener('mousemove', this.handleHoverIntent, true);
341
+ document.removeEventListener('pointermove', this.handleHoverIntent, true);
342
+ document.removeEventListener('pointerdown', this.handlePointerDown, true);
343
+ document.removeEventListener('click', this.handleClick, true);
77
344
  window.removeEventListener('popstate', this.handlePopState);
78
345
  this.prefetchManager?.stop();
79
-
80
- const windowWithHmr = window as typeof window & {
81
- __ecopages_reload_current_page__?: (options: { clearCache: boolean }) => Promise<void>;
82
- };
83
-
84
- windowWithHmr.__ecopages_reload_current_page__ = undefined;
346
+ this.unregisterNavigationRuntime?.();
347
+ this.unregisterNavigationRuntime = null;
348
+ this.started = false;
349
+ this.pendingHoverNavigation = null;
350
+ this.pendingPointerNavigation = null;
351
+ this.queuedNavigationHref = null;
352
+
353
+ const win = window as RouterWindow;
354
+ if (win[ACTIVE_ROUTER_KEY] === this) {
355
+ delete win[ACTIVE_ROUTER_KEY];
356
+ }
85
357
  }
86
358
 
87
359
  /**
@@ -123,35 +395,77 @@ export class EcoRouter {
123
395
  * Uses `event.composedPath()` to correctly detect clicks on anchors inside
124
396
  * Shadow DOM boundaries (Web Components).
125
397
  */
126
- private handleClick(event: MouseEvent): void {
127
- const link = event
128
- .composedPath()
129
- .find(
130
- (el) => el instanceof HTMLAnchorElement && el.matches(this.options.linkSelector),
131
- ) as HTMLAnchorElement | null;
398
+ private handlePointerDown(event: PointerEvent): void {
399
+ const link = this.getLinkFromEvent(event);
400
+ if (!link) {
401
+ this.pendingPointerNavigation = null;
402
+ return;
403
+ }
132
404
 
133
- if (!link) return;
405
+ const href = this.canInterceptLink(event, link);
406
+ this.pendingPointerNavigation = href
407
+ ? {
408
+ href,
409
+ timestamp: performance.now(),
410
+ }
411
+ : null;
134
412
 
135
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
136
- if (event.button !== 0) return;
413
+ if (href && this.pendingNavigations > 0) {
414
+ this.queuedNavigationHref = href;
415
+ }
416
+ }
137
417
 
138
- const target = link.getAttribute('target');
139
- if (target && target !== '_self') return;
418
+ private handleHoverIntent(event: MouseEvent | PointerEvent): void {
419
+ const link = this.getLinkFromEvent(event);
420
+ if (!link) {
421
+ return;
422
+ }
140
423
 
141
- if (link.hasAttribute(this.options.reloadAttribute)) return;
142
- if (link.hasAttribute('download')) return;
424
+ const href = this.canInterceptLink(event, link);
425
+ if (!href) {
426
+ return;
427
+ }
143
428
 
144
- const href = link.getAttribute('href');
145
- if (!href) return;
429
+ this.pendingHoverNavigation = {
430
+ href,
431
+ timestamp: performance.now(),
432
+ };
146
433
 
147
- if (href.startsWith('#')) return;
148
- if (href.startsWith('javascript:')) return;
434
+ if (this.pendingNavigations > 0) {
435
+ this.queuedNavigationHref = href;
436
+ }
437
+ }
149
438
 
150
- const url = new URL(href, window.location.origin);
439
+ private handleClick(event: MouseEvent): void {
440
+ const navigationRuntime = getEcoNavigationRuntime(window);
441
+ const link = this.getLinkFromEvent(event);
442
+ const href = link
443
+ ? this.canInterceptLink(event, link)
444
+ : (this.getRecoveredPointerHref() ?? this.getRecoveredHoverHref());
445
+ this.pendingPointerNavigation = null;
446
+ this.pendingHoverNavigation = null;
447
+ if (!href) return;
448
+ this.queuedNavigationHref = null;
449
+
450
+ if (this.isAnotherNavigationRuntimeActive()) {
451
+ event.preventDefault();
452
+ event.stopImmediatePropagation();
453
+ void navigationRuntime.requestNavigation({
454
+ href,
455
+ direction: 'forward',
456
+ source: 'browser-router',
457
+ });
458
+ return;
459
+ }
151
460
 
152
- if (!this.isSameOrigin(url)) return;
461
+ const url = new URL(href, window.location.origin);
153
462
 
154
463
  event.preventDefault();
464
+ if (this.pendingNavigations > 0) {
465
+ this.queuedNavigationHref = href;
466
+ this.cancelNavigationTransaction();
467
+ return;
468
+ }
155
469
  this.performNavigation(url, 'forward');
156
470
  }
157
471
 
@@ -160,6 +474,8 @@ export class EcoRouter {
160
474
  * Triggered by the History API's popstate event.
161
475
  */
162
476
  private handlePopState(_event: PopStateEvent): void {
477
+ if (this.isAnotherNavigationRuntimeActive()) return;
478
+
163
479
  const url = new URL(window.location.href);
164
480
  this.performNavigation(url, 'back');
165
481
  }
@@ -172,6 +488,23 @@ export class EcoRouter {
172
488
  return url.origin === window.location.origin;
173
489
  }
174
490
 
491
+ private cancelNavigationTransaction(): void {
492
+ getEcoNavigationRuntime(window).cancelCurrentNavigationTransaction();
493
+ }
494
+
495
+ private beginNavigationTransaction(): {
496
+ isStaleNavigation: () => boolean;
497
+ signal: AbortSignal;
498
+ complete: () => void;
499
+ } {
500
+ const transaction = getEcoNavigationRuntime(window).beginNavigationTransaction();
501
+ return {
502
+ isStaleNavigation: () => !transaction.isCurrent(),
503
+ signal: transaction.signal,
504
+ complete: () => transaction.complete(),
505
+ };
506
+ }
507
+
175
508
  /**
176
509
  * Executes the core navigation flow.
177
510
  *
@@ -191,79 +524,57 @@ export class EcoRouter {
191
524
  * @param direction - Navigation direction ('forward', 'back', or 'replace')
192
525
  */
193
526
  private async performNavigation(url: URL, direction: EcoNavigationEvent['direction']): Promise<void> {
194
- const previousUrl = new URL(window.location.href);
195
-
196
- this.abortController?.abort();
197
- this.abortController = new AbortController();
527
+ this.pendingNavigations++;
528
+ const { isStaleNavigation, signal, complete } = this.beginNavigationTransaction();
529
+ let queuedNavigationHref: string | null = null;
198
530
 
199
531
  try {
200
- const html = await this.fetchPage(url, this.abortController.signal);
201
- const newDocument = this.domSwapper.parseHTML(html, url);
202
-
203
- let shouldReload = false;
204
- const beforeSwapEvent: EcoBeforeSwapEvent = {
205
- url,
206
- direction,
207
- newDocument,
208
- reload: () => {
209
- shouldReload = true;
210
- },
211
- };
212
-
213
- document.dispatchEvent(new CustomEvent('eco:before-swap', { detail: beforeSwapEvent }));
214
-
215
- if (shouldReload) {
216
- window.location.href = url.href;
217
- return;
218
- }
219
-
220
- if (this.options.updateHistory && direction === 'forward') {
221
- window.history.pushState({}, '', url.href);
222
- } else if (direction === 'replace') {
223
- window.history.replaceState({}, '', url.href);
224
- }
225
-
226
- const useViewTransitions = this.options.viewTransitions;
227
- await this.domSwapper.preloadStylesheets(newDocument);
228
-
229
- if (useViewTransitions) {
230
- await this.viewTransitionManager.transition(() => {
231
- this.domSwapper.morphHead(newDocument);
232
- this.domSwapper.morphBody(newDocument);
233
- this.scrollManager.handleScroll(url, previousUrl);
234
- });
235
- } else {
236
- this.domSwapper.morphHead(newDocument);
237
- this.domSwapper.replaceBody(newDocument);
238
- this.scrollManager.handleScroll(url, previousUrl);
239
- }
240
-
241
- const afterSwapEvent: EcoAfterSwapEvent = {
242
- url,
243
- direction,
244
- };
245
-
246
- document.dispatchEvent(new CustomEvent('eco:after-swap', { detail: afterSwapEvent }));
532
+ const html = await this.fetchPage(url, signal);
533
+ if (isStaleNavigation()) return;
247
534
 
248
- this.prefetchManager?.observeNewLinks();
249
-
250
- // Cache the visited page for instant revisits (stale-while-revalidate)
251
- this.prefetchManager?.cacheVisitedPage(url.href, html);
535
+ const newDocument = this.domSwapper.parseHTML(html, url);
536
+ if (isStaleNavigation()) return;
252
537
 
253
- requestAnimationFrame(() => {
254
- document.dispatchEvent(
255
- new CustomEvent('eco:page-load', {
256
- detail: { url, direction } as EcoNavigationEvent,
257
- }),
258
- );
538
+ await this.commitDocumentNavigation(url, direction, newDocument, {
539
+ html,
540
+ isStaleNavigation,
259
541
  });
260
542
  } catch (error) {
543
+ if (isStaleNavigation()) return;
544
+
261
545
  if (error instanceof Error && error.name === 'AbortError') {
262
546
  return;
263
547
  }
264
548
 
265
549
  console.error('[ecopages] Navigation failed:', error);
266
550
  window.location.href = url.href;
551
+ } finally {
552
+ complete();
553
+ this.pendingNavigations--;
554
+
555
+ const navigationRuntime = getEcoNavigationRuntime(window);
556
+ if (!navigationRuntime.hasPendingNavigationTransaction()) {
557
+ queuedNavigationHref = this.queuedNavigationHref;
558
+ this.queuedNavigationHref = null;
559
+ }
560
+
561
+ if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
562
+ const ownerState = navigationRuntime.getOwnerState();
563
+
564
+ if (
565
+ ownerState.owner !== 'none' &&
566
+ ownerState.owner !== 'browser-router' &&
567
+ ownerState.canHandleSpaNavigation
568
+ ) {
569
+ void navigationRuntime.requestNavigation({
570
+ href: queuedNavigationHref,
571
+ direction: 'forward',
572
+ source: 'browser-router',
573
+ });
574
+ } else {
575
+ void this.performNavigation(new URL(queuedNavigationHref, window.location.origin), 'forward');
576
+ }
577
+ }
267
578
  }
268
579
  }
269
580
 
@@ -296,13 +607,32 @@ export class EcoRouter {
296
607
  }
297
608
  }
298
609
 
610
+ const ACTIVE_ROUTER_KEY = '__ecopages_browser_router__';
611
+
612
+ type RouterWindow = Window &
613
+ typeof globalThis & {
614
+ [ACTIVE_ROUTER_KEY]?: EcoRouter;
615
+ };
616
+
299
617
  /**
300
618
  * Creates and starts a router instance.
619
+ *
620
+ * Stops the previously active router (if any) before creating a new one so
621
+ * click listeners and coordinator registrations from earlier instances are
622
+ * cleaned up on re-execution (e.g. when the layout script is re-run via
623
+ * `data-eco-rerun` after a browser-router page commit).
624
+ *
301
625
  * @param options - Configuration options for the router
302
626
  * @returns A started EcoRouter instance
303
627
  */
304
628
  export function createRouter(options?: EcoRouterOptions): EcoRouter {
629
+ const win = window as RouterWindow;
630
+ const existingRouter = win[ACTIVE_ROUTER_KEY];
631
+ if (existingRouter) {
632
+ return existingRouter;
633
+ }
305
634
  const router = new EcoRouter(options);
635
+ win[ACTIVE_ROUTER_KEY] = router;
306
636
  router.start();
307
637
  return router;
308
638
  }
@@ -12,6 +12,9 @@
12
12
  */
13
13
  export declare class DomSwapper {
14
14
  private persistAttribute;
15
+ private pendingHeadScripts;
16
+ private pendingRerunScripts;
17
+ private rerunNonce;
15
18
  constructor(persistAttribute: string);
16
19
  /**
17
20
  * Parses HTML string into a Document, injecting a temporary base tag for URL resolution.
@@ -38,12 +41,22 @@ export declare class DomSwapper {
38
41
  * - Injects new scripts from the incoming page that are absent from the current head
39
42
  */
40
43
  morphHead(newDocument: Document): void;
44
+ /**
45
+ * Replays queued `data-eco-rerun` scripts after the body swap completes.
46
+ *
47
+ * Scripts are intentionally flushed after the new body is in place so DOM-
48
+ * dependent bootstraps bind against the incoming page rather than the page
49
+ * being replaced.
50
+ */
51
+ flushRerunScripts(): void;
52
+ shouldReplaceBodyForRerunScripts(): boolean;
41
53
  /**
42
54
  * Detects custom elements without shadow DOM (light-DOM custom elements).
43
55
  * These need full replacement rather than morphing, because morphdom would
44
56
  * strip JS-generated content from their light DOM children.
45
57
  */
46
58
  private isLightDomCustomElement;
59
+ private replaceCustomElement;
47
60
  /**
48
61
  * Morphs document body using morphdom.
49
62
  * Preserves persisted elements and hydrated custom elements.
@@ -57,6 +70,16 @@ export declare class DomSwapper {
57
70
  * Use when View Transitions are disabled.
58
71
  */
59
72
  replaceBody(newDocument: Document): void;
73
+ private collectRerunScripts;
74
+ private removeStaleHeadScripts;
75
+ private shouldPersistExecutableInlineHeadScript;
76
+ private isNonExecutableHeadScript;
77
+ private areHeadScriptsEquivalent;
78
+ private getHeadScriptKey;
79
+ private findExistingHeadScript;
80
+ private findExistingRerunScript;
81
+ private isExternalModuleRerunScript;
82
+ private createRerunScriptUrl;
60
83
  /**
61
84
  * Manually attaches declarative shadow DOM templates.
62
85
  * Browsers only process `<template shadowrootmode>` during initial parse.