fauna-api 1.0.759 → 1.0.771

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.
@@ -144,9 +144,10 @@ export function serializeDocumentStyles(doc = document) {
144
144
  function ownerDocOf(el) {
145
145
  return el.ownerDocument || document;
146
146
  }
147
- // Framed documents are inlined into their host page. Their html, head, and body
148
- // become divs so the viewer's HTML parser keeps the same tree the recorder walks,
149
- // which keeps synthetic ids aligned across frames.
147
+ // Framed documents are inlined into their host page's copy, and the viewer replays each as a
148
+ // nested frame. Serialized as HTML, their html, head, and body become divs marked
149
+ // `data-fauna-frame`, since a parser keeps no document inside another; recorded as nodes they
150
+ // keep their own tags.
150
151
  const FRAME_TAGS = new Set(["IFRAME", "FRAME", "OBJECT", "EMBED"]);
151
152
  const FRAME_SKIP_ATTRS = new Set(["src", "srcdoc", "sandbox", "allow", "data"]);
152
153
  const FRAME_PLACEHOLDER_TEXT = "Embedded frame unavailable in share";
@@ -224,11 +225,8 @@ export function deepFreezeClone(source) {
224
225
  }
225
226
  catch { /* skip */ }
226
227
  }
227
- if (frameRoot) {
228
+ if (frameRoot)
228
229
  clone.setAttribute("data-fauna-frame", frameRoot);
229
- if (frameRoot === "head")
230
- clone.setAttribute("hidden", "");
231
- }
232
230
  // A framed document's inputs aren't this window's HTMLInputElement, so go by tag.
233
231
  if (el.tagName === "INPUT" && el.type === "password") {
234
232
  clone.setAttribute("value", "");
@@ -539,13 +537,7 @@ export class DomRecorder {
539
537
  if (childId !== null)
540
538
  children.push(childId);
541
539
  }
542
- const frameRoot = frameRootTag(el);
543
- if (frameRoot) {
544
- attributes["data-fauna-frame"] = frameRoot;
545
- if (frameRoot === "head")
546
- attributes.hidden = "";
547
- }
548
- into[id] = { id, kind: "element", tag: frameRoot ? "div" : el.tagName.toLowerCase(), attributes, children };
540
+ into[id] = { id, kind: "element", tag: el.localName, attributes, children };
549
541
  return id;
550
542
  }
551
543
  /** The one child a frame's wrapper holds: its document root, or a placeholder text. */
@@ -697,39 +689,25 @@ export class DomRecorder {
697
689
  this.detachResize = null;
698
690
  }
699
691
  }
