From ae3354385008516e3f1d9689870b56670827ba56 Mon Sep 17 00:00:00 2001 From: Andrey Mnatsakanov Date: Tue, 4 Aug 2026 10:34:04 +0200 Subject: [PATCH] fix(dom): match setAttribute by qualified name, not local name only Node::set_attribute looked up the existing attribute by local name alone, while get_attribute and the remove_attribute op both match by qualified name. A parsed namespaced attribute is stored with a separate prefix (xlink:href -> prefix="xlink", local="href"), so setAttribute("xlink:href") never matched it: the update was pushed as a second attribute, getAttribute kept returning the stale original, and the element serialized xlink:href twice. Match set_attribute by qualified_name_eq, consistent with the read/remove paths, so the update lands in place and no duplicate is created. Fixes #547 --- crates/obscura-dom/src/tree.rs | 6 +++++- crates/obscura-js/src/runtime.rs | 16 ++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/obscura-dom/src/tree.rs b/crates/obscura-dom/src/tree.rs index eeef499..a00f233 100644 --- a/crates/obscura-dom/src/tree.rs +++ b/crates/obscura-dom/src/tree.rs @@ -136,7 +136,11 @@ impl Node { pub fn set_attribute(&mut self, name: &str, value: String) { if let NodeData::Element { attrs, .. } = &mut self.data { - if let Some(attr) = attrs.iter_mut().find(|a| a.name.local.as_ref() == name) { + // Match by qualified name, consistent with get_attribute and the + // remove_attribute op. A parsed namespaced attribute is stored with + // a separate prefix (e.g. xlink:href -> prefix="xlink", local="href"); + // matching on local name alone would miss it and push a duplicate. + if let Some(attr) = attrs.iter_mut().find(|a| a.qualified_name_eq(name)) { attr.value = value; } else { attrs.push(Attribute { diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 03a12fc..d457e91 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1662,6 +1662,22 @@ mod tests { assert_eq!(v, serde_json::json!("#icon|#icon|id,xlink:href")); } + #[test] + fn set_attribute_updates_a_parsed_namespaced_attribute_in_place() { + // setAttribute matched the stored attribute by local name only, so a + // parsed `xlink:href` (prefix=xlink, local=href) was never found by the + // qualified name "xlink:href": the update was pushed as a *second* + // attribute, getAttribute kept returning the stale original, and the + // element serialized `xlink:href` twice. + let mut rt = setup_runtime( + r##""##, + ); + let v = rt + .evaluate("(function(){var u=document.getElementById('u');u.setAttribute('xlink:href','#b');var dup=(u.outerHTML.match(/xlink:href/g)||[]).length;return u.getAttribute('xlink:href')+'|'+u.getAttributeNS('http://www.w3.org/1999/xlink','href')+'|'+u.getAttributeNames().join(',')+'|'+dup;})()") + .unwrap(); + assert_eq!(v, serde_json::json!("#b|#b|id,xlink:href|1")); + } + #[test] fn set_attribute_ns_validates_namespace_constraints() { let mut rt = setup_runtime("");