@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.
- package/CHANGELOG.md +9 -7
- package/README.md +36 -36
- package/package.json +2 -2
- package/src/client/eco-router.d.ts +35 -1
- package/src/client/eco-router.js +335 -75
- package/src/client/eco-router.ts +430 -100
- package/src/client/services/dom-swapper.d.ts +23 -0
- package/src/client/services/dom-swapper.js +210 -38
- package/src/client/services/dom-swapper.ts +296 -45
- package/src/client/services/view-transition-manager.d.ts +7 -1
- package/src/client/services/view-transition-manager.js +10 -5
- package/src/client/services/view-transition-manager.ts +13 -7
- package/src/client/types.d.ts +3 -0
- package/src/client/types.ts +4 -0
package/src/client/eco-router.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
310
|
+
if (request?.clearCache) {
|
|
311
|
+
this.prefetchManager?.invalidate(currentUrl);
|
|
312
|
+
}
|
|
62
313
|
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
127
|
-
const link = event
|
|
128
|
-
|
|
129
|
-
.
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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 (
|
|
136
|
-
|
|
413
|
+
if (href && this.pendingNavigations > 0) {
|
|
414
|
+
this.queuedNavigationHref = href;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
137
417
|
|
|
138
|
-
|
|
139
|
-
|
|
418
|
+
private handleHoverIntent(event: MouseEvent | PointerEvent): void {
|
|
419
|
+
const link = this.getLinkFromEvent(event);
|
|
420
|
+
if (!link) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
140
423
|
|
|
141
|
-
|
|
142
|
-
if (
|
|
424
|
+
const href = this.canInterceptLink(event, link);
|
|
425
|
+
if (!href) {
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
143
428
|
|
|
144
|
-
|
|
145
|
-
|
|
429
|
+
this.pendingHoverNavigation = {
|
|
430
|
+
href,
|
|
431
|
+
timestamp: performance.now(),
|
|
432
|
+
};
|
|
146
433
|
|
|
147
|
-
if (
|
|
148
|
-
|
|
434
|
+
if (this.pendingNavigations > 0) {
|
|
435
|
+
this.queuedNavigationHref = href;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
149
438
|
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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,
|
|
201
|
-
|
|
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.
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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.
|