diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 0581fda..9eb5b45 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -81,7 +81,19 @@ globalThis.dispatchEvent = function(event) { 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(); // Exact toString override for members whose native form is not just @@ -2556,6 +2568,23 @@ class Element extends Node { return this.getAttribute("href") || ""; } 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 /. 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); } @@ -5863,12 +5892,21 @@ globalThis.getComputedStyle = (el) => { // cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a // dozen properties must not trigger a dozen layout/native crossings. let rendered = null; - if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { - try { - const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); - rendered = raw ? JSON.parse(raw) : null; - } catch (e) {} - } + 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) { + try { + const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); + rendered = raw ? JSON.parse(raw) : null; + } catch (e) {} + } + computedNames = rendered ? Object.keys(rendered) : []; + }; + refreshRendered(); // React virtualization libraries (react-window, tanstack-virtual, // react-virtuoso) all compute container dimensions via getComputedStyle. // The defaults table previously returned `auto` for width/height and @@ -5924,6 +5962,7 @@ globalThis.getComputedStyle = (el) => { const lookup = (rawProp) => { if (typeof rawProp !== 'string') return ''; + refreshRendered(); const kebab = rawProp.replace(/([A-Z])/g, '-$1').toLowerCase(); if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) return rendered[kebab]; @@ -5944,8 +5983,14 @@ globalThis.getComputedStyle = (el) => { if (prop === Symbol.toPrimitive || prop === Symbol.toStringTag) return undefined; if (prop === 'getPropertyValue') return (name) => lookup(name); if (prop === 'getPropertyPriority') return () => ''; - if (prop === 'item') return (i) => ''; - if (prop === 'length') return 0; + if (prop === 'item') return (i) => { + refreshRendered(); + return computedNames[i | 0] || ''; + }; + if (prop === 'length') { + refreshRendered(); + return computedNames.length; + } if (prop === 'cssText') return ''; if (prop === 'parentRule') return null; // CSSStyleDeclaration's `has` trap intentionally reports every known diff --git a/crates/obscura-js/src/ops.rs b/crates/obscura-js/src/ops.rs index 97197b7..f18177d 100644 --- a/crates/obscura-js/src/ops.rs +++ b/crates/obscura-js/src/ops.rs @@ -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 = obscura_dom::tree::NodeId::new(nid); let mut gs = shared.borrow_mut(); - ensure_prepared_render(&mut gs) - .and_then(|prepared| prepared.computed_style(nid)) - .and_then(|snapshot| serde_json::to_string(&snapshot).ok()) - .unwrap_or_default() + let Some(prepared) = ensure_prepared_render(&mut gs) else { + return String::new(); + }; + 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 diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 1607826..770a655 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -2168,6 +2168,52 @@ mod tests { ); } + #[test] + fn hyperlink_content_attributes_reflect_through_the_idl_surface() { + let mut rt = setup_runtime( + r#" + English + "#, + ); + 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")] #[test] 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#" + + "#, + ); + 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")] async fn idle_event_loop_flushes_resolved_promise_continuations() { let mut rt = setup_runtime("
pending
"); diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 7fd8a5d..1729a49 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -955,7 +955,9 @@ impl Stylesheet { } let registration_changed_props = resolved_props != *parent_props; let has_own = !own.is_empty(); + let mut own_names = std::collections::HashSet::new(); for (k, v) in own { + own_names.insert(k.clone()); let registration = self.registered_custom_properties.get(&k); let set_initial = |props: &mut HashMap| { 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 { Some(resolved_props) } else { @@ -1419,7 +1444,11 @@ fn substitute_declarations(css: &str, props: &HashMap) -> String /// represents CSS's guaranteed-invalid value. Crucially, invalidity propagates /// through an intermediate custom property so an outer var() can use its own /// fallback (`--toggle:var(--missing) dark; color:var(--toggle,light)`). -fn substitute_var_value(input: &str, props: &HashMap, depth: u8) -> Option { +pub(crate) fn substitute_var_value( + input: &str, + props: &HashMap, + depth: u8, +) -> Option { if depth > 16 { return None; } diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 5f11ad7..295d972 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -225,6 +225,13 @@ impl OverflowClip { pub struct DomLayout { pub rects: HashMap, pub styles: HashMap, + /// 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>>, /// 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 /// accumulated translate; see `resolve_clip_rects`). `None` means @@ -1348,6 +1355,7 @@ fn cascade_walk( sheet: &crate::css::Stylesheet, matcher: &mut obscura_dom::selector::Matcher, styles: &mut HashMap, + custom_properties: &mut HashMap>>, parent_props: &std::rc::Rc>, container_evaluator: &mut Option<&mut crate::css::ContainerQueryEvaluator<'_>>, quirks_mode: bool, @@ -1471,6 +1479,7 @@ fn cascade_walk( if let Some(m) = effective_props { this_props = std::rc::Rc::new(m); } + custom_properties.insert(id, this_props.clone()); let (before_pseudo, after_pseudo) = if let Some(evaluator) = container_evaluator.as_deref_mut() { sheet.pseudo_styles_with_container_queries( @@ -1508,6 +1517,7 @@ fn cascade_walk( sheet, matcher, styles, + custom_properties, &this_props, container_evaluator, quirks_mode, @@ -1960,6 +1970,7 @@ fn layout_dom_once( let t1 = std::time::Instant::now(); let mut matcher = tree.matcher(); let mut styles: HashMap = HashMap::new(); + let mut custom_properties = HashMap::new(); let root_props = std::rc::Rc::new(HashMap::new()); let mut evaluator = snapshot.map(|snapshot| crate::css::ContainerQueryEvaluator::new(tree, snapshot)); @@ -1980,6 +1991,7 @@ fn layout_dom_once( &sheet, &mut matcher, &mut styles, + &mut custom_properties, &root_props, &mut evaluator_ref, quirks_mode, @@ -3948,6 +3960,7 @@ fn layout_dom_once( DomLayout { rects, styles, + custom_properties, clip_rects, translates, text_runs, diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index f98ef74..69ea183 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -461,6 +461,26 @@ impl PreparedRender { 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("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 { let horizontal_non_content = @@ -621,6 +641,18 @@ impl PreparedRender { 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> { + 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 /// path used by a later CSSOM integration and shares paint's clamped scroll, /// fixed-subtree, and sticky-positioning derivatives.