From 028ce8b7568e85e018642b4d880706bc0d074913 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sat, 15 Aug 2026 01:04:06 +0200 Subject: [PATCH 1/3] LibWeb: Test scrollable overflow measurement comprehensively Cover propagation through visible overflow, clipping, containment, positioned and transformed descendants, line fragments, padding, nested scroll containers, replaced SVG viewports, and physical directions. Compare primed overflow caches with cold measurements after indirect size and placement changes. Exercise percentages, constraints, transforms, text wrapping, flex, grid, nested scrollers, writing modes, inherited metrics, aspect ratios, tree rebuilds, containing blocks, and viewport resize. Exercise dynamic style and layout changes, insertion and removal, reused subtree movement, scroll range updates, offset clamping, and cached atomic inlines with padding and borders. --- .../scrollable-overflow-cache-validity.txt | 22 ++ ...ollable-overflow-cache-viewport-resize.txt | 2 + ...llable-overflow-measurement-directions.txt | 18 ++ ...crollable-overflow-measurement-dynamic.txt | 12 + .../css/scrollable-overflow-measurement.txt | 23 ++ .../scrollable-overflow-cache-validity.html | 208 ++++++++++++++++++ ...llable-overflow-cache-viewport-resize.html | 27 +++ ...lable-overflow-measurement-directions.html | 54 +++++ ...rollable-overflow-measurement-dynamic.html | 58 +++++ .../css/scrollable-overflow-measurement.html | 153 +++++++++++++ 10 files changed, 577 insertions(+) create mode 100644 Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-validity.txt create mode 100644 Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-viewport-resize.txt create mode 100644 Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-directions.txt create mode 100644 Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-dynamic.txt create mode 100644 Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt create mode 100644 Tests/LibWeb/Text/input/css/scrollable-overflow-cache-validity.html create mode 100644 Tests/LibWeb/Text/input/css/scrollable-overflow-cache-viewport-resize.html create mode 100644 Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-directions.html create mode 100644 Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-dynamic.html create mode 100644 Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html diff --git a/Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-validity.txt b/Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-validity.txt new file mode 100644 index 0000000000000..4bee1c5e983b1 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-validity.txt @@ -0,0 +1,22 @@ +percentage size: PASS +nested percentage size: PASS +min max constrained size: PASS +absolute percentage geometry: PASS +relative percentage offset: PASS +percentage transform: PASS +wrapped text: PASS +inline box wrapping: PASS +flex wrapping: PASS +flex percentage basis: PASS +grid percentage tracks: PASS +grid item placement: PASS +nested scroll containers: PASS +clipping boundary: PASS +layout containment boundary: PASS +vertical writing mode: PASS +inherited font size: PASS +aspect ratio: PASS +descendant insertion: PASS +descendant removal: PASS +display tree rebuild: PASS +containing block change: PASS diff --git a/Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-viewport-resize.txt b/Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-viewport-resize.txt new file mode 100644 index 0000000000000..00b9742a3de5f --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/scrollable-overflow-cache-viewport-resize.txt @@ -0,0 +1,2 @@ +before viewport resize: scroller=200x150 child=200x150 +after viewport resize: scroller=100x100 child=100x80 diff --git a/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-directions.txt b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-directions.txt new file mode 100644 index 0000000000000..b3d119fb2f91c --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-directions.txt @@ -0,0 +1,18 @@ +ltr-negative-x: 100x100 +ltr-positive-x: 180x100 +rtl-negative-x: 180x100 +rtl-positive-x: 100x100 +horizontal-negative-y: 100x100 +horizontal-positive-y: 100x180 +vertical-rl-negative-x: 180x100 +vertical-rl-positive-x: 100x100 +vertical-lr-negative-x: 100x100 +vertical-lr-positive-x: 180x100 +vertical-rtl-negative-y: 100x180 +vertical-rtl-positive-y: 100x100 +sideways-rl-negative-x: 180x100 +sideways-rl-positive-x: 100x100 +sideways-lr-negative-x: 100x100 +sideways-lr-positive-x: 180x100 +sideways-lr-negative-y: 100x180 +sideways-lr-positive-y: 100x100 diff --git a/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-dynamic.txt b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-dynamic.txt new file mode 100644 index 0000000000000..8419a17ddf3a6 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement-dynamic.txt @@ -0,0 +1,12 @@ +initial: 100x100 +after transform: 210x240 +after clipping: 100x100 +after unclipping: 210x240 +after relayout: 230x260 +at maximum: 130,160 +after shrink: 100x100 +after clamp: 0,0 +after insertion: 300x320 +after removal: 100x100 +before moving reused subtree: 100x130 +after moving reused subtree: 100x230 diff --git a/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt new file mode 100644 index 0000000000000..89a45d2071563 --- /dev/null +++ b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt @@ -0,0 +1,23 @@ +direct: 150x160 +nested-visible: 150x160 +nested-clipped: 100x100 +transformed: 160x180 +positioned: 160x180 +fixed: 100x100 +paint-contained: 100x100 +layout-contained: 100x100 +zero-area: 100x100 +visible-overflow: 170x180 +line-box: 190x100 +line-box-border-padding: 120x120 +relative-positioned: 160x180 +relative-positioned-inline: 160x180 +floated: 150x160 +visible-x-clipped-y: 150x100 +clipped-x-visible-y: 100x160 +padded-in-flow: 170x180 +nested-scroll-container: 100x100 +inner-scroll-container: 200x210 +replaced-svg: 100x100 +before containing block resize: 300x150 +after containing block resize: 100x50 diff --git a/Tests/LibWeb/Text/input/css/scrollable-overflow-cache-validity.html b/Tests/LibWeb/Text/input/css/scrollable-overflow-cache-validity.html new file mode 100644 index 0000000000000..875be1e98c71c --- /dev/null +++ b/Tests/LibWeb/Text/input/css/scrollable-overflow-cache-validity.html @@ -0,0 +1,208 @@ + + + + + diff --git a/Tests/LibWeb/Text/input/css/scrollable-overflow-cache-viewport-resize.html b/Tests/LibWeb/Text/input/css/scrollable-overflow-cache-viewport-resize.html new file mode 100644 index 0000000000000..c73b4d0e368fa --- /dev/null +++ b/Tests/LibWeb/Text/input/css/scrollable-overflow-cache-viewport-resize.html @@ -0,0 +1,27 @@ + + + + diff --git a/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-directions.html b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-directions.html new file mode 100644 index 0000000000000..89dfc4b328af8 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-directions.html @@ -0,0 +1,54 @@ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + diff --git a/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-dynamic.html b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-dynamic.html new file mode 100644 index 0000000000000..4dec4369c74c3 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement-dynamic.html @@ -0,0 +1,58 @@ + + + + +
+
+
+
+
+
+ + diff --git a/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html new file mode 100644 index 0000000000000..1116d6715d347 --- /dev/null +++ b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html @@ -0,0 +1,153 @@ + + + + +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+
+
+ +
+ + + +
+ +
+
+
+ +
+
+
+
+
+ +
+
+
+
+
+ +
+
+
+ +
+
+
+
+
+ +
+ + + +
+ +
+
+
+ + From 8f4cd51aee1e3f41d498c619e21206973c6f64d8 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sat, 15 Aug 2026 02:06:17 +0200 Subject: [PATCH 2/3] LibWeb: Cache recursive scrollable overflow measurement Cache contributions relative to each padding box, so valid results can survive subtree movement. Invalidate them from layout and overflow dirt, including containing-block ancestors. Associate entries with the immutable layout fragment that produced them. Preserve them when layout replays that fragment. Discard them when an ancestor or viewport relayout commits newly computed geometry. After full layout, start measurement only from the viewport, scroll containers and active scroll offsets. Recursive traversal reuses local caches, while isolated boxes are measured lazily when requested. Skip atomic inline boxes when their cached overflow is covered by the committed line fragment. Avoid stale overflow diffs after full layout, since that layout reset all paintable overflow state. Reduce StyleBench scrollable overflow work from about 943 ms to 85 ms while preserving metrics across clipping and containment barriers. --- Libraries/LibWeb/DOM/Document.cpp | 83 +++++++++++++++---- Libraries/LibWeb/DOM/Document.h | 13 ++- Libraries/LibWeb/Layout/LayoutRustBridge.cpp | 1 + Libraries/LibWeb/Layout/Node.cpp | 10 ++- .../LibWeb/Layout/ScrollableOverflow.cpp | 75 ++++++++++++++--- Libraries/LibWeb/Painting/Paintable.cpp | 22 +++++ Libraries/LibWeb/Painting/Paintable.h | 33 +++++--- Libraries/LibWeb/Rust/src/layout/commit.rs | 2 + .../LibWeb/Rust/src/layout/fragment_tree.rs | 3 + .../css/scrollable-overflow-measurement.txt | 4 + .../css/scrollable-overflow-measurement.html | 16 ++++ 11 files changed, 213 insertions(+), 49 deletions(-) diff --git a/Libraries/LibWeb/DOM/Document.cpp b/Libraries/LibWeb/DOM/Document.cpp index 451af006bc675..e3f5f5a17f406 100644 --- a/Libraries/LibWeb/DOM/Document.cpp +++ b/Libraries/LibWeb/DOM/Document.cpp @@ -1807,7 +1807,7 @@ void Document::recompute_containing_block_and_derive_abspos_escape_flags(Layout: // Refreshes every structure derived from committed layout results, shared by the partial and // full layout paths so neither can forget one. -void Document::after_layout_commit(LayoutTreeChanged layout_tree_changed) +void Document::after_layout_commit(LayoutTreeChanged layout_tree_changed, LayoutCommitScope layout_commit_scope, ReadonlySpan boxes_needing_eager_overflow_measurement) { // NB: Called during layout update. m_layout_root->invalidate_text_blocks_cache(); @@ -1821,7 +1821,10 @@ void Document::after_layout_commit(LayoutTreeChanged layout_tree_changed) // recalculation rebuilds the map inside its own measurement traversal instead. if (layout_tree_changed == LayoutTreeChanged::Yes && !m_needs_full_scrollable_overflow_recalculation) m_scrollable_overflow_contained_boxes_from_last_layout = Layout::collect_scrollable_overflow_contained_boxes(*m_layout_root); - update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates::HandledByAfterLayoutCommit); + if (layout_commit_scope == LayoutCommitScope::Full) + update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates::HandledByFullLayoutCommit, boxes_needing_eager_overflow_measurement); + else + update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates::HandledByAfterLayoutCommit); set_needs_accumulated_visual_contexts_update(true); @@ -2108,7 +2111,7 @@ Document::PartialRelayoutResult Document::try_partial_relayout(HashTable boxes_needing_eager_overflow_measurement; bridge.run_root_layout( *m_layout_root, viewport_rect.width(), viewport_rect.height(), should_collect_devtools_layout_data); + m_scrollable_overflow_contained_boxes_from_last_layout = Layout::collect_scrollable_overflow_contained_boxes( + *m_layout_root, [&](Layout::Box const& box) { + auto paintable = box.paintable_box(); + if (&box == m_layout_root.ptr() || box.is_scroll_container() || (paintable && !paintable->scroll_offset().is_zero())) + boxes_needing_eager_overflow_measurement.append(&box); + }); style_invalidation_counters().relayouts_performed++; @@ -2223,7 +2233,7 @@ void Document::update_layout(UpdateLayoutReason reason) ++m_full_layout_count; - after_layout_commit(LayoutTreeChanged::Yes); + after_layout_commit(LayoutTreeChanged::Yes, LayoutCommitScope::Full, boxes_needing_eager_overflow_measurement); m_layout_root->for_each_in_inclusive_subtree([](auto& node) { node.reset_needs_layout_update(); @@ -2481,7 +2491,7 @@ static void rebuild_sticky_insets(Layout::Node const& root) }); } -void Document::update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates derived_structure_updates) +void Document::update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates derived_structure_updates, ReadonlySpan boxes_needing_eager_measurement) { // For every box that will be re-measured, the overflow data it had before, so the diff below // can tell what actually changed; an empty value means the box's paintable was reset by a @@ -2497,6 +2507,30 @@ void Document::update_scrollable_overflow(ScrollableOverflowDerivedStructureUpda style_invalidation_counters().scrollable_overflow_recalculations++; + // The scroll offset can become invalid if the scrollable overflow rectangle has changed. For + // example, if the scroll container has been scrolled to the very end and then its scrollable + // overflow rect becomes smaller, the scroll offset would be out of bounds. Re-applying the + // current offset clamps it against the new rect. + auto clamp_scroll_offset = [](Painting::Paintable& paintable) { + if (!paintable.scroll_offset().is_zero()) + paintable.set_scroll_offset(paintable.scroll_offset()); + }; + + if (derived_structure_updates == ScrollableOverflowDerivedStructureUpdates::HandledByFullLayoutCommit) { + VERIFY(needs_full_recalculation); + + // A full-root commit reset every surviving paintable, including its overflow data and + // paint cache. There is therefore no old overflow to preserve or diff. Ordinary boxes are + // measured recursively when their overflow contributes to one of these roots, so they do + // not need separate eager measurement. + for (auto const* box : boxes_needing_eager_measurement) { + Layout::measure_scrollable_overflow(*box, m_scrollable_overflow_contained_boxes_from_last_layout); + if (auto box_paintable = box->paintable_box()) + clamp_scroll_offset(const_cast(*box_paintable)); + } + return; + } + auto record_and_clear_overflow_data = [&](Layout::Box const& box) { auto box_paintable = box.paintable_box(); if (!box_paintable) @@ -2528,6 +2562,8 @@ void Document::update_scrollable_overflow(ScrollableOverflowDerivedStructureUpda }); } for (auto const* containing_block = box->containing_block(); containing_block; containing_block = containing_block->containing_block()) { + if (auto containing_block_paintable = containing_block->paintable_box()) + const_cast(*containing_block_paintable).clear_cached_overflow_data(); if (!record_and_clear_overflow_data(*containing_block)) break; } @@ -2537,19 +2573,19 @@ void Document::update_scrollable_overflow(ScrollableOverflowDerivedStructureUpda if (old_overflow_data_by_box.is_empty()) return; - // The scroll offset can become invalid if the scrollable overflow rectangle has changed. For - // example, if the scroll container has been scrolled to the very end and then its scrollable - // overflow rect becomes smaller, the scroll offset would be out of bounds. Re-applying the - // current offset clamps it against the new rect. - auto clamp_scroll_offset = [](Painting::Paintable& paintable) { - if (!paintable.scroll_offset().is_zero()) - paintable.set_scroll_offset(paintable.scroll_offset()); - }; - for (auto const& it : old_overflow_data_by_box) { + auto box_paintable = it.key->paintable_box(); + if (!box_paintable) + continue; + + // Boxes reset by a subtree commit have no previous overflow data. They will be measured + // recursively if an ancestor reaches them. Measuring each one here would repeatedly walk + // the same containing-block chains after a small subtree update. + if (!it.value.has_value() && it.key != m_layout_root.ptr() && !it.key->is_scroll_container() && box_paintable->scroll_offset().is_zero()) + continue; + Layout::measure_scrollable_overflow(*it.key, m_scrollable_overflow_contained_boxes_from_last_layout); - if (auto box_paintable = it.key->paintable_box()) - clamp_scroll_offset(const_cast(*box_paintable)); + clamp_scroll_offset(const_cast(*box_paintable)); } bool any_overflow_changed = false; @@ -2599,6 +2635,14 @@ void Document::update_scrollable_overflow(ScrollableOverflowDerivedStructureUpda m_document->set_needs_repaint(); } +void Document::ensure_scrollable_overflow_is_measured(Layout::Box const& box) const +{ + auto paintable = box.paintable_box(); + if (!paintable || paintable->overflow_data().has_value() || paintable->cached_overflow_data().has_value()) + return; + Layout::measure_scrollable_overflow(box, m_scrollable_overflow_contained_boxes_from_last_layout); +} + void Document::update_paint_and_hit_testing_properties_if_needed() { // NB: Called during paint property resolution. @@ -8872,6 +8916,13 @@ void Document::schedule_scrollable_overflow_recalculation(Layout::Node const& la return; } + if (auto const* box = as_if(layout_node)) { + for (auto const* containing_box = box; containing_box; containing_box = containing_box->containing_block()) { + if (auto paintable = containing_box->paintable_box()) + const_cast(*paintable).clear_cached_overflow_data(); + } + } + if (m_needs_full_scrollable_overflow_recalculation) return; diff --git a/Libraries/LibWeb/DOM/Document.h b/Libraries/LibWeb/DOM/Document.h index c2bc00f5e4f83..6d7511bf619bf 100644 --- a/Libraries/LibWeb/DOM/Document.h +++ b/Libraries/LibWeb/DOM/Document.h @@ -475,8 +475,10 @@ class WEB_API Document enum class ScrollableOverflowDerivedStructureUpdates : u8 { UpdateAfterMeasure, HandledByAfterLayoutCommit, + HandledByFullLayoutCommit, }; - void update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates); + void ensure_scrollable_overflow_is_measured(Layout::Box const&) const; + void update_scrollable_overflow(ScrollableOverflowDerivedStructureUpdates, ReadonlySpan boxes_needing_eager_measurement = {}); void update_paint_and_hit_testing_properties_if_needed(); void update_animated_style_if_needed(); void update_style_computer_viewport_rect(); @@ -1425,7 +1427,11 @@ class WEB_API Document No, Yes, }; - void after_layout_commit(LayoutTreeChanged); + enum class LayoutCommitScope : u8 { + Subtree, + Full, + }; + void after_layout_commit(LayoutTreeChanged, LayoutCommitScope, ReadonlySpan boxes_needing_eager_overflow_measurement = {}); void run_unloading_cleanup_steps(); @@ -1799,8 +1805,7 @@ class WEB_API Document Vector> m_paintable_boxes_needing_scrollable_overflow_recalculation; // NB: Holds raw layout node pointers that are only safe to read while m_layout_root still owns // the tree they came from: every full layout rebuilds the map, layout tree teardown clears - // it, and its only reader, the scheduled scrollable overflow recalculation, runs only when - // layout is up to date. + // it, and overflow measurement only reads it while that root remains current. Layout::ContainedBoxesMap m_scrollable_overflow_contained_boxes_from_last_layout; CSS::SheetSetStyleCacheRegistry m_sheet_set_style_cache_registry; RefPtr m_hit_test_display_list; diff --git a/Libraries/LibWeb/Layout/LayoutRustBridge.cpp b/Libraries/LibWeb/Layout/LayoutRustBridge.cpp index 91a072d7044dd..757b45cce300a 100644 --- a/Libraries/LibWeb/Layout/LayoutRustBridge.cpp +++ b/Libraries/LibWeb/Layout/LayoutRustBridge.cpp @@ -805,6 +805,7 @@ RustFFI::FfiCommitSink LayoutRustBridge::commit_sink() }, .set_box_metrics = [](void*, void* paintable_pointer, RustFFI::FfiCommittedBoxMetrics metrics) { auto& paintable = *static_cast(paintable_pointer); + paintable.set_layout_fragment_identity(metrics.fragment_identity); auto& box_model = paintable.box_model(); box_model.inset = { CSSPixels::from_raw(metrics.inset_top), diff --git a/Libraries/LibWeb/Layout/Node.cpp b/Libraries/LibWeb/Layout/Node.cpp index 00a29ea7f63e1..072769533f5df 100644 --- a/Libraries/LibWeb/Layout/Node.cpp +++ b/Libraries/LibWeb/Layout/Node.cpp @@ -138,10 +138,12 @@ void Node::bump_fragment_cache_epoch() // into a fail-safe miss. void Node::bump_fragment_cache_epoch_of_self_and_ancestors() { - if (!fragment_cache_epochs_enabled()) - return; - for (auto* node = this; node; node = node->parent_ptr()) - ++node->node_data().fragment_cache_epoch; + for (auto* node = this; node; node = node->parent_ptr()) { + if (fragment_cache_epochs_enabled()) + ++node->node_data().fragment_cache_epoch; + if (auto* box = as_if(*node); box && box->paintable_box()) + const_cast(*box->paintable_box()).clear_cached_overflow_data(); + } } void* Node::arena_handle() const diff --git a/Libraries/LibWeb/Layout/ScrollableOverflow.cpp b/Libraries/LibWeb/Layout/ScrollableOverflow.cpp index 029e81a9b0a9a..ad78fca87af95 100644 --- a/Libraries/LibWeb/Layout/ScrollableOverflow.cpp +++ b/Libraries/LibWeb/Layout/ScrollableOverflow.cpp @@ -89,9 +89,11 @@ PhysicalOverflowDirections physical_overflow_directions(Box const& box) }; } -static CSSPixelRect apply_css_transform_to_overflow_rect(Box const& box, CSSPixelRect const& rect) +static CSSPixelRect apply_css_transform_to_overflow_rect(Box const& box, CSSPixelRect const& rect, bool has_css_transform) { auto const& paintable_box = *box.paintable_box(); + if (!has_css_transform) + return rect; auto transform_data = Painting::compute_transform(paintable_box, 1.0); if (!transform_data.has_value()) return rect; @@ -143,21 +145,43 @@ CSSPixelRect measure_scrollable_overflow(Box const& box, ContainedBoxesMap const auto const& paintable_box = *box.paintable_box(); - if (paintable_box.scrollable_overflow_rect().has_value()) - return paintable_box.scrollable_overflow_rect().value(); + if (paintable_box.overflow_data().has_value()) + return paintable_box.overflow_data()->scrollable_overflow_rect; + + auto const paintable_absolute_padding_box = paintable_box.absolute_padding_box_rect(); + auto const paintable_absolute_content_box = paintable_box.absolute_rect(); + + auto store_overflow_data = [&](Painting::Paintable::OverflowData overflow_data) { + const_cast(paintable_box).set_overflow_data(overflow_data); + + auto rect_relative_to_padding_box = overflow_data.scrollable_overflow_rect; + rect_relative_to_padding_box.translate_by({ -paintable_absolute_padding_box.x(), -paintable_absolute_padding_box.y() }); + const_cast(paintable_box).set_cached_overflow_data({ + .rect_relative_to_padding_box = rect_relative_to_padding_box, + .has_scrollable_overflow = overflow_data.has_scrollable_overflow, + }); + }; + + if (auto const& cached_overflow = paintable_box.cached_overflow_data(); cached_overflow.has_value()) { + auto scrollable_overflow_rect = cached_overflow->rect_relative_to_padding_box; + scrollable_overflow_rect.translate_by(paintable_absolute_padding_box.location()); + const_cast(paintable_box).set_overflow_data({ + .scrollable_overflow_rect = scrollable_overflow_rect, + .has_scrollable_overflow = cached_overflow->has_scrollable_overflow, + }); + return scrollable_overflow_rect; + } // https://drafts.csswg.org/css-overflow-3/#scrollable-overflow-calculation // The scrollable overflow area of a box is the union of: // - Its own padding box. - auto const paintable_absolute_padding_box = paintable_box.absolute_padding_box_rect(); - auto const paintable_absolute_content_box = paintable_box.absolute_rect(); auto scrollable_overflow_rect = paintable_absolute_padding_box; auto in_flow_and_floated_content_bounds = paintable_absolute_content_box; // Replaced SVG viewports clip their content if (is(box)) { - const_cast(paintable_box).set_overflow_data({ + store_overflow_data({ .scrollable_overflow_rect = scrollable_overflow_rect, .has_scrollable_overflow = false, }); @@ -206,8 +230,31 @@ CSSPixelRect measure_scrollable_overflow(Box const& box, ContainedBoxesMap const if (child.is_fixed_position()) continue; - auto untransformed_child_border_box = child.paintable_box()->absolute_border_box_rect(); - auto child_border_box = apply_css_transform_to_overflow_rect(child, untransformed_child_border_box); + auto const& child_paintable = *child.paintable_box(); + auto const child_has_css_transform = child_paintable.has_css_transform(); + if (child.position() == CSS::Positioning::Static && child.display().is_inline_outside() && !child.is_floating() && !child_has_css_transform) { + if (auto const& cached_overflow = child_paintable.cached_overflow_data(); cached_overflow.has_value()) { + auto const& border = child_paintable.box_model().border; + auto const has_border = border.top != 0 || border.right != 0 || border.bottom != 0 || border.left != 0; + if (!has_border) { + auto const& padding = child_paintable.box_model().padding; + CSSPixelRect content_box_relative_to_padding_box { + { padding.left, padding.top }, + { + child_paintable.content_width(), + child_paintable.content_height(), + }, + }; + // The committed line fragment already contributes this content box. A box with no border whose + // cached overflow fits inside the content box cannot expand its containing block's overflow. + if (content_box_relative_to_padding_box.contains(cached_overflow->rect_relative_to_padding_box)) + continue; + } + } + } + + auto untransformed_child_border_box = child_paintable.absolute_border_box_rect(); + auto child_border_box = apply_css_transform_to_overflow_rect(child, untransformed_child_border_box, child_has_css_transform); // NOTE: Only boxes that are not wholly in the unreachable scrollable overflow region contribute. auto wholly_in_unreachable_horizontal_axis = overflow_directions.horizontal_axis_is_positive @@ -234,13 +281,15 @@ CSSPixelRect measure_scrollable_overflow(Box const& box, ContainedBoxesMap const if (child.has_layout_containment() || child.has_paint_containment()) continue; - if (child.overflow_x() == CSS::Overflow::Visible || child.overflow_y() == CSS::Overflow::Visible) { - auto child_scrollable_overflow = apply_css_transform_to_overflow_rect(child, measure_scrollable_overflow(child, contained_boxes_map)); + auto const child_overflow_x = child.overflow_x(); + auto const child_overflow_y = child.overflow_y(); + if (child_overflow_x == CSS::Overflow::Visible || child_overflow_y == CSS::Overflow::Visible) { + auto child_scrollable_overflow = apply_css_transform_to_overflow_rect(child, measure_scrollable_overflow(child, contained_boxes_map), child_has_css_transform); if (!child_scrollable_overflow.is_empty()) { - if (child.overflow_x() == CSS::Overflow::Visible) { + if (child_overflow_x == CSS::Overflow::Visible) { scrollable_overflow_rect.unite_horizontally(child_scrollable_overflow); } - if (child.overflow_y() == CSS::Overflow::Visible) { + if (child_overflow_y == CSS::Overflow::Visible) { scrollable_overflow_rect.unite_vertically(child_scrollable_overflow); } } @@ -287,7 +336,7 @@ CSSPixelRect measure_scrollable_overflow(Box const& box, ContainedBoxesMap const has_scrollable_overflow = !paintable_absolute_padding_box.contains(scrollable_overflow_rect) && box.is_scroll_container(); } - const_cast(paintable_box).set_overflow_data({ + store_overflow_data({ .scrollable_overflow_rect = scrollable_overflow_rect, .has_scrollable_overflow = has_scrollable_overflow, }); diff --git a/Libraries/LibWeb/Painting/Paintable.cpp b/Libraries/LibWeb/Painting/Paintable.cpp index 48a9451a440f3..bf5f96d832877 100644 --- a/Libraries/LibWeb/Painting/Paintable.cpp +++ b/Libraries/LibWeb/Painting/Paintable.cpp @@ -1175,6 +1175,28 @@ CSSPixelPoint Paintable::minimum_scroll_offset() const }; } +bool Paintable::has_scrollable_overflow() const +{ + if (auto const* box = as_if(layout_node())) + document().ensure_scrollable_overflow_is_measured(*box); + if (m_overflow_data.has_value()) + return m_overflow_data->has_scrollable_overflow; + return m_cached_overflow_data.has_value() && m_cached_overflow_data->has_scrollable_overflow; +} + +Optional Paintable::scrollable_overflow_rect() const +{ + if (auto const* box = as_if(layout_node())) + document().ensure_scrollable_overflow_is_measured(*box); + if (m_overflow_data.has_value()) + return m_overflow_data->scrollable_overflow_rect; + if (!m_cached_overflow_data.has_value()) + return {}; + auto scrollable_overflow_rect = m_cached_overflow_data->rect_relative_to_padding_box; + scrollable_overflow_rect.translate_by(absolute_padding_box_rect().location()); + return scrollable_overflow_rect; +} + CSSPixelPoint Paintable::maximum_scroll_offset() const { auto scrollable_overflow_rect = this->scrollable_overflow_rect(); diff --git a/Libraries/LibWeb/Painting/Paintable.h b/Libraries/LibWeb/Painting/Paintable.h index b580ac94b8de3..d358746ce5603 100644 --- a/Libraries/LibWeb/Painting/Paintable.h +++ b/Libraries/LibWeb/Painting/Paintable.h @@ -223,6 +223,11 @@ class WEB_API Paintable bool has_scrollable_overflow { false }; }; + struct CachedOverflowData { + CSSPixelRect rect_relative_to_padding_box; + bool has_scrollable_overflow { false }; + }; + // Offset from the top left of the containing block's content edge. [[nodiscard]] CSSPixelPoint offset() const; @@ -281,12 +286,7 @@ class WEB_API Paintable CSSPixelPoint transform_to_local_coordinates(CSSPixelPoint position) const; - [[nodiscard]] bool has_scrollable_overflow() const - { - if (!m_overflow_data.has_value()) - return false; - return m_overflow_data->has_scrollable_overflow; - } + [[nodiscard]] bool has_scrollable_overflow() const; [[nodiscard]] bool has_css_transform() const; @@ -295,17 +295,24 @@ class WEB_API Paintable [[nodiscard]] bool overflow_property_applies() const; - [[nodiscard]] Optional scrollable_overflow_rect() const - { - if (!m_overflow_data.has_value()) - return {}; - return m_overflow_data->scrollable_overflow_rect; - } + [[nodiscard]] Optional scrollable_overflow_rect() const; [[nodiscard]] Optional const& overflow_data() const { return m_overflow_data; } void set_overflow_data(OverflowData data) { m_overflow_data = move(data); } void clear_overflow_data() { m_overflow_data.clear(); } + Optional const& cached_overflow_data() const { return m_cached_overflow_data; } + void set_cached_overflow_data(CachedOverflowData data) { m_cached_overflow_data = move(data); } + void clear_cached_overflow_data() { m_cached_overflow_data.clear(); } + void set_layout_fragment_identity(u64 identity) + { + VERIFY(identity); + if (m_layout_fragment_identity == identity) + return; + m_layout_fragment_identity = identity; + clear_cached_overflow_data(); + } + virtual void set_needs_repaint(InvalidateDisplayList = InvalidateDisplayList::Yes); virtual bool handle_mousewheel(Badge, CSSPixelPoint, unsigned buttons, unsigned modifiers, double wheel_delta_x, double wheel_delta_y); @@ -541,6 +548,8 @@ class WEB_API Paintable RefPtr m_stacking_context; Optional m_overflow_data; + Optional m_cached_overflow_data; + u64 m_layout_fragment_identity { 0 }; CSSPixelPoint m_offset; CSSPixelSize m_content_size; diff --git a/Libraries/LibWeb/Rust/src/layout/commit.rs b/Libraries/LibWeb/Rust/src/layout/commit.rs index 171fb96a6e805..83b16c52eb484 100644 --- a/Libraries/LibWeb/Rust/src/layout/commit.rs +++ b/Libraries/LibWeb/Rust/src/layout/commit.rs @@ -16,6 +16,7 @@ pub struct FfiTableCellCoordinates { #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[repr(C)] pub struct FfiCommittedBoxMetrics { + pub fragment_identity: u64, pub content_offset: crate::layout::FfiCssPixelPoint, pub content_inline_size: crate::layout::CssPixels, pub content_block_size: crate::layout::CssPixels, @@ -169,6 +170,7 @@ fn commit_subtree( sink.context, paintable, FfiCommittedBoxMetrics { + fragment_identity: fragment.identity, content_offset: link.committed_offset, content_inline_size: fragment.content_inline_size, content_block_size: fragment.content_block_size, diff --git a/Libraries/LibWeb/Rust/src/layout/fragment_tree.rs b/Libraries/LibWeb/Rust/src/layout/fragment_tree.rs index d193d2de101cf..fda04718ce98d 100644 --- a/Libraries/LibWeb/Rust/src/layout/fragment_tree.rs +++ b/Libraries/LibWeb/Rust/src/layout/fragment_tree.rs @@ -5,6 +5,7 @@ */ pub(crate) struct Fragment { + pub(crate) identity: u64, pub(crate) node: crate::layout::node_data::NodeSlotId, pub(crate) content_inline_size: CssPixels, pub(crate) content_block_size: CssPixels, @@ -156,6 +157,7 @@ fn snapshot_fragment( children: Vec, used: &UsedValues, ) -> std::rc::Rc { + static NEXT_IDENTITY: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); let line_data = used.line_data.get().map(std::cell::RefCell::take); let rare_payloads = used.rare_data.get().map(|cell| { let mut rare = cell.borrow_mut(); @@ -181,6 +183,7 @@ fn snapshot_fragment( computed_svg_path, ) = rare_payloads.unwrap_or_default(); std::rc::Rc::new(Fragment { + identity: NEXT_IDENTITY.fetch_add(1, std::sync::atomic::Ordering::Relaxed), node, content_inline_size: used.content_inline_size.get(), content_block_size: used.content_block_size.get(), diff --git a/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt index 89a45d2071563..bc4466d1328e1 100644 --- a/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt +++ b/Tests/LibWeb/Text/expected/css/scrollable-overflow-measurement.txt @@ -19,5 +19,9 @@ padded-in-flow: 170x180 nested-scroll-container: 100x100 inner-scroll-container: 200x210 replaced-svg: 100x100 +clipped-barrier: 200x210 +inside-clipped-barrier: 200x210 +contained-barrier: 220x230 +inside-contained-barrier: 220x230 before containing block resize: 300x150 after containing block resize: 100x50 diff --git a/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html index 1116d6715d347..23f5170aa24e9 100644 --- a/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html +++ b/Tests/LibWeb/Text/input/css/scrollable-overflow-measurement.html @@ -104,6 +104,18 @@ +
+
+
+
+
+ +
+
+
+
+
+
@@ -139,6 +151,10 @@ "nested-scroll-container", "inner-scroll-container", "replaced-svg", + "clipped-barrier", + "inside-clipped-barrier", + "contained-barrier", + "inside-contained-barrier", ]) { const box = document.getElementById(id); println(`${id}: ${box.scrollWidth}x${box.scrollHeight}`); From d9634d87d5f4c4469cfbdd37282ff0a937c947c5 Mon Sep 17 00:00:00 2001 From: Andreas Kling Date: Sat, 15 Aug 2026 11:13:50 +0200 Subject: [PATCH 3/3] LibWeb: Add fast_is() for SVGForeignObjectPaintable --- Libraries/LibWeb/Painting/Paintable.h | 1 + Libraries/LibWeb/Painting/SVGForeignObjectPaintable.h | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/Libraries/LibWeb/Painting/Paintable.h b/Libraries/LibWeb/Painting/Paintable.h index d358746ce5603..93a16a4a0277e 100644 --- a/Libraries/LibWeb/Painting/Paintable.h +++ b/Libraries/LibWeb/Painting/Paintable.h @@ -141,6 +141,7 @@ class WEB_API Paintable [[nodiscard]] virtual bool is_svg_svg_paintable() const { return false; } [[nodiscard]] virtual bool is_svg_path_paintable() const { return false; } [[nodiscard]] virtual bool is_svg_graphics_paintable() const { return false; } + [[nodiscard]] virtual bool is_svg_foreign_object_paintable() const { return false; } DOM::Document const& document() const; DOM::Document& document(); diff --git a/Libraries/LibWeb/Painting/SVGForeignObjectPaintable.h b/Libraries/LibWeb/Painting/SVGForeignObjectPaintable.h index 011d798f6a702..8a66b61712a20 100644 --- a/Libraries/LibWeb/Painting/SVGForeignObjectPaintable.h +++ b/Libraries/LibWeb/Painting/SVGForeignObjectPaintable.h @@ -31,6 +31,12 @@ class SVGForeignObjectPaintable final : public PaintableWithLines protected: SVGForeignObjectPaintable(Layout::SVGForeignObjectBox const&); + +private: + virtual bool is_svg_foreign_object_paintable() const override { return true; } }; +template<> +inline bool Paintable::fast_is() const { return is_svg_foreign_object_paintable(); } + }