diff --git a/crates/obscura-cdp/src/domains/dom.rs b/crates/obscura-cdp/src/domains/dom.rs index 7625a03..55b8820 100644 --- a/crates/obscura-cdp/src/domains/dom.rs +++ b/crates/obscura-cdp/src/domains/dom.rs @@ -236,6 +236,25 @@ pub async fn handle( let _ = page.evaluate(&code); Ok(json!({})) } + "scrollIntoViewIfNeeded" => { + let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; + let node_id = resolve_node_id(page, params)?; + // Obscura has no layout viewport to move, but the JS shim records + // this element for the hit testing used by subsequent input events. + let code = format!( + "(function() {{ var el = globalThis._wrap && globalThis._wrap({0}); \ + if (!el || typeof el.scrollIntoView !== 'function') return false; \ + el.scrollIntoView(); return true; }})()", + node_id + ); + let did_scroll = page.evaluate(&code).as_bool().unwrap_or(false); + if !did_scroll { + return Err(format!( + "node {node_id} could not be resolved to a scrollable element" + )); + } + Ok(json!({})) + } "setFileInputFiles" => { // Puppeteer's ElementHandle.uploadFile / Playwright's setInputFiles // drive an through this CDP call (issue #359). Read @@ -526,6 +545,102 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn scroll_into_view_if_needed_resolves_all_node_identifiers() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session = Some(format!("{page_id}-session")); + ctx.sessions.insert(session.clone().unwrap(), page_id); + + crate::domains::page::handle( + "navigate", + &json!({ + "url": "data:text/html,
", + "waitUntil": "load" + }), + &mut ctx, + &session, + ) + .await + .expect("navigate should succeed"); + + let query = handle( + "querySelector", + &json!({ "selector": "#target" }), + &mut ctx, + &session, + ) + .await + .expect("querySelector should succeed"); + let node_id = query["nodeId"].as_u64().expect("button nodeId"); + + let resolved = handle( + "resolveNode", + &json!({ "nodeId": node_id }), + &mut ctx, + &session, + ) + .await + .expect("resolveNode should succeed"); + let object_id = resolved["object"]["objectId"] + .as_str() + .expect("button objectId") + .to_string(); + + for params in [ + json!({ "nodeId": node_id }), + json!({ "backendNodeId": node_id }), + json!({ "objectId": object_id }), + ] { + ctx.get_session_page_mut(&session) + .unwrap() + .evaluate("globalThis.__obscura_click_target = null"); + + handle("scrollIntoViewIfNeeded", ¶ms, &mut ctx, &session) + .await + .expect("scrollIntoViewIfNeeded should succeed"); + + let target_id = ctx + .get_session_page_mut(&session) + .unwrap() + .evaluate("globalThis.__obscura_click_target && globalThis.__obscura_click_target.id"); + assert_eq!(target_id, json!("target")); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn scroll_into_view_if_needed_requires_a_node_identifier() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session = Some(format!("{page_id}-session")); + ctx.sessions.insert(session.clone().unwrap(), page_id); + + let error = handle( + "scrollIntoViewIfNeeded", + &json!({}), + &mut ctx, + &session, + ) + .await + .expect_err("missing node identifier should fail"); + + assert_eq!(error, "nodeId, backendNodeId, or objectId required"); + + let error = handle( + "scrollIntoViewIfNeeded", + &json!({ "nodeId": 999_999 }), + &mut ctx, + &session, + ) + .await + .expect_err("an unknown node should fail"); + + assert_eq!( + error, + "node 999999 could not be resolved to a scrollable element" + ); + } + // A hostile or heavy page can nest nodes tens of thousands deep (trivially // scriptable, and html5ever puts no cap on generic nesting). DOM.getDocument // with the standard depth:-1 (which becomes u32::MAX here) must handle such a diff --git a/crates/obscura-dom/src/lib.rs b/crates/obscura-dom/src/lib.rs index 6d3d6fc..6ad84be 100644 --- a/crates/obscura-dom/src/lib.rs +++ b/crates/obscura-dom/src/lib.rs @@ -7,4 +7,4 @@ pub mod selector; pub mod serialize; pub use tree::{Attribute, DomTree, Node, NodeData, NodeId}; -pub use tree_sink::{parse_html, parse_fragment}; +pub use tree_sink::{parse_fragment, parse_fragment_with_context, parse_html}; diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index 4c5acf4..6f85797 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -580,16 +580,21 @@ impl DomTree { // Fast path: a bare "#id" selector resolves through the id index in O(1) // instead of scanning every descendant. The index holds the first element // in tree order per id, which is exactly what the full scan would return. - if let Some(id) = simple_id_selector(selector) { - match self.get_element_by_id(id) { - // querySelector matches strict descendants of root only, so the - // indexed element must have root among its ancestors. - Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)), - // Index miss or stale entry (detached node) — fall through to full scan. - // The id_index is best-effort: it only registers nodes at creation time - // and does not update on reparent, so it can point to a detached clone - // while the live node (with the same id) is elsewhere in the tree. - _ => {} + // In quirks mode `#id` matches ASCII-case-insensitively, but the id + // index is keyed on the exact-case id, so skip the fast path and let the + // selector engine (below) do the case-insensitive match. + if !self.is_quirks() { + if let Some(id) = simple_id_selector(selector) { + match self.get_element_by_id(id) { + // querySelector matches strict descendants of root only, so the + // indexed element must have root among its ancestors. + Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)), + // Index miss or stale entry (detached node) — fall through to full scan. + // The id_index is best-effort: it only registers nodes at creation time + // and does not update on reparent, so it can point to a detached clone + // while the live node (with the same id) is elsewhere in the tree. + _ => {} + } } } let selector_list = parse_selector(selector)?; @@ -598,7 +603,7 @@ impl DomTree { MatchingMode::Normal, None, &mut caches, - QuirksMode::NoQuirks, + self.selector_quirks_mode(), NeedsSelectorFlags::No, MatchingForInvalidation::No, ); @@ -619,6 +624,16 @@ impl DomTree { Ok(None) } + // Map the document's quirks flag onto the selector crate's QuirksMode. In + // quirks mode the crate matches class/id selectors ASCII-case-insensitively. + fn selector_quirks_mode(&self) -> QuirksMode { + if self.is_quirks() { + QuirksMode::Quirks + } else { + QuirksMode::NoQuirks + } + } + pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result, String> { let selector_list = parse_selector(selector)?; let mut caches = selectors::context::SelectorCaches::default(); @@ -626,7 +641,7 @@ impl DomTree { MatchingMode::Normal, None, &mut caches, - QuirksMode::NoQuirks, + self.selector_quirks_mode(), NeedsSelectorFlags::No, MatchingForInvalidation::No, ); @@ -729,6 +744,34 @@ mod tests { assert!(result.is_none()); } + #[test] + fn quirks_mode_matches_class_and_id_case_insensitively() { + // No doctype => quirks mode; class/id match ASCII case-insensitively. + let tree = parse_html(r#"
x
"#); + assert!( + tree.query_selector(".foo").unwrap().is_some(), + ".foo should match class=\"Foo\" in quirks mode" + ); + assert!( + tree.query_selector("#bar").unwrap().is_some(), + "#bar should match id=\"Bar\" in quirks mode" + ); + } + + #[test] + fn standards_mode_matches_class_and_id_case_sensitively() { + // With a doctype => no-quirks; class/id remain case-sensitive. + let tree = parse_html(r#"
x
"#); + assert!( + tree.query_selector(".foo").unwrap().is_none(), + ".foo must NOT match class=\"Foo\" in standards mode" + ); + assert!( + tree.query_selector("#bar").unwrap().is_none(), + "#bar must NOT match id=\"Bar\" in standards mode" + ); + } + #[test] fn test_query_selector_complex() { let tree = parse_html( diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs index 40ce783..eeef499 100644 --- a/crates/obscura-dom/src/tree.rs +++ b/crates/obscura-dom/src/tree.rs @@ -207,6 +207,9 @@ pub(crate) struct DomTreeInner { pub(crate) free_list: Vec, pub(crate) document: NodeId, pub(crate) id_index: HashMap, + // Whether the document was parsed in (full) quirks mode. In quirks mode CSS + // class and id selectors match ASCII-case-insensitively. + pub(crate) quirks: bool, } impl DomTree { @@ -226,6 +229,7 @@ impl DomTree { free_list: Vec::new(), document: NodeId(0), id_index: HashMap::new(), + quirks: false, }), } } @@ -234,6 +238,17 @@ impl DomTree { self.inner.borrow().document } + /// Record whether the document was parsed in (full) quirks mode. + pub fn set_quirks(&self, quirks: bool) { + self.inner.borrow_mut().quirks = quirks; + } + + /// Whether the document is in (full) quirks mode, in which CSS class and id + /// selectors match ASCII-case-insensitively. + pub fn is_quirks(&self) -> bool { + self.inner.borrow().quirks + } + pub(crate) fn borrow_inner(&self) -> std::cell::Ref<'_, DomTreeInner> { self.inner.borrow() } @@ -533,12 +548,20 @@ impl DomTree { inner.id_index.remove(&id_str); } + // Only free slots that are currently live. Freeing an out-of-range id + // would panic on direct indexing, and freeing an already-freed slot + // would push it onto the free list a second time — later handing the + // same NodeId to two live nodes (aliasing). for desc_id in descendants { - inner.nodes[desc_id.index()] = None; - inner.free_list.push(desc_id.0); + if matches!(inner.nodes.get(desc_id.index()), Some(Some(_))) { + inner.nodes[desc_id.index()] = None; + inner.free_list.push(desc_id.0); + } + } + if matches!(inner.nodes.get(node_id.index()), Some(Some(_))) { + inner.nodes[node_id.index()] = None; + inner.free_list.push(node_id.0); } - inner.nodes[node_id.index()] = None; - inner.free_list.push(node_id.0); } pub fn children(&self, node_id: NodeId) -> Vec { @@ -996,6 +1019,30 @@ mod tests { assert!(node.is_document()); } + #[test] + fn remove_out_of_range_id_is_a_noop() { + let tree = DomTree::new(); + // Direct indexing into `nodes` panicked out-of-bounds for an id past the + // end of the slot vector; it must be a no-op instead. + tree.remove(NodeId::new(9999)); + assert_eq!(tree.len(), 1); + } + + #[test] + fn remove_twice_does_not_alias_slots() { + let tree = DomTree::new(); + let doc = tree.document(); + let a = tree.new_node(NodeData::Text { contents: "a".into() }); + tree.append_child(doc, a); + tree.remove(a); + // Removing the already-freed node again must not push its slot onto the + // free list a second time, or two later allocations alias one slot. + tree.remove(a); + let x = tree.new_node(NodeData::Text { contents: "x".into() }); + let y = tree.new_node(NodeData::Text { contents: "y".into() }); + assert_ne!(x, y, "double-free aliased two live nodes onto the same slot"); + } + #[test] fn test_append_child() { let tree = DomTree::new(); diff --git a/crates/obscura-dom/src/tree_sink.rs b/crates/obscura-dom/src/tree_sink.rs index 674732f..8720fd5 100644 --- a/crates/obscura-dom/src/tree_sink.rs +++ b/crates/obscura-dom/src/tree_sink.rs @@ -218,7 +218,10 @@ impl TreeSink for DomTree { x == y } - fn set_quirks_mode(&self, _mode: QuirksMode) { + fn set_quirks_mode(&self, mode: QuirksMode) { + // Only full quirks mode makes CSS class/id selectors case-insensitive; + // limited-quirks behaves like no-quirks for selector matching. + self.set_quirks(mode == QuirksMode::Quirks); } fn is_mathml_annotation_xml_integration_point(&self, target: &NodeId) -> bool { @@ -243,10 +246,14 @@ pub fn parse_html(html: &str) -> DomTree { } pub fn parse_fragment(html: &str) -> DomTree { - use html5ever::tendril::TendrilSink; - use html5ever::{parse_fragment, ParseOpts, QualName}; - let context_name = QualName::new(None, ns!(html), local_name!("body")); + parse_fragment_with_context(html, context_name) +} + +pub fn parse_fragment_with_context(html: &str, context_name: QualName) -> DomTree { + use html5ever::tendril::TendrilSink; + use html5ever::{parse_fragment, ParseOpts}; + let tree = DomTree::new(); parse_fragment(tree, ParseOpts::default(), context_name, vec![]) .from_utf8() diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 404e52e..72a13ac 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -24,7 +24,7 @@ '_isSpecialScheme', '_applyDocQueryEncoding', '_anchorBase', '_elemHrefURL', '_setElemHrefPart', '_pad', '_daysInMonth', '_isoWeek1Monday', '_inputParseNumber', '_inputFormatNumber', - '_htmlAttrName', '_convertNodes', '_elementClassFor', '_wrap', '_wrapEl', + '_htmlAttrName', '_convertNodes', '_parseHTMLFragment', '_elementClassFor', '_wrap', '_wrapEl', '_resolveUrl', '_registerIframe', '_base64ToUint8Array', '_bodyToUint8Array', '_arrayBufferFromBytes', '_installWasmStreamingFallback', '_urlParseOp', '_urlSetOp', @@ -36,6 +36,18 @@ '_rngAncestors', '_rngOrder', '_rngCmp', '_rngCheckOffset', '_idbRequest', '_idbObjectStore', '_idbTransaction', '_idbDatabase', '_makeListenerBox', + // WebIDL interfaces. A real browser exposes these on the global as + // enumerable:false; here they were assigned with `globalThis.X = X`, which + // defaults to enumerable:true and is detectable in one line: + // Object.getOwnPropertyDescriptor(window, 'Node').enumerable + // Pre-declaring them non-enumerable here is enough -- per the note above, + // the later `globalThis.X = X` assignments only update the value. + 'Node', 'Element', 'Document', 'DocumentFragment', 'DocumentType', + 'Text', 'Comment', 'CDATASection', 'ProcessingInstruction', 'CharacterData', + 'CSSStyleDeclaration', 'DOMTokenList', 'Screen', 'NetworkInformation', + 'MessageChannel', 'MessagePort', 'CustomElementRegistry', + 'XMLHttpRequestEventTarget', 'HTMLMediaElement', 'HTMLVideoElement', + 'HTMLAudioElement', 'WebGL2RenderingContext', ]; var _desc = { value: undefined, writable: true, enumerable: false, configurable: true }; for (var _i = 0; _i < _names.length; _i++) { @@ -456,12 +468,24 @@ const _scheduleAfter = (delay, fn) => { else Deno.core.ops.op_sleep(d).then(fn); }; +// Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF +// `acw_sc__v2` challenge drives `setTimeout('reload(arg2)', 2)`). A string is +// compiled and run in global scope, identical to a real browser; otherwise the +// call silently no-ops and JS-triggered navigations (cookie → reload) never fire. +const _coerceTimerFn = (fn) => { + if (typeof fn === "string") { + try { return new Function(fn); } catch (_) { return null; } + } + return typeof fn === "function" ? fn : null; +}; + globalThis.setTimeout = (fn, delay = 0, ...args) => { - if (typeof fn !== "function") return ++_tid; + const f = _coerceTimerFn(fn); + if (f === null) return ++_tid; const id = ++_tid; _scheduleAfter(delay, () => { if (_clearedTimers.has(id)) return; - try { fn(...args); } catch(e) { console.error("Timer error:", e); } + try { f(...args); } catch(e) { console.error("Timer error:", e); } }); return id; }; @@ -469,12 +493,13 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => { globalThis.clearTimeout = (id) => { _clearedTimers.add(id); }; globalThis.setInterval = (fn, delay = 0, ...args) => { - if (typeof fn !== "function") return ++_tid; + const f = _coerceTimerFn(fn); + if (f === null) return ++_tid; const id = ++_tid; _intervals.add(id); const tick = () => { if (!_intervals.has(id)) return; - try { fn(...args); } catch(e) { console.error("Interval error:", e); } + try { f(...args); } catch(e) { console.error("Interval error:", e); } if (!_intervals.has(id)) return; _scheduleAfter(delay, tick); }; @@ -564,35 +589,75 @@ const _CSS_PROPERTY_NAMES = [ ]; const _CSS_PROP_SET = new Set(_CSS_PROPERTY_NAMES); +// Parse a `style` attribute string (`"color: red; margin: 5px"`) into the given +// dashed-key store, replacing its contents in place. +function _parseCssInto(props, text) { + for (const k in props) delete props[k]; + if (text) String(text).split(";").forEach((p) => { + const i = p.indexOf(":"); + if (i > 0) { const k = p.slice(0, i).trim(); const v = p.slice(i + 1).trim(); if (k && v) props[_cssCamelToKebab(k)] = v; } + }); +} +function _serializeCss(props) { + const e = Object.entries(props); + return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : ""; +} + class CSSStyleDeclaration { - constructor() { - // Non-enumerable so it never leaks through the proxy's own-key traps. + constructor(owner) { + // Non-enumerable so they never leak through the proxy's own-key traps. Object.defineProperty(this, "_props", { value: {}, writable: true, enumerable: false, configurable: true }); + // The owner Element, if any. A live declaration reflects that element's + // `style` content attribute in both directions; an owner-less declaration + // (getComputedStyle fallback, stylesheet rules) is purely in-memory. + Object.defineProperty(this, "_owner", { value: owner || null, writable: true, enumerable: false, configurable: true }); + // Load the content attribute only when style is first observed. Keeping + // this as a primitive avoids allocating a separate sync object for every + // wrapped element. + Object.defineProperty(this, "_loaded", { value: !owner, writable: true, enumerable: false, configurable: true }); + } + // Pull the initial `style` attribute once. Later attribute mutations update + // the declaration directly from Element.setAttribute/removeAttribute, so + // repeated style reads do not cross the JS/Rust op boundary. + _pull() { + if (this._loaded) return; + _parseCssInto(this._props, this._owner.getAttribute("style")); + this._loaded = true; + } + _replaceFromAttribute(text) { + _parseCssInto(this._props, text); + this._loaded = true; + } + // Serialize `_props` back onto the owner's `style` attribute after a mutation, + // so el.style.x = … and cssText reflect into getAttribute('style') and + // serialization. No-op when owner-less. + _push() { + const o = this._owner; + if (!o) return; + const text = _serializeCss(this._props); + if (text) o.setAttribute("style", text); + else o.removeAttribute("style"); } // Storage is keyed by the dashed CSS name, matching CSSOM. The proxy maps the // camelCase IDL access (el.style.fontSize) onto the dashed key (font-size), so // getPropertyValue('font-size') and el.style.fontSize stay in sync. setProperty(name, value) { + this._pull(); const k = _cssCamelToKebab(String(name)); - if (value === "" || value == null) { delete this._props[k]; return; } - this._props[k] = String(value); + if (value === "" || value == null) delete this._props[k]; + else this._props[k] = String(value); + this._push(); } - removeProperty(name) { const k = _cssCamelToKebab(String(name)); const old = this._props[k]; delete this._props[k]; return old || ""; } - getPropertyValue(name) { return this._props[_cssCamelToKebab(String(name))] || ""; } + removeProperty(name) { this._pull(); const k = _cssCamelToKebab(String(name)); const old = this._props[k]; delete this._props[k]; this._push(); return old || ""; } + getPropertyValue(name) { this._pull(); return this._props[_cssCamelToKebab(String(name))] || ""; } getPropertyPriority() { return ""; } - get cssText() { - const e = Object.entries(this._props); - return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : ""; - } + get cssText() { this._pull(); return _serializeCss(this._props); } set cssText(v) { - for (const k in this._props) delete this._props[k]; - if (v) String(v).split(";").forEach((p) => { - const i = p.indexOf(":"); - if (i > 0) { const k = p.slice(0, i).trim(); const val = p.slice(i + 1).trim(); if (k && val) this._props[_cssCamelToKebab(k)] = val; } - }); + _parseCssInto(this._props, v); + this._push(); } - get length() { return Object.keys(this._props).length; } - item(i) { return Object.keys(this._props)[i] || ""; } + get length() { this._pull(); return Object.keys(this._props).length; } + item(i) { this._pull(); return Object.keys(this._props)[i] || ""; } } const _styleProxy = (decl) => new Proxy(decl, { @@ -603,6 +668,7 @@ const _styleProxy = (decl) => new Proxy(decl, { }, set(t, p, v) { if (typeof p === "symbol") { t[p] = v; return true; } + if (p === "_loaded") { t._loaded = v; return true; } if (p === "cssText") { t.cssText = v; return true; } if (/^\d+$/.test(p) || p in Object.getPrototypeOf(t)) return true; t.setProperty(p, v); @@ -611,11 +677,13 @@ const _styleProxy = (decl) => new Proxy(decl, { has(t, p) { if (typeof p !== "string") return Reflect.has(t, p); if (p in Object.getPrototypeOf(t)) return true; + t._pull(); if (_cssCamelToKebab(p) in t._props) return true; if (_CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) return true; return /^\d+$/.test(p) && +p < t.length; }, ownKeys(t) { + t._pull(); const keys = []; const n = t.length; for (let i = 0; i < n; i++) keys.push(String(i)); @@ -626,6 +694,7 @@ const _styleProxy = (decl) => new Proxy(decl, { }, getOwnPropertyDescriptor(t, p) { if (typeof p !== "string") return Reflect.getOwnPropertyDescriptor(t, p); + t._pull(); if (/^\d+$/.test(p) && +p < t.length) return { value: t.item(+p), writable: false, enumerable: true, configurable: true }; if (_cssCamelToKebab(p) in t._props || _CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) { return { value: t.getPropertyValue(p), writable: true, enumerable: true, configurable: true }; @@ -634,6 +703,33 @@ const _styleProxy = (decl) => new Proxy(decl, { }, }); +// Clone a single node (no children), used by Node.cloneNode. Elements are built +// with createElement/createElementNS and their content attributes copied, so no +// HTML parsing context is involved and every attribute (including style) is +// preserved. Text/Comment/DocumentFragment map to their factory; anything else +// yields null. +function _shallowCloneNode(node) { + const nt = node.nodeType; + if (nt === 3) return document.createTextNode(node.data != null ? node.data : (node.textContent || "")); + if (nt === 8) return document.createComment(node.data != null ? node.data : (node.nodeValue || "")); + if (nt === 11) return document.createDocumentFragment(); + if (nt !== 1) return null; + const ns = node.namespaceURI; + const el = (ns && ns !== "http://www.w3.org/1999/xhtml") + ? document.createElementNS(ns, node.nodeName) + : document.createElement(node.localName || node.nodeName.toLowerCase()); + const names = node.getAttributeNames ? node.getAttributeNames() : []; + for (const name of names) { + const v = node.getAttribute(name); + if (v !== null) el.setAttribute(name, v); + } + // CSS declarations currently live on the JS wrapper independently of the + // DOM attribute. Copy that state as well so styles assigned through + // `node.style` survive cloning even before attribute reflection runs. + if (node.style && node.style.cssText) el.style.cssText = node.style.cssText; + return el; +} + class Node { static ELEMENT_NODE = 1; static ATTRIBUTE_NODE = 2; @@ -824,32 +920,38 @@ class Node { contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; } hasChildNodes() { return _dom("has_child_nodes", this._nid) === "true"; } cloneNode(deep) { - const t = this.nodeType; - if (t === 1) { - if (deep) { - const wrapper = document.createElement('div'); - wrapper.innerHTML = _domParse("outer_html", this._nid) || ""; - const clone = wrapper.firstChild; - return clone; - } - const el = document.createElement(this.nodeName.toLowerCase()); - const html = _domParse("outer_html", this._nid) || ""; - const attrMatch = html.match(/^<[a-zA-Z][^\s>]*([\s\S]*?)>/); - if (attrMatch && attrMatch[1]) { - const attrStr = attrMatch[1].trim(); - const re = /([a-zA-Z_:][a-zA-Z0-9_.:-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g; - let m; - while ((m = re.exec(attrStr)) !== null) { - const name = m[1]; - const val = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] || ""; - if (name !== this.nodeName.toLowerCase()) el.setAttribute(name, val); + // Clone structurally via real DOM nodes rather than round-tripping through a + // throwaway
.innerHTML: the fragment parser discards elements that are + // not valid children of
(, ,