LibWeb: Skip repaint for visual context-only style changes

Let style changes that only rebuild compatible accumulated visual
contexts avoid marking the display list dirty. This lets transform
and nonzero opacity updates send visual context tree updates without
recording a new display list.

Keep repainting changes that affect display-list contents or can change
visual context tree compatibility, including zero-crossing opacity,
transform invertibility crossings, background-attachment, clipping,
mix-blend-mode, and perspective. Schedule accumulated visual context
updates for animations independently of repaint so animated
transform/effect updates keep reaching the document.

Cover compatible visual context reuse, incompatible tree shapes, and the
display-list invalidation cases with focused LibWeb tests.
This commit is contained in:
Andreas Kling 2026-06-16 21:13:48 +02:00 committed by Andreas Kling
parent 1b8072371b
commit b583fd3b79
18 changed files with 521 additions and 5 deletions

View file

@ -873,10 +873,10 @@ AnimationUpdateContext::~AnimationUpdateContext()
target->set_needs_layout_tree_update(true, DOM::SetNeedsLayoutTreeUpdateReason::KeyframeEffect);
}
}
if (invalidation.repaint) {
if (invalidation.rebuild_accumulated_visual_contexts)
element.document().set_needs_accumulated_visual_contexts_update(true);
if (invalidation.rebuild_accumulated_visual_contexts)
element.document().set_needs_accumulated_visual_contexts_update(true);
if (invalidation.repaint) {
target->set_needs_repaint();
}
if (invalidation.rebuild_stacking_context_tree)

View file

@ -12,6 +12,7 @@
#include <LibWeb/CSS/StyleValues/NumberStyleValue.h>
#include <LibWeb/CSS/StyleValues/OpacityValueStyleValue.h>
#include <LibWeb/CSS/StyleValues/StyleValueList.h>
#include <LibWeb/CSS/StyleValues/TransformationStyleValue.h>
namespace Web::CSS {
@ -59,6 +60,81 @@ static bool is_stacking_context_creating_value(CSS::PropertyID property_id, Styl
}
}
static bool opacity_change_affects_paintable_visibility(CSS::PropertyID property_id, StyleValue const* old_value, StyleValue const* new_value)
{
if (property_id != CSS::PropertyID::Opacity)
return false;
auto old_opacity = old_value ? old_value->as_opacity_value().resolved() : 1.0f;
auto new_opacity = new_value ? new_value->as_opacity_value().resolved() : 1.0f;
return (old_opacity == 0.0f) != (new_opacity == 0.0f);
}
static Optional<bool> transform_value_is_invertible(StyleValue const* value)
{
if (!value || value->to_keyword() == CSS::Keyword::None)
return true;
auto transformation_is_invertible = [](TransformationStyleValue const& transformation) -> Optional<bool> {
if (!transformation.can_be_converted_to_matrix_without_reference_box())
return {};
return transformation.to_matrix({}).is_invertible();
};
if (value->is_transformation())
return transformation_is_invertible(value->as_transformation());
if (value->is_value_list()) {
auto matrix = Gfx::FloatMatrix4x4::identity();
for (auto const& transformation : value->as_value_list().values()) {
if (!transformation->is_transformation())
return {};
if (!transformation->as_transformation().can_be_converted_to_matrix_without_reference_box())
return {};
matrix = matrix * transformation->as_transformation().to_matrix({});
}
return matrix.is_invertible();
}
return {};
}
static bool transform_change_requires_repaint(CSS::PropertyID property_id, StyleValue const* old_value, StyleValue const* new_value)
{
if (!AK::first_is_one_of(property_id, CSS::PropertyID::Transform, CSS::PropertyID::Scale))
return false;
// StackingContext::paint() omits non-invertibly transformed subtrees, so crossing
// this boundary changes display-list contents, not just the visual context matrix.
auto old_invertible = transform_value_is_invertible(old_value);
auto new_invertible = transform_value_is_invertible(new_value);
if (!old_invertible.has_value() || !new_invertible.has_value())
return true;
return old_invertible.value() != new_invertible.value();
}
static bool accumulated_visual_context_change_requires_repaint(CSS::PropertyID property_id, StyleValue const* old_value, StyleValue const* new_value)
{
if (opacity_change_affects_paintable_visibility(property_id, old_value, new_value))
return true;
if (transform_change_requires_repaint(property_id, old_value, new_value))
return true;
switch (property_id) {
case CSS::PropertyID::BackgroundAttachment:
case CSS::PropertyID::Clip:
case CSS::PropertyID::ClipPath:
case CSS::PropertyID::MixBlendMode:
case CSS::PropertyID::Perspective:
return true;
default:
break;
}
return false;
}
RequiredInvalidationAfterStyleChange compute_property_invalidation(CSS::PropertyID property_id, StyleValue const* old_value, StyleValue const* new_value)
{
RequiredInvalidationAfterStyleChange invalidation;
@ -135,8 +211,16 @@ RequiredInvalidationAfterStyleChange compute_property_invalidation(CSS::Property
}
invalidation.repaint = true;
if (CSS::property_affects_accumulated_visual_contexts(property_id))
if (CSS::property_affects_accumulated_visual_contexts(property_id)) {
invalidation.rebuild_accumulated_visual_contexts = true;
if (!accumulated_visual_context_change_requires_repaint(property_id, old_value, new_value)
&& !invalidation.rebuild_stacking_context_tree
&& !invalidation.relayout
&& !invalidation.rebuild_layout_tree
&& !invalidation.recompute_descendant_styles
&& !invalidation.inherited_style_changed)
invalidation.repaint = false;
}
return invalidation;
}

View file

@ -2041,7 +2041,10 @@ static void apply_element_style_invalidation_after_style_change(Element& element
static void apply_document_style_invalidation_after_style_change(Document& document, CSS::RequiredInvalidationAfterStyleChange const& invalidation)
{
if (!invalidation.is_none())
if (invalidation.repaint
|| invalidation.rebuild_stacking_context_tree
|| invalidation.relayout
|| invalidation.rebuild_layout_tree)
document.set_needs_to_record_display_list();
if (invalidation.rebuild_accumulated_visual_contexts)
document.set_needs_accumulated_visual_contexts_update(true);

View file

@ -54,3 +54,31 @@ TEST_CASE(compatibility_requires_same_shape)
EXPECT(!different_parent_tree.is_compatible_with(same_node_count_tree));
}
TEST_CASE(compatibility_requires_same_empty_effective_clip)
{
auto empty_clip_tree = AccumulatedVisualContextTree::create();
empty_clip_tree.append(ClipData { Web::DevicePixelRect {}, {} }, VISUAL_VIEWPORT_NODE_INDEX);
auto non_empty_clip_tree = AccumulatedVisualContextTree::create();
non_empty_clip_tree.append(ClipData { Web::DevicePixelRect { 0, 0, 1, 1 }, {} }, VISUAL_VIEWPORT_NODE_INDEX);
EXPECT(!empty_clip_tree.is_compatible_with(non_empty_clip_tree));
}
TEST_CASE(compatibility_requires_same_visual_context_types)
{
auto clip_tree = AccumulatedVisualContextTree::create();
clip_tree.append(ClipData { Web::DevicePixelRect { 0, 0, 1, 1 }, {} }, VISUAL_VIEWPORT_NODE_INDEX);
Gfx::Path path;
path.move_to({ 0, 0 });
path.line_to({ 1, 0 });
path.line_to({ 1, 1 });
path.close();
auto clip_path_tree = AccumulatedVisualContextTree::create();
clip_path_tree.append(ClipPathData { path, Web::DevicePixelRect { 0, 0, 1, 1 }, Gfx::WindingRule::Nonzero }, VISUAL_VIEWPORT_NODE_INDEX);
EXPECT(!clip_tree.is_compatible_with(clip_path_tree));
}

View file

@ -0,0 +1,30 @@
Before animation update:
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] transform=[1,0,0,1,10,0] origin=(58,58) (PaintableWithLines(BlockContainer<DIV>#box))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@2 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0
After animation update:
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] transform=[1,0,0,1,15,0] origin=(58,58) (PaintableWithLines(BlockContainer<DIV>#box))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@2 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0

View file

@ -0,0 +1,35 @@
Before background-attachment change:
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))
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@1 target_scroll_frame_index=0 rect=[0,0 200x200]
Save@1
AddClipRect@1 rect=[0,0 200x200]
PaintLinearGradient@1 rect=[0,0 200x600]
Restore@1
Restore@0
After background-attachment change:
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] scroll_compensation(frame_id=1)
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@1 target_scroll_frame_index=0 rect=[0,0 200x200]
Save@1
AddClipRect@1 rect=[0,0 200x200]
PaintLinearGradient@2 rect=[0,0 800x600]
Restore@1
Restore@0

View file

@ -0,0 +1,26 @@
Before clip change:
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))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x21]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,13 100x100]
FillRect@1 rect=[8,13 100x100] color=rgb(0, 128, 0)
Restore@0
After clip change:
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,13 50x50] (PaintableWithLines(BlockContainer<DIV>#box))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x21]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,13 100x100]
FillRect@2 rect=[8,13 100x100] color=rgb(0, 128, 0)
Restore@0

View file

@ -0,0 +1,27 @@
Before clip-path change:
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: 8,8 100x100, path: M8 8L108 8L108 108L8 108L8 8Z] (PaintableWithLines(BlockContainer<DIV>#box))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@2 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0
After clip-path change:
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))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
Restore@0

View file

@ -0,0 +1,29 @@
Before mix-blend-mode change:
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))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@1 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0
After mix-blend-mode change:
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] effects=[blend_mode=2] (PaintableWithLines(BlockContainer<DIV>#box))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@2 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0

View file

@ -0,0 +1,33 @@
Before perspective change:
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] transform=[1,0,0,1,0,0] origin=(58,58) (PaintableWithLines(BlockContainer<DIV>#child))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 100x100]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@2 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0
After perspective change:
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] perspective
[3] transform=[1,0,0,1,0,0] origin=(58,58) (PaintableWithLines(BlockContainer<DIV>#child))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 100x100]
CompositorWheelHitTestTarget@3 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@3 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0

View file

@ -0,0 +1,27 @@
Before transform change:
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))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
Restore@0
After transform change:
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] transform=[1,0,0,1,0,0] origin=(58,58) (PaintableWithLines(BlockContainer<DIV>#box))
DisplayList:
SaveLayer@0
CompositorWheelHitTestTarget@0 target_scroll_frame_index=0 rect=[0,0 800x600]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[0,0 800x121]
CompositorWheelHitTestTarget@1 target_scroll_frame_index=0 rect=[8,8 784x100]
CompositorWheelHitTestTarget@2 target_scroll_frame_index=0 rect=[8,8 100x100]
FillRect@2 rect=[8,8 100x100] color=rgb(0, 128, 0)
Restore@0

View file

@ -0,0 +1,31 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#box {
width: 100px;
height: 100px;
background: green;
}
</style>
<div id="box"></div>
<script>
test(() => {
const box = document.getElementById("box");
const animation = box.animate([
{ transform: "translateX(10px)" },
{ transform: "translateX(20px)" },
], 1000);
animation.pause();
animation.currentTime = 0;
const displayListBefore = internals.dumpDisplayList();
animation.currentTime = 500;
const displayListAfter = internals.dumpDisplayList();
println("Before animation update:");
println(displayListBefore);
println("After animation update:");
println(displayListAfter);
});
</script>

View file

@ -0,0 +1,31 @@
<!doctype html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#box {
width: 200px;
height: 200px;
background-image: linear-gradient(green, blue);
background-size: 100% 100vh;
background-repeat: no-repeat;
background-attachment: scroll;
}
</style>
<div id="box"></div>
<script>
test(() => {
const box = document.getElementById("box");
const displayListBefore = internals.dumpDisplayList();
box.style.backgroundAttachment = "fixed";
const displayListAfter = internals.dumpDisplayList();
println("Before background-attachment change:");
println(displayListBefore);
println("After background-attachment change:");
println(displayListAfter);
});
</script>

View file

@ -0,0 +1,26 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#box {
position: absolute;
width: 100px;
height: 100px;
background: green;
clip: auto;
}
</style>
<div id="box"></div>
<script>
test(() => {
const box = document.getElementById("box");
const displayListBefore = internals.dumpDisplayList();
box.style.clip = "rect(0, 50px, 50px, 0)";
const displayListAfter = internals.dumpDisplayList();
println("Before clip change:");
println(displayListBefore);
println("After clip change:");
println(displayListAfter);
});
</script>

