ladybird/Libraries/LibWeb/CSS/StyleInvalidation.cpp
Andreas Kling b583fd3b79 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.
2026-06-18 00:12:42 +02:00

228 lines
10 KiB
C++

/*
* Copyright (c) 2024, Aliaksandr Kalenik <kalenik.aliaksandr@gmail.com>
* Copyright (c) 2025, Manuel Zahariev <manuel@duck.com>
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/CSS/PropertyID.h>
#include <LibWeb/CSS/StyleInvalidation.h>
#include <LibWeb/CSS/StyleValues/FilterValueListStyleValue.h>
#include <LibWeb/CSS/StyleValues/KeywordStyleValue.h>
#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 {
static bool is_stacking_context_creating_value(CSS::PropertyID property_id, StyleValue const* value)
{
if (!value)
return false;
switch (property_id) {
case CSS::PropertyID::Opacity:
return value->as_opacity_value().resolved() < 1;
case CSS::PropertyID::Transform:
if (value->to_keyword() == CSS::Keyword::None)
return false;
if (value->is_value_list())
return value->as_value_list().size() > 0;
return value->is_transformation();
case CSS::PropertyID::Translate:
case CSS::PropertyID::Rotate:
case CSS::PropertyID::Scale:
return value->to_keyword() != CSS::Keyword::None;
case CSS::PropertyID::Filter:
case CSS::PropertyID::BackdropFilter:
if (value->is_keyword())
return value->to_keyword() != CSS::Keyword::None;
return value->is_filter_value_list();
case CSS::PropertyID::ClipPath:
case CSS::PropertyID::Mask:
case CSS::PropertyID::MaskImage:
case CSS::PropertyID::ViewTransitionName:
return value->to_keyword() != CSS::Keyword::None;
case CSS::PropertyID::Isolation:
return value->to_keyword() == CSS::Keyword::Isolate;
case CSS::PropertyID::MixBlendMode:
return value->to_keyword() != CSS::Keyword::Normal;
case CSS::PropertyID::ZIndex:
return value->to_keyword() != CSS::Keyword::Auto;
case CSS::PropertyID::Perspective:
case CSS::PropertyID::TransformStyle:
return value->to_keyword() != CSS::Keyword::None && value->to_keyword() != CSS::Keyword::Flat;
default:
// For properties we haven't optimized (contain, container-type, will-change, all),
// assume any value creates stacking context to be safe
return true;
}
}
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;
if (old_value == new_value)
return invalidation;
if (old_value && new_value && old_value->equals(*new_value))
return invalidation;
// NOTE: If the computed CSS display, position, content, or content-visibility property changes, we have to rebuild the entire layout tree.
// In the future, we should figure out ways to rebuild a smaller part of the tree.
if (AK::first_is_one_of(property_id, CSS::PropertyID::Display, CSS::PropertyID::Position, CSS::PropertyID::Content, CSS::PropertyID::ContentVisibility)) {
return RequiredInvalidationAfterStyleChange::full();
}
// NOTE: If the text-transform property changes, it may affect layout. Furthermore, since the
// Layout::TextNode caches the post-transform text, we have to update the layout tree.
if (property_id == CSS::PropertyID::TextTransform) {
invalidation.rebuild_layout_tree = true;
invalidation.relayout = true;
invalidation.repaint = true;
return invalidation;
}
// NOTE: If one of the overflow properties change, we rebuild the entire layout tree.
// This ensures that overflow propagation from root/body to viewport happens correctly.
// In the future, we can make this invalidation narrower.
if (property_id == CSS::PropertyID::OverflowX || property_id == CSS::PropertyID::OverflowY) {
return RequiredInvalidationAfterStyleChange::full();
}
if (AK::first_is_one_of(property_id, CSS::PropertyID::CounterReset, CSS::PropertyID::CounterSet, CSS::PropertyID::CounterIncrement)) {
invalidation.rebuild_layout_tree = true;
return invalidation;
}
if (AK::first_is_one_of(property_id, CSS::PropertyID::ContainerName, CSS::PropertyID::ContainerType))
invalidation.recompute_descendant_styles = true;
// OPTIMIZATION: Special handling for CSS `visibility`:
if (property_id == CSS::PropertyID::Visibility) {
// We don't need to relayout if the visibility changes from visible to hidden or vice versa. Only collapse requires relayout.
if ((old_value && old_value->to_keyword() == CSS::Keyword::Collapse) != (new_value && new_value->to_keyword() == CSS::Keyword::Collapse))
invalidation.relayout = true;
// Of course, we still have to repaint on any visibility change.
invalidation.repaint = true;
} else if (CSS::property_affects_layout(property_id)) {
invalidation.relayout = true;
}
if (CSS::property_affects_stacking_context(property_id)) {
// z-index changes always require rebuilding the stacking context tree because
// the value determines painting order within the tree, not just whether a
// stacking context is created. During tree construction, elements with
// z-index 0/auto are placed in m_positioned_descendants_and_stacking_contexts_
// with_stack_level_0, while elements with non-zero z-index are painted from
// m_children (negative z-index at step 3, positive at step 9 of CSS 2.1
// Appendix E). If z-index changes between non-auto values (e.g. 0 -> 10),
// both old and new values create stacking contexts, so the generic optimization
// below would skip the rebuild. But the element remains in the wrong list,
// causing it to be painted from both step 8 (m_positioned_descendants) and
// step 9 (m_children with z >= 1), resulting in double painting.
if (property_id == CSS::PropertyID::ZIndex) {
invalidation.rebuild_stacking_context_tree = true;
} else {
// OPTIMIZATION: Only rebuild stacking context tree when property crosses from a neutral value (doesn't create
// stacking context) to a creating value or vice versa.
bool old_creates = is_stacking_context_creating_value(property_id, old_value);
bool new_creates = is_stacking_context_creating_value(property_id, new_value);
if (old_creates != new_creates) {
invalidation.rebuild_stacking_context_tree = true;
}
}
}
invalidation.repaint = true;
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;
}
}