LibWeb: Avoid full viewport resize style invalidations

Use the viewport metric dependency flags to restyle only elements whose
computed values can change after a viewport resize. Descendants that
inherit changed values are reached through the existing inherited-style
update path.

Keep targeted style reads correct by treating pending media query
evaluation as style dirtiness. Seed the style computer with the latest
viewport before resolving pending animated style, so viewport-unit
keyframes do not use stale metrics.

Share pseudo-element recomputation with inherited-style updates, so
pseudos stay current when their originating element changes only via
inherited values.

Schedule animated style updates when inherited style recomputation can
affect existing animations.

Add viewport resize coverage for media queries, inherited font metrics,
monospace font-size recascade, line-height percentages, font-relative
and pending viewport-unit animations, inherited pseudo-elements, direct
pseudo viewport dependencies, and canvas currentColor reads.
This commit is contained in:
Andreas Kling 2026-05-25 09:36:42 +02:00 committed by Andreas Kling
parent b4ef1e9c15
commit ceb25e10d4
11 changed files with 470 additions and 62 deletions

View file

@ -2108,6 +2108,9 @@ void Document::update_style()
if (!browsing_context())
return;
// Fetch the viewport rect once, instead of repeatedly, during style computation.
style_computer().set_viewport_rect({}, viewport_rect());
update_animated_style_if_needed();
// Associated with each top-level browsing context is a current transition generation that is incremented on each
@ -2119,21 +2122,21 @@ void Document::update_style()
CSS::Invalidation::invalidate_style_for_pending_has_mutations(*this);
}
if (!m_style_invalidator->has_pending_invalidations() && !needs_full_style_update() && !needs_style_update() && !child_needs_style_update())
if (!m_style_invalidator->has_pending_invalidations() && !needs_full_style_update() && !needs_style_update() && !child_needs_style_update() && !m_needs_media_query_evaluation)
return;
m_style_invalidator->invalidate(*this);
// NOTE: If this is a document hosting <template> contents, style update is unnecessary.
if (m_created_for_appropriate_template_contents)
return;
// Fetch the viewport rect once, instead of repeatedly, during style computation.
style_computer().set_viewport_rect({}, viewport_rect());
if (m_needs_media_query_evaluation)
evaluate_media_rules();
if (!m_style_invalidator->has_pending_invalidations() && !needs_full_style_update() && !needs_style_update() && !child_needs_style_update())
return;
m_style_invalidator->invalidate(*this);
build_registered_properties_cache();
CSS::RequiredInvalidationAfterStyleChange invalidation;
@ -2154,6 +2157,37 @@ void Document::update_style()
apply_document_style_invalidation_after_style_change(*this, invalidation);
}
static bool element_or_pseudo_depends_on_viewport_metrics(Element const& element)
{
if (auto computed_properties = element.computed_properties(); computed_properties && computed_properties->depends_on_viewport_metrics())
return true;
bool depends_on_viewport_metrics = false;
element.for_each_synthetic_pseudo_element([&](CSS::PseudoElement, SyntheticPseudoElement const& pseudo_element) {
if (auto computed_properties = pseudo_element.computed_properties(); computed_properties && computed_properties->depends_on_viewport_metrics()) {
depends_on_viewport_metrics = true;
return IterationDecision::Break;
}
return IterationDecision::Continue;
});
return depends_on_viewport_metrics;
}
void Document::invalidate_style_for_viewport_change()
{
for_each_shadow_including_inclusive_descendant([](Node& node) {
auto* element = as_if<Element>(node);
if (!element)
return TraversalDecision::Continue;
// Descendants that inherit changed values are reached by the normal inherited-style invalidation path.
if (element_or_pseudo_depends_on_viewport_metrics(*element) || element->style_uses_if_css_function())
element->set_needs_style_update(true);
return TraversalDecision::Continue;
});
}
void Document::update_style_if_needed_for_element(AbstractElement const& abstract_element)
{
if (element_needs_style_update(abstract_element))
@ -2207,10 +2241,10 @@ GC::Ptr<CSS::ComputedProperties const> Document::update_style_for_element(Abstra
navigable->container()->document().update_layout(UpdateLayoutReason::ChildDocumentStyleUpdate);
if (browsing_context()) {
update_animated_style_if_needed();
style_computer().set_viewport_rect({}, viewport_rect());
update_animated_style_if_needed();
// Media query evaluation can enqueue normal style invalidations, so do it before deciding whether the full
// style traversal needs to run.
if (m_needs_media_query_evaluation)
@ -2337,6 +2371,8 @@ bool Document::element_needs_style_update(AbstractElement const& abstract_elemen
return true;
if (m_needs_invalidation_of_elements_affected_by_has)
return true;
if (m_needs_media_query_evaluation)
return true;
if (m_style_invalidator->has_pending_invalidations())
return true;

View file

@ -373,6 +373,7 @@ public:
void obtain_theme_color();
void update_style();
void invalidate_style_for_viewport_change();
void update_style_if_needed_for_element(AbstractElement const&);
enum class StyleUpdateMode : u8 {
Normal,

View file

@ -944,6 +944,67 @@ static CSS::RequiredInvalidationAfterStyleChange compute_required_invalidation(C
return invalidation;
}
CSS::RequiredInvalidationAfterStyleChange Element::recompute_pseudo_element_styles(bool& did_change_custom_properties, bool had_list_marker)
{
CSS::RequiredInvalidationAfterStyleChange invalidation;
auto& style_computer = document().style_computer();
// Any document change that can cause this element's style to change, could also affect its pseudo-elements.
auto recompute_pseudo_element_style = [&](CSS::PseudoElement pseudo_element) {
style_computer.push_ancestor(*this);
auto pseudo_element_style = computed_properties(pseudo_element);
auto new_pseudo_element_style = style_computer.compute_pseudo_element_style_if_needed({ *this, pseudo_element }, did_change_custom_properties);
// TODO: Can we be smarter about invalidation?
if (pseudo_element_style && new_pseudo_element_style) {
DOM::AbstractElement abstract_element { *this, pseudo_element };
invalidation |= compute_required_invalidation(*pseudo_element_style, *new_pseudo_element_style, document().font_computer(), pseudo_element_unsafe_layout_node(pseudo_element), abstract_element);
} else if (pseudo_element_style || new_pseudo_element_style) {
invalidation = CSS::RequiredInvalidationAfterStyleChange::full();
}
set_computed_properties(pseudo_element, move(new_pseudo_element_style));
style_computer.pop_ancestor(*this);
};
recompute_pseudo_element_style(CSS::PseudoElement::Before);
recompute_pseudo_element_style(CSS::PseudoElement::After);
recompute_pseudo_element_style(CSS::PseudoElement::FirstLetter);
recompute_pseudo_element_style(CSS::PseudoElement::Selection);
if (m_rendered_in_top_layer)
recompute_pseudo_element_style(CSS::PseudoElement::Backdrop);
if (had_list_marker || m_computed_properties->display().is_list_item())
recompute_pseudo_element_style(CSS::PseudoElement::Marker);
return invalidation;
}
void Element::apply_computed_style_to_layout_node_if_needed(CSS::RequiredInvalidationAfterStyleChange const& invalidation)
{
if (invalidation.rebuild_layout_tree || !unsafe_layout_node())
return;
// If we're keeping the layout tree, we can just apply the new style to the existing layout tree.
unsafe_layout_node()->apply_style(*m_computed_properties);
if (invalidation.repaint)
set_needs_repaint();
// Do the same for pseudo-elements.
for_each_synthetic_pseudo_element([&](CSS::PseudoElement pseudo_element_type, SyntheticPseudoElement const& pseudo_element) {
auto pseudo_element_style = computed_properties(pseudo_element_type);
if (!pseudo_element_style)
return;
if (auto node_with_style = pseudo_element.unsafe_layout_node()) {
node_with_style->apply_style(*pseudo_element_style);
if (invalidation.repaint && node_with_style->first_paintable())
node_with_style->first_paintable()->set_needs_repaint();
}
});
}
CSS::RequiredInvalidationAfterStyleChange Element::recompute_style(bool& did_change_custom_properties)
{
VERIFY(parent());
@ -1022,58 +1083,14 @@ CSS::RequiredInvalidationAfterStyleChange Element::recompute_style(bool& did_cha
});
}
// Any document change that can cause this element's style to change, could also affect its pseudo-elements.
auto recompute_pseudo_element_style = [&](CSS::PseudoElement pseudo_element) {
style_computer.push_ancestor(*this);
auto pseudo_element_style = computed_properties(pseudo_element);
auto new_pseudo_element_style = style_computer.compute_pseudo_element_style_if_needed({ *this, pseudo_element }, did_change_custom_properties);
// TODO: Can we be smarter about invalidation?
if (pseudo_element_style && new_pseudo_element_style) {
DOM::AbstractElement abstract_element { *this, pseudo_element };
invalidation |= compute_required_invalidation(*pseudo_element_style, *new_pseudo_element_style, document().font_computer(), pseudo_element_unsafe_layout_node(pseudo_element), abstract_element);
} else if (pseudo_element_style || new_pseudo_element_style) {
invalidation = CSS::RequiredInvalidationAfterStyleChange::full();
}
set_computed_properties(pseudo_element, move(new_pseudo_element_style));
style_computer.pop_ancestor(*this);
};
recompute_pseudo_element_style(CSS::PseudoElement::Before);
recompute_pseudo_element_style(CSS::PseudoElement::After);
recompute_pseudo_element_style(CSS::PseudoElement::FirstLetter);
recompute_pseudo_element_style(CSS::PseudoElement::Selection);
if (m_rendered_in_top_layer)
recompute_pseudo_element_style(CSS::PseudoElement::Backdrop);
if (had_list_marker || m_computed_properties->display().is_list_item())
recompute_pseudo_element_style(CSS::PseudoElement::Marker);
invalidation |= recompute_pseudo_element_styles(did_change_custom_properties, had_list_marker);
if (invalidation.is_none()) {
counters.element_style_noop_recomputations++;
return invalidation;
}
if (!invalidation.rebuild_layout_tree && unsafe_layout_node()) {
// If we're keeping the layout tree, we can just apply the new style to the existing layout tree.
unsafe_layout_node()->apply_style(*m_computed_properties);
if (invalidation.repaint)
set_needs_repaint();
// Do the same for pseudo-elements.
for_each_synthetic_pseudo_element([&](CSS::PseudoElement pseudo_element_type, SyntheticPseudoElement const& pseudo_element) {
auto pseudo_element_style = computed_properties(pseudo_element_type);
if (!pseudo_element_style)
return;
if (auto node_with_style = pseudo_element.unsafe_layout_node()) {
node_with_style->apply_style(*pseudo_element_style);
if (invalidation.repaint && node_with_style->first_paintable())
node_with_style->first_paintable()->set_needs_repaint();
}
});
}
apply_computed_style_to_layout_node_if_needed(invalidation);
return invalidation;
}
@ -1085,6 +1102,7 @@ CSS::RequiredInvalidationAfterStyleChange Element::recompute_inherited_style()
auto computed_properties = this->computed_properties();
VERIFY(computed_properties);
auto had_list_marker = computed_properties->display().is_list_item();
CSS::RequiredInvalidationAfterStyleChange invalidation;
@ -1134,17 +1152,18 @@ CSS::RequiredInvalidationAfterStyleChange Element::recompute_inherited_style()
invalidation |= CSS::compute_property_invalidation(property_id, old_value.ptr(), &new_value);
}
if (!invalidation.is_none() && !computed_properties->animated_property_values().is_empty())
document().set_needs_animated_style_update();
bool did_change_custom_properties = false;
invalidation |= recompute_pseudo_element_styles(did_change_custom_properties, had_list_marker);
if (invalidation.is_none()) {
counters.element_inherited_style_noop_recomputations++;
return invalidation;
}
// NB: unsafe_layout_node() because we're applying recomputed inherited styles during
// style recalculation, before layout has been updated.
if (unsafe_layout_node())
unsafe_layout_node()->apply_style(*computed_properties);
if (invalidation.repaint)
set_needs_repaint();
apply_computed_style_to_layout_node_if_needed(invalidation);
return invalidation;
}

View file

@ -668,6 +668,8 @@ private:
FlyString make_html_uppercased_qualified_name() const;
void exit_fullscreen_on_element_removal();
CSS::RequiredInvalidationAfterStyleChange recompute_pseudo_element_styles(bool& did_change_custom_properties, bool had_list_marker);
void apply_computed_style_to_layout_node_if_needed(CSS::RequiredInvalidationAfterStyleChange const&);
WebIDL::ExceptionOr<GC::Ptr<Node>> insert_adjacent(StringView where, GC::Ref<Node> node);

View file

@ -2938,8 +2938,7 @@ void Navigable::set_viewport_size(CSSPixelSize size, InvalidateDisplayList inval
}
if (auto document = active_document()) {
// NOTE: Resizing the viewport changes the reference value for viewport-relative CSS lengths.
document->invalidate_style(DOM::StyleInvalidationReason::NavigableSetViewportSize);
document->invalidate_style_for_viewport_change();
document->set_needs_media_query_evaluation();
document->set_needs_layout_update(DOM::SetNeedsLayoutReason::NavigableSetViewportSize);
}

View file

@ -0,0 +1,13 @@
initial viewport animated width: 45px
initial inherited-font animated width: 45px
initial root-font animated width: 45px
initial animated font size: 45px
initial animated inherited-font width: 67.5px
targeted pending animated outline width: 75px
resized viewport animated width: 75px
resized inherited-font animated width: 75px
resized root-font animated width: 75px
resized animated font size: 75px
resized animated inherited-font width: 112.5px
full invalidations: 0
style recomputations bounded: true

View file

@ -0,0 +1,5 @@
initial color: rgb(255, 0, 0)
initial canvas fill style: #ff0000
targeted canvas fill style: #008000
resized color: rgb(0, 128, 0)
media query full invalidations: 1

View file

@ -0,0 +1,14 @@
viewport width: 50px
inherited font size: 50px
monospace recascaded font size: 50px
percentage font size: 75px
percentage em width: 75px
percentage line height: 75px
calc percentage line height: 67.5px
explicit inherit width: 50px
pseudo width: 50px
pseudo originating font size: 50px
pseudo inherited font size: 50px
full invalidations: 0
style recomputations bounded: true
inherited recomputations present: true

View file

@ -0,0 +1,109 @@
<!DOCTYPE html>
<script src="../../include.js"></script>
<script>
function animationFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
asyncTest(async done => {
const iframe = document.createElement("iframe");
iframe.style.border = "0";
iframe.style.width = "300px";
iframe.style.height = "200px";
const loaded = new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
iframe.srcdoc = `
<!DOCTYPE html>
<style>
html {
font-size: 10vw;
}
#font-source {
font-size: 10vw;
}
#animated-font-source {
font-size: 16px;
}
</style>
<div id="viewport-target"></div>
<div id="font-source"><div id="inherited-font-target"></div></div>
<div id="root-font-target"></div>
<div id="animated-font-source"><div id="animated-inherited-font-target"></div></div>
<div id="pending-outline-animation-target"></div>
<script>
function pauseHalfway(animation) {
animation.pause();
animation.currentTime = 500;
}
pauseHalfway(document.getElementById("viewport-target").animate([{ width: "10vw" }, { width: "20vw" }], {
duration: 1000,
fill: "both",
}));
pauseHalfway(document.getElementById("inherited-font-target").animate([{ width: "1em" }, { width: "2em" }], {
duration: 1000,
fill: "both",
}));
pauseHalfway(document.getElementById("root-font-target").animate([{ width: "1rem" }, { width: "2rem" }], {
duration: 1000,
fill: "both",
}));
pauseHalfway(document.getElementById("animated-font-source").animate([{ fontSize: "10vw" }, { fontSize: "20vw" }], {
duration: 1000,
fill: "both",
}));
pauseHalfway(document.getElementById("animated-inherited-font-target").animate([{ width: "1em" }, { width: "2em" }], {
duration: 1000,
fill: "both",
}));
<\/script>
`;
document.body.appendChild(iframe);
await loaded;
const child = iframe.contentWindow;
const viewportTarget = child.document.getElementById("viewport-target");
const inheritedFontTarget = child.document.getElementById("inherited-font-target");
const rootFontTarget = child.document.getElementById("root-font-target");
const animatedFontSource = child.document.getElementById("animated-font-source");
const animatedInheritedFontTarget = child.document.getElementById("animated-inherited-font-target");
const pendingOutlineAnimationTarget = child.document.getElementById("pending-outline-animation-target");
child.internals.updateStyle();
println(`initial viewport animated width: ${child.getComputedStyle(viewportTarget).width}`);
println(`initial inherited-font animated width: ${child.getComputedStyle(inheritedFontTarget).width}`);
println(`initial root-font animated width: ${child.getComputedStyle(rootFontTarget).width}`);
println(`initial animated font size: ${child.getComputedStyle(animatedFontSource).fontSize}`);
println(`initial animated inherited-font width: ${child.getComputedStyle(animatedInheritedFontTarget).width}`);
const pendingOutlineAnimation = pendingOutlineAnimationTarget.animate([{ outlineWidth: "10vw" }, { outlineWidth: "20vw" }], {
duration: 1000,
fill: "both",
});
pendingOutlineAnimation.pause();
pendingOutlineAnimation.currentTime = 500;
child.internals.resetStyleInvalidationCounters();
iframe.style.width = "500px";
document.body.offsetWidth;
child.internals.updateStyle();
println(`targeted pending animated outline width: ${child.getComputedStyle(pendingOutlineAnimationTarget).outlineWidth}`);
await animationFrame();
child.document.body.offsetWidth;
child.internals.updateStyle();
const counters = child.internals.getStyleInvalidationCounters();
const elementCount = child.document.querySelectorAll("*").length;
println(`resized viewport animated width: ${child.getComputedStyle(viewportTarget).width}`);
println(`resized inherited-font animated width: ${child.getComputedStyle(inheritedFontTarget).width}`);
println(`resized root-font animated width: ${child.getComputedStyle(rootFontTarget).width}`);
println(`resized animated font size: ${child.getComputedStyle(animatedFontSource).fontSize}`);
println(`resized animated inherited-font width: ${child.getComputedStyle(animatedInheritedFontTarget).width}`);
println(`full invalidations: ${counters.fullStyleInvalidations}`);
println(`style recomputations bounded: ${counters.elementStyleRecomputations < elementCount}`);
done();
});
</script>

View file

@ -0,0 +1,65 @@
<!DOCTYPE html>
<script src="../../include.js"></script>
<script>
function animationFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
asyncTest(async done => {
const iframe = document.createElement("iframe");
iframe.style.border = "0";
iframe.style.width = "300px";
iframe.style.height = "200px";
const loaded = new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
iframe.srcdoc = `
<!DOCTYPE html>
<style>
#target {
color: rgb(255, 0, 0);
}
#canvas-target {
color: rgb(255, 0, 0);
}
@media (min-width: 400px) {
#target,
#canvas-target {
color: rgb(0, 128, 0);
}
}
</style>
<div id="target">target</div>
<canvas id="canvas-target"></canvas>
`;
document.body.appendChild(iframe);
await loaded;
const child = iframe.contentWindow;
const target = child.document.getElementById("target");
const canvasTarget = child.document.getElementById("canvas-target");
const context = canvasTarget.getContext("2d");
child.internals.updateStyle();
println(`initial color: ${child.getComputedStyle(target).color}`);
context.fillStyle = "currentColor";
println(`initial canvas fill style: ${context.fillStyle}`);
child.internals.resetStyleInvalidationCounters();
iframe.style.width = "500px";
document.body.offsetWidth;
context.fillStyle = "currentColor";
println(`targeted canvas fill style: ${context.fillStyle}`);
await animationFrame();
child.document.body.offsetWidth;
child.internals.updateStyle();
const counters = child.internals.getStyleInvalidationCounters();
println(`resized color: ${child.getComputedStyle(target).color}`);
println(`media query full invalidations: ${counters.fullStyleInvalidations}`);
done();
});
</script>

View file

@ -0,0 +1,145 @@
<!DOCTYPE html>
<script src="../../include.js"></script>
<script>
function animationFrame() {
return new Promise(resolve => requestAnimationFrame(resolve));
}
function childDocumentSource() {
return `
<!DOCTYPE html>
<style>
#viewport {
width: 10vw;
height: 4px;
}
#font-source {
font-size: 10vw;
}
#monospace-ancestor {
font-size: 10vw;
}
#monospace-target {
font-family: monospace;
}
html {
font-size: 10vw;
}
#percentage-font-source {
font-size: 150%;
}
#percentage-font-target {
width: 1em;
height: 1px;
}
#line-height-source {
font-size: 10vw;
}
#line-height-percentage {
line-height: 150%;
}
#line-height-calc {
line-height: calc(125% + 5px);
}
#inherit-source {
width: 10vw;
}
#inherit-child {
width: inherit;
}
#pseudo::before {
content: "";
display: block;
width: 10vw;
height: 1px;
}
#pseudo-font-source {
font-size: 10vw;
}
#pseudo-font-target::before {
content: "pseudo";
}
.bystander {
color: rgb(0, 0, 0);
}
</style>
<div id="viewport"></div>
<div id="font-source"><span id="font-child">font child</span></div>
<div id="monospace-ancestor"><span id="monospace-target">monospace target</span></div>
<div id="percentage-font-source"><div id="percentage-font-target"></div></div>
<div id="line-height-source">
<div id="line-height-percentage"></div>
<div id="line-height-calc"></div>
</div>
<div id="inherit-source"><div id="inherit-child"></div></div>
<div id="pseudo"></div>
<div id="pseudo-font-source"><span id="pseudo-font-target"></span></div>
<script>
for (let i = 0; i < 40; ++i) {
const bystander = document.createElement("div");
bystander.className = "bystander";
bystander.textContent = "bystander " + i;
document.body.appendChild(bystander);
}
<\/script>
`;
}
asyncTest(async done => {
const iframe = document.createElement("iframe");
iframe.style.border = "0";
iframe.style.width = "300px";
iframe.style.height = "200px";
const loaded = new Promise(resolve => iframe.addEventListener("load", resolve, { once: true }));
iframe.srcdoc = childDocumentSource();
document.body.appendChild(iframe);
await loaded;
const child = iframe.contentWindow;
child.internals.updateStyle();
child.document.body.offsetWidth;
child.internals.resetStyleInvalidationCounters();
iframe.style.width = "500px";
document.body.offsetWidth;
await animationFrame();
child.document.body.offsetWidth;
child.internals.updateStyle();
const counters = child.internals.getStyleInvalidationCounters();
const elementCount = child.document.querySelectorAll("*").length;
println(`viewport width: ${child.getComputedStyle(child.document.getElementById("viewport")).width}`);
println(`inherited font size: ${child.getComputedStyle(child.document.getElementById("font-child")).fontSize}`);
println(`monospace recascaded font size: ${child.getComputedStyle(child.document.getElementById("monospace-target")).fontSize}`);
println(`percentage font size: ${child.getComputedStyle(child.document.getElementById("percentage-font-source")).fontSize}`);
println(`percentage em width: ${child.getComputedStyle(child.document.getElementById("percentage-font-target")).width}`);
println(`percentage line height: ${child.getComputedStyle(child.document.getElementById("line-height-percentage")).lineHeight}`);
println(`calc percentage line height: ${child.getComputedStyle(child.document.getElementById("line-height-calc")).lineHeight}`);
println(`explicit inherit width: ${child.getComputedStyle(child.document.getElementById("inherit-child")).width}`);
println(`pseudo width: ${child.getComputedStyle(child.document.getElementById("pseudo"), "::before").width}`);
println(`pseudo originating font size: ${child.getComputedStyle(child.document.getElementById("pseudo-font-target")).fontSize}`);
println(`pseudo inherited font size: ${child.getComputedStyle(child.document.getElementById("pseudo-font-target"), "::before").fontSize}`);
println(`full invalidations: ${counters.fullStyleInvalidations}`);
println(`style recomputations bounded: ${counters.elementStyleRecomputations < elementCount / 2}`);
println(`inherited recomputations present: ${counters.elementInheritedStyleRecomputations > 0}`);
done();
});
</script>