fix(js): support in/Object.keys on style and dataset (CSSOM parity) (#357)
el.style (CSSStyleDeclaration) and el.dataset (DOMStringMap) were plain Proxies with only get/set traps, so `'color' in el.style`, `Object.keys (el.style)`, `'foo' in el.dataset`, and `Object.keys(el.dataset)` all failed, and Object.keys(el.style) leaked the internal `_props` (issue #356). cssText also serialized camelCase names without a trailing semicolon. Add has / ownKeys / getOwnPropertyDescriptor traps to both proxies so the `in` operator and enumeration behave like real CSSOM. CSSStyleDeclaration now stores properties by their dashed name (matching the spec), so el.style.fontSize and getPropertyValue('font-size') stay in sync, and cssText serializes dashed names with a trailing semicolon. A known CSS property list backs `in`/enumeration so feature detection ('object-fit' in el.style) works for unset properties. Closes #356.
This commit is contained in:
1 parent
14286e7ad6
commit
ad7a7a93d1
2 files changed
+176
-12
No files matched your search
Vendored
+135
-12
@@ -350,13 +350,95 @@ class MessageChannel {
|
||||
globalThis.MessageChannel = MessageChannel;
|
||||
globalThis.MessagePort = class MessagePort { constructor(){} postMessage(){} close(){} addEventListener(){} removeEventListener(){} };
|
||||
|
||||
const _cssCamelToKebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
const _cssKebabToCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
|
||||
// Standard CSS property names (camelCase). Real CSSStyleDeclaration exposes every
|
||||
// property as an enumerable accessor, so feature-detection code (`'gap' in
|
||||
// el.style`) and enumeration (`Object.keys(el.style)`) see the whole set, not
|
||||
// just the ones that happen to be assigned (issue #356).
|
||||
const _CSS_PROPERTY_NAMES = [
|
||||
"accentColor","alignContent","alignItems","alignSelf","all","animation","animationDelay",
|
||||
"animationDirection","animationDuration","animationFillMode","animationIterationCount",
|
||||
"animationName","animationPlayState","animationTimingFunction","appearance","aspectRatio",
|
||||
"backdropFilter","backfaceVisibility","background","backgroundAttachment","backgroundBlendMode",
|
||||
"backgroundClip","backgroundColor","backgroundImage","backgroundOrigin","backgroundPosition",
|
||||
"backgroundPositionX","backgroundPositionY","backgroundRepeat","backgroundSize","blockSize",
|
||||
"border","borderBlock","borderBlockColor","borderBlockEnd","borderBlockEndColor","borderBlockEndStyle",
|
||||
"borderBlockEndWidth","borderBlockStart","borderBlockStartColor","borderBlockStartStyle",
|
||||
"borderBlockStartWidth","borderBlockStyle","borderBlockWidth","borderBottom","borderBottomColor",
|
||||
"borderBottomLeftRadius","borderBottomRightRadius","borderBottomStyle","borderBottomWidth",
|
||||
"borderCollapse","borderColor","borderImage","borderImageOutset","borderImageRepeat",
|
||||
"borderImageSlice","borderImageSource","borderImageWidth","borderInline","borderInlineColor",
|
||||
"borderInlineEnd","borderInlineEndColor","borderInlineEndStyle","borderInlineEndWidth",
|
||||
"borderInlineStart","borderInlineStartColor","borderInlineStartStyle","borderInlineStartWidth",
|
||||
"borderInlineStyle","borderInlineWidth","borderLeft","borderLeftColor","borderLeftStyle",
|
||||
"borderLeftWidth","borderRadius","borderRight","borderRightColor","borderRightStyle",
|
||||
"borderRightWidth","borderSpacing","borderStyle","borderTop","borderTopColor","borderTopLeftRadius",
|
||||
"borderTopRightRadius","borderTopStyle","borderTopWidth","borderWidth","bottom","boxShadow",
|
||||
"boxSizing","breakAfter","breakBefore","breakInside","captionSide","caretColor","clear","clip",
|
||||
"clipPath","color","colorScheme","columnCount","columnFill","columnGap","columnRule","columnRuleColor",
|
||||
"columnRuleStyle","columnRuleWidth","columnSpan","columnWidth","columns","contain","container",
|
||||
"containerName","containerType","content","counterIncrement","counterReset","counterSet","cssFloat",
|
||||
"cursor","direction","display","emptyCells","filter","flex","flexBasis","flexDirection","flexFlow",
|
||||
"flexGrow","flexShrink","flexWrap","float","font","fontFamily","fontFeatureSettings","fontKerning",
|
||||
"fontOpticalSizing","fontSize","fontSizeAdjust","fontStretch","fontStyle","fontVariant",
|
||||
"fontVariantCaps","fontVariantLigatures","fontVariantNumeric","fontWeight","gap","grid","gridArea",
|
||||
"gridAutoColumns","gridAutoFlow","gridAutoRows","gridColumn","gridColumnEnd","gridColumnGap",
|
||||
"gridColumnStart","gridGap","gridRow","gridRowEnd","gridRowGap","gridRowStart","gridTemplate",
|
||||
"gridTemplateAreas","gridTemplateColumns","gridTemplateRows","height","hyphens","imageRendering",
|
||||
"inlineSize","inset","insetBlock","insetBlockEnd","insetBlockStart","insetInline","insetInlineEnd",
|
||||
"insetInlineStart","isolation","justifyContent","justifyItems","justifySelf","left","letterSpacing",
|
||||
"lineBreak","lineHeight","listStyle","listStyleImage","listStylePosition","listStyleType","margin",
|
||||
"marginBlock","marginBlockEnd","marginBlockStart","marginBottom","marginInline","marginInlineEnd",
|
||||
"marginInlineStart","marginLeft","marginRight","marginTop","mask","maxBlockSize","maxHeight",
|
||||
"maxInlineSize","maxWidth","minBlockSize","minHeight","minInlineSize","minWidth","mixBlendMode",
|
||||
"objectFit","objectPosition","offset","opacity","order","outline","outlineColor","outlineOffset",
|
||||
"outlineStyle","outlineWidth","overflow","overflowAnchor","overflowWrap","overflowX","overflowY",
|
||||
"overscrollBehavior","overscrollBehaviorBlock","overscrollBehaviorInline","overscrollBehaviorX",
|
||||
"overscrollBehaviorY","padding","paddingBlock","paddingBlockEnd","paddingBlockStart","paddingBottom",
|
||||
"paddingInline","paddingInlineEnd","paddingInlineStart","paddingLeft","paddingRight","paddingTop",
|
||||
"pageBreakAfter","pageBreakBefore","pageBreakInside","perspective","perspectiveOrigin","placeContent",
|
||||
"placeItems","placeSelf","pointerEvents","position","quotes","resize","right","rotate","rowGap",
|
||||
"scale","scrollBehavior","scrollMargin","scrollPadding","scrollSnapAlign","scrollSnapStop",
|
||||
"scrollSnapType","tabSize","tableLayout","textAlign","textAlignLast","textCombineUpright",
|
||||
"textDecoration","textDecorationColor","textDecorationLine","textDecorationSkipInk",
|
||||
"textDecorationStyle","textDecorationThickness","textEmphasis","textIndent","textJustify",
|
||||
"textOrientation","textOverflow","textRendering","textShadow","textTransform","textUnderlineOffset",
|
||||
"textUnderlinePosition","top","touchAction","transform","transformBox","transformOrigin",
|
||||
"transformStyle","transition","transitionDelay","transitionDuration","transitionProperty",
|
||||
"transitionTimingFunction","translate","unicodeBidi","userSelect","verticalAlign","visibility",
|
||||
"whiteSpace","width","willChange","wordBreak","wordSpacing","wordWrap","writingMode","zIndex","zoom",
|
||||
];
|
||||
const _CSS_PROP_SET = new Set(_CSS_PROPERTY_NAMES);
|
||||
|
||||
class CSSStyleDeclaration {
|
||||
constructor() { this._props = {}; }
|
||||
setProperty(name, value) { this._props[name] = String(value); }
|
||||
removeProperty(name) { const old = this._props[name]; delete this._props[name]; return old || ""; }
|
||||
getPropertyValue(name) { return this._props[name] || ""; }
|
||||
get cssText() { return Object.entries(this._props).map(([k,v]) => `${k}: ${v}`).join("; "); }
|
||||
set cssText(v) { this._props = {}; if(v) v.split(";").forEach(p => { const [k,...rest]=p.split(":"); if(k&&rest.length) this._props[k.trim()]=rest.join(":").trim(); }); }
|
||||
constructor() {
|
||||
// Non-enumerable so it never leaks through the proxy's own-key traps.
|
||||
Object.defineProperty(this, "_props", { value: {}, writable: true, enumerable: false, configurable: true });
|
||||
}
|
||||
// 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) {
|
||||
const k = _cssCamelToKebab(String(name));
|
||||
if (value === "" || value == null) { delete this._props[k]; return; }
|
||||
this._props[k] = String(value);
|
||||
}
|
||||
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))] || ""; }
|
||||
getPropertyPriority() { return ""; }
|
||||
get cssText() {
|
||||
const e = Object.entries(this._props);
|
||||
return e.length ? e.map(([k, v]) => `${k}: ${v}`).join("; ") + ";" : "";
|
||||
}
|
||||
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; }
|
||||
});
|
||||
}
|
||||
get length() { return Object.keys(this._props).length; }
|
||||
item(i) { return Object.keys(this._props)[i] || ""; }
|
||||
}
|
||||
@@ -364,13 +446,40 @@ class CSSStyleDeclaration {
|
||||
const _styleProxy = (decl) => new Proxy(decl, {
|
||||
get(t, p) {
|
||||
if (typeof p === "symbol" || p in t) return t[p];
|
||||
if (typeof p === "string") return t._props[p] || "";
|
||||
return undefined;
|
||||
if (/^\d+$/.test(p)) return t.item(+p);
|
||||
return t.getPropertyValue(p);
|
||||
},
|
||||
set(t, p, v) {
|
||||
if (typeof p === "string") { t._props[p] = String(v); return true; }
|
||||
t[p] = v; return true;
|
||||
if (typeof p === "symbol") { t[p] = v; return true; }
|
||||
if (p === "cssText") { t.cssText = v; return true; }
|
||||
if (/^\d+$/.test(p) || p in Object.getPrototypeOf(t)) return true;
|
||||
t.setProperty(p, v);
|
||||
return true;
|
||||
},
|
||||
has(t, p) {
|
||||
if (typeof p !== "string") return Reflect.has(t, p);
|
||||
if (p in Object.getPrototypeOf(t)) return true;
|
||||
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) {
|
||||
const keys = [];
|
||||
const n = t.length;
|
||||
for (let i = 0; i < n; i++) keys.push(String(i));
|
||||
const names = new Set(_CSS_PROPERTY_NAMES);
|
||||
for (const k of Object.keys(t._props)) names.add(_cssKebabToCamel(k));
|
||||
for (const name of names) keys.push(name);
|
||||
return keys;
|
||||
},
|
||||
getOwnPropertyDescriptor(t, p) {
|
||||
if (typeof p !== "string") return Reflect.getOwnPropertyDescriptor(t, p);
|
||||
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 };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
|
||||
class Node {
|
||||
@@ -1789,9 +1898,23 @@ class Element extends Node {
|
||||
get dataset() {
|
||||
if (this._dataset) return this._dataset;
|
||||
const el = this;
|
||||
const attrFor = (k) => "data-" + _cssCamelToKebab(k);
|
||||
// camelCase the part after the `data-` prefix, e.g. data-foo-bar -> fooBar.
|
||||
const dataKeys = () => el.getAttributeNames()
|
||||
.filter((n) => n.startsWith("data-"))
|
||||
.map((n) => _cssKebabToCamel(n.slice(5)));
|
||||
this._dataset = new Proxy({}, {
|
||||
get(_, k) { if(typeof k!=="string")return undefined; return el.getAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase()); },
|
||||
set(_, k, v) { el.setAttribute("data-"+k.replace(/([A-Z])/g,"-$1").toLowerCase(), v); return true; },
|
||||
get(_, k) { if (typeof k !== "string") return undefined; return el.hasAttribute(attrFor(k)) ? el.getAttribute(attrFor(k)) : undefined; },
|
||||
set(_, k, v) { el.setAttribute(attrFor(k), String(v)); return true; },
|
||||
has(_, k) { return typeof k === "string" && el.hasAttribute(attrFor(k)); },
|
||||
deleteProperty(_, k) { if (typeof k === "string") el.removeAttribute(attrFor(k)); return true; },
|
||||
ownKeys() { return dataKeys(); },
|
||||
getOwnPropertyDescriptor(_, k) {
|
||||
if (typeof k === "string" && el.hasAttribute(attrFor(k))) {
|
||||
return { value: el.getAttribute(attrFor(k)), writable: true, enumerable: true, configurable: true };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return this._dataset;
|
||||
}
|
||||
|
||||
@@ -1392,6 +1392,47 @@ mod tests {
|
||||
assert_eq!(text, serde_json::json!("BODY_TEXT"));
|
||||
}
|
||||
|
||||
/// Regression test for #356: the `in` operator and `Object.keys` must work on
|
||||
/// `el.style` (CSSStyleDeclaration) and `el.dataset` (DOMStringMap), `_props`
|
||||
/// must not leak, and cssText must serialize dashed names with a trailing
|
||||
/// semicolon.
|
||||
#[test]
|
||||
fn style_and_dataset_support_in_operator_and_keys() {
|
||||
let mut rt = setup_runtime("<html><body></body></html>");
|
||||
let result = rt
|
||||
.evaluate(
|
||||
r#"(() => {
|
||||
const el = document.createElement('div');
|
||||
el.style.color = 'red';
|
||||
el.style.fontSize = '14px';
|
||||
el.dataset.foo = 'bar';
|
||||
const keys = Object.keys(el.style);
|
||||
return JSON.stringify({
|
||||
colorInStyle: 'color' in el.style,
|
||||
objectFitInStyle: 'object-fit' in el.style,
|
||||
keysHasSet: keys.includes('color') && keys.includes('fontSize'),
|
||||
noPropsLeak: !keys.includes('_props'),
|
||||
fooInDataset: 'foo' in el.dataset,
|
||||
datasetKeys: Object.keys(el.dataset),
|
||||
cssText: el.style.cssText,
|
||||
length: el.style.length,
|
||||
getByDash: el.style.getPropertyValue('font-size')
|
||||
});
|
||||
})()"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p: serde_json::Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
|
||||
assert_eq!(p["colorInStyle"], true);
|
||||
assert_eq!(p["objectFitInStyle"], true);
|
||||
assert_eq!(p["keysHasSet"], true);
|
||||
assert_eq!(p["noPropsLeak"], true);
|
||||
assert_eq!(p["fooInDataset"], true);
|
||||
assert_eq!(p["datasetKeys"], serde_json::json!(["foo"]));
|
||||
assert_eq!(p["cssText"], "color: red; font-size: 14px;");
|
||||
assert_eq!(p["length"], 2);
|
||||
assert_eq!(p["getByDash"], "14px");
|
||||
}
|
||||
|
||||
/// Regression for #105: `element.querySelector` and `querySelectorAll`
|
||||
/// must scope to the receiver's subtree, not the whole document.
|
||||
#[test]
|
||||
|
||||
Reference in new issue
Block a user