From 80803cbec8fbfe67c504b15e45cd8e158507e01b Mon Sep 17 00:00:00 2001 From: Andrey Mnatsakanov Date: Sun, 26 Jul 2026 11:04:02 +0200 Subject: [PATCH 01/17] fix(js): sync el.style with the style attribute in both directions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSSStyleDeclaration was backed by an in-memory _props map that was never bridged to the element's style content attribute. Parsed inline styles were invisible to el.style.*, setAttribute('style', …) did not update el.style, and el.style.x = … never reached the attribute or serialization; getComputedStyle read the same disconnected declaration. Give the declaration its owner element: lazily pull the style attribute into _props on read (skipping the reparse when unchanged) and push _props back to the attribute on every mutation. Owner-less declarations (getComputedStyle fallback, stylesheet rules) stay purely in-memory, so their behavior is unchanged. Fixes #488 --- crates/obscura-js/js/bootstrap.js | 81 ++++++++++++++++++++++++------- crates/obscura-js/src/runtime.rs | 60 +++++++++++++++++++++++ 2 files changed, 123 insertions(+), 18 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 4a0f53f..b8de04a 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -514,35 +514,77 @@ 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 }); + // Last `style` attribute string we parsed/wrote, so a read can skip the + // reparse when the attribute has not changed underneath us. Held in a + // one-field object so the sync helpers can mutate it without a bare + // `this._x = …` assignment (which the style proxy would reroute into + // setProperty). + Object.defineProperty(this, "_sync", { value: { last: null }, writable: true, enumerable: false, configurable: true }); + } + // Pull the owner's `style` attribute into `_props` if it changed since our + // last read/write. Keeps parsed HTML and setAttribute('style', …) visible via + // el.style.*. No-op when owner-less. + _pull() { + const o = this._owner; + if (!o) return; + const attr = o.getAttribute("style"); + if (attr === this._sync.last) return; + _parseCssInto(this._props, attr); + this._sync.last = attr; + } + // 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); + this._sync.last = text; + 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, { @@ -561,11 +603,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)); @@ -576,6 +620,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 }; @@ -1180,7 +1225,7 @@ function _isSubmitButton(el) { class Element extends Node { constructor(nid) { super(nid); - this._style = _styleProxy(new CSSStyleDeclaration()); + this._style = _styleProxy(new CSSStyleDeclaration(this)); } // Element wrappers always back a nodeType-1 node (_wrap/_wrapEl only build an // Element for element nodes, and node ids are never freed-and-reused), so this diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index fdfeb4d..3fc4db0 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -1317,6 +1317,66 @@ mod tests { rt } + #[test] + fn style_attribute_parses_into_style_object() { + // Inline styles present in the parsed HTML must be visible via el.style.* + let mut rt = setup_runtime( + r#""#, + ); + assert_eq!( + rt.evaluate("document.getElementById('d').style.color").unwrap(), + serde_json::json!("red") + ); + assert_eq!( + rt.evaluate("document.getElementById('d').style.display").unwrap(), + serde_json::json!("none") + ); + } + + #[test] + fn set_style_attribute_updates_style_object() { + let mut rt = setup_runtime(r#"
hi
"#); + let margin = rt + .evaluate( + "(function(){var e=document.getElementById('d'); e.setAttribute('style','margin: 5px'); return e.style.margin;})()", + ) + .unwrap(); + assert_eq!(margin, serde_json::json!("5px")); + } + + #[test] + fn setting_style_property_updates_the_attribute_and_serialization() { + let mut rt = setup_runtime(r#"
hi
"#); + let attr = rt + .evaluate( + "(function(){var e=document.getElementById('d'); e.style.color='blue'; return e.getAttribute('style');})()", + ) + .unwrap(); + assert_eq!(attr, serde_json::json!("color: blue;")); + let html = rt + .evaluate("document.getElementById('d').outerHTML") + .unwrap(); + assert!( + html.as_str().unwrap().contains("color: blue"), + "outerHTML should carry the style set via el.style: {html}" + ); + } + + #[test] + fn style_object_reflects_external_attribute_change() { + // A later setAttribute('style', …) must supersede an earlier value read + // through el.style (the declaration re-syncs from the attribute). + let mut rt = setup_runtime( + r#"
hi
"#, + ); + let color = rt + .evaluate( + "(function(){var e=document.getElementById('d'); e.style.color; e.setAttribute('style','color: green'); return e.style.color;})()", + ) + .unwrap(); + assert_eq!(color, serde_json::json!("green")); + } + #[test] fn test_document_title() { let mut rt = setup_runtime("Test"); From 491ecfb0f1800b7d0c4680c724b88fc361ceb7e2 Mon Sep 17 00:00:00 2001 From: Andrey Mnatsakanov Date: Sun, 26 Jul 2026 11:09:42 +0200 Subject: [PATCH 02/17] fix(js): clone nodes structurally instead of via div.innerHTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cloneNode(true) round-tripped through a throwaway
's innerHTML, so the HTML fragment parser discarded elements that are not valid children of
(, ,