@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.
- 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
|
@@ -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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
await transition.finished;
|
|
57
|
-
} finally {
|
|
61
|
+
void transition.finished.finally(() => {
|
|
58
62
|
/**
|
|
59
|
-
* Cleanup view transition names and dynamic styles after
|
|
60
|
-
*
|
|
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
|
|
package/src/client/types.d.ts
CHANGED
|
@@ -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;
|
package/src/client/types.ts
CHANGED
|
@@ -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;
|