fix(js): sync el.style with the style attribute in both directions
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
This commit is contained in:
1 parent
e314b9b0f3
commit
80803cbec8
2 files changed
+123
-18
No files matched your search
Vendored
+63
-18
@@ -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
|
||||
|
||||
@@ -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#"<html><body><div id="d" style="color: red; display: none">hi</div></body></html>"#,
|
||||
);
|
||||
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#"<html><body><div id="d">hi</div></body></html>"#);
|
||||
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#"<html><body><div id="d">hi</div></body></html>"#);
|
||||
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#"<html><body><div id="d" style="color: red">hi</div></body></html>"#,
|
||||
);
|
||||
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("<html><head><title>Test</title></head><body></body></html>");
|
||||
|
||||
Reference in new issue
Block a user