diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs index cd3ccd0..c216f6f 100644 --- a/crates/obscura-dom/src/tree.rs +++ b/crates/obscura-dom/src/tree.rs @@ -223,6 +223,14 @@ impl DomTree { } pub fn append_child(&self, parent_id: NodeId, child_id: NodeId) { + // Per DOM spec, appending a node to itself is a HierarchyRequestError; + // here we treat it as a no-op rather than panic. Without this the + // sibling-pointer fixup below sets the node's prev_sibling to itself + // and every later child-walk loops forever (same failure mode that + // insert_before's self-cycle guard was added to prevent). + if parent_id == child_id { + return; + } self.detach(child_id); let mut inner = self.inner.borrow_mut(); diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index ecab100..0339c14 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -304,7 +304,29 @@ class Node { get nextSibling() { return _wrap(+_dom("next_sibling", this._nid)); } get previousSibling() { return _wrap(+_dom("prev_sibling", this._nid)); } appendChild(c) { - if (!c) return c; + if (!c || typeof c._nid !== 'number') return c; + if (c._nid === this._nid) return c; + // DocumentFragment per spec: move its children up to `this` and leave + // the fragment empty. Pages that build subtrees off-DOM (Bing's + // anti-malware shim, Google Shopping result rows, React/Preact + // hydration) appendChild a fragment and then read its former + // children's properties; without flattening, the fragment node + // itself gets grafted and the next `.something.length` access on + // the assumed-Element children explodes with + // "Cannot read properties of undefined (reading 'length')". + if (c.nodeType === 11) { + const kids = (c.childNodes || []).slice(); + const added = []; + for (const k of kids) { + if (typeof k._nid !== 'number') continue; + _dom("append_child", this._nid, k._nid); + added.push(k._nid); + } + if (globalThis.__mutationObservers?.length && added.length) { + globalThis.__notifyMutation('childList', this._nid, added, []); + } + return c; + } _dom("append_child", this._nid, c._nid); if (globalThis.__mutationObservers?.length) globalThis.__notifyMutation('childList', this._nid, [c._nid], []); if (c instanceof Element && c.tagName === 'SCRIPT') { @@ -368,14 +390,40 @@ class Node { return c; } replaceChild(newChild, oldChild) { - if (!oldChild || !newChild) return oldChild; + if (!oldChild || !newChild || typeof oldChild._nid !== 'number') return oldChild; + if (newChild.nodeType === 11) { + // Flatten fragment: insert each child before oldChild, then remove oldChild. + // op_dom("insert_before", newNode, refNode) -> insert newNode before refNode. + const kids = (newChild.childNodes || []).slice(); + for (const k of kids) { + if (typeof k._nid !== 'number') continue; + if (k._nid === oldChild._nid) continue; + _dom("insert_before", k._nid, oldChild._nid); + } + _dom("remove_child", oldChild._nid); + return oldChild; + } + if (typeof newChild._nid !== 'number') return oldChild; + if (newChild._nid === oldChild._nid) return oldChild; _dom("insert_before", newChild._nid, oldChild._nid); _dom("remove_child", oldChild._nid); return oldChild; } insertBefore(n, ref) { - if (!n) return n; + if (!n || typeof n._nid !== 'number') return n; if (!ref) { this.appendChild(n); return n; } + if (typeof ref._nid !== 'number') return n; + // DocumentFragment: flatten — insert each child of the fragment + // before ref (in order), then leave the fragment empty. + if (n.nodeType === 11) { + const kids = (n.childNodes || []).slice(); + for (const k of kids) { + if (typeof k._nid !== 'number') continue; + if (k._nid === ref._nid) continue; + _dom("insert_before", k._nid, ref._nid); + } + return n; + } _dom("insert_before", n._nid, ref._nid); return n; } @@ -872,8 +920,49 @@ class Element extends Node { set name(v) { this.setAttribute("name", v); } get placeholder() { return this.getAttribute("placeholder") || ""; } set placeholder(v) { this.setAttribute("placeholder", v); } - get href() { return this.getAttribute("href") || ""; } + get href() { + const raw = this.getAttribute("href"); + if (raw == null) return ""; + // Per spec, HTMLAnchorElement.href / HTMLAreaElement.href returns the + // serialized resolved URL (the content attribute resolved against the + // document base). Bing's anti-malware shim and many other libraries + // rely on this resolution to read `.hostname` / `.protocol`. We only + // resolve for /// so other elements that happen + // to carry an `href` attribute (custom elements, SVG , etc) keep + // the raw value they were assigned. + const tag = this.localName; + if (tag === 'a' || tag === 'area' || tag === 'base' || tag === 'link') { + try { + return new URL(raw, _domParse("document_url") || globalThis.location?.href || "about:blank").href; + } catch { return raw; } + } + return raw; + } set href(v) { this.setAttribute("href", v); } + // URL-decomposition IDL attributes for / . Without these, + // libraries that build an anchor and read parts of the resolved URL + // (Bing's CI.AntiMalware shim does `n=createElement("A"); n.href=src; + // n.hostname.length`) crash with "Cannot read properties of undefined". + _urlPart(part) { + const tag = this.localName; + if (tag !== 'a' && tag !== 'area') return ""; + const raw = this.getAttribute("href"); + if (raw == null || raw === "") return ""; + try { + const u = new URL(raw, _domParse("document_url") || globalThis.location?.href || "about:blank"); + return u[part] || ""; + } catch { return ""; } + } + get origin() { return this._urlPart('origin'); } + get protocol() { return this._urlPart('protocol'); } + get username() { return this._urlPart('username'); } + get password() { return this._urlPart('password'); } + get host() { return this._urlPart('host'); } + get hostname() { return this._urlPart('hostname'); } + get port() { return this._urlPart('port'); } + get pathname() { return this._urlPart('pathname'); } + get search() { return this._urlPart('search'); } + get hash() { return this._urlPart('hash'); } get src() { return this.getAttribute("src") || ""; } set src(v) { this.setAttribute("src", v);