@markless/router 0.9.0 → 0.10.0
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/dist/{create-server-entry-CVhSjR45.js → create-server-entry-BnaEfDHl.js} +288 -68
- package/dist/index.js +2 -2
- package/dist/{request-locals-BPfJaoM_.js → navigation-timing-CzH6VaBR.js} +2 -1
- package/dist/vite/runtime/create-server-entry.d.ts +1 -0
- package/dist/vite/runtime/create-server-entry.js +1 -1
- package/dist/vite/runtime/mdx-route.d.ts +188 -6
- package/dist/vite/runtime/mdx-route.js +124 -12
- package/dist/vite.js +384 -62
- package/package.json +5 -5
- package/src/vite/entries/fragment-entry.ts +433 -254
- package/src/vite/entries/prerender-wake-entry.ts +3 -1
- package/src/vite/entries/resume-entry.ts +36 -23
- package/src/vite/entries/resume-modules.ts +7 -0
|
@@ -14,6 +14,9 @@ export interface FragmentNavigationConfig {
|
|
|
14
14
|
readonly retireProperty: string;
|
|
15
15
|
readonly streamExecutorGlobal: string;
|
|
16
16
|
readonly streamedArmSelector: string;
|
|
17
|
+
readonly streamOpenProperty: string;
|
|
18
|
+
// The leaving page stays up this long after the click while the next one still streams its @pending arms.
|
|
19
|
+
readonly pendingDeadlineMs: number;
|
|
17
20
|
readonly requestPrefix: string;
|
|
18
21
|
readonly regionEndMarker: string;
|
|
19
22
|
readonly appendMarker: string;
|
|
@@ -22,6 +25,7 @@ export interface FragmentNavigationConfig {
|
|
|
22
25
|
readonly pageStartComment: string;
|
|
23
26
|
// The container's own attributes, never carried from the leaving page's element to the next one.
|
|
24
27
|
readonly containerAttributes: readonly [container: string, paths: string];
|
|
28
|
+
readonly keepAttributes: readonly [keep: string, own: string];
|
|
25
29
|
// 'document' apps swap the whole body: their shell renders from the request URL.
|
|
26
30
|
readonly documentLoads: boolean;
|
|
27
31
|
// A mouse fetches after resting this long on a link; press, focus and touch fetch at once.
|
|
@@ -33,6 +37,8 @@ export interface FragmentNavigationConfig {
|
|
|
33
37
|
readonly endpointPrefixes: readonly string[];
|
|
34
38
|
// At most this many visible-link fragments are fetched at idle on fast connections, in document order; 0 keeps HTML to intent only.
|
|
35
39
|
readonly idleFragments: number;
|
|
40
|
+
// An idle fetch stops once a page's region passes this many bytes, and that page waits for intent.
|
|
41
|
+
readonly idleFragmentBytes: number;
|
|
36
42
|
readonly idleWithoutConnectionInfo: boolean;
|
|
37
43
|
// false: nothing is fetched before a click.
|
|
38
44
|
readonly prefetch: boolean;
|
|
@@ -40,7 +46,9 @@ export interface FragmentNavigationConfig {
|
|
|
40
46
|
readonly pages: readonly string[];
|
|
41
47
|
}
|
|
42
48
|
|
|
43
|
-
|
|
49
|
+
// What a link's destination needs before it shows: its code, and the stylesheets the page that links it lacks.
|
|
50
|
+
export type FragmentPreload = ModulePreloadInput | { readonly href: string; readonly as: 'style' };
|
|
51
|
+
export type FragmentRouteCode = Record<string, readonly FragmentPreload[]>;
|
|
44
52
|
|
|
45
53
|
export type FragmentFetched = {
|
|
46
54
|
readonly url: string;
|
|
@@ -52,6 +60,10 @@ export type FragmentFetched = {
|
|
|
52
60
|
|
|
53
61
|
export type FragmentPrefetch = {
|
|
54
62
|
readonly at: number;
|
|
63
|
+
// The writes this page had sent when the fetch began: a later write makes it stale.
|
|
64
|
+
readonly writes: number;
|
|
65
|
+
// When the response's Cache-Control ends its reuse (on arrival for no-store and no-cache).
|
|
66
|
+
expires?: number;
|
|
55
67
|
readonly result: Promise<FragmentFetched>;
|
|
56
68
|
// The response is still arriving, so it holds a connection.
|
|
57
69
|
open: boolean;
|
|
@@ -61,9 +73,12 @@ export type FragmentPrefetch = {
|
|
|
61
73
|
// What the inline bootstrap leaves on the document for the swap module (entries/fragment-entry.ts).
|
|
62
74
|
export type FragmentNavigationState = {
|
|
63
75
|
readonly config: FragmentNavigationConfig;
|
|
64
|
-
readonly routes:
|
|
76
|
+
readonly routes: FragmentRouteCode;
|
|
65
77
|
readonly prefetches: Map<string, FragmentPrefetch>;
|
|
66
78
|
readonly visited: Map<string, string>;
|
|
79
|
+
// Writes this page has started or finished: each one makes every page fetched before it stale.
|
|
80
|
+
writes: number;
|
|
81
|
+
readonly fresh: (entry: FragmentPrefetch, now: number) => boolean;
|
|
67
82
|
shown: string;
|
|
68
83
|
readonly eligible: (anchor: Element | null | undefined, forClick?: boolean) => URL | undefined;
|
|
69
84
|
readonly preloadCode: (url: URL, low: boolean) => void;
|
|
@@ -99,6 +114,8 @@ type Session = {
|
|
|
99
114
|
streaming?: AbortController;
|
|
100
115
|
// The history entry on screen, and the last scroll offset seen for every entry this tab visited.
|
|
101
116
|
current?: string;
|
|
117
|
+
// An entry a navigation pushed whose page has not swapped in yet: the next navigation replaces it.
|
|
118
|
+
unshown?: string;
|
|
102
119
|
readonly positions: Map<string, { readonly x: number; readonly y: number }>;
|
|
103
120
|
};
|
|
104
121
|
|
|
@@ -161,12 +178,13 @@ function take(
|
|
|
161
178
|
parsedVisits.get(state)?.delete(key);
|
|
162
179
|
return {
|
|
163
180
|
at: now,
|
|
181
|
+
writes: state.writes,
|
|
164
182
|
result: Promise.resolve(ready ?? state.parse(url.href, cached)),
|
|
165
183
|
open: false,
|
|
166
184
|
abort: () => {},
|
|
167
185
|
};
|
|
168
186
|
}
|
|
169
|
-
if (existing &&
|
|
187
|
+
if (existing && state.fresh(existing, now)) return existing;
|
|
170
188
|
existing?.abort();
|
|
171
189
|
state.preloadCode(url, false);
|
|
172
190
|
return state.request(url, false);
|
|
@@ -259,13 +277,15 @@ function captureLoadListeners(d: Document, w: Window, into: LoadListener[]): ()
|
|
|
259
277
|
const own = Object.getOwnPropertyDescriptor(target, 'addEventListener');
|
|
260
278
|
const add = target.addEventListener;
|
|
261
279
|
target.addEventListener = function (
|
|
262
|
-
this: EventTarget,
|
|
280
|
+
this: EventTarget | undefined,
|
|
263
281
|
kind: string,
|
|
264
282
|
listener: EventListenerOrEventListenerObject | null,
|
|
265
283
|
options?: boolean | AddEventListenerOptions,
|
|
266
284
|
) {
|
|
267
|
-
|
|
268
|
-
|
|
285
|
+
// A bare call has no receiver; one for the event that already fired is kept off the target, which would hold it and its page for good.
|
|
286
|
+
const self = this ?? w;
|
|
287
|
+
if (kind === type && listener && self === target) return void into.push({ target, type, listener });
|
|
288
|
+
return add.call(self, kind, listener, options);
|
|
269
289
|
};
|
|
270
290
|
return () => {
|
|
271
291
|
if (own) Object.defineProperty(target, 'addEventListener', own);
|
|
@@ -382,12 +402,18 @@ async function mergeHead(d: Document, next: Document) {
|
|
|
382
402
|
else current.set(element.outerHTML, [element]);
|
|
383
403
|
}
|
|
384
404
|
const stylesheets: HTMLLinkElement[] = [];
|
|
405
|
+
const hidden: [Element, string | null][] = [];
|
|
385
406
|
const scripts: HTMLScriptElement[] = [];
|
|
386
407
|
let cursor: Element | null = null;
|
|
387
408
|
for (const element of Array.from(next.head.children)) {
|
|
388
409
|
const reused = current.get(element.outerHTML)?.shift();
|
|
389
410
|
const node = reused ?? d.adoptNode(element);
|
|
390
411
|
if (!reused) {
|
|
412
|
+
// An arriving stylesheet applies only with its page: applied before, it restyles the leaving page for nothing.
|
|
413
|
+
if (isStyleNode(node)) {
|
|
414
|
+
hidden.push([node, node.getAttribute('media')]);
|
|
415
|
+
node.setAttribute('media', 'not all');
|
|
416
|
+
}
|
|
391
417
|
if (cursor) cursor.after(node);
|
|
392
418
|
else d.head.prepend(node);
|
|
393
419
|
if (node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
|
|
@@ -405,7 +431,19 @@ async function mergeHead(d: Document, next: Document) {
|
|
|
405
431
|
else if ((element as HTMLScriptElement).type !== 'importmap') element.remove();
|
|
406
432
|
}
|
|
407
433
|
await loaded(d.defaultView!, stylesheets);
|
|
408
|
-
|
|
434
|
+
const show = () => {
|
|
435
|
+
for (const [node, media] of hidden)
|
|
436
|
+
if (media === null) node.removeAttribute('media');
|
|
437
|
+
else node.setAttribute('media', media);
|
|
438
|
+
};
|
|
439
|
+
return { stale, scripts, show };
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function isStyleNode(node: Element): boolean {
|
|
443
|
+
return (
|
|
444
|
+
node.localName === 'style' ||
|
|
445
|
+
(node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
|
|
446
|
+
);
|
|
409
447
|
}
|
|
410
448
|
|
|
411
449
|
// A swap applies only what the server changed between its documents; a script's own changes (a theme class) stay.
|
|
@@ -456,33 +494,27 @@ function syncHtmlAttributes(d: Document, next: Document, servedProperty: string)
|
|
|
456
494
|
servedAttributes.html.set(d, incoming);
|
|
457
495
|
}
|
|
458
496
|
|
|
459
|
-
// A
|
|
460
|
-
function syncBodyAttributes(
|
|
461
|
-
const incoming = attributePairs(next.body);
|
|
462
|
-
applyServedChanges(d, d.body, servedBefore(d, 'body', servedProperty), incoming);
|
|
463
|
-
servedAttributes.body.set(d, incoming);
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// A container <body> is replaced whole: the incoming one takes the outgoing's attributes plus the server's changes, its own container attributes as served.
|
|
467
|
-
function carryBodyAttributes(
|
|
497
|
+
// A <body> kept in place takes only the server's changes to its attributes; its own container attributes are as served.
|
|
498
|
+
function syncBodyAttributes(
|
|
468
499
|
d: Document,
|
|
469
|
-
|
|
470
|
-
to: HTMLElement,
|
|
471
|
-
own: readonly string[],
|
|
500
|
+
served: Element,
|
|
472
501
|
servedProperty: string,
|
|
502
|
+
own: readonly string[] = [],
|
|
473
503
|
): void {
|
|
474
|
-
const incoming = attributePairs(
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
applyServedChanges(d, merged, servedBefore(d, 'body', servedProperty), incoming);
|
|
478
|
-
for (const name of to.getAttributeNames())
|
|
479
|
-
if (!own.includes(name) && !merged.hasAttribute(name)) to.removeAttribute(name);
|
|
480
|
-
for (const attribute of merged.attributes)
|
|
481
|
-
if (!own.includes(attribute.name) && to.getAttribute(attribute.name) !== attribute.value)
|
|
482
|
-
to.setAttribute(attribute.name, attribute.value);
|
|
504
|
+
const incoming = attributePairs(served);
|
|
505
|
+
applyServedChanges(d, d.body, servedBefore(d, 'body', servedProperty), incoming);
|
|
506
|
+
syncOwnAttributes(d.body, served, own);
|
|
483
507
|
servedAttributes.body.set(d, incoming);
|
|
484
508
|
}
|
|
485
509
|
|
|
510
|
+
function syncOwnAttributes(element: Element, served: Element, own: readonly string[]): void {
|
|
511
|
+
for (const name of own) {
|
|
512
|
+
const value = served.getAttribute(name);
|
|
513
|
+
if (value === null) element.removeAttribute(name);
|
|
514
|
+
else if (element.getAttribute(name) !== value) element.setAttribute(name, value);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
486
518
|
// class and style are merged a token and a declaration at a time, so a script's own tokens are never dropped.
|
|
487
519
|
function applyServedChange(
|
|
488
520
|
d: Document,
|
|
@@ -548,15 +580,14 @@ function pageStart(host: Node, data: string): Comment | undefined {
|
|
|
548
580
|
return undefined;
|
|
549
581
|
}
|
|
550
582
|
|
|
551
|
-
function
|
|
552
|
-
const nodes =
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
while (node.nextSibling) nodes.append(node.nextSibling);
|
|
583
|
+
function nodesBetween(parent: Node, after: Node | undefined, before: Node | null): ChildNode[] {
|
|
584
|
+
const nodes: ChildNode[] = [];
|
|
585
|
+
for (
|
|
586
|
+
let node = after ? after.nextSibling : parent.firstChild;
|
|
587
|
+
node && node !== before;
|
|
588
|
+
node = node.nextSibling
|
|
589
|
+
)
|
|
590
|
+
nodes.push(node);
|
|
560
591
|
return nodes;
|
|
561
592
|
}
|
|
562
593
|
|
|
@@ -565,86 +596,195 @@ const scriptsIn = (element: Element): Element[] =>
|
|
|
565
596
|
|
|
566
597
|
const serverHostAttributes = new WeakMap<Document, Set<string>>();
|
|
567
598
|
|
|
568
|
-
//
|
|
569
|
-
function
|
|
599
|
+
// A container kept in place takes the server's attributes; one a script set on it, and the server never served, stays.
|
|
600
|
+
function syncHostAttributes(
|
|
570
601
|
d: Document,
|
|
571
|
-
|
|
572
|
-
|
|
602
|
+
host: Element,
|
|
603
|
+
served: Element,
|
|
573
604
|
own: readonly string[],
|
|
574
605
|
): void {
|
|
575
606
|
const server = serverHostAttributes.get(d);
|
|
576
|
-
serverHostAttributes.set(d, new Set(
|
|
577
|
-
for (const
|
|
578
|
-
if (
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
607
|
+
serverHostAttributes.set(d, new Set(served.getAttributeNames()));
|
|
608
|
+
for (const name of host.getAttributeNames())
|
|
609
|
+
if (!served.hasAttribute(name) && (server?.has(name) || own.includes(name)))
|
|
610
|
+
host.removeAttribute(name);
|
|
611
|
+
for (const attribute of served.attributes)
|
|
612
|
+
if (host.getAttribute(attribute.name) !== attribute.value)
|
|
613
|
+
host.setAttribute(attribute.name, attribute.value);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Kept nodes keep hover, focus, scroll and running transitions; inside a container scripts are always fresh, so the page boots again.
|
|
617
|
+
function morphChildren(d: Document, from: Node, to: Node, fresh: Element[], page = false): void {
|
|
618
|
+
morphRange(d, from, from.firstChild, null, Array.from(to.childNodes), fresh, page);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const sameKind = (from: ChildNode, to: ChildNode): boolean =>
|
|
622
|
+
from.nodeType === to.nodeType &&
|
|
623
|
+
from.nodeName === to.nodeName &&
|
|
624
|
+
isContainer(from) === isContainer(to) &&
|
|
625
|
+
(from.nodeType !== 1 || (from as Element).id === (to as Element).id);
|
|
626
|
+
|
|
627
|
+
function morphRange(
|
|
628
|
+
d: Document,
|
|
629
|
+
parent: Node,
|
|
630
|
+
cursor: ChildNode | null,
|
|
631
|
+
stop: Node | null,
|
|
632
|
+
incoming: readonly ChildNode[],
|
|
633
|
+
fresh: Element[],
|
|
634
|
+
page: boolean,
|
|
635
|
+
): void {
|
|
636
|
+
for (const next of incoming) {
|
|
637
|
+
if (cursor && cursor !== stop && kept(d, cursor, next, fresh)) {
|
|
638
|
+
cursor = cursor.nextSibling;
|
|
639
|
+
continue;
|
|
595
640
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
cursor = next;
|
|
641
|
+
// A node the next page dropped from a list: the one after it is the match.
|
|
642
|
+
if (cursor && cursor !== stop && !sameKind(cursor, next)) {
|
|
643
|
+
const after = cursor.nextSibling;
|
|
644
|
+
if (after && after !== stop && sameKind(after, next)) {
|
|
645
|
+
cursor.remove();
|
|
646
|
+
cursor = after;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
if (cursor && cursor !== stop && sameKind(cursor, next)) {
|
|
650
|
+
const after = cursor.nextSibling;
|
|
651
|
+
morphNode(d, cursor, next, fresh, page);
|
|
652
|
+
cursor = after;
|
|
609
653
|
continue;
|
|
610
654
|
}
|
|
611
|
-
const node = d.adoptNode(
|
|
612
|
-
|
|
655
|
+
const node = d.adoptNode(next);
|
|
656
|
+
parent.insertBefore(node, cursor && cursor !== stop ? cursor : stop);
|
|
613
657
|
if (node.nodeType === 1) fresh.push(node as Element);
|
|
614
658
|
}
|
|
615
|
-
while (cursor) {
|
|
616
|
-
const
|
|
659
|
+
while (cursor && cursor !== stop) {
|
|
660
|
+
const after: ChildNode | null = cursor.nextSibling;
|
|
617
661
|
cursor.remove();
|
|
618
|
-
cursor =
|
|
662
|
+
cursor = after;
|
|
619
663
|
}
|
|
620
664
|
}
|
|
621
665
|
|
|
622
|
-
|
|
666
|
+
let keepAttributes: readonly [keep: string, own: string] = ['', ''];
|
|
667
|
+
|
|
668
|
+
// Both pages render this element byte for byte: it stays, with what scripts did to it; only the page's own children inside are replaced.
|
|
669
|
+
function kept(d: Document, from: ChildNode, to: ChildNode, fresh: Element[]): boolean {
|
|
670
|
+
const [keep, own] = keepAttributes;
|
|
671
|
+
const hash = keep && from.nodeType === 1 && (from as Element).getAttribute(keep);
|
|
672
|
+
if (!hash || to.nodeType !== 1 || hash !== (to as Element).getAttribute(keep)) return false;
|
|
673
|
+
const slots = (root: Element) =>
|
|
674
|
+
root.hasAttribute(own)
|
|
675
|
+
? [root]
|
|
676
|
+
: [...root.querySelectorAll(`[${own}]`)].filter(
|
|
677
|
+
(slot) => !root.contains(slot.parentElement!.closest(`[${own}]`)),
|
|
678
|
+
);
|
|
679
|
+
const leaving = slots(from as Element);
|
|
680
|
+
const arriving = slots(to as Element);
|
|
681
|
+
if (leaving.length !== arriving.length) return false;
|
|
682
|
+
leaving.forEach((slot, index) => {
|
|
683
|
+
const nodes = [...arriving[index]!.childNodes].map((node) => d.adoptNode(node));
|
|
684
|
+
slot.replaceChildren(...nodes);
|
|
685
|
+
for (const node of nodes) if (node.nodeType === 1) fresh.push(node as Element);
|
|
686
|
+
});
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function morphNode(
|
|
691
|
+
d: Document,
|
|
692
|
+
from: ChildNode,
|
|
693
|
+
to: ChildNode,
|
|
694
|
+
fresh: Element[],
|
|
695
|
+
page: boolean,
|
|
696
|
+
): void {
|
|
623
697
|
if (from.nodeType !== 1) {
|
|
624
698
|
if ((from as CharacterData).data !== (to as CharacterData).data)
|
|
625
699
|
(from as CharacterData).data = (to as CharacterData).data;
|
|
626
700
|
return;
|
|
627
701
|
}
|
|
628
|
-
if (from.isEqualNode(to)) return;
|
|
629
702
|
const element = from as Element;
|
|
630
703
|
const incoming = to as Element;
|
|
631
|
-
|
|
704
|
+
const name = element.localName;
|
|
705
|
+
page ||= isContainer(element);
|
|
706
|
+
// A template's content is not its children, so a morph cannot see it; a custom element owns its own state.
|
|
707
|
+
if (
|
|
708
|
+
name === 'template' ||
|
|
709
|
+
(name === 'script' && (page || !element.isEqualNode(incoming))) ||
|
|
710
|
+
(name.includes('-') && !element.isEqualNode(incoming))
|
|
711
|
+
) {
|
|
632
712
|
const node = d.adoptNode(incoming);
|
|
633
713
|
element.replaceWith(node);
|
|
634
714
|
fresh.push(node);
|
|
635
715
|
return;
|
|
636
716
|
}
|
|
637
|
-
|
|
638
|
-
|
|
717
|
+
if (!page && element.isEqualNode(incoming)) return;
|
|
718
|
+
for (const attribute of element.getAttributeNames())
|
|
719
|
+
if (!incoming.hasAttribute(attribute)) element.removeAttribute(attribute);
|
|
639
720
|
for (const attribute of incoming.attributes)
|
|
640
721
|
if (element.getAttribute(attribute.name) !== attribute.value)
|
|
641
722
|
element.setAttribute(attribute.name, attribute.value);
|
|
642
|
-
|
|
723
|
+
if (page) resetControl(element);
|
|
724
|
+
if (name !== 'script') morphChildren(d, element, incoming, fresh, page);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// A kept control shows what the server rendered for the next page, as a landing would.
|
|
728
|
+
function resetControl(element: Element): void {
|
|
729
|
+
const name = element.localName;
|
|
730
|
+
if (name === 'input') {
|
|
731
|
+
const input = element as HTMLInputElement;
|
|
732
|
+
if (input.checked !== input.defaultChecked) input.checked = input.defaultChecked;
|
|
733
|
+
if (input.value !== input.defaultValue) input.value = input.defaultValue;
|
|
734
|
+
} else if (name === 'textarea') {
|
|
735
|
+
const area = element as HTMLTextAreaElement;
|
|
736
|
+
if (area.value !== area.defaultValue) area.value = area.defaultValue;
|
|
737
|
+
} else if (name === 'option') {
|
|
738
|
+
const option = element as HTMLOptionElement;
|
|
739
|
+
if (option.selected !== option.defaultSelected) option.selected = option.defaultSelected;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// A kept node takes the next page's classes in one step: a CSS transition between two pages' styles shows as morphing.
|
|
744
|
+
// Transitions stay off until the frame after the swap has computed its styles; turning them on then starts none.
|
|
745
|
+
// Transitions the swap starts are cancelled before each of its first frames paints; a sheet turning them off restyles the whole document twice.
|
|
746
|
+
function withoutTransitions(d: Document): () => void {
|
|
747
|
+
const w = d.defaultView as Window & typeof globalThis;
|
|
748
|
+
if (w.ResizeObserver && w.CSSTransition && d.getAnimations) {
|
|
749
|
+
const running = new Set(d.getAnimations());
|
|
750
|
+
const beforePaint = (frames: number) => {
|
|
751
|
+
const observer = new w.ResizeObserver(() => {
|
|
752
|
+
observer.disconnect();
|
|
753
|
+
for (const animation of d.getAnimations())
|
|
754
|
+
if (!running.has(animation) && animation instanceof w.CSSTransition) animation.cancel();
|
|
755
|
+
if (frames > 1) w.requestAnimationFrame(() => beforePaint(frames - 1));
|
|
756
|
+
});
|
|
757
|
+
observer.observe(d.documentElement);
|
|
758
|
+
};
|
|
759
|
+
return () => beforePaint(3);
|
|
760
|
+
}
|
|
761
|
+
const sheet = new w.CSSStyleSheet();
|
|
762
|
+
sheet.replaceSync('*,*::before,*::after{transition:none!important}');
|
|
763
|
+
d.adoptedStyleSheets = [...d.adoptedStyleSheets, sheet];
|
|
764
|
+
return () =>
|
|
765
|
+
w.requestAnimationFrame(() =>
|
|
766
|
+
w.requestAnimationFrame(() => {
|
|
767
|
+
d.adoptedStyleSheets = d.adoptedStyleSheets.filter((own) => own !== sheet);
|
|
768
|
+
}),
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Drops the leaving page's runtime state (the framework names its own `__m*`) and gives each container a new page lifetime.
|
|
773
|
+
function forgetRuntimeState(root: Element): void {
|
|
774
|
+
const forget = (node: Node) => {
|
|
775
|
+
for (const key of Object.keys(node))
|
|
776
|
+
if (key.startsWith('__m')) delete (node as unknown as Record<string, unknown>)[key];
|
|
777
|
+
if (isContainer(node)) (node as unknown as Record<string, unknown>).__mL = {};
|
|
778
|
+
};
|
|
779
|
+
forget(root);
|
|
780
|
+
const walker = root.ownerDocument.createTreeWalker(root, 129);
|
|
781
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) forget(node);
|
|
643
782
|
}
|
|
644
783
|
|
|
645
784
|
// Scrolled boxes (a sidebar) keep their offset when the same box is there after the swap:
|
|
646
785
|
// at its old place in the tree, or else by its id when the next page wraps it differently.
|
|
647
786
|
type ScrolledBox = {
|
|
787
|
+
readonly element: Element;
|
|
648
788
|
readonly path: string | undefined;
|
|
649
789
|
readonly id: string;
|
|
650
790
|
readonly kind: string;
|
|
@@ -683,6 +823,52 @@ function sameBox(root: Element, box: ScrolledBox): Element | undefined {
|
|
|
683
823
|
return named && root.contains(named) && boxKind(named) === box.kind ? named : undefined;
|
|
684
824
|
}
|
|
685
825
|
|
|
826
|
+
type HeldStream = {
|
|
827
|
+
readonly reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
828
|
+
readonly ended: boolean;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// Reads ahead until the stream ends or `ms` pass, then hands back a reader that replays what it read.
|
|
832
|
+
function holdStream(
|
|
833
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
834
|
+
ms: number,
|
|
835
|
+
w: Window,
|
|
836
|
+
): Promise<HeldStream> {
|
|
837
|
+
const chunks: Uint8Array[] = [];
|
|
838
|
+
let ended = false;
|
|
839
|
+
let failure: { readonly error: unknown } | undefined;
|
|
840
|
+
let wake = () => {};
|
|
841
|
+
const replay = {
|
|
842
|
+
read: async () => {
|
|
843
|
+
while (!chunks.length && !ended) await new Promise<void>((done) => (wake = done));
|
|
844
|
+
if (chunks.length) return { done: false, value: chunks.shift()! };
|
|
845
|
+
if (failure) throw failure.error;
|
|
846
|
+
return { done: true, value: undefined };
|
|
847
|
+
},
|
|
848
|
+
cancel: (reason?: unknown) => reader.cancel(reason),
|
|
849
|
+
} as ReadableStreamDefaultReader<Uint8Array>;
|
|
850
|
+
return new Promise((resolve) => {
|
|
851
|
+
const timer = w.setTimeout(
|
|
852
|
+
() => resolve({ reader: replay, ended: false }),
|
|
853
|
+
Math.max(0, ms),
|
|
854
|
+
);
|
|
855
|
+
void (async () => {
|
|
856
|
+
try {
|
|
857
|
+
for (let chunk = await reader.read(); !chunk.done; chunk = await reader.read()) {
|
|
858
|
+
chunks.push(chunk.value);
|
|
859
|
+
wake();
|
|
860
|
+
}
|
|
861
|
+
} catch (error) {
|
|
862
|
+
failure = { error };
|
|
863
|
+
}
|
|
864
|
+
ended = true;
|
|
865
|
+
wake();
|
|
866
|
+
w.clearTimeout(timer);
|
|
867
|
+
resolve({ reader: replay, ended: true });
|
|
868
|
+
})();
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
686
872
|
// Streamed @pending arms arrive after the region, one delimited piece each, and run exactly as on a landing.
|
|
687
873
|
async function streamAppends(
|
|
688
874
|
d: Document,
|
|
@@ -690,6 +876,7 @@ async function streamAppends(
|
|
|
690
876
|
fetched: FragmentFetched,
|
|
691
877
|
end: Node,
|
|
692
878
|
signal: AbortSignal,
|
|
879
|
+
arrived: boolean,
|
|
693
880
|
): Promise<string | undefined> {
|
|
694
881
|
const marker = state.config.appendMarker;
|
|
695
882
|
const decoder = new TextDecoder();
|
|
@@ -723,65 +910,15 @@ async function streamAppends(
|
|
|
723
910
|
}
|
|
724
911
|
}
|
|
725
912
|
flush(true);
|
|
913
|
+
// Arms that all arrived before the swap commit in its task, so @pending never paints.
|
|
914
|
+
if (arrived && !signal.aborted)
|
|
915
|
+
(d.defaultView as unknown as Record<string, { flush?: () => void } | undefined>)[
|
|
916
|
+
state.config.streamExecutorGlobal
|
|
917
|
+
]?.flush?.();
|
|
726
918
|
if ((end as Comment).data !== state.config.pageEndComment) end.parentNode?.removeChild(end);
|
|
727
919
|
return signal.aborted ? undefined : all;
|
|
728
920
|
}
|
|
729
921
|
|
|
730
|
-
// Inserting a big page whole lays it out in one long task; the swap lays it out this many elements at a time and yields between.
|
|
731
|
-
const CHUNK_ELEMENTS = 800;
|
|
732
|
-
|
|
733
|
-
type HeldPage = [parent: Node, before: Node | null, nodes: ChildNode[]][];
|
|
734
|
-
|
|
735
|
-
function holdPage(parent: Element, after: Node | undefined, before: Node | null): HeldPage {
|
|
736
|
-
const nodes: ChildNode[] = [];
|
|
737
|
-
for (let node = after ? after.nextSibling : parent.firstChild; node && node !== before;) {
|
|
738
|
-
const next: ChildNode | null = node.nextSibling;
|
|
739
|
-
nodes.push(node);
|
|
740
|
-
node.remove();
|
|
741
|
-
node = next;
|
|
742
|
-
}
|
|
743
|
-
return [[parent, before, nodes]];
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
const elementsIn = (node: Node) =>
|
|
747
|
-
node.nodeType === 1 ? (node as Element).getElementsByTagName('*').length + 1 : 0;
|
|
748
|
-
|
|
749
|
-
// Runs while the leaving page is held on screen; each chunk is laid out before yielding and an element that fits in one goes in whole.
|
|
750
|
-
function fillPage(
|
|
751
|
-
w: Window,
|
|
752
|
-
held: HeldPage,
|
|
753
|
-
budget: number,
|
|
754
|
-
current: () => boolean,
|
|
755
|
-
): true | Promise<boolean> {
|
|
756
|
-
const laidOut = () => {
|
|
757
|
-
void w.document.documentElement.offsetHeight;
|
|
758
|
-
return new Promise<boolean>((resolve) => {
|
|
759
|
-
const channel = new MessageChannel();
|
|
760
|
-
channel.port1.onmessage = () => resolve(current());
|
|
761
|
-
channel.port2.postMessage(0);
|
|
762
|
-
});
|
|
763
|
-
};
|
|
764
|
-
const nextChunk = () => laidOut().then((still) => still && fillPage(w, held, budget, current));
|
|
765
|
-
let used = 0;
|
|
766
|
-
while (held.length) {
|
|
767
|
-
const [parent, before, nodes] = held[0]!;
|
|
768
|
-
const size = elementsIn(nodes[0]!);
|
|
769
|
-
if (used > 0 && used + size > budget && size <= budget) return nextChunk();
|
|
770
|
-
const node = nodes.shift()!;
|
|
771
|
-
if (!nodes.length) held.shift();
|
|
772
|
-
if (size > budget && node.firstChild) {
|
|
773
|
-
const children = [...node.childNodes];
|
|
774
|
-
(node as Element).replaceChildren();
|
|
775
|
-
held.unshift([node, null, children]);
|
|
776
|
-
used += 1;
|
|
777
|
-
} else used += size;
|
|
778
|
-
parent.insertBefore(node, before);
|
|
779
|
-
if (used >= budget && held.length) return nextChunk();
|
|
780
|
-
}
|
|
781
|
-
// The last chunk is laid out in its own task too, so what runs once the page is in starts on a laid-out page.
|
|
782
|
-
return budget === Infinity ? true : laidOut();
|
|
783
|
-
}
|
|
784
|
-
|
|
785
922
|
export async function navigate(
|
|
786
923
|
d: Document,
|
|
787
924
|
url: URL,
|
|
@@ -794,20 +931,55 @@ export async function navigate(
|
|
|
794
931
|
containerAttribute = state.config.containerAttributes[0];
|
|
795
932
|
const session = sessionFor(d);
|
|
796
933
|
const id = ++session.navigation;
|
|
797
|
-
|
|
934
|
+
// The address follows the click at once, so Back before the page arrives returns to the page still shown.
|
|
935
|
+
const entryNow = () =>
|
|
936
|
+
(w.history.state as { __marklessFragment?: { id?: string } } | null)?.__marklessFragment
|
|
937
|
+
?.id;
|
|
938
|
+
let entered: string | undefined;
|
|
939
|
+
if (mode !== 'traverse') {
|
|
940
|
+
const unshown = session.unshown !== undefined && session.unshown === entryNow();
|
|
941
|
+
if (!unshown)
|
|
942
|
+
session.positions.set(session.current ?? entryNow() ?? LANDING_ENTRY, {
|
|
943
|
+
x: w.scrollX,
|
|
944
|
+
y: w.scrollY,
|
|
945
|
+
});
|
|
946
|
+
w.history.scrollRestoration = 'manual';
|
|
947
|
+
session.unshown = entered = entryId();
|
|
948
|
+
const entry = { __marklessFragment: { id: entered, x: 0, y: 0 } };
|
|
949
|
+
if (mode === 'push' && !unshown) w.history.pushState(entry, '', url.href);
|
|
950
|
+
else w.history.replaceState(entry, '', url.href);
|
|
951
|
+
}
|
|
952
|
+
const left = () => entered !== undefined && entryNow() !== entered;
|
|
953
|
+
const stale = () => id !== session.navigation || left();
|
|
798
954
|
const fallback = (href: string) => {
|
|
799
955
|
if (mode === 'traverse') w.location.reload();
|
|
800
|
-
else w.location.
|
|
956
|
+
else w.location.replace(href);
|
|
957
|
+
};
|
|
958
|
+
const started = w.performance.now();
|
|
959
|
+
const taken = take(state, url, mode === 'traverse', started);
|
|
960
|
+
const abandon = () => {
|
|
961
|
+
if (session.unshown === entered) session.unshown = undefined;
|
|
962
|
+
taken.abort();
|
|
801
963
|
};
|
|
802
|
-
const taken = take(state, url, mode === 'traverse', w.performance.now());
|
|
803
964
|
let fetched: FragmentFetched;
|
|
804
965
|
try {
|
|
805
966
|
fetched = await taken.result;
|
|
806
967
|
} catch {
|
|
807
|
-
if (
|
|
968
|
+
if (!stale()) fallback(url.href);
|
|
808
969
|
return;
|
|
809
970
|
}
|
|
810
|
-
if (
|
|
971
|
+
if (stale()) return abandon();
|
|
972
|
+
let arrived = !fetched.rest;
|
|
973
|
+
if (fetched.rest) {
|
|
974
|
+
const held = await holdStream(
|
|
975
|
+
fetched.rest,
|
|
976
|
+
state.config.pendingDeadlineMs - (w.performance.now() - started),
|
|
977
|
+
w,
|
|
978
|
+
);
|
|
979
|
+
arrived = held.ended;
|
|
980
|
+
fetched = { ...fetched, rest: held.reader };
|
|
981
|
+
}
|
|
982
|
+
if (stale()) return abandon();
|
|
811
983
|
// A prefetch is shared by every link to the page, so the address takes the clicked link's #fragment.
|
|
812
984
|
const landed = new URL(fetched.url);
|
|
813
985
|
landed.hash = url.hash;
|
|
@@ -833,7 +1005,7 @@ export async function navigate(
|
|
|
833
1005
|
const ranFiles = filesRan(d);
|
|
834
1006
|
const head = await mergeHead(d, next);
|
|
835
1007
|
await Promise.race([Promise.all(code), new Promise((resolve) => w.setTimeout(resolve, 3000))]);
|
|
836
|
-
if (
|
|
1008
|
+
if (stale()) return abandon();
|
|
837
1009
|
let restore = scroll;
|
|
838
1010
|
if (mode === 'traverse') {
|
|
839
1011
|
const key = (typeof scroll === 'object' ? scroll.id : undefined) ?? LANDING_ENTRY;
|
|
@@ -841,35 +1013,26 @@ export async function navigate(
|
|
|
841
1013
|
if (saved) restore = saved;
|
|
842
1014
|
session.current = key;
|
|
843
1015
|
} else {
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
LANDING_ENTRY;
|
|
849
|
-
session.positions.set(leaving, { x: w.scrollX, y: w.scrollY });
|
|
850
|
-
w.history.scrollRestoration = 'manual';
|
|
851
|
-
session.current = entryId();
|
|
852
|
-
const entry = { __marklessFragment: { id: session.current, x: 0, y: 0 } };
|
|
853
|
-
if (mode === 'push') w.history.pushState(entry, '', landed.href);
|
|
854
|
-
else w.history.replaceState(entry, '', landed.href);
|
|
1016
|
+
session.current = entered;
|
|
1017
|
+
session.unshown = undefined;
|
|
1018
|
+
if (landed.href !== w.location.href)
|
|
1019
|
+
w.history.replaceState(w.history.state, '', landed.href);
|
|
855
1020
|
}
|
|
856
|
-
// A scroll target or a restored position needs the whole page in place before it is applied; a page that fits in a chunk goes in at once.
|
|
857
|
-
const progressive =
|
|
858
|
-
restore === 'reset' &&
|
|
859
|
-
!url.hash &&
|
|
860
|
-
d.visibilityState !== 'hidden' &&
|
|
861
|
-
typeof d.startViewTransition === 'function' &&
|
|
862
|
-
incoming.getElementsByTagName('*').length > CHUNK_ELEMENTS;
|
|
863
1021
|
let region!: Element;
|
|
864
1022
|
let end!: Comment;
|
|
865
1023
|
let scripts: Element[] = [];
|
|
866
|
-
const commit =
|
|
1024
|
+
const commit = (): boolean => {
|
|
867
1025
|
if (id !== session.navigation) return false;
|
|
1026
|
+
// The leaving page keeps its stream until now: closed early, its unanswered @try runs the recipe in the browser.
|
|
1027
|
+
session.streaming?.abort();
|
|
1028
|
+
const settle = withoutTransitions(d);
|
|
1029
|
+
keepAttributes = state.config.keepAttributes ?? ['', ''];
|
|
868
1030
|
const offsets: ScrolledBox[] = [];
|
|
869
1031
|
for (const element of state.scrolled) {
|
|
870
1032
|
if (!element.isConnected || !scope.contains(element)) continue;
|
|
871
1033
|
if (element.scrollTop || element.scrollLeft)
|
|
872
1034
|
offsets.push({
|
|
1035
|
+
element,
|
|
873
1036
|
path: elementPath(scope, element),
|
|
874
1037
|
id: element.id,
|
|
875
1038
|
kind: boxKind(element),
|
|
@@ -879,102 +1042,111 @@ export async function navigate(
|
|
|
879
1042
|
}
|
|
880
1043
|
state.scrolled.clear();
|
|
881
1044
|
retire(state, outgoing);
|
|
1045
|
+
forgetRuntimeState(outgoing);
|
|
882
1046
|
(w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
|
|
883
1047
|
for (const element of d.querySelectorAll(state.config.streamedArmSelector))
|
|
884
1048
|
element.remove();
|
|
885
1049
|
state.shown = pageKey(new URL(w.location.href));
|
|
886
1050
|
if (documentLoads) syncHtmlAttributes(d, next, state.config.servedHtmlAttributesProperty);
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
let start: Comment | undefined;
|
|
890
|
-
let held: HeldPage | undefined;
|
|
1051
|
+
const fresh: Element[] = [];
|
|
1052
|
+
region = outgoing;
|
|
891
1053
|
if (documentLoads && !isContainer(d.body)) {
|
|
892
|
-
|
|
893
|
-
region = d.body;
|
|
1054
|
+
syncBodyAttributes(d, next.body, state.config.servedBodyAttributesProperty);
|
|
894
1055
|
morphChildren(d, region, incoming, fresh);
|
|
895
|
-
scripts = fresh.flatMap(scriptsIn);
|
|
896
1056
|
region.append((end = d.createComment('')));
|
|
897
1057
|
} else {
|
|
898
|
-
|
|
1058
|
+
const own = state.config.containerAttributes;
|
|
899
1059
|
if (outgoing === d.body)
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
outgoing as HTMLElement,
|
|
903
|
-
region as HTMLElement,
|
|
904
|
-
state.config.containerAttributes,
|
|
905
|
-
state.config.servedBodyAttributesProperty,
|
|
906
|
-
);
|
|
1060
|
+
syncBodyAttributes(d, incoming, state.config.servedBodyAttributesProperty, own);
|
|
1061
|
+
else syncHostAttributes(d, outgoing, incoming, own);
|
|
907
1062
|
const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
|
|
908
1063
|
if (leavingEnd) {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
// The document's own nodes after the page stay; a document app morphs them to its next shell.
|
|
915
|
-
const focused = d.activeElement as HTMLElement | null;
|
|
916
|
-
const kept = nodesAfter(d, leavingEnd);
|
|
917
|
-
const arriving = nodesAfter(d, end);
|
|
918
|
-
const fresh: Element[] = [];
|
|
919
|
-
if (documentLoads) morphChildren(d, kept, arriving, fresh);
|
|
920
|
-
const leavingStart = pageStart(outgoing, state.config.pageStartComment);
|
|
921
|
-
start = pageStart(region, state.config.pageStartComment);
|
|
922
|
-
if (leavingStart || start) {
|
|
923
|
-
start ??= region.insertBefore(
|
|
1064
|
+
const arrivingEnd = pageEnd(incoming, state.config.pageEndComment) ?? null;
|
|
1065
|
+
const arrivingStart = pageStart(incoming, state.config.pageStartComment);
|
|
1066
|
+
let leavingStart = pageStart(outgoing, state.config.pageStartComment);
|
|
1067
|
+
if (!leavingStart && arrivingStart)
|
|
1068
|
+
leavingStart = outgoing.insertBefore(
|
|
924
1069
|
d.createComment(state.config.pageStartComment),
|
|
925
|
-
|
|
1070
|
+
outgoing.firstChild,
|
|
1071
|
+
);
|
|
1072
|
+
morphRange(
|
|
1073
|
+
d,
|
|
1074
|
+
outgoing,
|
|
1075
|
+
leavingStart ? leavingStart.nextSibling : outgoing.firstChild,
|
|
1076
|
+
leavingEnd,
|
|
1077
|
+
nodesBetween(incoming, arrivingStart, arrivingEnd),
|
|
1078
|
+
fresh,
|
|
1079
|
+
true,
|
|
1080
|
+
);
|
|
1081
|
+
end = leavingEnd;
|
|
1082
|
+
// The document's own nodes around the page stay; a document app morphs them to its next shell.
|
|
1083
|
+
if (documentLoads) {
|
|
1084
|
+
morphRange(
|
|
1085
|
+
d,
|
|
1086
|
+
outgoing,
|
|
1087
|
+
leavingEnd.nextSibling,
|
|
1088
|
+
null,
|
|
1089
|
+
arrivingEnd ? nodesBetween(incoming, arrivingEnd, null) : [],
|
|
1090
|
+
fresh,
|
|
1091
|
+
false,
|
|
926
1092
|
);
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
1093
|
+
if (leavingStart)
|
|
1094
|
+
morphRange(
|
|
1095
|
+
d,
|
|
1096
|
+
outgoing,
|
|
1097
|
+
outgoing.firstChild,
|
|
1098
|
+
leavingStart,
|
|
1099
|
+
arrivingStart ? nodesBetween(incoming, undefined, arrivingStart) : [],
|
|
1100
|
+
fresh,
|
|
1101
|
+
false,
|
|
1102
|
+
);
|
|
933
1103
|
}
|
|
934
|
-
scripts = [...scriptsIn(region), ...fresh.flatMap(scriptsIn)];
|
|
935
|
-
region.append(kept);
|
|
936
|
-
held = holdPage(region, start, end);
|
|
937
|
-
outgoing.replaceWith(region);
|
|
938
|
-
// The document's own nodes moved to the new element; focus inside them stays put.
|
|
939
|
-
if (focused && focused !== d.activeElement && region.contains(focused))
|
|
940
|
-
focused.focus({ preventScroll: true });
|
|
941
1104
|
} else {
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1105
|
+
morphRange(
|
|
1106
|
+
d,
|
|
1107
|
+
outgoing,
|
|
1108
|
+
outgoing.firstChild,
|
|
1109
|
+
null,
|
|
1110
|
+
Array.from(incoming.childNodes),
|
|
1111
|
+
fresh,
|
|
1112
|
+
true,
|
|
1113
|
+
);
|
|
1114
|
+
outgoing.after((end = d.createComment('')));
|
|
946
1115
|
}
|
|
947
1116
|
}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
fillPage(
|
|
951
|
-
w,
|
|
952
|
-
held,
|
|
953
|
-
progressive ? CHUNK_ELEMENTS : Infinity,
|
|
954
|
-
() => id === session.navigation,
|
|
955
|
-
);
|
|
956
|
-
if (filled instanceof Promise && !(await filled)) return false;
|
|
957
|
-
if (progressive) w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
1117
|
+
scripts = fresh.flatMap(scriptsIn);
|
|
1118
|
+
// A box the swap kept still has its offset; scrolling it again would lay out the page before it paints.
|
|
958
1119
|
// 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
|
|
959
1120
|
for (const box of offsets)
|
|
960
|
-
|
|
1121
|
+
if (!box.element.isConnected)
|
|
1122
|
+
sameBox(scope, box)?.scrollTo({
|
|
1123
|
+
top: box.top,
|
|
1124
|
+
left: box.left,
|
|
1125
|
+
behavior: 'instant',
|
|
1126
|
+
});
|
|
1127
|
+
head.show();
|
|
961
1128
|
for (const element of head.stale) element.remove();
|
|
962
1129
|
if (next.title && d.title !== next.title) d.title = next.title;
|
|
1130
|
+
settle();
|
|
963
1131
|
return true;
|
|
964
1132
|
};
|
|
965
|
-
|
|
966
|
-
if (progressive) {
|
|
967
|
-
// The leaving page stays on screen while the next one is laid out a chunk at a time; it then paints in one frame, with no animation.
|
|
968
|
-
let result = false;
|
|
969
|
-
const transition = d.startViewTransition(async () => {
|
|
970
|
-
result = await commit();
|
|
971
|
-
});
|
|
972
|
-
transition.ready.catch(() => {});
|
|
973
|
-
transition.finished.catch(() => {});
|
|
974
|
-
await transition.updateCallbackDone.then(() => transition.skipTransition(), reportAsync);
|
|
975
|
-
committed = result;
|
|
976
|
-
} else committed = await commit();
|
|
1133
|
+
const committed = commit();
|
|
977
1134
|
if (!committed) return;
|
|
1135
|
+
const controller = new AbortController();
|
|
1136
|
+
session.streaming = controller;
|
|
1137
|
+
const host = d as unknown as Record<string, unknown>;
|
|
1138
|
+
const openKey = state.config.streamOpenProperty;
|
|
1139
|
+
const open = fetched.rest
|
|
1140
|
+
? new Promise<void>((done) => {
|
|
1141
|
+
controller.signal.addEventListener('abort', () => done(), { once: true });
|
|
1142
|
+
})
|
|
1143
|
+
: undefined;
|
|
1144
|
+
if (open) host[openKey] = open;
|
|
1145
|
+
else delete host[openKey];
|
|
1146
|
+
const ended = () => {
|
|
1147
|
+
if (open && host[openKey] === open) delete host[openKey];
|
|
1148
|
+
controller.abort();
|
|
1149
|
+
};
|
|
978
1150
|
const ran = runScripts(
|
|
979
1151
|
d,
|
|
980
1152
|
[...head.scripts, ...(scripts as HTMLScriptElement[])],
|
|
@@ -987,29 +1159,36 @@ export async function navigate(
|
|
|
987
1159
|
if (restore === 'reset') {
|
|
988
1160
|
const target = url.hash ? d.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
|
|
989
1161
|
if (target) target.scrollIntoView({ behavior: 'instant' });
|
|
990
|
-
else
|
|
1162
|
+
else w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
991
1163
|
} else if (restore !== 'manual')
|
|
992
1164
|
w.scrollTo({ top: restore.y, left: restore.x, behavior: 'instant' });
|
|
993
1165
|
d.dispatchEvent(new CustomEvent('marklessrouterrendered'));
|
|
994
1166
|
state.rescan();
|
|
995
|
-
const controller = new AbortController();
|
|
996
|
-
session.streaming = controller;
|
|
997
1167
|
const key = state.shown;
|
|
998
|
-
void streamAppends(d, state, fetched, end, controller.signal).then(
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1168
|
+
void streamAppends(d, state, fetched, end, controller.signal, arrived).then(
|
|
1169
|
+
(rest) => {
|
|
1170
|
+
ended();
|
|
1171
|
+
if (rest === undefined) return;
|
|
1172
|
+
state.visited.delete(key);
|
|
1173
|
+
const text = `${fetched.prefix}${state.config.regionEndMarker}${rest}`;
|
|
1174
|
+
state.visited.set(key, text);
|
|
1175
|
+
while (state.visited.size > 8) state.visited.delete(state.visited.keys().next().value!);
|
|
1176
|
+
const parsed = parsedVisits.get(state) ?? new Map<string, FragmentFetched>();
|
|
1177
|
+
parsedVisits.set(state, parsed);
|
|
1178
|
+
// Parsing runs no page code; done at idle, a Back to this page only swaps.
|
|
1179
|
+
const idle = () => {
|
|
1180
|
+
if (state.visited.get(key) === text)
|
|
1181
|
+
parsed.set(key, state.parse(fetched.url, text));
|
|
1182
|
+
for (const other of parsed.keys())
|
|
1183
|
+
if (!state.visited.has(other)) parsed.delete(other);
|
|
1184
|
+
};
|
|
1185
|
+
if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
|
|
1186
|
+
else w.setTimeout(idle, 200);
|
|
1187
|
+
},
|
|
1188
|
+
(error: unknown) => {
|
|
1189
|
+
ended();
|
|
1190
|
+
reportAsync(error);
|
|
1191
|
+
},
|
|
1192
|
+
);
|
|
1014
1193
|
await ran;
|
|
1015
1194
|
}
|