@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.
@@ -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
- export type FragmentRouteCode = Record<string, readonly ModulePreloadInput[]>;
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: Record<string, readonly ModulePreloadInput[]>;
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 && now - existing.at < state.config.prefetchTtlMs) return 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
- if (kind === type && listener && this === target) into.push({ target, type, listener });
267
- return add.call(this, kind, listener, options);
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
- return { stale, scripts };
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
- // Only attributes a server document set are the server's to remove: scripts own the rest (a theme class).
411
- const serverHtmlAttributes = new WeakMap<Document, Set<string>>();
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 root = d.documentElement;
415
- const incoming = next.documentElement;
416
- const served =
417
- serverHtmlAttributes.get(d) ??
418
- ((root as unknown as Record<string, readonly string[] | undefined>)[servedProperty] || []);
419
- for (const name of served)
420
- if (!incoming.hasAttribute(name)) root.removeAttribute(name);
421
- for (const attribute of incoming.attributes)
422
- if (root.getAttribute(attribute.name) !== attribute.value)
423
- root.setAttribute(attribute.name, attribute.value);
424
- serverHtmlAttributes.set(d, new Set(incoming.getAttributeNames()));
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 nodesBefore(d: Document, node: Node): DocumentFragment {
446
- const nodes = d.createDocumentFragment();
447
- while (node.previousSibling) nodes.prepend(node.previousSibling);
448
- return nodes;
449
- }
450
-
451
- function nodesAfter(d: Document, node: Node): DocumentFragment {
452
- const nodes = d.createDocumentFragment();
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
- // The incoming container is a fresh element; attributes a script set on the outgoing one stay.
463
- function carryHostAttributes(
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
- from: Element,
466
- to: Element,
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(to.getAttributeNames()));
471
- for (const attribute of from.attributes)
472
- if (
473
- !to.hasAttribute(attribute.name) &&
474
- !server?.has(attribute.name) &&
475
- !own.includes(attribute.name)
476
- )
477
- to.setAttribute(attribute.name, attribute.value);
478
- }
479
-
480
- // A document app's shell has no runtime, so it is morphed in place: unchanged chrome keeps its nodes.
481
- // Containers are always replaced whole: their DOM is the new page's resume payload's to locate.
482
- function morphChildren(d: Document, from: Node, to: Node, fresh: Element[]): void {
483
- let cursor = from.firstChild;
484
- const skipContainers = () => {
485
- while (cursor && isContainer(cursor)) {
486
- const next: ChildNode | null = cursor.nextSibling;
487
- cursor.remove();
488
- cursor = next;
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
- for (const incoming of Array.from(to.childNodes)) {
492
- skipContainers();
493
- if (
494
- cursor &&
495
- cursor.nodeType === incoming.nodeType &&
496
- cursor.nodeName === incoming.nodeName &&
497
- !isContainer(cursor) &&
498
- !isContainer(incoming)
499
- ) {
500
- const next = cursor.nextSibling;
501
- morphNode(d, cursor, incoming, fresh);
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(incoming);
506
- from.insertBefore(node, cursor);
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 next = cursor.nextSibling;
659
+ while (cursor && cursor !== stop) {
660
+ const after: ChildNode | null = cursor.nextSibling;
511
661
  cursor.remove();
512
- cursor = next;
662
+ cursor = after;
513
663
  }
514
664
  }
515
665
 
516
- function morphNode(d: Document, from: ChildNode, to: ChildNode, fresh: Element[]): void {
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
- if (element.localName === 'script' || element.localName.includes('-')) {
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
- for (const name of element.getAttributeNames())
532
- if (!incoming.hasAttribute(name)) element.removeAttribute(name);
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
- morphChildren(d, element, incoming, fresh);
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
- session.streaming?.abort();
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.assign(href);
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 (id === session.navigation) fallback(url.href);
968
+ if (!stale()) fallback(url.href);
694
969
  return;
695
970
  }
696
- if (id !== session.navigation) return taken.abort();
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 (id !== session.navigation) return taken.abort();
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
- const leaving =
731
- session.current ??
732
- (w.history.state as { __marklessFragment?: { id?: string } } | null)?.__marklessFragment
733
- ?.id ??
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
- state.scrolled.clear();
755
- retire(state, outgoing);
756
- (w as unknown as Record<string, unknown>)[state.config.streamExecutorGlobal] = undefined;
757
- for (const element of d.querySelectorAll(state.config.streamedArmSelector)) element.remove();
758
- state.shown = pageKey(new URL(w.location.href));
759
- if (documentLoads) syncHtmlAttributes(d, next, state.config.servedHtmlAttributesProperty);
760
- let region: Element;
761
- let end: Comment;
762
- let start: Comment | undefined;
763
- let scripts: Element[];
764
- let held: HeldPage | undefined;
765
- if (documentLoads && !isContainer(d.body)) {
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 = d.body;
768
- morphChildren(d, region, incoming, fresh);
769
- scripts = fresh.flatMap(scriptsIn);
770
- region.append((end = d.createComment('')));
771
- } else {
772
- region = d.adoptNode(incoming);
773
- const leavingEnd = pageEnd(outgoing, state.config.pageEndComment);
774
- if (leavingEnd) {
775
- carryHostAttributes(d, outgoing, region, state.config.containerAttributes);
776
- end =
777
- pageEnd(region, state.config.pageEndComment) ??
778
- region.appendChild(d.createComment(state.config.pageEndComment));
779
- // The document's own nodes after the page stay; a document app morphs them to its next shell.
780
- const focused = d.activeElement as HTMLElement | null;
781
- const kept = nodesAfter(d, leavingEnd);
782
- const arriving = nodesAfter(d, end);
783
- const fresh: Element[] = [];
784
- if (documentLoads) morphChildren(d, kept, arriving, fresh);
785
- const leavingStart = pageStart(outgoing, state.config.pageStartComment);
786
- start = pageStart(region, state.config.pageStartComment);
787
- if (leavingStart || start) {
788
- start ??= region.insertBefore(
789
- d.createComment(state.config.pageStartComment),
790
- region.firstChild,
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
- const keptBefore = leavingStart
793
- ? nodesBefore(d, leavingStart)
794
- : d.createDocumentFragment();
795
- const arrivingBefore = nodesBefore(d, start);
796
- if (documentLoads) morphChildren(d, keptBefore, arrivingBefore, fresh);
797
- region.insertBefore(keptBefore, start);
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
- // A scroll target or a restored position needs the whole page in place before it is applied.
814
- const progressive = restore === 'reset' && !url.hash && d.visibilityState !== 'hidden';
815
- const filled = held && fillPage(w, held, progressive ? FRAME_ELEMENTS : Infinity, () => id === session.navigation);
816
- if (filled instanceof Promise) {
817
- w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
818
- if (!(await filled)) return;
819
- }
820
- // 'instant': a `scroll-behavior: smooth` page would otherwise animate every landing.
821
- for (const box of offsets)
822
- sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
823
- for (const element of head.stale) element.remove();
824
- if (next.title && d.title !== next.title) d.title = next.title;
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 if (!(filled instanceof Promise)) w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
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((rest) => {
846
- if (rest === undefined) return;
847
- state.visited.delete(key);
848
- const text = `${fetched.prefix}${state.config.regionEndMarker}${rest}`;
849
- state.visited.set(key, text);
850
- while (state.visited.size > 8) state.visited.delete(state.visited.keys().next().value!);
851
- const parsed = parsedVisits.get(state) ?? new Map<string, FragmentFetched>();
852
- parsedVisits.set(state, parsed);
853
- // Parsing runs no page code; done at idle, a Back to this page only swaps.
854
- const idle = () => {
855
- if (state.visited.get(key) === text) parsed.set(key, state.parse(fetched.url, text));
856
- for (const other of parsed.keys()) if (!state.visited.has(other)) parsed.delete(other);
857
- };
858
- if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
859
- else w.setTimeout(idle, 200);
860
- }, reportAsync);
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
  }