LibWeb: Track out-of-flow descendant visual contexts

Accumulated visual contexts only recorded the state used by normal
descendants. Fixed descendants started from the visual viewport, and
absolute descendants rebuilt their state from the containing block. That
could drop CSS clip and clip-path nodes from ancestors between the
positioned box and descendant, even though those clips still apply.

Carry separate transient AVC states for normal descendants,
absolute-position descendants, and fixed-position descendants while
building the AVC tree. The top-down paintable walk adds effects and CSS
clipping to positioned descendant contexts, and containing blocks switch
those contexts back to the normal context.

Fixes https://github.com/LadybirdBrowser/ladybird/issues/9909
This commit is contained in:
Aliaksandr Kalenik 2026-06-05 22:30:39 +01:00 committed by Alexander Kalenik
parent ec421d7fa4
commit 221219e00c
5 changed files with 157 additions and 58 deletions

View file

@ -206,6 +206,36 @@ static Optional<ClipData> compute_clip_data(PaintableBox const& paintable_box, C
return {};
}
static Optional<ClipData> compute_css_clip_data(PaintableBox const& paintable_box, DevicePixelConverter const& converter)
{
if (auto css_clip = paintable_box.get_clip_rect(); css_clip.has_value()) {
auto effective_rect = effective_css_clip_rect(*css_clip);
return ClipData { converter.rounded_device_rect(effective_rect), {} };
}
return {};
}
static Optional<ClipPathData> compute_basic_shape_clip_path_data(PaintableBox const& paintable_box, CSS::ComputedValues const& computed_values, DevicePixelConverter const& converter, float scale)
{
// FIXME: Support other geometry boxes. See: https://drafts.fxtf.org/css-masking/#typedef-geometry-box
auto const& clip_path = computed_values.clip_path();
if (!clip_path.has_value() || !clip_path->is_basic_shape())
return {};
auto masking_area = paintable_box.absolute_border_box_rect();
auto reference_box = CSSPixelRect { {}, masking_area.size() };
auto const& basic_shape = clip_path->basic_shape();
auto path = basic_shape.to_path(reference_box, paintable_box.layout_node());
path.offset(masking_area.top_left().template to_type<float>());
auto fill_rule = basic_shape.basic_shape().visit(
[](CSS::Polygon const& polygon) { return polygon.fill_rule; },
[](CSS::Path const& path) { return path.fill_rule; },
[](auto const&) { return Gfx::WindingRule::Nonzero; });
auto device_path = path.copy_transformed(Gfx::AffineTransform {}.set_scale(scale, scale));
auto device_bounding_rect = converter.rounded_device_rect(masking_area);
return ClipPathData { move(device_path), device_bounding_rect, fill_rule };
}
AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaintable& viewport_paintable)
{
auto& document = viewport_paintable.document();
@ -239,12 +269,13 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint
viewport_paintable.set_accumulated_visual_context(VISUAL_VIEWPORT_NODE_INDEX);
viewport_paintable.set_accumulated_visual_context_for_descendants(viewport_state_for_descendants);
viewport_paintable.for_each_in_subtree_of_type<PaintableBox>([&](auto& paintable_box) {
auto visual_parent_paintable = paintable_box.parent();
auto* visual_parent = as_if<PaintableBox>(visual_parent_paintable.ptr());
if (!visual_parent)
return TraversalDecision::Continue;
struct DescendantVisualContexts {
VisualContextIndex normal;
VisualContextIndex absolute_position;
VisualContextIndex fixed_position;
};
auto build_paintable_box = [&](auto& self, PaintableBox& paintable_box, DescendantVisualContexts inherited_contexts) -> void {
// Resolve filters before make_effects_data reads them.
auto const& paintable_box_computed_values = paintable_box.computed_values();
if (paintable_box_computed_values.filter().has_filters())
@ -255,39 +286,29 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint
VisualContextIndex inherited_state;
if (paintable_box.is_fixed_position()) {
inherited_state = visual_viewport_context_index;
inherited_state = inherited_contexts.fixed_position;
} else if (paintable_box.is_absolutely_positioned()) {
// For position: absolute, use containing block's state to correctly escape scroll containers.
auto containing = paintable_box.containing_block();
inherited_state = containing->accumulated_visual_context_for_descendants_index();
// Abspos elements escape scroll containers and overflow clips of non-positioned
// ancestors, but cannot escape stacking contexts created by intermediate effects
// (opacity, mix-blend-mode, isolation). Walk from visual parent to containing
// block and collect these intermediate effects.
// NOTE: transforms/perspectives/filters establish containing blocks for abspos,
// so they cannot appear as intermediates.
Vector<VisualContextData, 4> intermediate_effects;
RefPtr<Paintable> containing_paintable = containing;
for (RefPtr<Paintable> paintable = visual_parent; paintable && paintable != containing_paintable; paintable = paintable->parent()) {
auto* ancestor_box = as_if<PaintableBox>(paintable.ptr());
if (!ancestor_box)
continue;
if (auto effects = make_effects_data(*ancestor_box); effects.has_value())
intermediate_effects.append(effects.release_value());
}
for (auto& effects : intermediate_effects.in_reverse())
inherited_state = append_node(inherited_state, move(effects));
inherited_state = inherited_contexts.absolute_position;
} else {
// For position: relative/static, use visual parent's state directly.
// This avoids duplicate transform/perspective allocations that would occur with
// the containing block + intermediate walk approach.
inherited_state = visual_parent->accumulated_visual_context_for_descendants_index();
// In-flow and relatively positioned boxes inherit the normal descendant context from their visual parent.
inherited_state = inherited_contexts.normal;
}
// Build this element's own state from inherited state.
VisualContextIndex own_state = inherited_state;
// Out-of-flow descendants can skip overflow and scroll clips from intermediate ancestors. Keep their visual
// contexts separate as we descend, and replace them with the normal descendant context only when this box
// establishes the relevant containing block.
VisualContextIndex state_for_absolute_position_descendants = inherited_contexts.absolute_position;
VisualContextIndex state_for_fixed_position_descendants = inherited_contexts.fixed_position;
auto append_to_own_and_positioned_descendant_contexts = [&](auto const& data) {
own_state = append_node(own_state, data);
state_for_absolute_position_descendants = append_node(state_for_absolute_position_descendants, data);
state_for_fixed_position_descendants = append_node(state_for_fixed_position_descendants, data);
};
if (paintable_box.is_sticky_position()) {
// For sticky elements, use enclosing_scroll_frame which holds the sticky frame.
// own_scroll_frame may be a different scroll frame if the sticky element also has scrollable overflow.
@ -298,7 +319,7 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint
auto const& computed_values = paintable_box.computed_values();
if (auto effects = make_effects_data(paintable_box); effects.has_value())
own_state = append_node(own_state, effects.release_value());
append_to_own_and_positioned_descendant_contexts(effects.value());
if (auto transform_data = compute_transform(paintable_box, computed_values, pixel_ratio); transform_data.has_value()) {
paintable_box.set_has_non_invertible_css_transform(!transform_data->matrix.is_invertible());
@ -307,26 +328,11 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint
paintable_box.set_has_non_invertible_css_transform(false);
}
if (auto css_clip = paintable_box.get_clip_rect(); css_clip.has_value()) {
auto effective_rect = effective_css_clip_rect(*css_clip);
own_state = append_node(own_state, ClipData { converter.rounded_device_rect(effective_rect), {} });
}
if (auto css_clip = compute_css_clip_data(paintable_box, converter); css_clip.has_value())
append_to_own_and_positioned_descendant_contexts(css_clip.value());
// FIXME: Support other geometry boxes. See: https://drafts.fxtf.org/css-masking/#typedef-geometry-box
if (auto const& clip_path = computed_values.clip_path(); clip_path.has_value() && clip_path->is_basic_shape()) {
auto masking_area = paintable_box.absolute_border_box_rect();
auto reference_box = CSSPixelRect { {}, masking_area.size() };
auto const& basic_shape = clip_path->basic_shape();
auto path = basic_shape.to_path(reference_box, paintable_box.layout_node());
path.offset(masking_area.top_left().template to_type<float>());
auto fill_rule = basic_shape.basic_shape().visit(
[](CSS::Polygon const& polygon) { return polygon.fill_rule; },
[](CSS::Path const& path) { return path.fill_rule; },
[](auto const&) { return Gfx::WindingRule::Nonzero; });
auto device_path = path.copy_transformed(Gfx::AffineTransform {}.set_scale(scale, scale));
auto device_bounding_rect = converter.rounded_device_rect(masking_area);
own_state = append_node(own_state, ClipPathData { move(device_path), device_bounding_rect, fill_rule });
}
if (auto clip_path_data = compute_basic_shape_clip_path_data(paintable_box, computed_values, converter, scale); clip_path_data.has_value())
append_to_own_and_positioned_descendant_contexts(clip_path_data.value());
paintable_box.set_accumulated_visual_context(own_state);
@ -395,8 +401,30 @@ AccumulatedVisualContextTree build_accumulated_visual_context_tree(ViewportPaint
}
paintable_box.set_accumulated_visual_context_for_descendants(state_for_descendants);
if (paintable_box.layout_node().establishes_an_absolute_positioning_containing_block())
state_for_absolute_position_descendants = state_for_descendants;
if (paintable_box.layout_node().establishes_a_fixed_positioning_containing_block())
state_for_fixed_position_descendants = state_for_descendants;
return TraversalDecision::Continue;
DescendantVisualContexts child_contexts {
state_for_descendants,
state_for_absolute_position_descendants,
state_for_fixed_position_descendants,
};
paintable_box.for_each_child_of_type<PaintableBox>([&](PaintableBox& child) {
self(self, child, child_contexts);
return IterationDecision::Continue;
});
};
DescendantVisualContexts viewport_contexts {
viewport_state_for_descendants,
viewport_state_for_descendants,
visual_viewport_context_index,
};
viewport_paintable.for_each_child_of_type<PaintableBox>([&](PaintableBox& child) {
build_paintable_box(build_paintable_box, child, viewport_contexts);
return IterationDecision::Continue;
});
return visual_context_tree;

View file

@ -2,8 +2,8 @@ AccumulatedVisualContext Tree:
[0] transform=[1,0,0,1,0,0] origin=(0,0) (ViewportPaintable(Viewport<#document>))
[1] scroll_frame_id=1 (PaintableWithLines(BlockContainer<PRE>#out))
[2] clip=[8,8 200x200] (PaintableWithLines(BlockContainer(anonymous)))
[5] effects=[opacity=0.5]
[6] effects=[opacity=0.3] (PaintableWithLines(BlockContainer<DIV>.abspos))
[4] effects=[opacity=0.5]
[7] effects=[opacity=0.3] (PaintableWithLines(BlockContainer<DIV>.abspos))
DisplayList:
SaveLayer@0
@ -11,7 +11,7 @@ SaveLayer@0
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x221]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x200]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 200x200]
CompositorWheelHitTestTarget@6 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@6 rect=[8,8 100x100] color=rgb(0, 0, 0)
CompositorWheelHitTestTarget@7 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@7 rect=[8,8 100x100] color=rgb(0, 0, 0)
Restore@0

View file

@ -0,0 +1,20 @@
AccumulatedVisualContext Tree:
[0] transform=[1,0,0,1,0,0] origin=(0,0) (ViewportPaintable(Viewport<#document>))
[1] scroll_frame_id=1 (PaintableWithLines(BlockContainer<PRE>#out))
[2] clip_path=[bounds: 0,0 200x200, path: M50 50L150 50L150 150L50 150L50 50Z] (PaintableWithLines(BlockContainer<DIV>.clip-path-ancestor))
[3] clip_path=[bounds: 0,0 200x200, path: M50 50L150 50L150 150L50 150L50 50Z] (PaintableWithLines(BlockContainer<DIV>.abspos))
[5] clip=[220,0 100x100] (PaintableWithLines(BlockContainer<DIV>.clip-ancestor))
[7] clip=[220,0 100x100] (PaintableWithLines(BlockContainer<DIV>.fixed))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x213]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x200]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[0,0 200x200]
CompositorWheelHitTestTarget@3 target_scroll_frame_index=0 rect=[0,0 200x200]
FillRect@3 rect=[0,0 200x200] color=rgb(255, 0, 0)
CompositorWheelHitTestTarget@5 target_scroll_frame_index=0 rect=[220,0 200x200]
CompositorWheelHitTestTarget@7 target_scroll_frame_index=0 rect=[220,0 200x200]
FillRect@7 rect=[220,0 200x200] color=rgb(0, 128, 0)
Restore@0

View file

@ -3,7 +3,7 @@ AccumulatedVisualContext Tree:
[1] scroll_frame_id=1 (PaintableWithLines(BlockContainer<PRE>#out))
[2] clip=[8,8 200x200]
[3] effects=[opacity=0.5]
[4] transform=[1,0,0,1,20,20] origin=(8,8) (SVGPathPaintable(SVGGeometryBox<rect>))
[6] transform=[1,0,0,1,20,20] origin=(8,8) (SVGPathPaintable(SVGGeometryBox<rect>))
DisplayList:
SaveLayer@0
@ -12,6 +12,6 @@ SaveLayer@0
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x200]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x200]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 200x200]
FillPath@4 path_bounding_rect=[8,8 100x100]
FillPath@6 path_bounding_rect=[8,8 100x100]
Restore@0

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
.clip-path-ancestor {
width: 200px;
height: 200px;
clip-path: inset(50px);
}
.clip-path-ancestor .abspos {
position: absolute;
top: 0;
left: 0;
width: 200px;
height: 200px;
background: red;
}
.clip-ancestor {
position: absolute;
top: 0;
left: 220px;
width: 200px;
height: 200px;
clip: rect(0px, 100px, 100px, 0px);
}
.clip-ancestor .fixed {
position: fixed;
top: 0;
left: 220px;
width: 200px;
height: 200px;
background: green;
}
</style>
<div class="clip-path-ancestor">
<div class="abspos"></div>
</div>
<div class="clip-ancestor">
<div class="fixed"></div>
</div>
<script>
test(() => {
println(internals.dumpDisplayList());
});
</script>