@ecopages/browser-router 0.2.0-alpha.8 → 0.2.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.
@@ -1,634 +0,0 @@
1
- /**
2
- * Client-side router for Ecopages with morphdom-based DOM diffing.
3
- * @module eco-router
4
- */
5
-
6
- import type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types.ts';
7
- import { ECO_DOCUMENT_OWNER_ATTRIBUTE, getEcoNavigationRuntime } from '@ecopages/core/router/navigation-coordinator';
8
- import {
9
- getAnchorFromNavigationEvent,
10
- recoverPendingNavigationHref,
11
- type EcoPendingNavigationIntent,
12
- } from '@ecopages/core/router/link-intent';
13
- import { DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC, DEFAULT_OPTIONS } from './types.ts';
14
- import { syncDocumentElementAttributes } from './document-element-sync.ts';
15
- import { DomSwapper, ScrollManager, ViewTransitionManager, PrefetchManager } from './services/index.ts';
16
-
17
- /**
18
- * Intercepts same-origin link clicks and performs client-side navigation
19
- * using morphdom for efficient DOM diffing. Supports View Transitions API.
20
- */
21
- export class EcoRouter {
22
- private options: Required<EcoRouterOptions>;
23
- private unregisterNavigationRuntime: (() => void) | null = null;
24
- private started = false;
25
- private pendingNavigations = 0;
26
- private pendingPointerNavigation: EcoPendingNavigationIntent | null = null;
27
- private pendingHoverNavigation: EcoPendingNavigationIntent | null = null;
28
- private queuedNavigationHref: string | null = null;
29
-
30
- private domSwapper: DomSwapper;
31
- private scrollManager: ScrollManager;
32
- private viewTransitionManager: ViewTransitionManager;
33
- private prefetchManager: PrefetchManager | null = null;
34
-
35
- constructor(options: EcoRouterOptions = {}) {
36
- this.options = {
37
- ...DEFAULT_OPTIONS,
38
- ...options,
39
- documentElementAttributesToSync: [
40
- ...(options.documentElementAttributesToSync ?? DEFAULT_DOCUMENT_ELEMENT_ATTRIBUTES_TO_SYNC),
41
- ],
42
- };
43
-
44
- this.domSwapper = new DomSwapper(this.options.persistAttribute);
45
- this.scrollManager = new ScrollManager(this.options.scrollBehavior, this.options.smoothScroll);
46
- this.viewTransitionManager = new ViewTransitionManager(this.options.viewTransitions);
47
-
48
- if (this.options.prefetch !== false) {
49
- this.prefetchManager = new PrefetchManager({
50
- ...this.options.prefetch,
51
- linkSelector: this.options.linkSelector,
52
- });
53
- }
54
-
55
- this.handleClick = this.handleClick.bind(this);
56
- this.handleHoverIntent = this.handleHoverIntent.bind(this);
57
- this.handlePointerDown = this.handlePointerDown.bind(this);
58
- this.handlePopState = this.handlePopState.bind(this);
59
- }
60
-
61
- private getLinkFromEvent(event: MouseEvent | PointerEvent): HTMLAnchorElement | null {
62
- return getAnchorFromNavigationEvent(event, this.options.linkSelector);
63
- }
64
-
65
- private canInterceptLink(event: MouseEvent | PointerEvent, link: HTMLAnchorElement): string | null {
66
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return null;
67
- if (event.button !== 0) return null;
68
-
69
- const target = link.getAttribute('target');
70
- if (target && target !== '_self') return null;
71
-
72
- if (link.hasAttribute(this.options.reloadAttribute)) return null;
73
- if (link.hasAttribute('download')) return null;
74
-
75
- const href = link.getAttribute('href');
76
- if (!href) return null;
77
-
78
- if (href.startsWith('#')) return null;
79
- if (href.startsWith('javascript:')) return null;
80
-
81
- const url = new URL(href, window.location.origin);
82
- if (!this.isSameOrigin(url)) return null;
83
-
84
- return href;
85
- }
86
-
87
- private getRecoveredPointerHref(): string | null {
88
- const href = recoverPendingNavigationHref(
89
- this.pendingPointerNavigation,
90
- this.pendingNavigations > 0,
91
- performance.now(),
92
- );
93
-
94
- if (!href) {
95
- this.pendingPointerNavigation = null;
96
- }
97
-
98
- return href;
99
- }
100
-
101
- private getRecoveredHoverHref(): string | null {
102
- const href = recoverPendingNavigationHref(
103
- this.pendingHoverNavigation,
104
- this.pendingNavigations > 0,
105
- performance.now(),
106
- );
107
-
108
- if (!href) {
109
- this.pendingHoverNavigation = null;
110
- }
111
-
112
- return href;
113
- }
114
-
115
- private isAnotherNavigationRuntimeActive(): boolean {
116
- const ownerState = getEcoNavigationRuntime(window).getOwnerState();
117
- return (
118
- ownerState.owner !== 'none' && ownerState.owner !== 'browser-router' && ownerState.canHandleSpaNavigation
119
- );
120
- }
121
-
122
- private getDocumentOwner(doc: Document) {
123
- return getEcoNavigationRuntime(window).resolveDocumentOwner(doc, 'browser-router');
124
- }
125
-
126
- private adoptDocumentOwner(doc: Document): void {
127
- getEcoNavigationRuntime(window).adoptDocumentOwner(doc, 'browser-router');
128
- }
129
-
130
- private syncDocumentElementAttributes(newDocument: Document): void {
131
- syncDocumentElementAttributes(document, newDocument, this.options.documentElementAttributesToSync);
132
- }
133
-
134
- private reloadDocument(url: URL): void {
135
- window.location.assign(url.href);
136
- }
137
-
138
- /**
139
- * Commits a fully fetched document into the live page.
140
- *
141
- * When browser-router accepts a handoff from another runtime, it delays source
142
- * runtime cleanup until the incoming document has been prepared and is ready to
143
- * commit. That ordering avoids the blank-page window we previously hit when a
144
- * delegated navigation went stale after the source runtime had already torn
145
- * itself down.
146
- */
147
- private async commitDocumentNavigation(
148
- url: URL,
149
- direction: EcoNavigationEvent['direction'],
150
- newDocument: Document,
151
- options: {
152
- html?: string;
153
- isStaleNavigation?: () => boolean;
154
- } = {},
155
- ): Promise<void> {
156
- const previousUrl = new URL(window.location.href);
157
- const navigationRuntime = getEcoNavigationRuntime(window);
158
- const isStaleNavigation = options.isStaleNavigation ?? (() => false);
159
- const currentDocumentOwner = navigationRuntime.resolveDocumentOwner(document, 'browser-router');
160
- const newDocumentOwner = navigationRuntime.resolveDocumentOwner(newDocument, 'browser-router');
161
- const activeOwner = navigationRuntime.getOwnerState().owner;
162
- const shouldCleanupCurrentOwner =
163
- currentDocumentOwner !== newDocumentOwner &&
164
- currentDocumentOwner !== 'browser-router' &&
165
- activeOwner === currentDocumentOwner;
166
- let shouldReload = false;
167
- const beforeSwapEvent: EcoBeforeSwapEvent = {
168
- url,
169
- direction,
170
- newDocument,
171
- reload: () => {
172
- shouldReload = true;
173
- },
174
- };
175
-
176
- document.dispatchEvent(new CustomEvent('eco:before-swap', { detail: beforeSwapEvent }));
177
- if (isStaleNavigation()) return;
178
-
179
- if (shouldReload) {
180
- if (shouldCleanupCurrentOwner) {
181
- await navigationRuntime.cleanupOwner(currentDocumentOwner);
182
- }
183
- if (isStaleNavigation()) return;
184
- this.reloadDocument(url);
185
- return;
186
- }
187
-
188
- const useViewTransitions = this.options.viewTransitions;
189
- await this.domSwapper.preloadStylesheets(newDocument);
190
- if (isStaleNavigation()) return;
191
-
192
- // Defer source-runtime cleanup until the incoming document is ready to win.
193
- if (shouldCleanupCurrentOwner) {
194
- await navigationRuntime.cleanupOwner(currentDocumentOwner);
195
- }
196
-
197
- if (isStaleNavigation()) return;
198
-
199
- const commitSwap = () => {
200
- if (isStaleNavigation()) return;
201
-
202
- if (this.options.updateHistory && direction === 'forward') {
203
- window.history.pushState({}, '', url.href);
204
- } else if (direction === 'replace') {
205
- window.history.replaceState({}, '', url.href);
206
- }
207
-
208
- this.syncDocumentElementAttributes(newDocument);
209
- this.domSwapper.morphHead(newDocument);
210
- if (useViewTransitions && !this.domSwapper.shouldReplaceBodyForRerunScripts()) {
211
- this.domSwapper.morphBody(newDocument);
212
- } else {
213
- this.domSwapper.replaceBody(newDocument);
214
- }
215
- this.domSwapper.flushRerunScripts();
216
- this.scrollManager.handleScroll(url, previousUrl);
217
- };
218
-
219
- if (useViewTransitions) {
220
- await this.viewTransitionManager.transition(commitSwap);
221
- } else {
222
- commitSwap();
223
- }
224
-
225
- if (isStaleNavigation()) return;
226
-
227
- navigationRuntime.adoptDocumentOwner(newDocument, 'browser-router');
228
-
229
- const afterSwapEvent: EcoAfterSwapEvent = {
230
- url,
231
- direction,
232
- };
233
-
234
- document.dispatchEvent(new CustomEvent('eco:after-swap', { detail: afterSwapEvent }));
235
-
236
- this.prefetchManager?.observeNewLinks();
237
-
238
- if (options.html) {
239
- this.prefetchManager?.cacheVisitedPage(url.href, options.html);
240
- }
241
-
242
- requestAnimationFrame(() => {
243
- if (isStaleNavigation()) return;
244
-
245
- document.dispatchEvent(
246
- new CustomEvent('eco:page-load', {
247
- detail: { url, direction } as EcoNavigationEvent,
248
- }),
249
- );
250
- });
251
- }
252
-
253
- /**
254
- * Starts the router and begins intercepting navigation.
255
- *
256
- * Attaches click handlers for links and popstate handlers for browser
257
- * back/forward buttons. Also starts the prefetch manager if configured.
258
- */
259
- public start(): void {
260
- if (this.started) {
261
- return;
262
- }
263
-
264
- const navigationRuntime = getEcoNavigationRuntime(window);
265
-
266
- document.addEventListener('mouseover', this.handleHoverIntent, true);
267
- document.addEventListener('pointerover', this.handleHoverIntent, true);
268
- document.addEventListener('mousemove', this.handleHoverIntent, true);
269
- document.addEventListener('pointermove', this.handleHoverIntent, true);
270
- document.addEventListener('pointerdown', this.handlePointerDown, true);
271
- document.addEventListener('click', this.handleClick, true);
272
- window.addEventListener('popstate', this.handlePopState);
273
- this.prefetchManager?.start();
274
- this.unregisterNavigationRuntime?.();
275
- this.unregisterNavigationRuntime = navigationRuntime.register({
276
- owner: 'browser-router',
277
- navigate: async (request) => {
278
- await this.performNavigation(
279
- new URL(request.href, window.location.origin),
280
- request.direction ?? 'forward',
281
- );
282
- return true;
283
- },
284
- handoffNavigation: async (request) => {
285
- const { isStaleNavigation, complete } = this.beginNavigationTransaction();
286
- if (isStaleNavigation()) return true;
287
- try {
288
- await this.commitDocumentNavigation(
289
- new URL(request.finalHref ?? request.href, window.location.origin),
290
- request.direction ?? 'forward',
291
- request.document,
292
- { html: request.html, isStaleNavigation },
293
- );
294
- } finally {
295
- complete();
296
- }
297
- return true;
298
- },
299
- reloadCurrentPage: async (request) => {
300
- if (this.pendingNavigations > 0) return;
301
-
302
- const currentUrl = window.location.pathname + window.location.search;
303
-
304
- if (request?.clearCache) {
305
- this.prefetchManager?.invalidate(currentUrl);
306
- }
307
-
308
- await this.performNavigation(new URL(currentUrl, window.location.origin), 'replace');
309
- },
310
- cleanupBeforeHandoff: async () => {
311
- this.cancelNavigationTransaction();
312
- },
313
- });
314
- this.adoptDocumentOwner(document);
315
-
316
- // Cache the initial page for instant back-navigation
317
- const initialHtml = document.documentElement.outerHTML;
318
- this.prefetchManager?.cacheVisitedPage(window.location.href, initialHtml);
319
- this.started = true;
320
- }
321
-
322
- /**
323
- * Stops the router and cleans up all event listeners.
324
- * After calling this, navigation will fall back to full page reloads.
325
- */
326
- public stop(): void {
327
- if (!this.started) {
328
- return;
329
- }
330
-
331
- this.cancelNavigationTransaction();
332
- document.removeEventListener('mouseover', this.handleHoverIntent, true);
333
- document.removeEventListener('pointerover', this.handleHoverIntent, true);
334
- document.removeEventListener('mousemove', this.handleHoverIntent, true);
335
- document.removeEventListener('pointermove', this.handleHoverIntent, true);
336
- document.removeEventListener('pointerdown', this.handlePointerDown, true);
337
- document.removeEventListener('click', this.handleClick, true);
338
- window.removeEventListener('popstate', this.handlePopState);
339
- this.prefetchManager?.stop();
340
- this.unregisterNavigationRuntime?.();
341
- this.unregisterNavigationRuntime = null;
342
- this.started = false;
343
- this.pendingHoverNavigation = null;
344
- this.pendingPointerNavigation = null;
345
- this.queuedNavigationHref = null;
346
-
347
- const win = window as RouterWindow;
348
- if (win[ACTIVE_ROUTER_KEY] === this) {
349
- delete win[ACTIVE_ROUTER_KEY];
350
- }
351
- }
352
-
353
- /**
354
- * Programmatic navigation.
355
- * Falls back to full page reload for cross-origin URLs.
356
- * @param href - The URL to navigate to
357
- * @param options - Navigation options
358
- * @param options.replace - If true, replaces the current history entry instead of pushing
359
- */
360
- public async navigate(href: string, options: { replace?: boolean } = {}): Promise<void> {
361
- const url = new URL(href, window.location.origin);
362
-
363
- if (!this.isSameOrigin(url)) {
364
- window.location.href = href;
365
- return;
366
- }
367
-
368
- await this.performNavigation(url, options.replace ? 'replace' : 'forward');
369
- }
370
-
371
- /**
372
- * Manually prefetch a URL.
373
- * @param href - The URL to prefetch
374
- */
375
- public async prefetch(href: string): Promise<void> {
376
- if (!this.prefetchManager) {
377
- console.warn('[ecopages] Prefetching is disabled. Enable it in router options.');
378
- return;
379
- }
380
- return this.prefetchManager.prefetch(href);
381
- }
382
-
383
- /**
384
- * Intercepts link clicks for client-side navigation.
385
- *
386
- * Filters out clicks with modifier keys (opens new tab), non-left clicks,
387
- * external links, download links, and links with the reload attribute.
388
- *
389
- * Uses `event.composedPath()` to correctly detect clicks on anchors inside
390
- * Shadow DOM boundaries (Web Components).
391
- */
392
- private handlePointerDown(event: PointerEvent): void {
393
- const link = this.getLinkFromEvent(event);
394
- if (!link) {
395
- this.pendingPointerNavigation = null;
396
- return;
397
- }
398
-
399
- const href = this.canInterceptLink(event, link);
400
- this.pendingPointerNavigation = href
401
- ? {
402
- href,
403
- timestamp: performance.now(),
404
- }
405
- : null;
406
-
407
- if (href && this.pendingNavigations > 0) {
408
- this.queuedNavigationHref = href;
409
- }
410
- }
411
-
412
- private handleHoverIntent(event: MouseEvent | PointerEvent): void {
413
- const link = this.getLinkFromEvent(event);
414
- if (!link) {
415
- return;
416
- }
417
-
418
- const href = this.canInterceptLink(event, link);
419
- if (!href) {
420
- return;
421
- }
422
-
423
- this.pendingHoverNavigation = {
424
- href,
425
- timestamp: performance.now(),
426
- };
427
-
428
- if (this.pendingNavigations > 0) {
429
- this.queuedNavigationHref = href;
430
- }
431
- }
432
-
433
- private handleClick(event: MouseEvent): void {
434
- const navigationRuntime = getEcoNavigationRuntime(window);
435
- const link = this.getLinkFromEvent(event);
436
- const href = link
437
- ? this.canInterceptLink(event, link)
438
- : (this.getRecoveredPointerHref() ?? this.getRecoveredHoverHref());
439
- this.pendingPointerNavigation = null;
440
- this.pendingHoverNavigation = null;
441
- if (!href) return;
442
- this.queuedNavigationHref = null;
443
-
444
- if (this.isAnotherNavigationRuntimeActive()) {
445
- event.preventDefault();
446
- event.stopImmediatePropagation();
447
- void navigationRuntime.requestNavigation({
448
- href,
449
- direction: 'forward',
450
- source: 'browser-router',
451
- });
452
- return;
453
- }
454
-
455
- const url = new URL(href, window.location.origin);
456
-
457
- event.preventDefault();
458
- if (this.pendingNavigations > 0) {
459
- this.queuedNavigationHref = href;
460
- this.cancelNavigationTransaction();
461
- return;
462
- }
463
- this.performNavigation(url, 'forward');
464
- }
465
-
466
- /**
467
- * Handles browser back/forward navigation.
468
- * Triggered by the History API's popstate event.
469
- */
470
- private handlePopState(_event: PopStateEvent): void {
471
- if (this.isAnotherNavigationRuntimeActive()) return;
472
-
473
- const url = new URL(window.location.href);
474
- this.performNavigation(url, 'back');
475
- }
476
-
477
- /**
478
- * Checks if a URL shares the same origin as the current page.
479
- * Cross-origin navigation always falls back to full page reload.
480
- */
481
- private isSameOrigin(url: URL): boolean {
482
- return url.origin === window.location.origin;
483
- }
484
-
485
- private cancelNavigationTransaction(): void {
486
- getEcoNavigationRuntime(window).cancelCurrentNavigationTransaction();
487
- }
488
-
489
- private beginNavigationTransaction(): {
490
- isStaleNavigation: () => boolean;
491
- signal: AbortSignal;
492
- complete: () => void;
493
- } {
494
- const transaction = getEcoNavigationRuntime(window).beginNavigationTransaction();
495
- return {
496
- isStaleNavigation: () => !transaction.isCurrent(),
497
- signal: transaction.signal,
498
- complete: () => transaction.complete(),
499
- };
500
- }
501
-
502
- /**
503
- * Executes the core navigation flow.
504
- *
505
- * Orchestrates fetching, DOM swapping, and lifecycle events:
506
- *
507
- * 1. **Fetch** - Retrieves HTML (from cache or network)
508
- * 2. **eco:before-swap** - Allows listeners to force a full reload
509
- * 3. **History update** - Updates URL before DOM swap so Web Components
510
- * see the correct URL in their `connectedCallback`
511
- * 4. **Stylesheet preload** - Prevents FOUC by loading styles first
512
- * 5. **DOM swap** - Morphs head/body, optionally with View Transition
513
- * 6. **Lifecycle events** - Dispatches `eco:after-swap` and `eco:page-load`
514
- *
515
- * Falls back to full page reload on network errors.
516
- *
517
- * @param url - The target URL to navigate to
518
- * @param direction - Navigation direction ('forward', 'back', or 'replace')
519
- */
520
- private async performNavigation(url: URL, direction: EcoNavigationEvent['direction']): Promise<void> {
521
- this.pendingNavigations++;
522
- const { isStaleNavigation, signal, complete } = this.beginNavigationTransaction();
523
- let queuedNavigationHref: string | null = null;
524
-
525
- try {
526
- const html = await this.fetchPage(url, signal);
527
- if (isStaleNavigation()) return;
528
-
529
- const newDocument = this.domSwapper.parseHTML(html, url);
530
- if (isStaleNavigation()) return;
531
-
532
- await this.commitDocumentNavigation(url, direction, newDocument, {
533
- html,
534
- isStaleNavigation,
535
- });
536
- } catch (error) {
537
- if (isStaleNavigation()) return;
538
-
539
- if (error instanceof Error && error.name === 'AbortError') {
540
- return;
541
- }
542
-
543
- console.error('[ecopages] Navigation failed:', error);
544
- window.location.href = url.href;
545
- } finally {
546
- complete();
547
- this.pendingNavigations--;
548
-
549
- const navigationRuntime = getEcoNavigationRuntime(window);
550
- if (!navigationRuntime.hasPendingNavigationTransaction()) {
551
- queuedNavigationHref = this.queuedNavigationHref;
552
- this.queuedNavigationHref = null;
553
- }
554
-
555
- if (queuedNavigationHref && queuedNavigationHref !== window.location.pathname + window.location.search) {
556
- const ownerState = navigationRuntime.getOwnerState();
557
-
558
- if (
559
- ownerState.owner !== 'none' &&
560
- ownerState.owner !== 'browser-router' &&
561
- ownerState.canHandleSpaNavigation
562
- ) {
563
- void navigationRuntime.requestNavigation({
564
- href: queuedNavigationHref,
565
- direction: 'forward',
566
- source: 'browser-router',
567
- });
568
- } else {
569
- void this.performNavigation(new URL(queuedNavigationHref, window.location.origin), 'forward');
570
- }
571
- }
572
- }
573
- }
574
-
575
- /**
576
- * Fetches the HTML content of a page.
577
- * @param url - The URL to fetch
578
- * @param signal - AbortSignal for cancelling the request
579
- * @throws Error if the response is not ok
580
- */
581
- private async fetchPage(url: URL, signal: AbortSignal): Promise<string> {
582
- if (this.prefetchManager) {
583
- const cachedHtml = this.prefetchManager.getCachedHtml(url.href);
584
- if (cachedHtml) {
585
- return cachedHtml;
586
- }
587
- }
588
-
589
- const response = await fetch(url.href, {
590
- signal,
591
- headers: {
592
- Accept: 'text/html',
593
- },
594
- });
595
-
596
- if (!response.ok) {
597
- throw new Error(`Failed to fetch page: ${response.status}`);
598
- }
599
-
600
- return response.text();
601
- }
602
- }
603
-
604
- const ACTIVE_ROUTER_KEY = '__ecopages_browser_router__';
605
-
606
- type RouterWindow = Window &
607
- typeof globalThis & {
608
- [ACTIVE_ROUTER_KEY]?: EcoRouter;
609
- };
610
-
611
- /**
612
- * Creates and starts a router instance.
613
- *
614
- * Stops the previously active router (if any) before creating a new one so
615
- * click listeners and coordinator registrations from earlier instances are
616
- * cleaned up on re-execution (e.g. when the layout script is re-run via
617
- * `data-eco-rerun` after a browser-router page commit).
618
- *
619
- * @param options - Configuration options for the router
620
- * @returns A started EcoRouter instance
621
- */
622
- export function createRouter(options?: EcoRouterOptions): EcoRouter {
623
- const win = window as RouterWindow;
624
- const existingRouter = win[ACTIVE_ROUTER_KEY];
625
- if (existingRouter) {
626
- return existingRouter;
627
- }
628
- const router = new EcoRouter(options);
629
- win[ACTIVE_ROUTER_KEY] = router;
630
- router.start();
631
- return router;
632
- }
633
-
634
- export type { EcoRouterOptions, EcoNavigationEvent, EcoBeforeSwapEvent, EcoAfterSwapEvent } from './types';