View file

@ -0,0 +1,25 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#box {
width: 100px;
height: 100px;
background: green;
clip-path: inset(0);
}
</style>
<div id="box"></div>
<script>
test(() => {
const box = document.getElementById("box");
const displayListBefore = internals.dumpDisplayList();
box.style.clipPath = "inset(50%)";
const displayListAfter = internals.dumpDisplayList();
println("Before clip-path change:");
println(displayListBefore);
println("After clip-path change:");
println(displayListAfter);
});
</script>

View file

@ -0,0 +1,25 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#box {
width: 100px;
height: 100px;
background: green;
mix-blend-mode: normal;
}
</style>
<div id="box"></div>
<script>
test(() => {
const box = document.getElementById("box");
const displayListBefore = internals.dumpDisplayList();
box.style.mixBlendMode = "multiply";
const displayListAfter = internals.dumpDisplayList();
println("Before mix-blend-mode change:");
println(displayListBefore);
println("After mix-blend-mode change:");
println(displayListAfter);
});
</script>

View file

@ -0,0 +1,31 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#box {
width: 100px;
height: 100px;
perspective: none;
}
#child {
width: 100px;
height: 100px;
background: green;
transform: translateZ(10px);
}
</style>
<div id="box"><div id="child"></div></div>
<script>
test(() => {
const box = document.getElementById("box");
const displayListBefore = internals.dumpDisplayList();
box.style.perspective = "100px";
const displayListAfter = internals.dumpDisplayList();
println("Before perspective change:");
println(displayListBefore);
println("After perspective change:");
println(displayListAfter);
});
</script>

View file

@ -0,0 +1,25 @@
<!doctype html>
<script src="../include.js"></script>
<style>
#box {
width: 100px;
height: 100px;
background: green;
transform: scale(0);
}
</style>
<div id="box"></div>
<script>
test(() => {
const box = document.getElementById("box");
const displayListBefore = internals.dumpDisplayList();
box.style.transform = "scale(1)";
const displayListAfter = internals.dumpDisplayList();
println("Before transform change:");
println(displayListBefore);
println("After transform change:");
println(displayListAfter);
});
</script>