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);
|
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 {
|
class CSSStyleDeclaration {
|
||||||
constructor() {
|
constructor(owner) {
|
||||||
// Non-enumerable so it never leaks through the proxy's own-key traps.
|
// Non-enumerable so they never leak through the proxy's own-key traps.
|
||||||
Object.defineProperty(this, "_props", { value: {}, writable: true, enumerable: false, configurable: true });
|
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
|
// 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
|
// camelCase IDL access (el.style.fontSize) onto the dashed key (font-size), so
|
||||||
// getPropertyValue('font-size') and el.style.fontSize stay in sync.
|
// getPropertyValue('font-size') and el.style.fontSize stay in sync.
|
||||||
setProperty(name, value) {
|
setProperty(name, value) {
|
||||||
|
this._pull();
|
||||||
const k = _cssCamelToKebab(String(name));
|
const k = _cssCamelToKebab(String(name));
|
||||||
if (value === "" || value == null) { delete this._props[k]; return; }
|
if (value === "" || value == null) delete this._props[k];
|
||||||
this._props[k] = String(value);
|
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 || ""; }
|
removeProperty(name) { this._pull(); const k = _cssCamelToKebab(String(name)); const old = this._props[k]; delete this._props[k]; this._push(); return old || ""; }
|
||||||
getPropertyValue(name) { return this._props[_cssCamelToKebab(String(name))] || ""; }
|
getPropertyValue(name) { this._pull(); return this._props[_cssCamelToKebab(String(name))] || ""; }
|
||||||
getPropertyPriority() { return ""; }
|
getPropertyPriority() { return ""; }
|
||||||
get cssText() {
|
get cssText() { this._pull(); return _serializeCss(this._props); }
|
||||||
const e = Object.entries(this._props);
|
|
||||||
return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : "";
|
|
||||||
}
|
|
||||||
set cssText(v) {
|
set cssText(v) {
|
||||||
for (const k in this._props) delete this._props[k];
|
_parseCssInto(this._props, v);
|
||||||
if (v) String(v).split(";").forEach((p) => {
|
this._push();
|
||||||
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; }
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
get length() { return Object.keys(this._props).length; }
|
get length() { this._pull(); return Object.keys(this._props).length; }
|
||||||
item(i) { return Object.keys(this._props)[i] || ""; }
|
item(i) { this._pull(); return Object.keys(this._props)[i] || ""; }
|
||||||
}
|
}
|
||||||
|
|
||||||
const _styleProxy = (decl) => new Proxy(decl, {
|
const _styleProxy = (decl) => new Proxy(decl, {
|
||||||
@@ -561,11 +603,13 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
|||||||
has(t, p) {
|
has(t, p) {
|
||||||
if (typeof p !== "string") return Reflect.has(t, p);
|
if (typeof p !== "string") return Reflect.has(t, p);
|
||||||
if (p in Object.getPrototypeOf(t)) return true;
|
if (p in Object.getPrototypeOf(t)) return true;
|
||||||
|
t._pull();
|
||||||
if (_cssCamelToKebab(p) in t._props) return true;
|
if (_cssCamelToKebab(p) in t._props) return true;
|
||||||
if (_CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) return true;
|
if (_CSS_PROP_SET.has(p) || _CSS_PROP_SET.has(_cssKebabToCamel(p))) return true;
|
||||||
return /^\d+$/.test(p) && +p < t.length;
|
return /^\d+$/.test(p) && +p < t.length;
|
||||||
},
|
},
|
||||||
ownKeys(t) {
|
ownKeys(t) {
|
||||||
|
t._pull();
|
||||||
const keys = [];
|
const keys = [];
|
||||||
const n = t.length;
|
const n = t.length;
|
||||||
for (let i = 0; i < n; i++) keys.push(String(i));
|
for (let i = 0; i < n; i++) keys.push(String(i));
|
||||||
@@ -576,6 +620,7 @@ const _styleProxy = (decl) => new Proxy(decl, {
|
|||||||
},
|
},
|
||||||
getOwnPropertyDescriptor(t, p) {
|
getOwnPropertyDescriptor(t, p) {
|
||||||
if (typeof p !== "string") return Reflect.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 (/^\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))) {
|
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 };
|
return { value: t.getPropertyValue(p), writable: true, enumerable: true, configurable: true };
|
||||||
@@ -1180,7 +1225,7 @@ function _isSubmitButton(el) {
|
|||||||
class Element extends Node {
|
class Element extends Node {
|
||||||
constructor(nid) {
|
constructor(nid) {
|
||||||
super(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 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
|
// Element for element nodes, and node ids are never freed-and-reused), so this
|
||||||
|
|||||||
@@ -1317,6 +1317,66 @@ mod tests {
|
|||||||
rt
|
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]
|
#[test]
|
||||||
fn test_document_title() {
|
fn test_document_title() {
|
||||||
let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>");
|
let mut rt = setup_runtime("<html><head><title>Test</title></head><body></body></html>");
|
||||||
|
|||||||
Reference in new issue
Block a user