From 71b9b4fbab2a081368869eb7c660cabf27ff188b Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Sun, 24 May 2026 20:14:17 +0000 Subject: [PATCH] fix(js,dom): scope element queries, real timers, form/prepend/equal (#180) - Element.querySelector / querySelectorAll scope to the receiver subtree via a new query_selector_scoped Rust op, not always document root. Same for DocumentFragment. - setTimeout respects its delay argument via a new async op_sleep. setInterval is a real repeating timer instead of an alias to setTimeout. - document.forms / images / links return live querySelectorAll results instead of hardcoded []. - HTMLFormElement subclasses Element with .elements / .length, and form nodes are wrapped as HTMLFormElement so instanceof works. submit() stays on Element.prototype to preserve the existing submit-event dispatch and form-data navigation path. - Element.prepend has a real implementation. - Node.isEqualNode does a structural compare (nodeType, nodeName, nodeValue, attributes, recursive children) instead of nid identity. - Fix long-standing insert_before arg-order bug: bootstrap.js was passing (parent, new, ref) but _dom only forwards two args, so ref was silently dropped. Pass (new, ref) directly. Same fix in replaceChild. Adds 3 selector unit tests and 6 runtime regression tests covering the JS shim fixes. Closes #105. --- crates/obscura-dom/src/selector.rs | 59 +++++++++++++-- crates/obscura-js/js/bootstrap.js | 108 +++++++++++++++++++++------- crates/obscura-js/src/ops.rs | 17 +++++ crates/obscura-js/src/runtime.rs | 111 +++++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 31 deletions(-) diff --git a/crates/obscura-dom/src/selector.rs b/crates/obscura-dom/src/selector.rs index d0298c0..da72f08 100644 --- a/crates/obscura-dom/src/selector.rs +++ b/crates/obscura-dom/src/selector.rs @@ -500,6 +500,14 @@ pub fn parse_selector(selector: &str) -> Result, S impl DomTree { pub fn query_selector(&self, selector: &str) -> Result, String> { + self.query_selector_from(self.document(), selector) + } + + pub fn query_selector_all(&self, selector: &str) -> Result, String> { + self.query_selector_all_from(self.document(), selector) + } + + pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result, String> { let selector_list = parse_selector(selector)?; let mut caches = selectors::context::SelectorCaches::default(); let mut context = MatchingContext::new( @@ -511,8 +519,7 @@ impl DomTree { MatchingForInvalidation::No, ); - let doc = self.document(); - for desc_id in self.descendants(doc) { + for desc_id in self.descendants(root) { let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false); if is_element { let element = DomElement::new(self, desc_id); @@ -528,7 +535,7 @@ impl DomTree { Ok(None) } - pub fn query_selector_all(&self, selector: &str) -> Result, String> { + 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(); let mut context = MatchingContext::new( @@ -541,8 +548,7 @@ impl DomTree { ); let mut results = Vec::new(); - let doc = self.document(); - for desc_id in self.descendants(doc) { + for desc_id in self.descendants(root) { let is_element = self.with_node(desc_id, |n| n.is_element()).unwrap_or(false); if is_element { let element = DomElement::new(self, desc_id); @@ -638,4 +644,47 @@ mod tests { let results = tree.query_selector_all(".list .item.active").unwrap(); assert_eq!(results.len(), 2); } + + #[test] + fn test_query_selector_all_from_scopes_to_subtree() { + let tree = parse_html( + r#"
in a
in b
"#, + ); + let a = tree.get_element_by_id("a").expect("div#a"); + let b = tree.get_element_by_id("b").expect("div#b"); + + // Document-rooted: sees both spans. + assert_eq!(tree.query_selector_all(".x").unwrap().len(), 2); + // Scoped to #a: only its descendant span. + let in_a = tree.query_selector_all_from(a, ".x").unwrap(); + assert_eq!(in_a.len(), 1); + let in_b = tree.query_selector_all_from(b, ".x").unwrap(); + assert_eq!(in_b.len(), 1); + assert_ne!(in_a[0], in_b[0]); + } + + #[test] + fn test_query_selector_from_returns_first_in_subtree_only() { + let tree = parse_html( + r#"

first

second

outside

"#, + ); + let s = tree.get_element_by_id("s").expect("section#s"); + + // Scoped to #s: skip the outside paragraph; return the first inside. + let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside"); + assert_eq!(tree.text_content(first_in_s), "first"); + } + + #[test] + fn test_query_selector_from_excludes_self() { + // The root element itself must not match its own scoped query, per + // the spec: querySelector matches descendants only. + let tree = parse_html(r#"
child
"#); + let root = tree.get_element_by_id("root").expect("div#root"); + + // Only descendants are candidates: `.x` on root finds nothing. + assert!(tree.query_selector_from(root, ".x").unwrap().is_none()); + // `span` finds the child. + assert!(tree.query_selector_from(root, "span").unwrap().is_some()); + } } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index d2a9d59..15c84aa 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -146,27 +146,42 @@ globalThis.console = { }; let _tid = 0; -const _pendingTimers = new Map(); const _clearedTimers = new Set(); +const _intervals = new Set(); + +const _scheduleAfter = (delay, fn) => { + const d = Math.max(0, Number(delay) || 0); + if (d === 0) Promise.resolve().then(fn); + else Deno.core.ops.op_sleep(d).then(fn); +}; globalThis.setTimeout = (fn, delay = 0, ...args) => { if (typeof fn !== "function") return ++_tid; const id = ++_tid; - _pendingTimers.set(id, { fn, args, delay }); - Promise.resolve().then(() => { - if (!_clearedTimers.has(id) && _pendingTimers.has(id)) { - _pendingTimers.delete(id); - try { fn(...args); } catch(e) { console.error("Timer error:", e); } - } + _scheduleAfter(delay, () => { + if (_clearedTimers.has(id)) return; + try { fn(...args); } catch(e) { console.error("Timer error:", e); } }); return id; }; -globalThis.clearTimeout = (id) => { _clearedTimers.add(id); _pendingTimers.delete(id); }; -globalThis.setInterval = (fn, delay, ...args) => { - return setTimeout(fn, delay, ...args); +globalThis.clearTimeout = (id) => { _clearedTimers.add(id); }; + +globalThis.setInterval = (fn, delay = 0, ...args) => { + if (typeof fn !== "function") 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); } + if (!_intervals.has(id)) return; + _scheduleAfter(delay, tick); + }; + _scheduleAfter(delay, tick); + return id; }; -globalThis.clearInterval = globalThis.clearTimeout; + +globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); }; globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0); globalThis.cancelAnimationFrame = globalThis.clearTimeout; globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn)); @@ -292,14 +307,14 @@ class Node { } replaceChild(newChild, oldChild) { if (!oldChild || !newChild) return oldChild; - _dom("insert_before", this._nid, newChild._nid, oldChild._nid); + _dom("insert_before", newChild._nid, oldChild._nid); _dom("remove_child", oldChild._nid); return oldChild; } insertBefore(n, ref) { if (!n) return n; if (!ref) { this.appendChild(n); return n; } - _dom("insert_before", this._nid, n._nid, ref._nid); + _dom("insert_before", n._nid, ref._nid); return n; } contains(o) { return o ? _dom("contains", this._nid, o._nid) === "true" : false; } @@ -341,7 +356,28 @@ class Node { } getRootNode() { return globalThis.document; } normalize() {} // no-op - isEqualNode(other) { return other && this._nid === other._nid; } + isEqualNode(other) { + if (!other) return false; + if (this._nid === other._nid) return true; + if (this.nodeType !== other.nodeType) return false; + if (this.nodeName !== other.nodeName) return false; + if (this.nodeValue !== other.nodeValue) return false; + const a = this.attributes ? this.attributes : null; + const b = other.attributes ? other.attributes : null; + if ((a && a.length) || (b && b.length)) { + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (other.getAttribute(a[i].name) !== a[i].value) return false; + } + } + const cA = this.childNodes || []; + const cB = other.childNodes || []; + if (cA.length !== cB.length) return false; + for (let i = 0; i < cA.length; i++) { + if (!cA[i].isEqualNode(cB[i])) return false; + } + return true; + } isSameNode(other) { return other && this._nid === other._nid; } addEventListener() {} removeEventListener() {} dispatchEvent() { return true; } } @@ -464,9 +500,9 @@ class Element extends Node { hasAttribute(n) { return this.getAttribute(n) !== null; } hasAttributes() { return true; } // Simplified getAttributeNS(ns, n) { return this.getAttribute(n); } - querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } + querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); } querySelectorAll(s) { - const ids = _domParse("query_selector_all", s) || []; + const ids = _domParse("query_selector_all_scoped", this._nid, s) || []; const list = ids.map(_wrapEl).filter(Boolean); list.item = (i) => list[i] || null; list.forEach = Array.prototype.forEach.bind(list); @@ -791,7 +827,14 @@ class Element extends Node { } remove() { if (this.parentNode) this.parentNode.removeChild(this); } append(...nodes) { for (const n of nodes) { if (typeof n === "string") this.appendChild(document.createTextNode(n)); else this.appendChild(n); } } - prepend() {} + prepend(...nodes) { + const ref = this.firstChild; + for (const n of nodes) { + const node = (typeof n === "string") ? document.createTextNode(n) : n; + if (ref) this.insertBefore(node, ref); + else this.appendChild(node); + } + } } class Document extends Node { @@ -1005,9 +1048,9 @@ class Document extends Node { }; } get styleSheets() { return []; } - get forms() { return []; } - get images() { return []; } - get links() { return []; } + get forms() { return this.querySelectorAll("form"); } + get images() { return this.querySelectorAll("img"); } + get links() { return this.querySelectorAll("a[href], area[href]"); } get scripts() { return this.querySelectorAll("script"); } get cookie() { return Deno.core.ops.op_get_cookies(); @@ -1048,9 +1091,9 @@ class DocumentFragment extends Node { get nodeName() { return "#document-fragment"; } get innerHTML() { return _domParse("inner_html", this._nid) ?? ""; } set innerHTML(v) { _dom("set_inner_html", this._nid, String(v ?? "")); } - querySelector(s) { return _wrapEl(+_dom("query_selector", s)); } + querySelector(s) { return _wrapEl(+_dom("query_selector_scoped", this._nid, s)); } querySelectorAll(s) { - const ids = _domParse("query_selector_all", s) || []; + const ids = _domParse("query_selector_all_scoped", this._nid, s) || []; const list = ids.map(_wrapEl).filter(Boolean); list.item = (i) => list[i] || null; return list; @@ -1085,12 +1128,17 @@ class DocumentType extends Node { } const _cache = new Map(); +function _elementClassFor(nid) { + const tag = _domParse("tag_name", nid); + if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement; + return Element; +} function _wrap(nid) { if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null; if (_cache.has(nid)) return _cache.get(nid); const t = +_dom("node_type", nid); let n; - if (t === 1) n = new Element(nid); + if (t === 1) { const C = _elementClassFor(nid); n = new C(nid); } else if (t === 3) n = new Text(nid); else if (t === 8) n = new Comment(nid); else if (t === 9) n = new Document(nid); @@ -1101,7 +1149,8 @@ function _wrap(nid) { function _wrapEl(nid) { if (nid < 0 || nid === null || nid === undefined || isNaN(nid)) return null; if (_cache.has(nid)) return _cache.get(nid); - const n = new Element(nid); + const C = _elementClassFor(nid); + const n = new C(nid); _cache.set(nid, n); return n; } @@ -2124,7 +2173,13 @@ globalThis.HTMLAnchorElement = Element; globalThis.HTMLImageElement = Element; globalThis.HTMLInputElement = Element; globalThis.HTMLButtonElement = Element; -globalThis.HTMLFormElement = Element; +globalThis.HTMLFormElement = class HTMLFormElement extends Element { + get elements() { return this.querySelectorAll("input, select, textarea, button, fieldset, output, object"); } + get length() { return this.elements.length; } + // Inherit submit() from Element.prototype: it dispatches the cancelable + // 'submit' event and (if not prevented) builds form data and navigates. + reset() { for (const f of this.elements) { if ('value' in f) f.value = ''; } } +}; globalThis.HTMLSelectElement = Element; globalThis.HTMLTextAreaElement = Element; globalThis.HTMLLabelElement = Element; @@ -2193,8 +2248,9 @@ globalThis.Range = class Range { setStart(){} setEnd(){} collapse(){} selectNode Element.prototype.focus, Element.prototype.blur, Element.prototype.cloneNode, Element.prototype.attachShadow, Element.prototype.insertAdjacentHTML, Element.prototype.scrollIntoView, - Element.prototype.append, Element.prototype.remove, + Element.prototype.append, Element.prototype.prepend, Element.prototype.remove, Element.prototype.before, Element.prototype.after, Element.prototype.replaceWith, + HTMLFormElement.prototype.reset, Element.prototype.getContext, Element.prototype.toDataURL, Element.prototype.toBlob, Node.prototype.appendChild, Node.prototype.removeChild, Node.prototype.replaceChild, Node.prototype.insertBefore, diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index c3e807f..24aa66e 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -120,6 +120,17 @@ fn op_dom(state: &OpState, #[string] cmd: String, #[string] arg1: String, #[stri .map(|ids| ids.iter().map(|id| id.index() as i32).collect()).unwrap_or_default(); serde_json::to_string(&ids).unwrap_or("[]".into()) } + "query_selector_scoped" => { + let root_nid = arg1.parse::().unwrap_or(0); + dom.query_selector_from(NodeId::new(root_nid), &arg2).ok().flatten() + .map(|id| id.index().to_string()).unwrap_or("-1".into()) + } + "query_selector_all_scoped" => { + let root_nid = arg1.parse::().unwrap_or(0); + let ids: Vec = dom.query_selector_all_from(NodeId::new(root_nid), &arg2).ok() + .map(|ids| ids.iter().map(|id| id.index() as i32).collect()).unwrap_or_default(); + serde_json::to_string(&ids).unwrap_or("[]".into()) + } "node_type" => { let nid = arg1.parse::().unwrap_or(0); dom.get_node(NodeId::new(nid)).map(|n| match &n.data { @@ -742,6 +753,11 @@ fn op_navigate(state: &OpState, #[string] url: &str, #[string] method: &str, #[s gs.pending_navigation = Some((url.to_string(), method.to_string(), body.to_string())); } +#[op2(async)] +async fn op_sleep(#[number] millis: u64) { + tokio::time::sleep(std::time::Duration::from_millis(millis)).await; +} + pub fn build_extension() -> Extension { Extension { name: "obscura_dom", @@ -752,6 +768,7 @@ pub fn build_extension() -> Extension { op_get_cookies(), op_set_cookie(), op_navigate(), + op_sleep(), ]), ..Default::default() } diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index c7df9eb..f6e49dc 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -965,6 +965,117 @@ mod tests { assert_eq!(text, serde_json::json!("BODY_TEXT")); } + /// Regression for #105: `element.querySelector` and `querySelectorAll` + /// must scope to the receiver's subtree, not the whole document. + #[test] + fn element_query_selector_is_scoped_to_subtree() { + let mut rt = setup_runtime( + r#"
in a
in b
"#, + ); + let text = rt + .evaluate("document.getElementById('a').querySelector('.x').textContent") + .unwrap(); + assert_eq!(text, serde_json::json!("in a")); + + let count_in_a = rt + .evaluate("document.getElementById('a').querySelectorAll('.x').length") + .unwrap(); + assert_eq!(count_in_a.as_f64().unwrap() as i64, 1); + + // Document-scoped query still sees both. + let count_doc = rt.evaluate("document.querySelectorAll('.x').length").unwrap(); + assert_eq!(count_doc.as_f64().unwrap() as i64, 2); + } + + /// Regression for #105: `document.forms` / `images` / `links` must be + /// live, not hardcoded `[]`. jQuery 1.x's submit-event setup iterates + /// `document.forms` and crashes when it's empty for pages that have forms. + #[test] + fn document_forms_images_links_are_live() { + let mut rt = setup_runtime( + r#"
lno-href"#, + ); + assert_eq!(rt.evaluate("document.forms.length").unwrap().as_f64().unwrap() as i64, 2); + assert_eq!(rt.evaluate("document.images.length").unwrap().as_f64().unwrap() as i64, 1); + assert_eq!(rt.evaluate("document.links.length").unwrap().as_f64().unwrap() as i64, 1); + } + + /// Regression for #105: `HTMLFormElement` must expose `.elements` so + /// frameworks that probe form field collections work. + #[test] + fn html_form_element_exposes_elements_collection() { + let mut rt = setup_runtime( + r#"
"#, + ); + let n = rt + .evaluate("document.getElementById('f').elements.length") + .unwrap(); + assert_eq!(n.as_f64().unwrap() as i64, 3); + let is_form = rt + .evaluate("document.getElementById('f') instanceof HTMLFormElement") + .unwrap(); + assert_eq!(is_form, serde_json::json!(true)); + } + + /// Regression for #105: `Element.prepend` must actually insert at the + /// start, not silently no-op. + #[test] + fn element_prepend_inserts_at_start() { + let mut rt = setup_runtime(r#"
existing
"#); + rt.evaluate( + r#" + const c = document.getElementById('c'); + const n = document.createElement('span'); + n.id = 'first'; + c.prepend(n); + "#, + ) + .unwrap(); + let first_id = rt.evaluate("document.getElementById('c').firstChild.id").unwrap(); + assert_eq!(first_id, serde_json::json!("first")); + let count = rt.evaluate("document.getElementById('c').childNodes.length").unwrap(); + assert_eq!(count.as_f64().unwrap() as i64, 2); + } + + /// Regression for #105: `isEqualNode` compares structure, not identity. + /// Framework diff algorithms rely on this. + #[test] + fn is_equal_node_does_structural_compare() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#" + const a = document.createElement('div'); a.setAttribute('class', 'x'); a.innerHTML = 'hi'; + const b = document.createElement('div'); b.setAttribute('class', 'x'); b.innerHTML = 'hi'; + const c = document.createElement('div'); c.innerHTML = 'bye'; + return [a.isEqualNode(b), a.isEqualNode(c), a.isSameNode(b)]; + "#, + ) + .unwrap(); + assert_eq!(result, serde_json::json!([true, false, false])); + } + + /// Regression for the long-standing insert_before arg-order bug noted + /// in CLAUDE.md: bootstrap.js was passing (parent, new, ref) but `_dom` + /// forwards only two args, silently dropping `ref`. With the fix, + /// `insertBefore` actually inserts. + #[test] + fn insert_before_inserts_node_at_correct_position() { + let mut rt = setup_runtime(r#"
bc
"#); + let order = rt + .evaluate( + r#" + const p = document.getElementById('p'); + const a = document.createElement('span'); + a.id = 'a'; + p.insertBefore(a, document.getElementById('b')); + return Array.from(p.children).map(e => e.id).join(','); + "#, + ) + .unwrap(); + assert_eq!(order, serde_json::json!("a,b,c")); + } + #[test] fn test_console_log() { let mut rt = setup_runtime("");