From 7827a6a788f8e403ad0844e341bdb76132cdfdef Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:02:55 +0000 Subject: [PATCH] render: honor modern containing-block triggers Track transform, filter, backdrop-filter, perspective, containment, will-change, and content-visibility triggers independently. Use the combined state for absolute and fixed containing-block ancestry. --- crates/obscura-render/src/dom.rs | 5 +- crates/obscura-render/src/lib.rs | 22 +++++-- crates/obscura-render/src/style.rs | 71 +++++++++++++++++++++- crates/obscura-render/tests/layout_test.rs | 34 +++++++++++ 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/crates/obscura-render/src/dom.rs b/crates/obscura-render/src/dom.rs index 8edfdf1..550cad7 100644 --- a/crates/obscura-render/src/dom.rs +++ b/crates/obscura-render/src/dom.rs @@ -1190,12 +1190,13 @@ fn reparent_inset_positioned_nodes( // boxes capture absolute descendants; only transformed boxes capture // fixed descendants. The full walk stays O(n). let own_box = reverse.get(&dom_id).copied(); - let abs_child_cb = if style.position.is_some() || style.transform_establishes_containing_block { + let establishes_cb = style.establishes_positioning_containing_block(); + let abs_child_cb = if style.position.is_some() || establishes_cb { own_box.unwrap_or(inherited_abs_cb) } else { inherited_abs_cb }; - let fixed_child_cb = if style.transform_establishes_containing_block { + let fixed_child_cb = if establishes_cb { own_box.unwrap_or(inherited_fixed_cb) } else { inherited_fixed_cb diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 13d4a4d..d31b725 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -415,10 +415,10 @@ pub struct LayoutStyle { /// off-screen skip-link out of view instead of painting it on-screen. Not /// inherited (transform is a non-inherited property). pub transform_translate: Option<(Dimension, Dimension)>, - /// Any computed transform other than `none` establishes containing blocks - /// for absolute and fixed descendants, even when its visual transform - /// function is not yet supported by the paint engine. - pub transform_establishes_containing_block: bool, + /// Independent CSS-property triggers that establish containing blocks for + /// absolute and fixed descendants. Kept as a bitset so `filter:none` + /// cannot clear a transform/containment trigger from another property. + pub containing_block_triggers: u16, /// `transform: scale(sx[, sy])` / `scaleX` / `scaleY`, captured as (sx, sy). /// Parsed and stored so a value that mixes scale with translate still yields /// its translate part; scale is not yet folded into paint geometry (doing so @@ -432,6 +432,20 @@ pub struct LayoutStyle { pub box_shadow: Option, } +pub(crate) const CB_TRIGGER_TRANSFORM: u16 = 1 << 0; +pub(crate) const CB_TRIGGER_FILTER: u16 = 1 << 1; +pub(crate) const CB_TRIGGER_BACKDROP_FILTER: u16 = 1 << 2; +pub(crate) const CB_TRIGGER_PERSPECTIVE: u16 = 1 << 3; +pub(crate) const CB_TRIGGER_CONTAIN: u16 = 1 << 4; +pub(crate) const CB_TRIGGER_WILL_CHANGE: u16 = 1 << 5; +pub(crate) const CB_TRIGGER_CONTENT_VISIBILITY: u16 = 1 << 6; + +impl LayoutStyle { + pub(crate) fn establishes_positioning_containing_block(&self) -> bool { + self.containing_block_triggers != 0 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Float { Left, diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 660318c..aefe5bc 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -599,6 +599,39 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { "grid-column" => set_grid_placement(style, value, true), "grid-row" => set_grid_placement(style, value, false), "transform" => parse_transform(style, value), + "filter" => set_containing_block_trigger( + style, + crate::CB_TRIGGER_FILTER, + non_none_value(value), + ), + "backdrop-filter" | "-webkit-backdrop-filter" => set_containing_block_trigger( + style, + crate::CB_TRIGGER_BACKDROP_FILTER, + non_none_value(value), + ), + "perspective" => set_containing_block_trigger( + style, + crate::CB_TRIGGER_PERSPECTIVE, + non_none_value(value), + ), + "contain" => { + let establishes = value + .split_whitespace() + .any(|v| matches!(v.to_ascii_lowercase().as_str(), "layout" | "paint" | "strict" | "content")); + set_containing_block_trigger(style, crate::CB_TRIGGER_CONTAIN, establishes); + } + "will-change" => { + let establishes = value + .split([',', ' ']) + .map(str::trim) + .any(|v| matches!(v.to_ascii_lowercase().as_str(), "transform" | "filter" | "backdrop-filter" | "perspective" | "contain")); + set_containing_block_trigger(style, crate::CB_TRIGGER_WILL_CHANGE, establishes); + } + "content-visibility" => set_containing_block_trigger( + style, + crate::CB_TRIGGER_CONTENT_VISIBILITY, + value.trim().eq_ignore_ascii_case("auto"), + ), "box-shadow" | "-webkit-box-shadow" => { style.box_shadow = parse_box_shadow(value, style.color); } @@ -623,14 +656,14 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { if v.eq_ignore_ascii_case("none") { style.transform_translate = None; style.transform_scale = None; - style.transform_establishes_containing_block = false; + set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, false); return; } let functions = transform_functions(v); if functions.is_empty() { return; } - style.transform_establishes_containing_block = true; + set_containing_block_trigger(style, crate::CB_TRIGGER_TRANSFORM, true); let zero = crate::Dimension::Px(0.0); let (mut tx, mut ty): (Option, Option) = (None, None); let (mut sx, mut sy): (Option, Option) = (None, None); @@ -686,6 +719,19 @@ fn parse_transform(style: &mut LayoutStyle, value: &str) { } } +fn non_none_value(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() && !value.eq_ignore_ascii_case("none") +} + +fn set_containing_block_trigger(style: &mut LayoutStyle, trigger: u16, enabled: bool) { + if enabled { + style.containing_block_triggers |= trigger; + } else { + style.containing_block_triggers &= !trigger; + } +} + /// Split a `transform` value into its `name(args)` functions, in source order. /// Tokenizes on parentheses (tracking depth so a nested `calc(...)` inside an /// argument stays intact); the function name is the trailing identifier run @@ -2098,6 +2144,27 @@ mod tests { assert_eq!(s.height, crate::Dimension::Px(50.0)); } + #[test] + fn containing_block_property_triggers_are_independent() { + let s = compute_style("div", Some("transform:rotate(0deg);filter:none")); + assert!(s.establishes_positioning_containing_block()); + + let s = compute_style("div", Some("filter:blur(0);transform:none")); + assert!(s.establishes_positioning_containing_block()); + + let s = compute_style( + "div", + Some("contain:layout;content-visibility:visible;filter:none"), + ); + assert!(s.establishes_positioning_containing_block()); + + let s = compute_style( + "div", + Some("contain:none;content-visibility:visible;filter:none;perspective:none"), + ); + assert!(!s.establishes_positioning_containing_block()); + } + #[test] fn margin_shorthand_expands() { let s = compute_style("div", Some("margin: 10px 20px")); diff --git a/crates/obscura-render/tests/layout_test.rs b/crates/obscura-render/tests/layout_test.rs index 5e98730..800be28 100644 --- a/crates/obscura-render/tests/layout_test.rs +++ b/crates/obscura-render/tests/layout_test.rs @@ -203,6 +203,40 @@ fn transforms_establish_absolute_and_fixed_containing_blocks() { assert!((fixed_viewport.y - 40.0).abs() < 1.0, "positioned ancestor captured fixed y: {fixed_viewport:?}"); } +#[test] +fn modern_effects_establish_containing_blocks_independently() { + let tree = parse_html(include_str!("../../../render-repros/modern-containing-block-triggers.html")); + let layout = layout_dom(&tree, (900.0, 700.0)); + let cases = [ + ("filter-cb", "filter-badge"), + ("perspective-cb", "perspective-badge"), + ("contain-cb", "contain-badge"), + ("will-change-cb", "will-change-badge"), + ("visibility-cb", "visibility-badge"), + ]; + for (cb_id, badge_id) in cases { + let cb = layout.rects[&tree.get_element_by_id(cb_id).unwrap()]; + let badge = layout.rects[&tree.get_element_by_id(badge_id).unwrap()]; + assert!((badge.x - cb.x - 75.0).abs() < 1.0, "{badge_id} wrong x: cb={cb:?} badge={badge:?}"); + assert!((badge.y - cb.y - 55.0).abs() < 1.0, "{badge_id} wrong y: cb={cb:?} badge={badge:?}"); + } + + let filter_cb = layout.rects[&tree.get_element_by_id("filter-cb").unwrap()]; + let filter_fixed = layout.rects[&tree.get_element_by_id("filter-fixed").unwrap()]; + assert!((filter_fixed.x - filter_cb.x - 5.0).abs() < 1.0); + assert!((filter_fixed.y - filter_cb.y - 5.0).abs() < 1.0); + let contain_cb = layout.rects[&tree.get_element_by_id("contain-cb").unwrap()]; + let contain_fixed = layout.rects[&tree.get_element_by_id("contain-fixed").unwrap()]; + assert!((contain_fixed.x - contain_cb.x - 5.0).abs() < 1.0); + assert!((contain_fixed.y - contain_cb.y - 5.0).abs() < 1.0); + + // Pinned Chromium 145 does not make container-type:inline-size a + // positioning containing block. Keep it as a negative control. + let container_badge = layout.rects[&tree.get_element_by_id("container-badge").unwrap()]; + assert!((container_badge.x - 775.0).abs() < 1.0, "container-type control x: {container_badge:?}"); + assert!((container_badge.y - 175.0).abs() < 1.0, "container-type control y: {container_badge:?}"); +} + #[test] fn long_text_run_wraps_across_multiple_lines() { // A long single text node with no inline elements breaking it up must