700
- /** An iframe's own defaults, for the divs inlined frames become. A framed document inherits
701
- * nothing from the page around it, so its html starts from initial values. */
702
- const FRAME_BOX_CSS = `:where([data-fauna-iframe]) { display: inline-block; width: 300px; height: 150px; border: 2px inset; overflow: hidden; vertical-align: baseline }
703
- :where([data-fauna-iframe]) > :where([data-fauna-frame="html"]) { all: initial; display: block; width: 100%; height: 100%; overflow: auto }`;
704
- const FRAME_ELEMENT_SELECTOR = /(^|[\s>+~,(])i?frame(?=$|[\s>+~,.#:[)])/gi;
705
- const FRAME_ROOT_SELECTOR = /(^|[\s>+~,(])(?:html|:root)(?=$|[\s>+~,.#:[)])/g;
706
- const FRAME_BODY_SELECTOR = /(^|[\s>+~,(])body(?=$|[\s>+~,.#:[)])/g;
707
- /** A framed document's rule for inside `@scope` at its html div, with its root and body
708
- * pointed at the divs standing in for them. Null for rules `@scope` can't hold
709
- * (fonts, keyframes, imports), which stay where they are. */
710
- function frameScopedRule(rule) {
711
- const text = rule.cssText;
712
- const open = text.indexOf("{");
713
- if (open < 0)
714
- return null;
715
- const selector = rule.selectorText;
716
- if (typeof selector === "string") {
717
- const scoped = selector.replace(FRAME_ROOT_SELECTOR, "$1:scope").replace(FRAME_BODY_SELECTOR, '$1[data-fauna-frame="body"]');
718
- return `${scoped} ${text.slice(open)}`;
719
- }
720
- const children = rule.cssRules;
721
- if (!children || !/^@(media|supports|container|layer)\b/.test(text))
722
- return null;
723
- const inner = Array.from(children).map(frameScopedRule);
724
- if (inner.some((t) => t === null))
725
- return null;
726
- return `${text.slice(0, open)}{ ${inner.join("\n")} }`;
727
- }
692
+ // A wrapper replays as an iframe that never loads anything: the replayer writes its document.
693
+ const FRAME_BOX_SKIP = new Set(["src", "srcdoc", "sandbox", "allow", "data", "loading", "name"]);
694
+ /** A frame showing a placeholder instead of a document centers it. */
695
+ const FRAME_PLACEHOLDER_BODY = "margin:0;height:100vh;display:flex;align-items:center;justify-content:center;color:#666;font:12px/1.4 system-ui,sans-serif";
696
+ const SVG_NS = "http://www.w3.org/2000/svg";
697
+ const MATH_NS = "http://www.w3.org/1998/Math/MathML";
698
+ /** How long a scroll keeps being reapplied while its document grows into it. */
699
+ const SCROLL_SETTLE_MS = 3000;
728
700
  /**
729
701
  * Re-render a shared page into a frame inside `mount` and apply incoming `WebMutation`s.
730
702
  * The page is someone else's, so the frame is sandboxed without scripts: the replayer
731
703
  * writes into it from this page, and nothing the page carries ever runs. The replayer
732
704
  * keeps its own id->Node map mirroring the recorder's synthetic ids.
705
+ *
706
+ * Every frame the page holds replays as a nested iframe of that frame, which inherits the
707
+ * sandbox, so a framed page lays out against its own viewport: fixed positions, viewport
708
+ * units and media queries resolve against the frame, as they did on the host. Nothing
709
+ * inside the copy can run, so no event there ever reaches this page; the replayer places
710
+ * each frame's document right after the frame joins the copy.
733
711
  */
734
712
  export class DomReplayer {
735
713
  constructor(mount) {
@@ -745,6 +723,12 @@ export class DomReplayer {
745
723
  writable: true,
746
724
  value: new Map()
747
725
  });
726
+ Object.defineProperty(this, "ids", {
727
+ enumerable: true,
728
+ configurable: true,
729
+ writable: true,
730
+ value: new WeakMap()
731
+ });
748
732
  Object.defineProperty(this, "_viewport", {
749
733
  enumerable: true,
750
734
  configurable: true,
@@ -771,25 +755,34 @@ export class DomReplayer {
771
755
  writable: true,
772
756
  value: 0
773
757
  });
774
- Object.defineProperty(this, "frameScopes", {
758
+ Object.defineProperty(this, "boxes", {
775
759
  enumerable: true,
776
760
  configurable: true,
777
761
  writable: true,
778
- value: 0
762
+ value: new WeakMap()
779
763
  });
780
- Object.defineProperty(this, "scopedSheets", {
764
+ /** The box each framed document's node sits in. */
765
+ Object.defineProperty(this, "boxOf", {
781
766
  enumerable: true,
782
767
  configurable: true,
783
768
  writable: true,
784
- value: new WeakSet()
769
+ value: new WeakMap()
785
770
  });
786
- Object.defineProperty(this, "boxSheet", {
771
+ /** Boxes built before they joined the copy, so without a document yet. */
772
+ Object.defineProperty(this, "unplaced", {
787
773
  enumerable: true,
788
774
  configurable: true,
789
775
  writable: true,
790
- value: null
776
+ value: new Set()
791
777
  });
792
- Object.defineProperty(this, "boxes", {
778
+ /** Where the host has each scrolled element, and since when. */
779
+ Object.defineProperty(this, "scrolls", {
780
+ enumerable: true,
781
+ configurable: true,
782
+ writable: true,
783
+ value: new Map()
784
+ });
785
+ Object.defineProperty(this, "scrollFrame", {
793
786
  enumerable: true,
794
787
  configurable: true,
795
788
  writable: true,
@@ -844,7 +837,7 @@ export class DomReplayer {
844
837
  * The `<html>/<head>/<body>` structure and DFS ids match the host's.
845
838
  */
846
839
  async applyHtml(html, viewport) {
847
- this.nodes.clear();
840
+ this.reset();
848
841
  this._viewport = viewport ?? null;
849
842
  const load = ++this.loads;
850
843
  // Drop scripts that survived capture so Chrome doesn't log a sandbox error for each.
@@ -860,10 +853,14 @@ export class DomReplayer {
860
853
  if (!iframe)
861
854
  return;
862
855
  this.bindIds();
863
- this.restyle();
864
- // A textarea shows its text, not its value attribute.
865
- for (const field of Array.from(iframe.contentDocument?.querySelectorAll("textarea[value]") ?? [])) {
866
- field.value = field.getAttribute("value") ?? "";
856
+ this.unfold(iframe.contentDocument);
857
+ this.settle();
858
+ for (const doc of this.documents()) {
859
+ this.mirrorStates(doc);
860
+ // A textarea shows its text, not its value attribute.
861
+ for (const field of Array.from(doc.querySelectorAll("textarea[value]"))) {
862
+ field.value = field.getAttribute("value") ?? "";
863
+ }
867
864
  }
868
865
  this.flushQueued();
869
866
  }
@@ -879,7 +876,7 @@ export class DomReplayer {
879
876
  let nextId = 1;
880
877
  const walk = (node) => {
881
878
  if (node.nodeType === Node.TEXT_NODE) {
882
- this.nodes.set(nextId++, node);
879
+ this.bind(nextId++, node);
883
880
  return;
884
881
  }
885
882
  if (node.nodeType !== Node.ELEMENT_NODE)
@@ -888,11 +885,11 @@ export class DomReplayer {
888
885
  // The snapshot's combined sheet isn't a node on the host.
889
886
  if (BLOCKED_TAGS.has(el.tagName) || el.hasAttribute("data-fauna-sheets"))
890
887
  return;
891
- this.nodes.set(nextId++, el);
892
- for (const child of Array.from(el.childNodes))
888
+ this.bind(nextId++, el);
889
+ for (const child of this.boxes.get(el)?.content ?? Array.from(el.childNodes))
893
890
  walk(child);
894
891
  };
895
- walk(this.docRoot());
892
+ walk(root);
896
893
  }
897
894
  /** Elements carry `data-fauna-node`, text-only elements name their text with
898
895
  * `data-fauna-text`, and every other text node follows a `fauna:N` comment. A marker
@@ -905,13 +902,15 @@ export class DomReplayer {
905
902
  const el = node;
906
903
  const id = el.getAttribute("data-fauna-node");
907
904
  if (id)
908
- this.nodes.set(Number(id), el);
905
+ this.bind(Number(id), el);
909
906
  const textId = el.getAttribute("data-fauna-text");
910
907
  if (textId) {
911
908
  const text = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE)
912
909
  ?? el.appendChild(doc.createTextNode(""));
913
- this.nodes.set(Number(textId), text);
910
+ this.bind(Number(textId), text);
914
911
  }
912
+ for (const child of this.boxes.get(el)?.content ?? [])
913
+ this.bindMarked(child);
915
914
  continue;
916
915
  }
917
916
  const match = /^fauna:(\d+)$/.exec(node.nodeValue ?? "");
@@ -921,127 +920,249 @@ export class DomReplayer {
921
920
  if (!text || text.nodeType !== Node.TEXT_NODE)
922
921
  text = node.parentNode?.insertBefore(doc.createTextNode(""), node.nextSibling) ?? null;
923
922
  if (text)
924
- this.nodes.set(Number(match[1]), text);
923
+ this.bind(Number(match[1]), text);
925
924
  }
926
925
  }
927
- /** The document the replay renders into: its iframe's for full documents, else the mount's. */
928
- getDocument() {
929
- return this.ownerDoc();
926
+ bind(id, node) {
927
+ this.nodes.set(id, node);
928
+ this.ids.set(node, id);
930
929
  }
931
- /** Scope inlined frames' styles, mirror hover and focus, and size frame boxes, for a full document's frame. */
932
- restyle() {
933
- this.scopeFrames();
934
- this.mirrorStates();
935
- this.frameBoxes();
930
+ /** `next` takes over the id `old` was bound to. */
931
+ rebind(old, next) {
932
+ const id = this.ids.get(old);
933
+ if (id !== undefined)
934
+ this.bind(id, next);
935
+ }
936
+ reset() {
937
+ this.nodes.clear();
938
+ this.ids = new WeakMap();
939
+ this.boxes = new WeakMap();
940
+ this.boxOf = new WeakMap();
941
+ this.unplaced.clear();
942
+ this.scrolls.clear();
936
943
  }
937
944
  /**
938
- * Inlined frames are divs, so they get an iframe's box: inline block, 300 by 150 and
939
- * clipped unless the page says otherwise, their `width`, `height` and `frameborder`
940
- * attributes as sizes and border, and the framed document filling them. All at zero
941
- * specificity in a sheet of its own, so page CSS wins, like a real iframe's
942
- * presentational hints. No nodes join the replay, so ids stay aligned.
945
+ * A replay's HTML can't hold one document inside another, so an inlined frame's html,
946
+ * head and body arrive as divs marked `data-fauna-frame`. Every wrapper becomes a frame
947
+ * box, holding them as the html, head and body they stand for, under the ids they were
948
+ * bound to. Wrappers come in document order, so a frame's box is in the copy, with its
949
+ * document, before the frames inside it are unfolded.
943
950
  */
944
- frameBoxes() {
945
- const doc = this.iframe?.contentDocument;
946
- const view = doc?.defaultView;
947
- if (!doc || !view)
948
- return;
949
- if (!this.boxSheet || !doc.adoptedStyleSheets.includes(this.boxSheet)) {
950
- this.boxSheet = new view.CSSStyleSheet();
951
- doc.adoptedStyleSheets = [...doc.adoptedStyleSheets, this.boxSheet];
952
- }
953
- const size = (value) => {
954
- const v = value?.trim() ?? "";
955
- return /^\d+(\.\d+)?$/.test(v) ? `${v}px` : /^\d+(\.\d+)?(px|%)$/.test(v) ? v : "";
951
+ unfold(doc) {
952
+ const real = (div, tag) => {
953
+ const el = doc.createElement(tag);
954
+ for (const attr of Array.from(div.attributes))
955
+ if (attr.name !== "data-fauna-frame")
956
+ el.setAttribute(attr.name, attr.value);
957
+ this.rebind(div, el);
958
+ return el;
956
959
  };
957
- const rules = [FRAME_BOX_CSS];
958
- for (const el of Array.from(doc.querySelectorAll("[data-fauna-iframe]"))) {
959
- const width = size(el.getAttribute("width"));
960
- const height = size(el.getAttribute("height"));
961
- const borderless = el.getAttribute("frameborder") === "0";
962
- if (!width && !height && !borderless)
960
+ for (const wrapper of Array.from(doc.querySelectorAll("[data-fauna-iframe]"))) {
961
+ const frame = this.createBox(doc, Array.from(wrapper.attributes, (attr) => [attr.name, attr.value]));
962
+ const content = Array.from(wrapper.childNodes, (child) => {
963
+ if (child.nodeType !== Node.ELEMENT_NODE || child.getAttribute("data-fauna-frame") !== "html")
964
+ return child;
965
+ const html = real(child, "html");
966
+ for (const part of Array.from(child.childNodes)) {
967
+ const name = part.nodeType === Node.ELEMENT_NODE ? part.getAttribute("data-fauna-frame") : null;
968
+ if (name !== "head" && name !== "body") {
969
+ html.appendChild(part);
970
+ continue;
971
+ }
972
+ const el = real(part, name);
973
+ el.append(...Array.from(part.childNodes));
974
+ html.appendChild(el);
975
+ }
976
+ return html;
977
+ });
978
+ this.rebind(wrapper, frame);
979
+ wrapper.replaceWith(frame);
980
+ for (const node of content)
981
+ this.insertInto(frame, node, null);
982
+ }
983
+ }
984
+ /** An iframe standing for a frame's wrapper, which never loads anything of its own. */
985
+ createBox(doc, attributes) {
986
+ const frame = doc.createElement("iframe");
987
+ for (const [name, value] of attributes) {
988
+ if (FRAME_BOX_SKIP.has(name.toLowerCase()))
963
989
  continue;
964
- let box = el.getAttribute("data-fauna-box");
965
- if (!box) {
966
- box = String(++this.boxes);
967
- el.setAttribute("data-fauna-box", box);
990
+ try {
991
+ frame.setAttribute(name, value);
968
992
  }
969
- const declarations = [width && `width: ${width}`, height && `height: ${height}`, borderless && "border: 0"].filter(Boolean);
970
- rules.push(`:where([data-fauna-box="${box}"]) { ${declarations.join("; ")} }`);
993
+ catch { /* invalid attr name — skip */ }
971
994
  }
972
- this.boxSheet.replaceSync(rules.join("\n"));
995
+ this.boxes.set(frame, { content: [] });
996
+ return frame;
973
997
  }
974
- /** Scroll a replayed element, or a framed document's html, to the host's `data-fauna-scroll`. */
975
- applyScroll(el) {
976
- const [x, y] = (el.getAttribute("data-fauna-scroll") ?? "").split(",").map((n) => Number(n) || 0);
977
- el.scrollLeft = x ?? 0;
978
- el.scrollTop = y ?? 0;
998
+ /** Insert into `parent`, or, for a frame box, into the framed document it shows. */
999
+ insertInto(parent, node, before) {
1000
+ const frame = parent;
1001
+ const box = this.boxes.get(frame);
1002
+ if (!box) {
1003
+ parent.insertBefore(node, before?.parentNode === parent ? before : null);
1004
+ return;
1005
+ }
1006
+ const at = before ? box.content.indexOf(before) : -1;
1007
+ box.content.splice(at < 0 ? box.content.length : at, 0, node);
1008
+ this.boxOf.set(node, frame);
1009
+ this.place(frame, box);
979
1010
  }
980
1011
  /**
981
- * A framed document is inlined into the replay, so its stylesheets would style the
982
- * whole copy: a site's `body { background }` repaints the page around its frame. Each
983
- * of its sheets goes inside `@scope` at the frame's html div instead. Sheets from other
984
- * origins can't be read, so they stay as they are.
1012
+ * Put a box's framed document into the box's own document: its html as the document's
1013
+ * root, and anything else (a placeholder) into a body of its own. A box gets its
1014
+ * document the moment it joins the copy; until then what it shows waits in `content`,
1015
+ * where changes apply all the same.
985
1016
  */
986
- scopeFrames() {
987
- const doc = this.iframe?.contentDocument;
988
- if (!doc)
1017
+ place(frame, box) {
1018
+ const doc = frame.isConnected ? frame.contentDocument : null;
1019
+ if (!doc) {
1020
+ this.unplaced.add(frame);
989
1021
  return;
990
- for (const sheet of Array.from(doc.styleSheets)) {
991
- if (this.scopedSheets.has(sheet))
992
- continue;
993
- const root = sheet.ownerNode?.closest?.('[data-fauna-frame="html"]');
994
- if (!root)
995
- continue;
996
- let rules;
997
- try {
998
- rules = sheet.cssRules;
999
- }
1000
- catch {
1001
- continue;
1022
+ }
1023
+ this.unplaced.delete(frame);
1024
+ const root = box.content.find((node) => node.nodeName === "HTML");
1025
+ if (root && doc.documentElement !== root) {
1026
+ if (doc.documentElement)
1027
+ doc.replaceChild(root, doc.documentElement);
1028
+ else
1029
+ doc.appendChild(root);
1030
+ this.mirrorStates(doc);
1031
+ }
1032
+ const rest = box.content.filter((node) => node !== root);
1033
+ if (!rest.length)
1034
+ return;
1035
+ const html = doc.documentElement ?? doc.appendChild(doc.createElement("html"));
1036
+ const body = doc.body ?? html.appendChild(doc.createElement("body"));
1037
+ if (!root)
1038
+ body.setAttribute("style", FRAME_PLACEHOLDER_BODY);
1039
+ for (const node of rest)
1040
+ if (node.parentNode !== body)
1041
+ body.appendChild(node);
1042
+ }
1043
+ /** Give every box that has joined the copy its document, the boxes inside them included. */
1044
+ settle() {
1045
+ for (let placed = true; placed && this.unplaced.size;) {
1046
+ placed = false;
1047
+ for (const frame of [...this.unplaced]) {
1048
+ if (!frame.isConnected)
1049
+ continue;
1050
+ const box = this.boxes.get(frame);
1051
+ if (box)
1052
+ this.place(frame, box);
1053
+ placed || (placed = !this.unplaced.has(frame));
1002
1054
  }
1003
- this.scopedSheets.add(sheet);
1004
- let scope = root.getAttribute("data-fauna-scope");
1005
- if (!scope) {
1006
- scope = String(++this.frameScopes);
1007
- root.setAttribute("data-fauna-scope", scope);
1055
+ }
1056
+ }
1057
+ /** The copy's document and every framed document in it. */
1058
+ documents() {
1059
+ const out = [];
1060
+ const visit = (doc) => {
1061
+ if (!doc)
1062
+ return;
1063
+ out.push(doc);
1064
+ for (const frame of Array.from(doc.querySelectorAll("iframe")))
1065
+ visit(frame.contentDocument);
1066
+ };
1067
+ visit(this.iframe?.contentDocument);
1068
+ return out;
1069
+ }
1070
+ /** Take a removed node out of the copy, and every id under it, framed documents included. */
1071
+ drop(node) {
1072
+ const frame = this.boxOf.get(node);
1073
+ const content = frame ? this.boxes.get(frame)?.content : undefined;
1074
+ const at = content?.indexOf(node) ?? -1;
1075
+ if (at >= 0)
1076
+ content.splice(at, 1);
1077
+ this.boxOf.delete(node);
1078
+ node.parentNode?.removeChild(node);
1079
+ for (const stack = [node]; stack.length;) {
1080
+ const next = stack.pop();
1081
+ const id = this.ids.get(next);
1082
+ if (id !== undefined && this.nodes.get(id) === next)
1083
+ this.nodes.delete(id);
1084
+ if (next.nodeType === Node.ELEMENT_NODE) {
1085
+ this.scrolls.delete(next);
1086
+ this.unplaced.delete(next);
1008
1087
  }
1009
- for (let i = 0; i < rules.length; i++) {
1010
- const original = rules[i].cssText;
1011
- const inner = frameScopedRule(rules[i]);
1012
- if (inner === null)
1088
+ for (const child of this.boxes.get(next)?.content ?? Array.from(next.childNodes))
1089
+ stack.push(child);
1090
+ }
1091
+ }
1092
+ /** Keep `el` (a framed document's html for the frame's own scroll) where the host has it. */
1093
+ scrollTo(el) {
1094
+ const [x = 0, y = 0] = (el.getAttribute("data-fauna-scroll") ?? "").split(",").map((n) => Number(n) || 0);
1095
+ if (!x && !y)
1096
+ this.scrolls.delete(el);
1097
+ else
1098
+ this.scrolls.set(el, { x, y, since: Date.now() });
1099
+ this.scrollOnce(el, x, y);
1100
+ this.rescroll();
1101
+ }
1102
+ /** Whether `el` is where the host has it, after moving it there. */
1103
+ scrollOnce(el, x, y) {
1104
+ if (!el.isConnected)
1105
+ return false;
1106
+ const doc = el.ownerDocument;
1107
+ if (el === doc.documentElement) {
1108
+ const view = doc.defaultView;
1109
+ if (!view)
1110
+ return false;
1111
+ if (view.scrollX !== x || view.scrollY !== y)
1112
+ view.scrollTo(x, y);
1113
+ return view.scrollX === x && view.scrollY === y;
1114
+ }
1115
+ if (el.scrollLeft !== x)
1116
+ el.scrollLeft = x;
1117
+ if (el.scrollTop !== y)
1118
+ el.scrollTop = y;
1119
+ return el.scrollLeft === x && el.scrollTop === y;
1120
+ }
1121
+ /**
1122
+ * A scroll lands only once its document is long enough, which can be after the changes
1123
+ * that grow it, or after its images and fonts load. Once a frame after any change, and
1124
+ * every frame while a recent scroll hasn't landed, scrolls go back where the host has them.
1125
+ */
1126
+ rescroll() {
1127
+ const view = this.mount.ownerDocument?.defaultView;
1128
+ if (this.scrollFrame || !this.scrolls.size || !view)
1129
+ return;
1130
+ this.scrollFrame = view.requestAnimationFrame(() => {
1131
+ this.scrollFrame = 0;
1132
+ const now = Date.now();
1133
+ let short = false;
1134
+ for (const [el, at] of this.scrolls) {
1135
+ if (!el.isConnected)
1013
1136
  continue;
1014
- sheet.deleteRule(i);
1015
- try {
1016
- sheet.insertRule(`@scope ([data-fauna-scope="${scope}"]) { ${inner} }`, i);
1017
- }
1018
- catch {
1019
- sheet.insertRule(original, i);
1020
- }
1137
+ if (!this.scrollOnce(el, at.x, at.y) && now - at.since < SCROLL_SETTLE_MS)
1138
+ short = true;
1021
1139
  }
1022
- }
1140
+ if (short)
1141
+ this.rescroll();
1142
+ });
1143
+ }
1144
+ /** The document the replay renders into: its iframe's for full documents, else the mount's. */
1145
+ getDocument() {
1146
+ return this.ownerDoc();
1023
1147
  }
1024
1148
  /**
1025
1149
  * `:hover` and `:focus` never apply in a replay. The host marks what they would match
1026
1150
  * with `data-fauna-hover` and the focus attributes, so point those selectors at the
1027
- * attributes instead; both weigh the same in the cascade. Only a full document's own
1028
- * frame is rewritten.
1151
+ * attributes instead; both weigh the same in the cascade. Only the copy's own documents
1152
+ * are rewritten: its frame's, and each framed document's.
1029
1153
  */
1030
- mirrorStates() {
1031
- const doc = this.iframe?.contentDocument;
1032
- if (!doc)
1154
+ mirrorStates(doc) {
1155
+ if (doc === this.mount.ownerDocument)
1033
1156
  return;
1034
1157
  const visit = (rules) => {
1035
1158
  for (const rule of Array.from(rules)) {
1036
1159
  const style = rule;
1037
- if (typeof style.selectorText === "string" && /:(hover|focus)|frame/i.test(style.selectorText)) {
1038
- // Inlined frames are divs, so rules for iframes point at their marker.
1160
+ if (typeof style.selectorText === "string" && /:(hover|focus)/.test(style.selectorText)) {
1039
1161
  style.selectorText = style.selectorText
1040
1162
  .replace(/:hover\b/g, "[data-fauna-hover]")
1041
1163
  .replace(/:focus-visible\b/g, "[data-fauna-focus-visible]")
1042
1164
  .replace(/:focus-within\b/g, "[data-fauna-focus-within]")
1043
- .replace(/:focus(?![-\w])/g, "[data-fauna-focus]")
1044
- .replace(FRAME_ELEMENT_SELECTOR, "$1[data-fauna-iframe]");
1165
+ .replace(/:focus(?![-\w])/g, "[data-fauna-focus]");
1045
1166
  }
1046
1167
  const nested = rule.cssRules;
1047
1168
  if (nested)
@@ -1058,58 +1179,79 @@ export class DomReplayer {
1058
1179
  ownerDoc() {
1059
1180
  return this.iframe?.contentDocument ?? this.mount.ownerDocument;
1060
1181
  }
1061
- /** `seen` keeps a subtree that names a node twice (or itself) from building forever. */
1062
- build(id, map, seen = new Set()) {
1182
+ /**
1183
+ * `seen` keeps a subtree that names a node twice (or itself) from building forever. SVG
1184
+ * and MathML take their namespace from where they sit, as the HTML parser gives it.
1185
+ */
1186
+ build(id, map, ns, seen = new Set()) {
1063
1187
  const spec = map[id];
1064
1188
  if (!spec || seen.has(id))
1065
1189
  return null;
1066
1190
  seen.add(id);
1191
+ const doc = this.ownerDoc();
1067
1192
  if (spec.kind === "text") {
1068
- const text = this.ownerDoc().createTextNode(spec.text);
1069
- this.nodes.set(id, text);
1193
+ const text = doc.createTextNode(spec.text);
1194
+ this.bind(id, text);
1070
1195
  return text;
1071
1196
  }
1072
1197
  let el;
1198
+ const box = "data-fauna-iframe" in spec.attributes;
1199
+ const space = spec.tag === "svg" ? SVG_NS : spec.tag === "math" ? MATH_NS : ns;
1073
1200
  try {
1074
- el = this.ownerDoc().createElement(spec.tag);
1201
+ if (box)
1202
+ el = this.createBox(doc, Object.entries(spec.attributes));
1203
+ else
1204
+ el = space ? doc.createElementNS(space, spec.tag) : doc.createElement(spec.tag);
1075
1205
  }
1076
1206
  catch {
1077
1207
  return null;
1078
1208
  }
1079
- for (const [name, value] of Object.entries(spec.attributes)) {
1080
- try {
1081
- el.setAttribute(name, value);
1209
+ if (!box) {
1210
+ for (const [name, value] of Object.entries(spec.attributes)) {
1211
+ try {
1212
+ el.setAttribute(name, value);
1213
+ }
1214
+ catch { /* invalid attr name — skip */ }
1082
1215
  }
1083
- catch { /* invalid attr name — skip */ }
1084
1216
  }
1085
- this.nodes.set(id, el);
1217
+ this.bind(id, el);
1218
+ const inner = space === SVG_NS && spec.tag === "foreignObject" ? null : space;
1086
1219
  for (const childId of spec.children) {
1087
- const child = this.build(childId, map, seen);
1220
+ const child = this.build(childId, map, inner, seen);
1088
1221
  if (child)
1089
- el.appendChild(child);
1222
+ this.insertInto(el, child, null);
1090
1223
  }
1224
+ if (el.localName === "textarea")
1225
+ el.value = el.getAttribute("value") ?? "";
1226
+ if (el.hasAttribute("data-fauna-scroll"))
1227
+ this.scrollTo(el);
1091
1228
  return el;
1092
1229
  }
1093
- buildFromList(node, list) {
1230
+ buildFromList(node, list, parent) {
1094
1231
  const map = {};
1095
1232
  for (const n of list)
1096
1233
  map[n.id] = n;
1097
- return this.build(node.id, map);
1234
+ const el = parent;
1235
+ const foreign = (el.namespaceURI === SVG_NS || el.namespaceURI === MATH_NS) && el.localName !== "foreignObject";
1236
+ return this.build(node.id, map, foreign ? el.namespaceURI : null);
1098
1237
  }
1099
1238
  /** Render a fresh snapshot, replacing whatever the mount currently holds. */
1100
1239
  async apply(snapshot) {
1101
- this.nodes.clear();
1240
+ this.reset();
1102
1241
  this._viewport = snapshot.viewport ?? null;
1103
1242
  const load = ++this.loads;
1104
1243
  const iframe = await this.openFrame("<!doctype html><html><head></head><body></body></html>", load);
1105
1244
  if (!iframe)
1106
1245
  return;
1107
1246
  const doc = iframe.contentDocument;
1108
- const root = this.build(snapshot.root, snapshot.nodes);
1247
+ const root = this.build(snapshot.root, snapshot.nodes, null);
1109
1248
  if (root?.nodeName === "HTML")
1110
1249
  doc.replaceChild(root, doc.documentElement);
1111
1250
  else if (root)
1112
1251
  doc.body.replaceChildren(root);
1252
+ this.settle();
1253
+ this.mirrorStates(doc);
1254
+ this.rescroll();
1113
1255
  this.flushQueued();
1114
1256
  }
1115
1257
  /** Apply one incremental mutation to the rendered tree. */
@@ -1121,10 +1263,12 @@ export class DomReplayer {
1121
1263
  switch (mutation.kind) {
1122
1264
  case "text": {
1123
1265
  const node = this.nodes.get(mutation.id);
1124
- if (node)
1125
- node.textContent = mutation.text;
1126
- if (node?.parentNode?.nodeName === "STYLE")
1127
- this.restyle();
1266
+ if (!node)
1267
+ return;
1268
+ node.textContent = mutation.text;
1269
+ if (node.parentNode?.nodeName === "STYLE")
1270
+ this.mirrorStates(node.ownerDocument);
1271
+ this.rescroll();
1128
1272
  return;
1129
1273
  }
1130
1274
  case "attribute": {
@@ -1134,6 +1278,8 @@ export class DomReplayer {
1134
1278
  if (node?.nodeType !== Node.ELEMENT_NODE)
1135
1279
  return;
1136
1280
  const el = node;
1281
+ if (this.boxes.has(el) && FRAME_BOX_SKIP.has(mutation.name.toLowerCase()))
1282
+ return;
1137
1283
  if (mutation.value === null)
1138
1284
  el.removeAttribute(mutation.name);
1139
1285
  else {
@@ -1147,32 +1293,31 @@ export class DomReplayer {
1147
1293
  el.value = mutation.value ?? "";
1148
1294
  }
1149
1295
  if (mutation.name === "data-fauna-scroll")
1150
- this.applyScroll(el);
1151
- if ((mutation.name === "width" || mutation.name === "height" || mutation.name === "frameborder") && el.hasAttribute("data-fauna-iframe")) {
1152
- this.frameBoxes();
1153
- }
1296
+ this.scrollTo(el);
1297
+ else
1298
+ this.rescroll();
1154
1299
  return;
1155
1300
  }
1156
1301
  case "remove": {
1157
1302
  const node = this.nodes.get(mutation.id);
1158
- if (node && node.parentNode)
1159
- node.parentNode.removeChild(node);
1160
- this.nodes.delete(mutation.id);
1303
+ if (node)
1304
+ this.drop(node);
1161
1305
  return;
1162
1306
  }
1163
1307
  case "insert": {
1164
1308
  const parent = this.nodes.get(mutation.parent);
1165
1309
  if (!parent || (parent.nodeType !== Node.ELEMENT_NODE && parent.nodeType !== Node.DOCUMENT_FRAGMENT_NODE))
1166
1310
  return;
1167
- const built = this.buildFromList(mutation.node, mutation.subtree);
1311
+ const built = this.buildFromList(mutation.node, mutation.subtree, parent);
1168
1312
  if (!built)
1169
1313
  return;
1170
1314
  const sibling = mutation.before !== null ? this.nodes.get(mutation.before) ?? null : null;
1171
- parent.insertBefore(built, sibling?.parentNode === parent ? sibling : null);
1172
- if (parent.nodeName === "STYLE" || mutation.subtree.some((n) => n.kind === "element" && n.tag === "style"))
1173
- this.restyle();
1174
- else if (mutation.subtree.some((n) => n.kind === "element" && "data-fauna-iframe" in n.attributes))
1175
- this.frameBoxes();
1315
+ this.insertInto(parent, built, sibling);
1316
+ this.settle();
1317
+ if (parent.nodeName === "STYLE" || mutation.subtree.some((n) => n.kind === "element" && n.tag === "style")) {
1318
+ this.mirrorStates(built.ownerDocument);
1319
+ }
1320
+ this.rescroll();
1176
1321
  return;
1177
1322
  }
1178
1323
  case "viewport":
@@ -1419,6 +1564,7 @@ export function pageToHtml(root = document.documentElement) {
1419
1564
  return style;
1420
1565
  }
1421
1566
  }
1567
+ // A framed document's html, head and body go out as divs the replay turns back into them.
1422
1568
  const rootTag = el.ownerDocument !== document ? el.tagName.toLowerCase() : "";
1423
1569
  const frameRoot = rootTag === "html" || rootTag === "head" || rootTag === "body" ? rootTag : "";
1424
1570
  const clone = doc.createElement(frameRoot ? "div" : el.tagName.toLowerCase());
@@ -1431,11 +1577,8 @@ export function pageToHtml(root = document.documentElement) {
1431
1577
  catch { /* skip */ }
1432
1578
  }
1433
1579
  mark(clone, el);
1434
- if (frameRoot) {
1580
+ if (frameRoot)
1435
1581
  clone.setAttribute("data-fauna-frame", frameRoot);
1436
- if (frameRoot === "head")
1437
- clone.setAttribute("hidden", "");
1438
- }
1439
1582
  if (RAW_TEXT.has(el.nodeName)) {
1440
1583
  const text = Array.from(el.childNodes).find((child) => child.nodeType === Node.TEXT_NODE);
1441
1584
  const textId = text ? recorder?.id(text) : undefined;
@@ -1508,8 +1651,8 @@ export function pageToHtml(root = document.documentElement) {
1508
1651
  }
1509
1652
  /** Install a MutationObserver that posts WebMutations to `window[pushName]` as
1510
1653
  * `{ token, mutation }`, so the host knows which recorder sent each one.
1511
- * Framed documents are walked, observed, and re-inlined on navigation, with
1512
- * their html, head, and body addressed as the divs `pageToHtml` emits.
1654
+ * Framed documents are walked, observed, and re-inlined on navigation, each
1655
+ * as its own html, head, and body, which viewers replay as a nested frame.
1513
1656
  * With `remoteFrames`, a frame whose document this page can't read is left to
1514
1657
  * the host, which streams it from the frame itself: its wrapper says
1515
1658
  * `data-fauna-iframe="remote"` and holds nothing. With `framed`, this document
@@ -1762,12 +1905,6 @@ export function pageInstallRecorder(pushName, options) {
1762
1905
  return null;
1763
1906
  }
1764
1907
  };
1765
- const frameRootTag = (el) => {
1766
- if (el.ownerDocument === document && !framed)
1767
- return "";
1768
- const tag = el.tagName.toLowerCase();
1769
- return tag === "html" || tag === "head" || tag === "body" ? tag : "";
1770
- };
1771
1908
  // URLs resolve against their own document. The copy, and the host fetching assets
1772
1909
  // from the top page, would resolve a framed document's against something else, so
1773
1910
  // they go out absolute. Same rules as pageToHtml.
@@ -1810,8 +1947,7 @@ export function pageInstallRecorder(pushName, options) {
1810
1947
  };
1811
1948
  // A framed document's linked stylesheet goes out in place as a <style> with one text
1812
1949
  // node holding its rules, like pageToHtml, so both number the same nodes. Viewers fetch
1813
- // nothing cross origin, and the copy can scope it. A sheet still loading sends its
1814
- // rules when it loads.
1950
+ // nothing cross origin. A sheet still loading sends its rules when it loads.
1815
1951
  const isFrameSheet = (el) => el.tagName === "LINK" && (el.ownerDocument !== document || framed) && /\bstylesheet\b/i.test(el.getAttribute("rel") ?? "");
1816
1952
  const sheetCss = (link) => {
1817
1953
  try {
@@ -2016,13 +2152,7 @@ export function pageInstallRecorder(pushName, options) {
2016
2152
  const shadow = shadowOf(el);
2017
2153
  if (shadow)
2018
2154
  children.push(serializeShadow(shadow, into));
2019
- const frameRoot = frameRootTag(el);
2020
- if (frameRoot) {
2021
- attributes["data-fauna-frame"] = frameRoot;
2022
- if (frameRoot === "head")
2023
- attributes.hidden = "";
2024
- }
2025
- into[id] = { id, kind: "element", tag: frameRoot ? "div" : el.tagName.toLowerCase(), attributes, children };
2155
+ into[id] = { id, kind: "element", tag: el.localName, attributes, children };
2026
2156
  return id;
2027
2157
  };
2028
2158
  const reinline = (el) => {