Merge remote-tracking branch 'origin/main' into maintainer/pr-492-fix

This commit is contained in:
SGavrl committed 2026-08-01 19:51:52 +00:00
commit 108e777418
5 files changed
+243 -37

No files matched your search

+55 -12
View File
@@ -580,16 +580,21 @@ impl DomTree {
// Fast path: a bare "#id" selector resolves through the id index in O(1) // 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 // 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. // in tree order per id, which is exactly what the full scan would return.
if let Some(id) = simple_id_selector(selector) { // In quirks mode `#id` matches ASCII-case-insensitively, but the id
match self.get_element_by_id(id) { // index is keyed on the exact-case id, so skip the fast path and let the
// querySelector matches strict descendants of root only, so the // selector engine (below) do the case-insensitive match.
// indexed element must have root among its ancestors. if !self.is_quirks() {
Some(nid) if self.ancestors(nid).contains(&root) => return Ok(Some(nid)), if let Some(id) = simple_id_selector(selector) {
// Index miss or stale entry (detached node) — fall through to full scan. match self.get_element_by_id(id) {
// The id_index is best-effort: it only registers nodes at creation time // querySelector matches strict descendants of root only, so the
// and does not update on reparent, so it can point to a detached clone // indexed element must have root among its ancestors.
// while the live node (with the same id) is elsewhere in the tree. 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)?; let selector_list = parse_selector(selector)?;
@@ -598,7 +603,7 @@ impl DomTree {
MatchingMode::Normal, MatchingMode::Normal,
None, None,
&mut caches, &mut caches,
QuirksMode::NoQuirks, self.selector_quirks_mode(),
NeedsSelectorFlags::No, NeedsSelectorFlags::No,
MatchingForInvalidation::No, MatchingForInvalidation::No,
); );
@@ -619,6 +624,16 @@ impl DomTree {
Ok(None) 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<Vec<NodeId>, String> { pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> {
let selector_list = parse_selector(selector)?; let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default(); let mut caches = selectors::context::SelectorCaches::default();
@@ -626,7 +641,7 @@ impl DomTree {
MatchingMode::Normal, MatchingMode::Normal,
None, None,
&mut caches, &mut caches,
QuirksMode::NoQuirks, self.selector_quirks_mode(),
NeedsSelectorFlags::No, NeedsSelectorFlags::No,
MatchingForInvalidation::No, MatchingForInvalidation::No,
); );
@@ -729,6 +744,34 @@ mod tests {
assert!(result.is_none()); 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#"<div class="Foo" id="Bar">x</div>"#);
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#"<!DOCTYPE html><div class="Foo" id="Bar">x</div>"#);
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] #[test]
fn test_query_selector_complex() { fn test_query_selector_complex() {
let tree = parse_html( let tree = parse_html(
+51 -4
View File
@@ -143,6 +143,9 @@ pub(crate) struct DomTreeInner {
pub(crate) free_list: Vec<u32>, pub(crate) free_list: Vec<u32>,
pub(crate) document: NodeId, pub(crate) document: NodeId,
pub(crate) id_index: HashMap<String, NodeId>, pub(crate) id_index: HashMap<String, NodeId>,
// 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 { impl DomTree {
@@ -162,6 +165,7 @@ impl DomTree {
free_list: Vec::new(), free_list: Vec::new(),
document: NodeId(0), document: NodeId(0),
id_index: HashMap::new(), id_index: HashMap::new(),
quirks: false,
}), }),
} }
} }
@@ -170,6 +174,17 @@ impl DomTree {
self.inner.borrow().document 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> { pub(crate) fn borrow_inner(&self) -> std::cell::Ref<'_, DomTreeInner> {
self.inner.borrow() self.inner.borrow()
} }
@@ -469,12 +484,20 @@ impl DomTree {
inner.id_index.remove(&id_str); 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 { for desc_id in descendants {
inner.nodes[desc_id.index()] = None; if matches!(inner.nodes.get(desc_id.index()), Some(Some(_))) {
inner.free_list.push(desc_id.0); 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<NodeId> { pub fn children(&self, node_id: NodeId) -> Vec<NodeId> {
@@ -932,6 +955,30 @@ mod tests {
assert!(node.is_document()); 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] #[test]
fn test_append_child() { fn test_append_child() {
let tree = DomTree::new(); let tree = DomTree::new();
+4 -1
View File
@@ -218,7 +218,10 @@ impl TreeSink for DomTree {
x == y 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 { fn is_mathml_annotation_xml_integration_point(&self, target: &NodeId) -> bool {
+35 -20
View File
@@ -456,12 +456,24 @@ const _scheduleAfter = (delay, fn) => {
else Deno.core.ops.op_sleep(d).then(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) => { globalThis.setTimeout = (fn, delay = 0, ...args) => {
if (typeof fn !== "function") return ++_tid; const f = _coerceTimerFn(fn);
if (f === null) return ++_tid;
const id = ++_tid; const id = ++_tid;
_scheduleAfter(delay, () => { _scheduleAfter(delay, () => {
if (_clearedTimers.has(id)) return; 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; return id;
}; };
@@ -469,12 +481,13 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => {
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); }; globalThis.clearTimeout = (id) => { _clearedTimers.add(id); };
globalThis.setInterval = (fn, delay = 0, ...args) => { globalThis.setInterval = (fn, delay = 0, ...args) => {
if (typeof fn !== "function") return ++_tid; const f = _coerceTimerFn(fn);
if (f === null) return ++_tid;
const id = ++_tid; const id = ++_tid;
_intervals.add(id); _intervals.add(id);
const tick = () => { const tick = () => {
if (!_intervals.has(id)) return; 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; if (!_intervals.has(id)) return;
_scheduleAfter(delay, tick); _scheduleAfter(delay, tick);
}; };
@@ -4226,13 +4239,13 @@ if (typeof Response === 'undefined') {
} }
if (!Element.prototype.replaceWith) { if (!Element.prototype.replaceWith) {
// _convertNodes turns any non-node argument (numbers, booleans, null, …) into
// a Text node via String(n), matching the spec and append()/prepend(); the
// old `typeof n === 'string'` check corrupted insert_before for other types.
Element.prototype.replaceWith = function(...nodes) { Element.prototype.replaceWith = function(...nodes) {
const parent = this.parentNode; const parent = this.parentNode;
if (!parent) return; if (!parent) return;
for (const n of nodes) { for (const n of _convertNodes(nodes)) parent.insertBefore(n, this);
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this);
else parent.insertBefore(n, this);
}
parent.removeChild(this); parent.removeChild(this);
}; };
_markNative(Element.prototype.replaceWith); _markNative(Element.prototype.replaceWith);
@@ -4241,10 +4254,7 @@ if (!Element.prototype.before) {
Element.prototype.before = function(...nodes) { Element.prototype.before = function(...nodes) {
const parent = this.parentNode; const parent = this.parentNode;
if (!parent) return; if (!parent) return;
for (const n of nodes) { for (const n of _convertNodes(nodes)) parent.insertBefore(n, this);
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), this);
else parent.insertBefore(n, this);
}
}; };
_markNative(Element.prototype.before); _markNative(Element.prototype.before);
} }
@@ -4253,10 +4263,7 @@ if (!Element.prototype.after) {
const parent = this.parentNode; const parent = this.parentNode;
if (!parent) return; if (!parent) return;
const ref = this.nextSibling; const ref = this.nextSibling;
for (const n of nodes) { for (const n of _convertNodes(nodes)) parent.insertBefore(n, ref);
if (typeof n === 'string') parent.insertBefore(document.createTextNode(n), ref);
else parent.insertBefore(n, ref);
}
}; };
_markNative(Element.prototype.after); _markNative(Element.prototype.after);
} }
@@ -5498,11 +5505,16 @@ globalThis.XMLSerializer = class XMLSerializer {
}; };
globalThis.performance = globalThis.performance || { globalThis.performance = globalThis.performance || {
now: (function() { now: (function() {
var _lastMs = -1, _sub = 0; // Monotonically non-decreasing: return the wall-clock offset, but never a
// value below the last one. Equal readings are allowed, and avoiding a
// synthetic per-call increment keeps tight loops from advancing the clock
// faster than real elapsed time.
var _last = -Infinity;
return function() { return function() {
var ms = Date.now() - (globalThis.performance.timeOrigin || 0); var ms = Date.now() - (globalThis.performance.timeOrigin || 0);
if (ms !== _lastMs) { _lastMs = ms; _sub = 0; } else { _sub += 0.1; } if (ms < _last) return _last;
return ms + _sub; _last = ms;
return _last;
}; };
})(), })(),
mark(){}, measure(){}, mark(){}, measure(){},
@@ -5751,7 +5763,10 @@ globalThis.atob = globalThis.atob || ((s) => { const c="ABCDEFGHIJKLMNOPQRSTUVWX
const stack = [{state: null, url: undefined}]; // initial entry; url=undefined means "use document URL" const stack = [{state: null, url: undefined}]; // initial entry; url=undefined means "use document URL"
let idx = 0; let idx = 0;
const resolveOrFallback = (url) => { const resolveOrFallback = (url) => {
if (url === null || url === undefined) return undefined; // A missing url (pushState/replaceState called with < 3 args) keeps the
// current document URL per the HTML spec — capture it so the entry does not
// reset location back to the original document URL.
if (url === null || url === undefined) return __currentUrl();
try { return new URL(String(url), __currentUrl()).href; } catch (e) { return String(url); } try { return new URL(String(url), __currentUrl()).href; } catch (e) { return String(url); }
}; };
const applyVirtual = () => { const applyVirtual = () => {
+98
View File
@@ -1317,6 +1317,104 @@ mod tests {
rt rt
} }
#[tokio::test(flavor = "current_thread")]
async fn string_timeout_handler_executes_in_global_scope() {
let mut rt = setup_runtime("<html><body></body></html>");
rt.evaluate(
"var __timerValue='pending'; setTimeout('__timerValue=\"done\"', 0)",
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate("globalThis.__timerValue").unwrap(),
serde_json::json!("done")
);
}
#[tokio::test(flavor = "current_thread")]
async fn string_interval_handler_repeats_and_can_clear_itself() {
let mut rt = setup_runtime("<html><body></body></html>");
rt.evaluate("globalThis.__ticks=0").unwrap();
rt.evaluate(
"globalThis.__timerId=setInterval('__ticks++;if(__ticks===2)clearInterval(__timerId)',1)",
)
.unwrap();
rt.run_event_loop_bounded(100).await.unwrap();
assert_eq!(
rt.evaluate("globalThis.__ticks").unwrap(),
serde_json::json!(2.0)
);
}
#[test]
fn performance_now_is_monotonic_under_bursty_calls() {
let mut rt = setup_runtime("<html><body></body></html>");
// Hammer performance.now() so many calls land in the same millisecond and
// the wall clock rolls over repeatedly; the value must never go backwards.
let violations = rt
.evaluate(
"(function(){var prev=-Infinity, bad=0; for(var i=0;i<500000;i++){var t=performance.now(); if(t<prev) bad++; prev=t;} return bad;})()",
)
.unwrap();
assert_eq!(violations.as_f64(), Some(0.0), "performance.now() went backwards");
}
#[test]
fn performance_now_does_not_outrun_elapsed_time() {
let mut rt = setup_runtime("<html><body></body></html>");
let lead = rt
.evaluate(
"(function(){for(var i=0;i<500000;i++)performance.now(); return performance.now()-(Date.now()-performance.timeOrigin);})()",
)
.unwrap();
assert!(
lead.as_f64().unwrap() <= 1.0,
"performance.now() advanced ahead of elapsed time: {lead}"
);
}
#[test]
fn childnode_helpers_coerce_non_string_primitives_to_text() {
let mut rt = setup_runtime(r#"<html><body><div id="p"><span id="t">x</span></div></body></html>"#);
let before = rt
.evaluate("(function(){var t=document.getElementById('t'); t.before(5); return t.previousSibling ? t.previousSibling.textContent : 'NULL';})()")
.unwrap();
assert_eq!(before, serde_json::json!("5"));
let after = rt
.evaluate("(function(){var t=document.getElementById('t'); t.after(true); return t.nextSibling ? t.nextSibling.textContent : 'NULL';})()")
.unwrap();
assert_eq!(after, serde_json::json!("true"));
let replaced = rt
.evaluate("(function(){var t=document.getElementById('t'); t.replaceWith(42); return document.getElementById('p').textContent;})()")
.unwrap();
assert!(
replaced.as_str().unwrap().contains("42"),
"replaceWith(42) should leave text '42': {replaced}"
);
}
#[test]
fn replace_state_without_url_preserves_current_location() {
let mut rt = setup_runtime("<html><body></body></html>");
let path = rt
.evaluate(
"(function(){history.pushState({}, '', '/dashboard'); history.replaceState({scroll:1}); return location.pathname;})()",
)
.unwrap();
assert_eq!(path, serde_json::json!("/dashboard"));
}
#[test]
fn push_state_without_url_preserves_current_location() {
let mut rt = setup_runtime("<html><body></body></html>");
let path = rt
.evaluate(
"(function(){history.pushState({}, '', '/a'); history.pushState({b:1}); return location.pathname;})()",
)
.unwrap();
assert_eq!(path, serde_json::json!("/a"));
}
#[test] #[test]
fn style_attribute_parses_into_style_object() { fn style_attribute_parses_into_style_object() {
// Inline styles present in the parsed HTML must be visible via el.style.* // Inline styles present in the parsed HTML must be visible via el.style.*