@markless/router 0.8.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-BsYTWdxi.js → create-server-entry-BnaEfDHl.js} +311 -66
- package/dist/index.js +2 -2
- package/dist/{request-locals-BDAhNpEa.js → navigation-timing-CzH6VaBR.js} +4 -2
- 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 +190 -7
- package/dist/vite/runtime/mdx-route.js +125 -13
- package/dist/vite.js +411 -62
- package/package.json +5 -5
- package/src/vite/entries/fragment-entry.ts +570 -238
- 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
|
@@ -10,9 +10,13 @@ export interface FragmentNavigationConfig {
|
|
|
10
10
|
readonly scrollAttribute: string;
|
|
11
11
|
readonly routeScriptType: string;
|
|
12
12
|
readonly servedHtmlAttributesProperty: string;
|
|
13
|
+
readonly servedBodyAttributesProperty: string;
|
|
13
14
|
readonly retireProperty: string;
|
|
14
15
|
readonly streamExecutorGlobal: string;
|
|
15
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;
|
|
16
20
|
readonly requestPrefix: string;
|
|
17
21
|
readonly regionEndMarker: string;
|
|
18
22
|
readonly appendMarker: string;
|
|
@@ -21,6 +25,7 @@ export interface FragmentNavigationConfig {
|
|
|
21
25
|
readonly pageStartComment: string;
|
|
22
26
|
// The container's own attributes, never carried from the leaving page's element to the next one.
|
|
23
27
|
readonly containerAttributes: readonly [container: string, paths: string];
|
|
28
|
+
readonly keepAttributes: readonly [keep: string, own: string];
|
|
24
29
|
// 'document' apps swap the whole body: their shell renders from the request URL.
|
|
25
30
|
readonly documentLoads: boolean;
|
|
26
31
|
// A mouse fetches after resting this long on a link; press, focus and touch fetch at once.
|
|
@@ -32,6 +37,8 @@ export interface FragmentNavigationConfig {
|
|
|
32
37
|
readonly endpointPrefixes: readonly string[];
|
|
33
38
|
// At most this many visible-link fragments are fetched at idle on fast connections, in document order; 0 keeps HTML to intent only.
|
|
34
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;
|
|
35
42
|
readonly idleWithoutConnectionInfo: boolean;
|
|
36
43
|
// false: nothing is fetched before a click.
|
|
37
44
|
readonly prefetch: boolean;
|
|
@@ -39,7 +46,9 @@ export interface FragmentNavigationConfig {
|
|
|
39
46
|
readonly pages: readonly string[];
|
|
40
47
|
}
|
|
41
48
|
|
|
42
|
-
|
|
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[]>;
|
|
43
52
|
|
|
44
53
|
export type FragmentFetched = {
|
|
45
54
|
readonly url: string;
|
|
@@ -51,6 +60,10 @@ export type FragmentFetched = {
|
|
|
51
60
|
|
|
52
61
|
export type FragmentPrefetch = {
|
|
53
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;
|
|
54
67
|
readonly result: Promise<FragmentFetched>;
|
|
55
68
|
// The response is still arriving, so it holds a connection.
|
|
56
69
|
open: boolean;
|
|
@@ -60,9 +73,12 @@ export type FragmentPrefetch = {
|
|
|
60
73
|
// What the inline bootstrap leaves on the document for the swap module (entries/fragment-entry.ts).
|
|
61
74
|
export type FragmentNavigationState = {
|
|
62
75
|
readonly config: FragmentNavigationConfig;
|
|
63
|
-
readonly routes:
|
|
76
|
+
readonly routes: FragmentRouteCode;
|
|
64
77
|
readonly prefetches: Map<string, FragmentPrefetch>;
|
|
65
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;
|
|
66
82
|
shown: string;
|
|
67
83
|
readonly eligible: (anchor: Element | null | undefined, forClick?: boolean) => URL | undefined;
|
|
68
84
|
readonly preloadCode: (url: URL, low: boolean) => void;
|
|
@@ -98,6 +114,8 @@ type Session = {
|
|
|
98
114
|
streaming?: AbortController;
|
|
99
115
|
// The history entry on screen, and the last scroll offset seen for every entry this tab visited.
|
|
100
116
|
current?: string;
|
|
117
|
+
// An entry a navigation pushed whose page has not swapped in yet: the next navigation replaces it.
|
|
118
|
+
unshown?: string;
|
|
101
119
|
readonly positions: Map<string, { readonly x: number; readonly y: number }>;
|
|
102
120
|
};
|
|
103
121
|
|
|
@@ -160,12 +178,13 @@ function take(
|
|
|
160
178
|
parsedVisits.get(state)?.delete(key);
|
|
161
179
|
return {
|
|
162
180
|
at: now,
|
|
181
|
+
writes: state.writes,
|
|
163
182
|
result: Promise.resolve(ready ?? state.parse(url.href, cached)),
|
|
164
183
|
open: false,
|
|
165
184
|
abort: () => {},
|
|
166
185
|
};
|
|
167
186
|
}
|
|
168
|
-
if (existing &&
|
|
187
|
+
if (existing && state.fresh(existing, now)) return existing;
|
|
169
188
|
existing?.abort();
|
|
170
189
|
state.preloadCode(url, false);
|
|
171
190
|
return state.request(url, false);
|
|
@@ -258,13 +277,15 @@ function captureLoadListeners(d: Document, w: Window, into: LoadListener[]): ()
|
|
|
258
277
|
const own = Object.getOwnPropertyDescriptor(target, 'addEventListener');
|
|
259
278
|
const add = target.addEventListener;
|
|
260
279
|
target.addEventListener = function (
|
|
261
|
-
this: EventTarget,
|
|
280
|
+
this: EventTarget | undefined,
|
|
262
281
|
kind: string,
|
|
263
282
|
listener: EventListenerOrEventListenerObject | null,
|
|
264
283
|
options?: boolean | AddEventListenerOptions,
|
|
265
284
|
) {
|
|
266
|
-
|
|
267
|
-
|
|
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);
|
|
268
289
|
};
|
|
269
290
|
return () => {
|
|
270
291
|
if (own) Object.defineProperty(target, 'addEventListener', own);
|
|
@@ -381,12 +402,18 @@ async function mergeHead(d: Document, next: Document) {
|
|
|
381
402
|
else current.set(element.outerHTML, [element]);
|
|
382
403
|
}
|
|
383
404
|
const stylesheets: HTMLLinkElement[] = [];
|
|
405
|
+
const hidden: [Element, string | null][] = [];
|
|
384
406
|
const scripts: HTMLScriptElement[] = [];
|
|
385
407
|
let cursor: Element | null = null;
|
|
386
408
|
for (const element of Array.from(next.head.children)) {
|
|
387
409
|
const reused = current.get(element.outerHTML)?.shift();
|
|
388
410
|
const node = reused ?? d.adoptNode(element);
|
|
389
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
|
+
}
|
|
390
417
|
if (cursor) cursor.after(node);
|
|
391
418
|
else d.head.prepend(node);
|
|
392
419
|
if (node.localName === 'link' && (node as HTMLLinkElement).rel === 'stylesheet')
|
|
@@ -404,24 +431,135 @@ async function mergeHead(d: Document, next: Document) {
|
|
|
404
431
|
else if ((element as HTMLScriptElement).type !== 'importmap') element.remove();
|
|
405
432
|
}
|
|
406
433
|
await loaded(d.defaultView!, stylesheets);
|
|
407
|
-
|
|
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
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// A swap applies only what the server changed between its documents; a script's own changes (a theme class) stay.
|
|
450
|
+
type ServedAttributes = ReadonlyMap<string, string>;
|
|
451
|
+
const servedAttributes = {
|
|
452
|
+
html: new WeakMap<Document, ServedAttributes>(),
|
|
453
|
+
body: new WeakMap<Document, ServedAttributes>(),
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const attributePairs = (element: Element): ServedAttributes =>
|
|
457
|
+
new Map(Array.from(element.attributes, (attribute) => [attribute.name, attribute.value]));
|
|
458
|
+
|
|
459
|
+
// What the served document gave <html> or <body>: the last swap's incoming, else the landing's own record.
|
|
460
|
+
function servedBefore(
|
|
461
|
+
d: Document,
|
|
462
|
+
which: 'html' | 'body',
|
|
463
|
+
servedProperty: string,
|
|
464
|
+
): ServedAttributes {
|
|
465
|
+
return (
|
|
466
|
+
servedAttributes[which].get(d) ??
|
|
467
|
+
new Map(
|
|
468
|
+
(
|
|
469
|
+
d.documentElement as unknown as Record<
|
|
470
|
+
string,
|
|
471
|
+
readonly [string, string][] | undefined
|
|
472
|
+
>
|
|
473
|
+
)[servedProperty],
|
|
474
|
+
)
|
|
475
|
+
);
|
|
408
476
|
}
|
|
409
477
|
|
|
410
|
-
|
|
411
|
-
|
|
478
|
+
function applyServedChanges(
|
|
479
|
+
d: Document,
|
|
480
|
+
target: HTMLElement,
|
|
481
|
+
served: ServedAttributes,
|
|
482
|
+
incoming: ServedAttributes,
|
|
483
|
+
): void {
|
|
484
|
+
for (const name of new Set([...served.keys(), ...incoming.keys()])) {
|
|
485
|
+
const before = served.get(name) ?? null;
|
|
486
|
+
const after = incoming.get(name) ?? null;
|
|
487
|
+
if (before !== after) applyServedChange(d, target, name, before, after);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
412
490
|
|
|
413
491
|
function syncHtmlAttributes(d: Document, next: Document, servedProperty: string): void {
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
492
|
+
const incoming = attributePairs(next.documentElement);
|
|
493
|
+
applyServedChanges(d, d.documentElement, servedBefore(d, 'html', servedProperty), incoming);
|
|
494
|
+
servedAttributes.html.set(d, incoming);
|
|
495
|
+
}
|
|
496
|
+
|
|
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(
|
|
499
|
+
d: Document,
|
|
500
|
+
served: Element,
|
|
501
|
+
servedProperty: string,
|
|
502
|
+
own: readonly string[] = [],
|
|
503
|
+
): void {
|
|
504
|
+
const incoming = attributePairs(served);
|
|
505
|
+
applyServedChanges(d, d.body, servedBefore(d, 'body', servedProperty), incoming);
|
|
506
|
+
syncOwnAttributes(d.body, served, own);
|
|
507
|
+
servedAttributes.body.set(d, incoming);
|
|
508
|
+
}
|
|
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
|
+
|
|
518
|
+
// class and style are merged a token and a declaration at a time, so a script's own tokens are never dropped.
|
|
519
|
+
function applyServedChange(
|
|
520
|
+
d: Document,
|
|
521
|
+
root: HTMLElement,
|
|
522
|
+
name: string,
|
|
523
|
+
before: string | null,
|
|
524
|
+
after: string | null,
|
|
525
|
+
): void {
|
|
526
|
+
if (name === 'class') {
|
|
527
|
+
const was = classTokens(before);
|
|
528
|
+
const will = classTokens(after);
|
|
529
|
+
const removed = [...was].filter((token) => !will.has(token));
|
|
530
|
+
const added = [...will].filter((token) => !was.has(token));
|
|
531
|
+
if (removed.length) root.classList.remove(...removed);
|
|
532
|
+
if (added.length) root.classList.add(...added);
|
|
533
|
+
} else if (name === 'style') {
|
|
534
|
+
const was = declarations(d, before);
|
|
535
|
+
const will = declarations(d, after);
|
|
536
|
+
for (const property of was.keys())
|
|
537
|
+
if (!will.has(property)) root.style.removeProperty(property);
|
|
538
|
+
for (const [property, [value, priority]] of will) {
|
|
539
|
+
const old = was.get(property);
|
|
540
|
+
if (old?.[0] !== value || old[1] !== priority)
|
|
541
|
+
root.style.setProperty(property, value, priority);
|
|
542
|
+
}
|
|
543
|
+
} else if (after === null) root.removeAttribute(name);
|
|
544
|
+
else root.setAttribute(name, after);
|
|
545
|
+
if (after === null && !root.getAttribute(name)?.trim()) root.removeAttribute(name);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const classTokens = (value: string | null): Set<string> =>
|
|
549
|
+
new Set(value?.split(/\s+/).filter(Boolean));
|
|
550
|
+
|
|
551
|
+
function declarations(d: Document, value: string | null): Map<string, [string, string]> {
|
|
552
|
+
const style = d.createElement('div').style;
|
|
553
|
+
style.cssText = value ?? '';
|
|
554
|
+
const result = new Map<string, [string, string]>();
|
|
555
|
+
for (let index = 0; index < style.length; index++) {
|
|
556
|
+
const property = style.item(index);
|
|
557
|
+
result.set(property, [
|
|
558
|
+
style.getPropertyValue(property),
|
|
559
|
+
style.getPropertyPriority(property),
|
|
560
|
+
]);
|
|
561
|
+
}
|
|
562
|
+
return result;
|
|
425
563
|
}
|
|
426
564
|
|
|
427
565
|
// Set from the page's config at the start of every swap.
|
|
@@ -442,15 +580,14 @@ function pageStart(host: Node, data: string): Comment | undefined {
|
|
|
442
580
|
return undefined;
|
|
443
581
|
}
|
|
444
582
|
|
|
445
|
-
function
|
|
446
|
-
const nodes =
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
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);
|
|
454
591
|
return nodes;
|
|
455
592
|
}
|
|
456
593
|
|
|
@@ -459,86 +596,195 @@ const scriptsIn = (element: Element): Element[] =>
|
|
|
459
596
|
|
|
460
597
|
const serverHostAttributes = new WeakMap<Document, Set<string>>();
|
|
461
598
|
|
|
462
|
-
//
|
|
463
|
-
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(
|
|
464
601
|
d: Document,
|
|
465
|
-
|
|
466
|
-
|
|
602
|
+
host: Element,
|
|
603
|
+
served: Element,
|
|
467
604
|
own: readonly string[],
|
|
468
605
|
): void {
|
|
469
606
|
const server = serverHostAttributes.get(d);
|
|
470
|
-
serverHostAttributes.set(d, new Set(
|
|
471
|
-
for (const
|
|
472
|
-
if (
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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;
|
|
489
640
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
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;
|
|
503
653
|
continue;
|
|
504
654
|
}
|
|
505
|
-
const node = d.adoptNode(
|
|
506
|
-
|
|
655
|
+
const node = d.adoptNode(next);
|
|
656
|
+
parent.insertBefore(node, cursor && cursor !== stop ? cursor : stop);
|
|
507
657
|
if (node.nodeType === 1) fresh.push(node as Element);
|
|
508
658
|
}
|
|
509
|
-
while (cursor) {
|
|
510
|
-
const
|
|
659
|
+
while (cursor && cursor !== stop) {
|
|
660
|
+
const after: ChildNode | null = cursor.nextSibling;
|
|
511
661
|
cursor.remove();
|
|
512
|
-
cursor =
|
|
662
|
+
cursor = after;
|
|
513
663
|
}
|
|
514
664
|
}
|
|
515
665
|
|
|
516
|
-
|
|
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 {
|
|
517
697
|
if (from.nodeType !== 1) {
|
|
518
698
|
if ((from as CharacterData).data !== (to as CharacterData).data)
|
|
519
699
|
(from as CharacterData).data = (to as CharacterData).data;
|
|
520
700
|
return;
|
|
521
701
|
}
|
|
522
|
-
if (from.isEqualNode(to)) return;
|
|
523
702
|
const element = from as Element;
|
|
524
703
|
const incoming = to as Element;
|
|
525
|
-
|
|
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
|
+
) {
|
|
526
712
|
const node = d.adoptNode(incoming);
|
|
527
713
|
element.replaceWith(node);
|
|
528
714
|
fresh.push(node);
|
|
529
715
|
return;
|
|
530
716
|
}
|
|
531
|
-
|
|
532
|
-
|
|
717
|
+
if (!page && element.isEqualNode(incoming)) return;
|
|
718
|
+
for (const attribute of element.getAttributeNames())
|
|
719
|
+
if (!incoming.hasAttribute(attribute)) element.removeAttribute(attribute);
|
|
533
720
|
for (const attribute of incoming.attributes)
|
|
534
721
|
if (element.getAttribute(attribute.name) !== attribute.value)
|
|
535
722
|
element.setAttribute(attribute.name, attribute.value);
|
|
536
|
-
|
|
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);
|
|
537
782
|
}
|
|
538
783
|
|
|
539
784
|
// Scrolled boxes (a sidebar) keep their offset when the same box is there after the swap:
|
|
540
785
|
// at its old place in the tree, or else by its id when the next page wraps it differently.
|
|
541
786
|
type ScrolledBox = {
|
|
787
|
+
readonly element: Element;
|
|
542
788
|
readonly path: string | undefined;
|
|
543
789
|
readonly id: string;
|
|
544
790
|
readonly kind: string;
|
|
@@ -577,6 +823,52 @@ function sameBox(root: Element, box: ScrolledBox): Element | undefined {
|
|
|
577
823
|
return named && root.contains(named) && boxKind(named) === box.kind ? named : undefined;
|
|
578
824
|
}
|
|
579
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
|
+
|
|
580
872
|
// Streamed @pending arms arrive after the region, one delimited piece each, and run exactly as on a landing.
|
|
581
873
|
async function streamAppends(
|
|
582
874
|
d: Document,
|
|
@@ -584,6 +876,7 @@ async function streamAppends(
|
|
|
584
876
|
fetched: FragmentFetched,
|
|
585
877
|
end: Node,
|
|
586
878
|
signal: AbortSignal,
|
|
879
|
+
arrived: boolean,
|
|
587
880
|
): Promise<string | undefined> {
|
|
588
881
|
const marker = state.config.appendMarker;
|
|
589
882
|
const decoder = new TextDecoder();
|
|
@@ -617,57 +910,15 @@ async function streamAppends(
|
|
|
617
910
|
}
|
|
618
911
|
}
|
|
619
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?.();
|
|
620
918
|
if ((end as Comment).data !== state.config.pageEndComment) end.parentNode?.removeChild(end);
|
|
621
919
|
return signal.aborted ? undefined : all;
|
|
622
920
|
}
|
|
623
921
|
|
|
624
|
-
// Inserting a big page whole lays it out in one long frame; the HTML parser yields while it streams a document, and the swap does the same.
|
|
625
|
-
const FRAME_ELEMENTS = 800;
|
|
626
|
-
|
|
627
|
-
type HeldPage = [parent: Node, before: Node | null, nodes: ChildNode[]][];
|
|
628
|
-
|
|
629
|
-
function holdPage(parent: Element, after: Node | undefined, before: Node | null): HeldPage {
|
|
630
|
-
const nodes: ChildNode[] = [];
|
|
631
|
-
for (let node = after ? after.nextSibling : parent.firstChild; node && node !== before; ) {
|
|
632
|
-
const next: ChildNode | null = node.nextSibling;
|
|
633
|
-
nodes.push(node);
|
|
634
|
-
node.remove();
|
|
635
|
-
node = next;
|
|
636
|
-
}
|
|
637
|
-
return [[parent, before, nodes]];
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const elementsIn = (node: Node) =>
|
|
641
|
-
node.nodeType === 1 ? (node as Element).getElementsByTagName('*').length + 1 : 0;
|
|
642
|
-
|
|
643
|
-
// Places a frame's worth synchronously, so the first paint shows the top of the page; true when everything is in.
|
|
644
|
-
function fillPage(
|
|
645
|
-
w: Window,
|
|
646
|
-
held: HeldPage,
|
|
647
|
-
budget: number,
|
|
648
|
-
current: () => boolean,
|
|
649
|
-
): true | Promise<boolean> {
|
|
650
|
-
let used = 0;
|
|
651
|
-
while (held.length) {
|
|
652
|
-
const [parent, before, nodes] = held[0]!;
|
|
653
|
-
const node = nodes.shift()!;
|
|
654
|
-
if (!nodes.length) held.shift();
|
|
655
|
-
const size = elementsIn(node);
|
|
656
|
-
if (used + size > budget && node.firstChild) {
|
|
657
|
-
const children = [...node.childNodes];
|
|
658
|
-
(node as Element).replaceChildren();
|
|
659
|
-
held.unshift([node, null, children]);
|
|
660
|
-
used += 1;
|
|
661
|
-
} else used += size;
|
|
662
|
-
parent.insertBefore(node, before);
|
|
663
|
-
if (used >= budget && held.length)
|
|
664
|
-
return new Promise<void>((resolve) =>
|
|
665
|
-
w.requestAnimationFrame(() => w.setTimeout(resolve, 0)),
|
|
666
|
-
).then(() => current() && fillPage(w, held, budget, current));
|
|
667
|
-
}
|
|
668
|
-
return true;
|
|
669
|
-
}
|
|
670
|
-
|
|
671
922
|
export async function navigate(
|
|
672
923
|
d: Document,
|
|
673
924
|
url: URL,
|
|
@@ -680,20 +931,55 @@ export async function navigate(
|
|
|
680
931
|
containerAttribute = state.config.containerAttributes[0];
|
|
681
932
|
const session = sessionFor(d);
|
|
682
933
|
const id = ++session.navigation;
|
|
683
|
-
|
|
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();
|
|
684
954
|
const fallback = (href: string) => {
|
|
685
955
|
if (mode === 'traverse') w.location.reload();
|
|
686
|
-
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();
|
|
687
963
|
};
|
|
688
|
-
const taken = take(state, url, mode === 'traverse', w.performance.now());
|
|
689
964
|
let fetched: FragmentFetched;
|
|
690
965
|
try {
|
|
691
966
|
fetched = await taken.result;
|
|
692
967
|
} catch {
|
|
693
|
-
if (
|
|
968
|
+
if (!stale()) fallback(url.href);
|
|
694
969
|
return;
|
|
695
970
|
}
|
|
696
|
-
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();
|
|
697
983
|
// A prefetch is shared by every link to the page, so the address takes the clicked link's #fragment.
|
|
698
984
|
const landed = new URL(fetched.url);
|
|
699
985
|
landed.hash = url.hash;
|
|
@@ -719,7 +1005,7 @@ export async function navigate(
|
|
|
719
1005
|
const ranFiles = filesRan(d);
|
|
720
1006
|
const head = await mergeHead(d, next);
|
|
721
1007
|
await Promise.race([Promise.all(code), new Promise((resolve) => w.setTimeout(resolve, 3000))]);
|
|
722
|
-
if (
|
|
1008
|
+
if (stale()) return abandon();
|
|
723
1009
|
let restore = scroll;
|
|
724
1010
|
if (mode === 'traverse') {
|
|
725
1011
|
const key = (typeof scroll === 'object' ? scroll.id : undefined) ?? LANDING_ENTRY;
|
|
@@ -727,101 +1013,140 @@ export async function navigate(
|
|
|
727
1013
|
if (saved) restore = saved;
|
|
728
1014
|
session.current = key;
|
|
729
1015
|
} else {
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
LANDING_ENTRY;
|
|
735
|
-
session.positions.set(leaving, { x: w.scrollX, y: w.scrollY });
|
|
736
|
-
w.history.scrollRestoration = 'manual';
|
|
737
|
-
session.current = entryId();
|
|
738
|
-
const entry = { __marklessFragment: { id: session.current, x: 0, y: 0 } };
|
|
739
|
-
if (mode === 'push') w.history.pushState(entry, '', landed.href);
|
|
740
|
-
else w.history.replaceState(entry, '', landed.href);
|
|
741
|
-
}
|
|
742
|
-
const offsets: ScrolledBox[] = [];
|
|
743
|
-
for (const element of state.scrolled) {
|
|
744
|
-
if (!element.isConnected || !scope.contains(element)) continue;
|
|
745
|
-
if (element.scrollTop || element.scrollLeft)
|
|
746
|
-
offsets.push({
|
|
747
|
-
path: elementPath(scope, element),
|
|
748
|
-
id: element.id,
|
|
749
|
-
kind: boxKind(element),
|
|
750
|
-
top: element.scrollTop,
|
|
751
|
-
left: element.scrollLeft,
|
|
752
|
-
});
|
|
1016
|
+
session.current = entered;
|
|
1017
|
+
session.unshown = undefined;
|
|
1018
|
+
if (landed.href !== w.location.href)
|
|
1019
|
+
w.history.replaceState(w.history.state, '', landed.href);
|
|
753
1020
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
1021
|
+
let region!: Element;
|
|
1022
|
+
let end!: Comment;
|
|
1023
|
+
let scripts: Element[] = [];
|
|
1024
|
+
const commit = (): boolean => {
|
|
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 ?? ['', ''];
|
|
1030
|
+
const offsets: ScrolledBox[] = [];
|
|
1031
|
+
for (const element of state.scrolled) {
|
|
1032
|
+
if (!element.isConnected || !scope.contains(element)) continue;
|
|
1033
|
+
if (element.scrollTop || element.scrollLeft)
|
|
1034
|
+
offsets.push({
|
|
1035
|
+
element,
|
|
1036
|
+
path: elementPath(scope, element),
|
|
1037
|
+
id: element.id,
|
|
1038
|
+
kind: boxKind(element),
|
|
1039
|
+
top: element.scrollTop,
|
|
1040
|
+
left: element.scrollLeft,
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
state.scrolled.clear();
|
|
1044
|
+
retire(state, outgoing);
|
|
1045
|
+
forgetRuntimeState(outgoing);
|
|
1046
|
+
(w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
|
|
1047
|
+
for (const element of d.querySelectorAll(state.config.streamedArmSelector))
|
|
1048
|
+
element.remove();
|
|
1049
|
+
state.shown = pageKey(new URL(w.location.href));
|
|
1050
|
+
if (documentLoads) syncHtmlAttributes(d, next, state.config.servedHtmlAttributesProperty);
|
|
766
1051
|
const fresh: Element[] = [];
|
|
767
|
-
region =
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
1052
|
+
region = outgoing;
|
|
1053
|
+
if (documentLoads && !isContainer(d.body)) {
|
|
1054
|
+
syncBodyAttributes(d, next.body, state.config.servedBodyAttributesProperty);
|
|
1055
|
+
morphChildren(d, region, incoming, fresh);
|
|
1056
|
+
region.append((end = d.createComment('')));
|
|
1057
|
+
} else {
|
|
1058
|
+
const own = state.config.containerAttributes;
|
|
1059
|
+
if (outgoing === d.body)
|
|
1060
|
+
syncBodyAttributes(d, incoming, state.config.servedBodyAttributesProperty, own);
|
|
1061
|
+
else syncHostAttributes(d, outgoing, incoming, own);
|
|
1062
|
+
const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
|
|
1063
|
+
if (leavingEnd) {
|
|
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(
|
|
1069
|
+
d.createComment(state.config.pageStartComment),
|
|
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,
|
|
791
1080
|
);
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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,
|
|
1092
|
+
);
|
|
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
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
} else {
|
|
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('')));
|
|
798
1115
|
}
|
|
799
|
-
scripts = [...scriptsIn(region), ...fresh.flatMap(scriptsIn)];
|
|
800
|
-
region.append(kept);
|
|
801
|
-
held = holdPage(region, start, end);
|
|
802
|
-
outgoing.replaceWith(region);
|
|
803
|
-
// The document's own nodes moved to the new element; focus inside them stays put.
|
|
804
|
-
if (focused && focused !== d.activeElement && region.contains(focused))
|
|
805
|
-
focused.focus({ preventScroll: true });
|
|
806
|
-
} else {
|
|
807
|
-
scripts = scriptsIn(region);
|
|
808
|
-
held = holdPage(region, undefined, null);
|
|
809
|
-
outgoing.replaceWith(region);
|
|
810
|
-
region.after((end = d.createComment('')));
|
|
811
1116
|
}
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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.
|
|
1119
|
+
// 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
|
|
1120
|
+
for (const box of offsets)
|
|
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();
|
|
1128
|
+
for (const element of head.stale) element.remove();
|
|
1129
|
+
if (next.title && d.title !== next.title) d.title = next.title;
|
|
1130
|
+
settle();
|
|
1131
|
+
return true;
|
|
1132
|
+
};
|
|
1133
|
+
const committed = commit();
|
|
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
|
+
};
|
|
825
1150
|
const ran = runScripts(
|
|
826
1151
|
d,
|
|
827
1152
|
[...head.scripts, ...(scripts as HTMLScriptElement[])],
|
|
@@ -834,29 +1159,36 @@ export async function navigate(
|
|
|
834
1159
|
if (restore === 'reset') {
|
|
835
1160
|
const target = url.hash ? d.getElementById(decodeURIComponent(url.hash.slice(1))) : null;
|
|
836
1161
|
if (target) target.scrollIntoView({ behavior: 'instant' });
|
|
837
|
-
else
|
|
1162
|
+
else w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
|
838
1163
|
} else if (restore !== 'manual')
|
|
839
1164
|
w.scrollTo({ top: restore.y, left: restore.x, behavior: 'instant' });
|
|
840
1165
|
d.dispatchEvent(new CustomEvent('marklessrouterrendered'));
|
|
841
1166
|
state.rescan();
|
|
842
|
-
const controller = new AbortController();
|
|
843
|
-
session.streaming = controller;
|
|
844
1167
|
const key = state.shown;
|
|
845
|
-
void streamAppends(d, state, fetched, end, controller.signal).then(
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
+
);
|
|
861
1193
|
await ran;
|
|
862
1194
|
}
|