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