@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.
@@ -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
- 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[]>;
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: Record<string, readonly ModulePreloadInput[]>;
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 && now - existing.at < state.config.prefetchTtlMs) return 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
- if (kind === type && listener && this === target) into.push({ target, type, listener });
268
- 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);
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
- 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
+ );
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 shell <body> the swap morphs in place takes only the server's changes to its attributes.
460
- function syncBodyAttributes(d: Document, next: Document, servedProperty: string): void {
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
- from: HTMLElement,
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(to);
475
- const merged = d.createElement(from.localName);
476
- for (const attribute of from.attributes) merged.setAttribute(attribute.name, attribute.value);
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 nodesBefore(d: Document, node: Node): DocumentFragment {
552
- const nodes = d.createDocumentFragment();
553
- while (node.previousSibling) nodes.prepend(node.previousSibling);
554
- return nodes;
555
- }
556
-
557
- function nodesAfter(d: Document, node: Node): DocumentFragment {
558
- const nodes = d.createDocumentFragment();
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
- // The incoming container is a fresh element; attributes a script set on the outgoing one stay.
569
- 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(
570
601
  d: Document,
571
- from: Element,
572
- to: Element,
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(to.getAttributeNames()));
577
- for (const attribute of from.attributes)
578
- if (
579
- !to.hasAttribute(attribute.name) &&
580
- !server?.has(attribute.name) &&
581
- !own.includes(attribute.name)
582
- )
583
- to.setAttribute(attribute.name, attribute.value);
584
- }
585
-
586
- // A document app's shell has no runtime, so it is morphed in place: unchanged chrome keeps its nodes.
587
- // Containers are always replaced whole: their DOM is the new page's resume payload's to locate.
588
- function morphChildren(d: Document, from: Node, to: Node, fresh: Element[]): void {
589
- let cursor = from.firstChild;
590
- const skipContainers = () => {
591
- while (cursor && isContainer(cursor)) {
592
- const next: ChildNode | null = cursor.nextSibling;
593
- cursor.remove();
594
- 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;
595
640
  }
596
- };
597
- for (const incoming of Array.from(to.childNodes)) {
598
- skipContainers();
599
- if (
600
- cursor &&
601
- cursor.nodeType === incoming.nodeType &&
602
- cursor.nodeName === incoming.nodeName &&
603
- !isContainer(cursor) &&
604
- !isContainer(incoming)
605
- ) {
606
- const next = cursor.nextSibling;
607
- morphNode(d, cursor, incoming, fresh);
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(incoming);
612
- from.insertBefore(node, cursor);
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 next = cursor.nextSibling;
659
+ while (cursor && cursor !== stop) {
660
+ const after: ChildNode | null = cursor.nextSibling;
617
661
  cursor.remove();
618
- cursor = next;
662
+ cursor = after;
619
663
  }
620
664
  }
621
665
 
622
- 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 {
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
- 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
+ ) {
632
712
  const node = d.adoptNode(incoming);
633
713
  element.replaceWith(node);
634
714
  fresh.push(node);
635
715
  return;
636
716
  }
637
- for (const name of element.getAttributeNames())
638
- 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);
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
- 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);
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
- 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();
798
954
  const fallback = (href: string) => {
799
955
  if (mode === 'traverse') w.location.reload();
800
- 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();
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 (id === session.navigation) fallback(url.href);
968
+ if (!stale()) fallback(url.href);
808
969
  return;
809
970
  }
810
- 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();
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 (id !== session.navigation) return taken.abort();
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
- const leaving =
845
- session.current ??
846
- (w.history.state as { __marklessFragment?: { id?: string } } | null)?.__marklessFragment
847
- ?.id ??
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 = async (): Promise<boolean> => {
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
- if (documentLoads && !isContainer(d.body))
888
- syncBodyAttributes(d, next, state.config.servedBodyAttributesProperty);
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
- const fresh: Element[] = [];
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
- region = d.adoptNode(incoming);
1058
+ const own = state.config.containerAttributes;
899
1059
  if (outgoing === d.body)
900
- carryBodyAttributes(
901
- d,
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
- if (outgoing !== d.body)
910
- carryHostAttributes(d, outgoing, region, state.config.containerAttributes);
911
- end =
912
- pageEnd(region, state.config.pageEndComment) ??
913
- region.appendChild(d.createComment(state.config.pageEndComment));
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
- region.firstChild,
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
- const keptBefore = leavingStart
928
- ? nodesBefore(d, leavingStart)
929
- : d.createDocumentFragment();
930
- const arrivingBefore = nodesBefore(d, start);
931
- if (documentLoads) morphChildren(d, keptBefore, arrivingBefore, fresh);
932
- region.insertBefore(keptBefore, start);
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
- scripts = scriptsIn(region);
943
- held = holdPage(region, undefined, null);
944
- outgoing.replaceWith(region);
945
- region.after((end = d.createComment('')));
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
- const filled =
949
- held &&
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
- sameBox(scope, box)?.scrollTo({ top: box.top, left: box.left, behavior: 'instant' });
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
- let committed: boolean;
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 if (!progressive) w.scrollTo({ top: 0, left: 0, behavior: 'instant' });
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((rest) => {
999
- if (rest === undefined) return;
1000
- state.visited.delete(key);
1001
- const text = `${fetched.prefix}${state.config.regionEndMarker}${rest}`;
1002
- state.visited.set(key, text);
1003
- while (state.visited.size > 8) state.visited.delete(state.visited.keys().next().value!);
1004
- const parsed = parsedVisits.get(state) ?? new Map<string, FragmentFetched>();
1005
- parsedVisits.set(state, parsed);
1006
- // Parsing runs no page code; done at idle, a Back to this page only swaps.
1007
- const idle = () => {
1008
- if (state.visited.get(key) === text) parsed.set(key, state.parse(fetched.url, text));
1009
- for (const other of parsed.keys()) if (!state.visited.has(other)) parsed.delete(other);
1010
- };
1011
- if (w.requestIdleCallback) w.requestIdleCallback(idle, { timeout: 2000 });
1012
- else w.setTimeout(idle, 200);
1013
- }, 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
+ );
1014
1193
  await ran;
1015
1194
  }