Expose live computed CSS variables to scripts

This commit is contained in:
SGavrl committed 2026-07-31 08:26:08 +00:00
1 parent 2343681497
commit 5f67bad4fe
6 files changed
+258 -8

No files matched your search

+48 -3
View File
@@ -81,7 +81,19 @@ globalThis.dispatchEvent = function(event) {
return !event.defaultPrevented; return !event.defaultPrevented;
}; };
const _dom = (cmd, a1, a2) => Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); let _domMutationEpoch = 0;
const _DOM_MUTATION_COMMANDS = new Set([
"append_child", "insert_before", "remove_child",
"set_attribute", "remove_attribute",
"set_text_content", "set_inner_html", "set_inner_html_context",
"create_element", "create_text_node", "create_comment_node",
"create_document_fragment", "clone_node",
]);
const _dom = (cmd, a1, a2) => {
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
if (_DOM_MUTATION_COMMANDS.has(cmd)) _domMutationEpoch++;
return result;
};
const _nativeFns = new Set(); const _nativeFns = new Set();
// Exact toString override for members whose native form is not just // Exact toString override for members whose native form is not just
@@ -2556,6 +2568,23 @@ class Element extends Node {
return this.getAttribute("href") || ""; return this.getAttribute("href") || "";
} }
set href(v) { this.setAttribute("href", v); } set href(v) { this.setAttribute("href", v); }
// HTMLHyperlinkElementUtils / HTMLAnchorElement reflected content
// attributes. Real-world locale, routing, and analytics code commonly
// enumerates `[hreflang]` links and reads the IDL property rather than
// getAttribute(); leaving it undefined aborts the entire component even
// though the attribute is present in the DOM.
get hreflang() { return this.getAttribute("hreflang") || ""; }
set hreflang(v) { this.setAttribute("hreflang", v); }
get rel() { return this.getAttribute("rel") || ""; }
set rel(v) { this.setAttribute("rel", v); }
get target() { return this.getAttribute("target") || ""; }
set target(v) { this.setAttribute("target", v); }
get download() { return this.getAttribute("download") || ""; }
set download(v) { this.setAttribute("download", v); }
get ping() { return this.getAttribute("ping") || ""; }
set ping(v) { this.setAttribute("ping", v); }
get referrerPolicy() { return this.getAttribute("referrerpolicy") || ""; }
set referrerPolicy(v) { this.setAttribute("referrerpolicy", v); }
// HTMLHyperlinkElementUtils URL-decomposition members, live on <a>/<area>. // HTMLHyperlinkElementUtils URL-decomposition members, live on <a>/<area>.
get protocol() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.protocol : ''; } get protocol() { const u = (this.localName === 'a' || this.localName === 'area') ? _elemHrefURL(this) : null; return u ? u.protocol : ''; }
set protocol(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'protocol', v); } set protocol(v) { if (this.localName === 'a' || this.localName === 'area') _setElemHrefPart(this, 'protocol', v); }
@@ -5863,12 +5892,21 @@ globalThis.getComputedStyle = (el) => {
// cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a // cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a
// dozen properties must not trigger a dozen layout/native crossings. // dozen properties must not trigger a dozen layout/native crossings.
let rendered = null; let rendered = null;
let renderedEpoch = -1;
let computedNames = [];
const refreshRendered = () => {
if (renderedEpoch === _domMutationEpoch) return;
renderedEpoch = _domMutationEpoch;
rendered = null;
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
try { try {
const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); const raw = Deno.core.ops.op_computed_style(String(el._nid | 0));
rendered = raw ? JSON.parse(raw) : null; rendered = raw ? JSON.parse(raw) : null;
} catch (e) {} } catch (e) {}
} }
computedNames = rendered ? Object.keys(rendered) : [];
};
refreshRendered();
// React virtualization libraries (react-window, tanstack-virtual, // React virtualization libraries (react-window, tanstack-virtual,
// react-virtuoso) all compute container dimensions via getComputedStyle. // react-virtuoso) all compute container dimensions via getComputedStyle.
// The defaults table previously returned `auto` for width/height and // The defaults table previously returned `auto` for width/height and
@@ -5924,6 +5962,7 @@ globalThis.getComputedStyle = (el) => {
const lookup = (rawProp) => { const lookup = (rawProp) => {
if (typeof rawProp !== 'string') return ''; if (typeof rawProp !== 'string') return '';
refreshRendered();
const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase();
if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab))
return rendered[kebab]; return rendered[kebab];
@@ -5944,8 +5983,14 @@ globalThis.getComputedStyle = (el) => {
if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined; if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined;
if (prop === 'getPropertyValue') return (name) => lookup(name); if (prop === 'getPropertyValue') return (name) => lookup(name);
if (prop === 'getPropertyPriority') return () => ''; if (prop === 'getPropertyPriority') return () => '';
if (prop === 'item') return (i) => ''; if (prop === 'item') return (i) => {
if (prop === 'length') return 0; refreshRendered();
return computedNames[i | 0] || '';
};
if (prop === 'length') {
refreshRendered();
return computedNames.length;
}
if (prop === 'cssText') return ''; if (prop === 'cssText') return '';
if (prop === 'parentRule') return null; if (prop === 'parentRule') return null;
// CSSStyleDeclaration's `has` trap intentionally reports every known // CSSStyleDeclaration's `has` trap intentionally reports every known
+17 -4
View File
@@ -2473,10 +2473,23 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String {
let nid: u32 = nid_str.parse().unwrap_or(0); let nid: u32 = nid_str.parse().unwrap_or(0);
let nid = obscura_dom::tree::NodeId::new(nid); let nid = obscura_dom::tree::NodeId::new(nid);
let mut gs = shared.borrow_mut(); let mut gs = shared.borrow_mut();
ensure_prepared_render(&mut gs) let Some(prepared) = ensure_prepared_render(&mut gs) else {
.and_then(|prepared| prepared.computed_style(nid)) return String::new();
.and_then(|snapshot| serde_json::to_string(&snapshot).ok()) };
.unwrap_or_default() let Some(snapshot) = prepared.computed_style(nid) else {
return String::new();
};
let custom = prepared
.computed_custom_properties(nid)
.unwrap_or_default();
let mut object = serde_json::Map::with_capacity(snapshot.len() + custom.len());
for (name, value) in snapshot {
object.insert(name.to_string(), serde_json::Value::String(value));
}
for (name, value) in custom {
object.insert(name, serde_json::Value::String(value));
}
serde_json::Value::Object(object).to_string()
} }
/// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in /// Root scrolling overflow in CSS pixels. The JS CSSOM probes this op only in
+118
View File
@@ -2168,6 +2168,52 @@ mod tests {
); );
} }
#[test]
fn hyperlink_content_attributes_reflect_through_the_idl_surface() {
let mut rt = setup_runtime(
r#"<html><body>
<a id="locale" hreflang="en-US" rel="alternate"
target="_blank" download="guide.pdf"
ping="/audit" referrerpolicy="no-referrer">English</a>
</body></html>"#,
);
let result = rt
.evaluate(
r#"
const link = document.getElementById("locale");
const initial = [
link.hreflang, link.rel, link.target, link.download,
link.ping, link.referrerPolicy,
link.hreflang.split("-")[1]
];
link.hreflang = "de-DE";
link.referrerPolicy = "origin";
return [
initial,
link.getAttribute("hreflang"),
link.getAttribute("referrerpolicy")
];
"#,
)
.unwrap();
assert_eq!(
result,
serde_json::json!([
[
"en-US",
"alternate",
"_blank",
"guide.pdf",
"/audit",
"no-referrer",
"US"
],
"de-DE",
"origin"
])
);
}
#[cfg(feature = "render")] #[cfg(feature = "render")]
#[test] #[test]
fn computed_style_uses_renderer_stylesheet_cascade_and_invalidates() { fn computed_style_uses_renderer_stylesheet_cascade_and_invalidates() {
@@ -2239,6 +2285,78 @@ mod tests {
); );
} }
#[cfg(feature = "render")]
#[test]
fn computed_style_exposes_cascaded_custom_properties_and_invalidates() {
let dom = parse_html(
r#"<html><head><style>
:root { --inherited-space: 17px; --derived-space: var(--inherited-space); }
#nav {
--r-globalnav-font-size:17px;
--local-scale:1.25;
font-size:var(--r-globalnav-font-size);
}
</style></head><body>
<nav id="nav"><span id="child"></span></nav>
</body></html>"#,
);
let mut rt = ObscuraJsRuntime::new();
rt.set_dom(dom);
rt.set_viewport(400.0, 200.0);
rt.run_page_init();
assert_eq!(
rt.evaluate(
r#"
const nav = document.getElementById("nav");
const child = document.getElementById("child");
const navStyle = getComputedStyle(nav);
const childStyle = getComputedStyle(child);
let enumeratesBase = false;
for (let i = 0; i < navStyle.length; i++) {
if (navStyle.item(i) === "--r-globalnav-font-size")
enumeratesBase = true;
}
return [
navStyle.fontSize,
navStyle.getPropertyValue("--r-globalnav-font-size"),
parseInt(navStyle.fontSize) /
parseInt(navStyle.getPropertyValue("--r-globalnav-font-size")),
navStyle.getPropertyValue("--inherited-space"),
navStyle.getPropertyValue("--derived-space"),
navStyle.getPropertyValue("--local-scale"),
childStyle.getPropertyValue("--inherited-space"),
childStyle.getPropertyValue("--local-scale"),
enumeratesBase
];
"#,
)
.unwrap(),
serde_json::json!([
"17px", "17px", 1,
"17px", "17px", "1.25", "17px", "1.25", true
])
);
assert_eq!(
rt.evaluate(
r#"
const nav = document.getElementById("nav");
const computed = getComputedStyle(nav);
nav.style.setProperty("--inherited-space", "23px");
nav.style.fontSize = "19px";
return [
computed.fontSize,
computed.getPropertyValue("--inherited-space"),
computed.getPropertyValue("--derived-space")
];
"#,
)
.unwrap(),
serde_json::json!(["19px", "23px", "17px"])
);
}
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
async fn idle_event_loop_flushes_resolved_promise_continuations() { async fn idle_event_loop_flushes_resolved_promise_continuations() {
let mut rt = setup_runtime("<html><body><div id='state'>pending</div></body></html>"); let mut rt = setup_runtime("<html><body><div id='state'>pending</div></body></html>");
+30 -1
View File
@@ -955,7 +955,9 @@ impl Stylesheet {
} }
let registration_changed_props = resolved_props != *parent_props; let registration_changed_props = resolved_props != *parent_props;
let has_own = !own.is_empty(); let has_own = !own.is_empty();
let mut own_names = std::collections::HashSet::new();
for (k, v) in own { for (k, v) in own {
own_names.insert(k.clone());
let registration = self.registered_custom_properties.get(&k); let registration = self.registered_custom_properties.get(&k);
let set_initial = |props: &mut HashMap<String, String>| { let set_initial = |props: &mut HashMap<String, String>| {
if let Some(initial) = registration.and_then(|entry| entry.initial_value.as_ref()) { if let Some(initial) = registration.and_then(|entry| entry.initial_value.as_ref()) {
@@ -997,6 +999,29 @@ impl Stylesheet {
} }
} }
} }
// Custom properties inherit their computed value, not their original
// token stream. Resolve only declarations won on this element against
// the complete same-element environment (so forward references work),
// then pass those substituted values to descendants. Re-resolving an
// inherited `--b:var(--a)` after a child overrides `--a` is observably
// wrong: browsers keep the parent's already-computed `--b`.
let resolution_environment = resolved_props.clone();
for name in own_names {
let Some(value) = resolution_environment.get(&name) else {
continue;
};
if let Some(computed) = substitute_var_value(value, &resolution_environment, 0) {
resolved_props.insert(name, computed);
} else if let Some(initial) = self
.registered_custom_properties
.get(&name)
.and_then(|entry| entry.initial_value.clone())
{
resolved_props.insert(name, initial);
} else {
resolved_props.remove(&name);
}
}
let effective = if has_own || registration_changed_props { let effective = if has_own || registration_changed_props {
Some(resolved_props) Some(resolved_props)
} else { } else {
@@ -1419,7 +1444,11 @@ fn substitute_declarations(css: &str, props: &HashMap<String, String>) -> String
/// represents CSS's guaranteed-invalid value. Crucially, invalidity propagates /// represents CSS's guaranteed-invalid value. Crucially, invalidity propagates
/// through an intermediate custom property so an outer var() can use its own /// through an intermediate custom property so an outer var() can use its own
/// fallback (`--toggle:var(--missing) dark; color:var(--toggle,light)`). /// fallback (`--toggle:var(--missing) dark; color:var(--toggle,light)`).
fn substitute_var_value(input: &str, props: &HashMap<String, String>, depth: u8) -> Option<String> { pub(crate) fn substitute_var_value(
input: &str,
props: &HashMap<String, String>,
depth: u8,
) -> Option<String> {
if depth > 16 { if depth > 16 {
return None; return None;
} }
+13
View File
@@ -225,6 +225,13 @@ impl OverflowClip {
pub struct DomLayout { pub struct DomLayout {
pub rects: HashMap<NodeId, Rect>, pub rects: HashMap<NodeId, Rect>,
pub styles: HashMap<NodeId, crate::LayoutStyle>, pub styles: HashMap<NodeId, crate::LayoutStyle>,
/// Computed custom properties in force for each element. Values are
/// reference-counted because the overwhelmingly common case is inheritance
/// without an override: an entire subtree can share one cascade map while
/// CSSOM still exposes every inherited `--token` through
/// `getComputedStyle()`.
pub custom_properties:
HashMap<NodeId, std::rc::Rc<HashMap<String, String>>>,
/// The per-axis clip inherited from ancestor non-visible overflow, keyed /// The per-axis clip inherited from ancestor non-visible overflow, keyed
/// per node, in SCREEN space (the clip owner's box shifted by the owner's /// per node, in SCREEN space (the clip owner's box shifted by the owner's
/// accumulated translate; see `resolve_clip_rects`). `None` means /// accumulated translate; see `resolve_clip_rects`). `None` means
@@ -1348,6 +1355,7 @@ fn cascade_walk(
sheet: &crate::css::Stylesheet, sheet: &crate::css::Stylesheet,
matcher: &mut obscura_dom::selector::Matcher, matcher: &mut obscura_dom::selector::Matcher,
styles: &mut HashMap<NodeId, crate::LayoutStyle>, styles: &mut HashMap<NodeId, crate::LayoutStyle>,
custom_properties: &mut HashMap<NodeId, std::rc::Rc<HashMap<String, String>>>,
parent_props: &std::rc::Rc<HashMap<String, String>>, parent_props: &std::rc::Rc<HashMap<String, String>>,
container_evaluator: &mut Option<&mut crate::css::ContainerQueryEvaluator<'_>>, container_evaluator: &mut Option<&mut crate::css::ContainerQueryEvaluator<'_>>,
quirks_mode: bool, quirks_mode: bool,
@@ -1471,6 +1479,7 @@ fn cascade_walk(
if let Some(m) = effective_props { if let Some(m) = effective_props {
this_props = std::rc::Rc::new(m); this_props = std::rc::Rc::new(m);
} }
custom_properties.insert(id, this_props.clone());
let (before_pseudo, after_pseudo) = let (before_pseudo, after_pseudo) =
if let Some(evaluator) = container_evaluator.as_deref_mut() { if let Some(evaluator) = container_evaluator.as_deref_mut() {
sheet.pseudo_styles_with_container_queries( sheet.pseudo_styles_with_container_queries(
@@ -1508,6 +1517,7 @@ fn cascade_walk(
sheet, sheet,
matcher, matcher,
styles, styles,
custom_properties,
&this_props, &this_props,
container_evaluator, container_evaluator,
quirks_mode, quirks_mode,
@@ -1960,6 +1970,7 @@ fn layout_dom_once(
let t1 = std::time::Instant::now(); let t1 = std::time::Instant::now();
let mut matcher = tree.matcher(); let mut matcher = tree.matcher();
let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new(); let mut styles: HashMap<NodeId, crate::LayoutStyle> = HashMap::new();
let mut custom_properties = HashMap::new();
let root_props = std::rc::Rc::new(HashMap::new()); let root_props = std::rc::Rc::new(HashMap::new());
let mut evaluator = let mut evaluator =
snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot)); snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot));
@@ -1980,6 +1991,7 @@ fn layout_dom_once(
&sheet, &sheet,
&mut matcher, &mut matcher,
&mut styles, &mut styles,
&mut custom_properties,
&root_props, &root_props,
&mut evaluator_ref, &mut evaluator_ref,
quirks_mode, quirks_mode,
@@ -3948,6 +3960,7 @@ fn layout_dom_once(
DomLayout { DomLayout {
rects, rects,
styles, styles,
custom_properties,
clip_rects, clip_rects,
translates, translates,
text_runs, text_runs,
+32
View File
@@ -461,6 +461,26 @@ impl PreparedRender {
css_color(style.background_color.unwrap_or([0, 0, 0, 0])), css_color(style.background_color.unwrap_or([0, 0, 0, 0])),
); );
out.insert("color", css_color(style.color.unwrap_or([0, 0, 0, 255]))); out.insert("color", css_color(style.color.unwrap_or([0, 0, 0, 255])));
out.insert("font-size", css_px(style.font_size.unwrap_or(16.0)));
out.insert(
"font-weight",
style
.font_weight
.clone()
.unwrap_or_else(|| "400".to_string()),
);
if let Some(family) = &style.font_family {
out.insert("font-family", family.clone());
}
out.insert(
"line-height",
match style.line_height.unwrap_or(crate::LineHeight::Normal) {
crate::LineHeight::Normal => "normal".to_string(),
crate::LineHeight::Ratio(value) => css_number(value),
crate::LineHeight::Px(value) => css_px(value),
crate::LineHeight::Relative(value) => dimension_css(value, "normal"),
},
);
if let Some(rect) = rect { if let Some(rect) = rect {
let horizontal_non_content = let horizontal_non_content =
@@ -621,6 +641,18 @@ impl PreparedRender {
Some(out) Some(out)
} }
/// Cascaded custom properties exposed by CSSOM alongside the compact
/// fixed-property snapshot above. These maps are shared across unchanged
/// inherited subtrees by `DomLayout`, so reading one does not require a
/// second cascade or duplicate every design token for every descendant.
pub fn computed_custom_properties(
&self,
id: obscura_dom::tree::NodeId,
) -> Option<HashMap<String, String>> {
let properties = self.layout.custom_properties.get(&id)?;
Some(properties.as_ref().clone())
}
/// Border box in the current root viewport. This is the read-only geometry /// Border box in the current root viewport. This is the read-only geometry
/// path used by a later CSSOM integration and shares paint's clamped scroll, /// path used by a later CSSOM integration and shares paint's clamped scroll,
/// fixed-subtree, and sticky-positioning derivatives. /// fixed-subtree, and sticky-positioning derivatives.