From 9b73f41f554c8e83b19df9511d29a73396ba329d Mon Sep 17 00:00:00 2001 From: Pierre Le Fevre Date: Fri, 5 Jun 2026 04:13:02 +0200 Subject: [PATCH] Add CSSOM view shims for Bing hydration Reference: isu issue 280 --- crates/js/src/dom_bridge.rs | 316 +++++++++++++++++++++++++++++++++ crates/js/src/iframe_bridge.rs | 1 + 2 files changed, 317 insertions(+) diff --git a/crates/js/src/dom_bridge.rs b/crates/js/src/dom_bridge.rs index 90f3b46..70e898b 100644 --- a/crates/js/src/dom_bridge.rs +++ b/crates/js/src/dom_bridge.rs @@ -1538,6 +1538,7 @@ fn register_node_methods( ("closest", element_closest), ("focus", element_focus), ("blur", element_blur), + ("getBoundingClientRect", element_get_bounding_client_rect), ]; for &(name, callback) in element_methods { let func = make_native(gc, name, callback); @@ -4836,6 +4837,20 @@ pub fn resolve_dom_get( drop(doc); Some(create_style_object(gc, shapes, bridge, node_id, &style_str)) } + "offsetWidth" | "clientWidth" | "scrollWidth" => { + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { + return None; + } + Some(Value::Number(element_box_dimension(&doc, node_id, "width"))) + } + "offsetHeight" | "clientHeight" | "scrollHeight" => { + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { + return None; + } + Some(Value::Number(element_box_dimension( + &doc, node_id, "height", + ))) + } // ── Form control properties ───────────── "value" => { @@ -6790,6 +6805,264 @@ fn camel_to_kebab(s: &str) -> String { result } +// ── CSSOM View helpers ────────────────────────────────────────────── + +pub fn init_cssom_view_api(vm: &mut Vm) { + let func = make_native(&mut vm.gc, "getComputedStyle", window_get_computed_style); + vm.set_global("getComputedStyle", Value::Function(func)); +} + +fn window_get_computed_style( + args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("getComputedStyle: no document"))?; + let node_id = args + .first() + .and_then(|value| value_to_node_id(ctx.gc, ctx.shapes, value)) + .ok_or_else(|| RuntimeError::type_error("getComputedStyle: expected Element"))?; + + let doc = bridge.document.borrow(); + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { + return Err(RuntimeError::type_error( + "getComputedStyle: expected Element", + )); + } + Ok(create_computed_style_object( + ctx.gc, ctx.shapes, &doc, node_id, + )) +} + +fn computed_style_get_property_value( + args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let name = args + .first() + .map(|value| value.to_js_string(ctx.gc)) + .unwrap_or_default(); + let keys = computed_style_lookup_keys(&name); + + if let Value::Object(style_ref) = &ctx.this { + if let Some(HeapObject::Object(data)) = ctx.gc.get(*style_ref) { + for key in keys { + if let Some(prop) = data.get_property(&key, ctx.shapes) { + return Ok(prop.value.clone()); + } + } + } + } + + Ok(Value::String(String::new())) +} + +fn element_get_bounding_client_rect( + _args: &[Value], + ctx: &mut NativeContext, +) -> Result { + let bridge = ctx + .dom_bridge + .ok_or_else(|| RuntimeError::type_error("getBoundingClientRect: no document"))?; + let node_id = value_to_node_id(ctx.gc, ctx.shapes, &ctx.this) + .ok_or_else(|| RuntimeError::type_error("getBoundingClientRect: expected Element"))?; + + let doc = bridge.document.borrow(); + if !matches!(doc.node_data(node_id), NodeData::Element { .. }) { + return Err(RuntimeError::type_error( + "getBoundingClientRect: expected Element", + )); + } + + let width = element_box_dimension(&doc, node_id, "width"); + let height = element_box_dimension(&doc, node_id, "height"); + let mut rect = ObjectData::new(); + for (key, value) in [ + ("x", 0.0), + ("y", 0.0), + ("top", 0.0), + ("left", 0.0), + ("width", width), + ("height", height), + ("right", width), + ("bottom", height), + ] { + rect.insert_property( + key.to_string(), + Property::data(Value::Number(value)), + ctx.shapes, + ); + } + Ok(Value::Object(ctx.gc.alloc(HeapObject::Object(rect)))) +} + +fn create_computed_style_object( + gc: &mut Gc, + shapes: &mut ShapeTable, + doc: &Document, + node_id: NodeId, +) -> Value { + let mut data = ObjectData::new(); + + for (name, value) in default_computed_style_properties(doc, node_id) { + insert_computed_style_property(&mut data, shapes, name, value); + } + + if let Some(style_attr) = doc.get_attribute(node_id, "style") { + for (name, value) in parse_inline_style_declarations(style_attr) { + insert_computed_style_property(&mut data, shapes, &name, value); + } + } + + let get_property = make_native(gc, "getPropertyValue", computed_style_get_property_value); + data.insert_property( + "getPropertyValue".to_string(), + Property::builtin(Value::Function(get_property)), + shapes, + ); + + Value::Object(gc.alloc(HeapObject::Object(data))) +} + +fn default_computed_style_properties( + doc: &Document, + node_id: NodeId, +) -> [(&'static str, String); 25] { + let display = default_display_for_tag(doc.tag_name(node_id)).to_string(); + [ + ("display", display), + ("visibility", "visible".to_string()), + ("position", "static".to_string()), + ("box-sizing", "content-box".to_string()), + ("opacity", "1".to_string()), + ("font-size", "16px".to_string()), + ("line-height", "normal".to_string()), + ("width", default_dimension_style(doc, node_id, "width")), + ("height", default_dimension_style(doc, node_id, "height")), + ("min-width", "0px".to_string()), + ("min-height", "0px".to_string()), + ("max-width", "none".to_string()), + ("max-height", "none".to_string()), + ("margin-top", "0px".to_string()), + ("margin-right", "0px".to_string()), + ("margin-bottom", "0px".to_string()), + ("margin-left", "0px".to_string()), + ("padding-top", "0px".to_string()), + ("padding-right", "0px".to_string()), + ("padding-bottom", "0px".to_string()), + ("padding-left", "0px".to_string()), + ("border-top-width", "0px".to_string()), + ("border-right-width", "0px".to_string()), + ("border-bottom-width", "0px".to_string()), + ("border-left-width", "0px".to_string()), + ] +} + +fn insert_computed_style_property( + data: &mut ObjectData, + shapes: &mut ShapeTable, + name: &str, + value: String, +) { + let normalized = normalize_css_property_name(name); + let camel = kebab_to_camel(&normalized); + let value = Value::String(value); + data.insert_property(camel, Property::data(value.clone()), shapes); + data.insert_property(normalized, Property::data(value), shapes); +} + +fn computed_style_lookup_keys(name: &str) -> Vec { + let normalized = normalize_css_property_name(name); + let camel = kebab_to_camel(&normalized); + if normalized == camel { + vec![normalized] + } else { + vec![normalized, camel] + } +} + +fn normalize_css_property_name(name: &str) -> String { + camel_to_kebab(name.trim()).to_ascii_lowercase() +} + +fn parse_inline_style_declarations(style: &str) -> Vec<(String, String)> { + style + .split(';') + .filter_map(|decl| { + let (name, value) = decl.split_once(':')?; + let name = name.trim(); + if name.is_empty() { + return None; + } + Some((name.to_string(), value.trim().to_string())) + }) + .collect() +} + +fn inline_style_value(doc: &Document, node_id: NodeId, property: &str) -> Option { + let target = normalize_css_property_name(property); + let style = doc.get_attribute(node_id, "style")?; + parse_inline_style_declarations(style) + .into_iter() + .find(|(name, _)| normalize_css_property_name(name) == target) + .map(|(_, value)| value) +} + +fn default_dimension_style(doc: &Document, node_id: NodeId, property: &str) -> String { + doc.get_attribute(node_id, property) + .and_then(|value| parse_css_px(value).map(format_css_px)) + .unwrap_or_else(|| "auto".to_string()) +} + +fn element_box_dimension(doc: &Document, node_id: NodeId, property: &str) -> f64 { + inline_style_value(doc, node_id, property) + .and_then(|value| parse_css_px(&value)) + .or_else(|| doc.get_attribute(node_id, property).and_then(parse_css_px)) + .unwrap_or(0.0) +} + +fn parse_css_px(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let numeric = trimmed.strip_suffix("px").unwrap_or(trimmed).trim(); + numeric.parse::().ok().filter(|n| n.is_finite()) +} + +fn format_css_px(value: f64) -> String { + if value.fract() == 0.0 { + format!("{}px", value as i64) + } else { + format!("{value}px") + } +} + +fn default_display_for_tag(tag: Option<&str>) -> &'static str { + match tag { + Some("html" | "body" | "address" | "article" | "aside" | "blockquote" | "div") + | Some("dl" | "fieldset" | "figcaption" | "figure" | "footer" | "form") + | Some("h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "header" | "hr") + | Some("main" | "nav" | "ol" | "p" | "pre" | "section" | "ul") => "block", + Some("li") => "list-item", + Some("table") => "table", + Some("thead") => "table-header-group", + Some("tbody") => "table-row-group", + Some("tfoot") => "table-footer-group", + Some("tr") => "table-row", + Some("td" | "th") => "table-cell", + Some("colgroup") => "table-column-group", + Some("col") => "table-column", + Some("caption") => "table-caption", + Some("script" | "style" | "template" | "title" | "meta" | "link" | "head") => "none", + Some("button" | "canvas" | "iframe" | "img" | "input" | "select" | "textarea") => { + "inline-block" + } + _ => "inline", + } +} + // ── Event system ──────────────────────────────────────────────────── // Internal property keys for Event objects. @@ -10634,6 +10907,49 @@ mod tests { } } + #[test] + fn test_window_get_computed_style_reads_inline_styles_and_defaults() { + let mut vm = setup_lifecycle_vm( + r#"
"#, + ); + let js = r#" + var d = document.getElementById("d"); + var s = window.getComputedStyle(d); + typeof getComputedStyle + ":" + + s.marginLeft + ":" + + s.getPropertyValue("margin-right") + ":" + + s.getPropertyValue("margin-top") + ":" + + s.getPropertyValue("font-size") + ":" + + s.display + ":" + + d.offsetWidth + ":" + + (s.getPropertyValue("does-not-exist") === "") + "#; + let ast = Parser::parse(js).unwrap(); + let func = compiler::compile(&ast).unwrap(); + match vm.execute(&func).unwrap() { + Value::String(s) => assert_eq!(s, "function:12px:3px:0px:16px:flex:42:true"), + v => panic!("expected computed style summary, got {v:?}"), + } + } + + #[test] + fn test_get_bounding_client_rect_returns_inline_dimensions() { + let mut vm = setup_lifecycle_vm( + r#"
"#, + ); + let js = r#" + var rect = document.getElementById("d").getBoundingClientRect(); + rect.x + ":" + rect.y + ":" + rect.width + ":" + rect.height + ":" + + rect.right + ":" + rect.bottom + "#; + let ast = Parser::parse(js).unwrap(); + let func = compiler::compile(&ast).unwrap(); + match vm.execute(&func).unwrap() { + Value::String(s) => assert_eq!(s, "0:0:42:11:42:11"), + v => panic!("expected bounding rect summary, got {v:?}"), + } + } + #[test] fn test_ready_state_transitions() { let mut vm = setup_lifecycle_vm(""); diff --git a/crates/js/src/iframe_bridge.rs b/crates/js/src/iframe_bridge.rs index 85777d7..4393281 100644 --- a/crates/js/src/iframe_bridge.rs +++ b/crates/js/src/iframe_bridge.rs @@ -217,6 +217,7 @@ pub fn init_window_object(vm: &mut Vm, context_id: &str, origin: &str) { // Set as global. vm.set_global("window", Value::Object(window_ref)); vm.set_global("this", Value::Object(window_ref)); + crate::dom_bridge::init_cssom_view_api(vm); // Also make document accessible from window if it exists. if let Some(doc_val) = vm.get_global("document").cloned() { -- 2.51.2