ladybird/Libraries/LibWeb/Compositor/AsyncScrollingState.cpp
Andreas Kling b57cc2bb7a LibWeb: Invalidate async wheel state on listener changes
Bump a page-level wheel listener generation whenever non-passive wheel
listeners are added or removed. Store that generation in async scrolling
snapshots, and mark compositor wheel admission stale when listener state
changes before a current snapshot arrives.

This keeps the UI-process compositor wheel bypass from trusting an old
no-listener snapshot after script has installed a cancelable wheel
listener. Add async-scrolling coverage for the listener generation bump
and the fresh routing state after a blocking wheel listener is added.
2026-05-12 20:57:08 +02:00

320 lines
16 KiB
C++

/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/Compositor/AsyncScrollingState.h>
#include <LibWeb/DOM/DOMEventListener.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/EventTarget.h>
#include <LibWeb/HTML/Navigable.h>
#include <LibWeb/HTML/NavigableContainer.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/Painting/DisplayList.h>
#include <LibWeb/Painting/PaintableBox.h>
#include <LibWeb/Painting/ScrollState.h>
#include <LibWeb/Painting/ViewportPaintable.h>
#include <LibWeb/PixelUnits.h>
#include <LibWeb/TraversalDecision.h>
namespace Web::Compositor {
static Gfx::FloatPoint css_point_to_device_point(CSSPixelPoint point, double device_pixels_per_css_pixel)
{
auto scale = static_cast<float>(device_pixels_per_css_pixel);
return { point.x().to_float() * scale, point.y().to_float() * scale };
}
static Gfx::FloatSize css_size_to_device_size(CSSPixelSize size, double device_pixels_per_css_pixel)
{
auto scale = static_cast<float>(device_pixels_per_css_pixel);
return { size.width().to_float() * scale, size.height().to_float() * scale };
}
static Gfx::FloatRect css_rect_to_device_rect(CSSPixelRect rect, double device_pixels_per_css_pixel)
{
return { css_point_to_device_point(rect.location(), device_pixels_per_css_pixel), css_size_to_device_size(rect.size(), device_pixels_per_css_pixel) };
}
static Optional<float> css_inset_to_device_inset(Optional<CSSPixels> inset, double device_pixels_per_css_pixel)
{
if (!inset.has_value())
return {};
return inset->to_float() * static_cast<float>(device_pixels_per_css_pixel);
}
static CSSPixelPoint maximum_scroll_offset_for(Painting::PaintableBox const& paintable_box)
{
CSSPixelPoint max_scroll_offset;
auto scrollable_overflow_rect = paintable_box.scrollable_overflow_rect();
if (!scrollable_overflow_rect.has_value())
return max_scroll_offset;
auto scrollport_rect = paintable_box.absolute_padding_box_rect();
max_scroll_offset.set_x(max(CSSPixels(0), scrollable_overflow_rect->width() - scrollport_rect.width()));
max_scroll_offset.set_y(max(CSSPixels(0), scrollable_overflow_rect->height() - scrollport_rect.height()));
return max_scroll_offset;
}
static AsyncScrollNodeID scroll_node_id_for(UniqueNodeID document_id, Painting::ScrollFrameIndex scroll_frame_index)
{
return { .document_id = document_id, .scroll_frame_index = scroll_frame_index };
}
static Optional<AsyncScrollNodeID> scroll_node_id_for_scroll_frame_index(AsyncScrollingState const& async_scrolling_state, Painting::ScrollFrameIndex scroll_frame_index)
{
for (auto const& scroll_node : async_scrolling_state.scroll_nodes) {
if (scroll_node.node_id.scroll_frame_index == scroll_frame_index)
return scroll_node.node_id;
}
return {};
}
static Optional<AsyncScrollNodeID> parent_scroll_node_id_for(AsyncScrollingState const& async_scrolling_state, Painting::ScrollState const& scroll_state, Painting::ScrollFrameIndex parent_scroll_frame_index)
{
for (auto scroll_frame_index = parent_scroll_frame_index; scroll_frame_index.value(); scroll_frame_index = scroll_state.frame_at(scroll_frame_index).parent_index()) {
if (auto node_id = scroll_node_id_for_scroll_frame_index(async_scrolling_state, scroll_frame_index); node_id.has_value())
return node_id;
}
return {};
}
static bool has_blocking_wheel_event_listener(DOM::EventTarget& event_target)
{
for (auto listener : event_target.event_listener_list()) {
if (AK::first_is_one_of(listener->type, "wheel"sv, "mousewheel"sv) && listener->passive != true)
return true;
}
return false;
}
static bool is_root_wheel_event_target(DOM::Node& node)
{
auto& document = node.document();
return &node == document.document_element() || &node == document.body();
}
static void collect_root_blocking_wheel_event_regions(AsyncScrollingState& async_scrolling_state, DOM::Document& document)
{
if (auto window = document.navigable() ? document.navigable()->active_window() : nullptr; window && has_blocking_wheel_event_listener(*window)) {
async_scrolling_state.has_blocking_wheel_event_listeners = true;
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
}
if (has_blocking_wheel_event_listener(document)) {
async_scrolling_state.has_blocking_wheel_event_listeners = true;
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
}
if (auto* document_element = document.document_element(); document_element && has_blocking_wheel_event_listener(*document_element)) {
async_scrolling_state.has_blocking_wheel_event_listeners = true;
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
}
if (auto* body = document.body(); body && has_blocking_wheel_event_listener(*body)) {
async_scrolling_state.has_blocking_wheel_event_listeners = true;
async_scrolling_state.has_blocking_wheel_event_region_covering_viewport = true;
}
}
AsyncScrollingState collect_async_scrolling_state(HTML::Navigable& navigable, Painting::ViewportPaintable& document_paintable, Gfx::IntRect viewport_rect)
{
auto device_pixels_per_css_pixel = navigable.page().client().device_pixels_per_css_pixel();
auto const& scroll_state = document_paintable.scroll_state();
auto document_id = document_paintable.document().unique_id();
AsyncScrollingState async_scrolling_state;
Vector<Painting::ScrollFrameIndex> parent_scroll_frame_indices;
async_scrolling_state.viewport_rect = viewport_rect;
async_scrolling_state.wheel_event_listener_state_generation = navigable.page().wheel_event_listener_state_generation();
collect_root_blocking_wheel_event_regions(async_scrolling_state, document_paintable.document());
document_paintable.for_each_in_inclusive_subtree_of_type<Painting::PaintableBox>([&](auto& paintable_box) {
if (!async_scrolling_state.has_blocking_wheel_event_region_covering_viewport) {
if (auto node = paintable_box.dom_node(); node && !is_root_wheel_event_target(*node) && has_blocking_wheel_event_listener(*node)) {
async_scrolling_state.has_blocking_wheel_event_listeners = true;
async_scrolling_state.blocking_wheel_event_regions.append({
.visual_context_index = paintable_box.accumulated_visual_context_index(),
.rect = css_rect_to_device_rect(paintable_box.absolute_united_border_box_rect(), device_pixels_per_css_pixel),
});
}
}
if (auto node = paintable_box.dom_node(); node && node->is_navigable_container() && as<HTML::NavigableContainer>(*node).content_navigable()) {
async_scrolling_state.main_thread_wheel_event_regions.append({
.visual_context_index = paintable_box.accumulated_visual_context_index(),
.rect = css_rect_to_device_rect(paintable_box.absolute_united_border_box_rect(), device_pixels_per_css_pixel),
});
}
auto sticky_frame_index = paintable_box.enclosing_scroll_frame_index();
if (paintable_box.is_sticky_position() && sticky_frame_index.value()) {
auto const& frame = scroll_state.frame_at(sticky_frame_index);
if (frame.is_sticky() && frame.has_sticky_constraints()) {
auto const& constraints = frame.sticky_constraints();
auto const& insets = constraints.insets;
async_scrolling_state.sticky_areas.append({
.document_id = document_id,
.scroll_frame_index = sticky_frame_index,
.parent_scroll_frame_index = frame.parent_index(),
.nearest_scrolling_ancestor_index = scroll_state.nearest_scrolling_ancestor(sticky_frame_index),
.position_relative_to_scroll_ancestor = css_point_to_device_point(constraints.position_relative_to_scroll_ancestor, device_pixels_per_css_pixel),
.border_box_size = css_size_to_device_size(constraints.border_box_size, device_pixels_per_css_pixel),
.scrollport_size = css_size_to_device_size(constraints.scrollport_size, device_pixels_per_css_pixel),
.containing_block_region = css_rect_to_device_rect(constraints.containing_block_region, device_pixels_per_css_pixel),
.needs_parent_offset_adjustment = constraints.needs_parent_offset_adjustment,
.inset_top = css_inset_to_device_inset(insets.top, device_pixels_per_css_pixel),
.inset_right = css_inset_to_device_inset(insets.right, device_pixels_per_css_pixel),
.inset_bottom = css_inset_to_device_inset(insets.bottom, device_pixels_per_css_pixel),
.inset_left = css_inset_to_device_inset(insets.left, device_pixels_per_css_pixel),
});
}
}
auto scroll_frame_index = paintable_box.own_scroll_frame_index();
if (scroll_frame_index.value() && paintable_box.could_be_scrolled_by_wheel_event()) {
auto parent_scroll_frame_index = scroll_state.frame_at(scroll_frame_index).parent_index();
async_scrolling_state.scroll_nodes.append({
.node_id = scroll_node_id_for(document_id, scroll_frame_index),
.parent_node_id = {},
.hit_test_visual_context_index = paintable_box.accumulated_visual_context_index(),
.scrollport_rect = navigable.page().css_to_device_rect(paintable_box.absolute_padding_box_rect()).template to_type<int>(),
.scroll_offset = css_point_to_device_point(paintable_box.scroll_offset(), device_pixels_per_css_pixel),
.max_scroll_offset = css_point_to_device_point(maximum_scroll_offset_for(paintable_box), device_pixels_per_css_pixel),
.is_viewport = paintable_box.is_viewport_paintable(),
});
parent_scroll_frame_indices.append(parent_scroll_frame_index);
}
return TraversalDecision::Continue;
});
VERIFY(parent_scroll_frame_indices.size() == async_scrolling_state.scroll_nodes.size());
for (size_t i = 0; i < async_scrolling_state.scroll_nodes.size(); ++i)
async_scrolling_state.scroll_nodes[i].parent_node_id = parent_scroll_node_id_for(async_scrolling_state, scroll_state, parent_scroll_frame_indices[i]);
async_scrolling_state.blocking_wheel_event_regions_are_current = async_scrolling_state.has_blocking_wheel_event_listeners;
return async_scrolling_state;
}
WheelRoutingAdmission wheel_routing_admission_for(AsyncScrollingState const& state)
{
if (state.has_blocking_wheel_event_listeners)
return WheelRoutingAdmission::BlockingWheelEventListeners;
bool found_viewport_node = false;
for (auto const& node : state.scroll_nodes) {
if (node.is_viewport) {
found_viewport_node = true;
break;
}
}
if (!found_viewport_node)
return WheelRoutingAdmission::NoViewportScrollNode;
return WheelRoutingAdmission::Accepted;
}
StringView wheel_routing_admission_to_string(WheelRoutingAdmission admission)
{
switch (admission) {
case WheelRoutingAdmission::Accepted:
return "accepted"sv;
case WheelRoutingAdmission::NoAsyncScrollingState:
return "no async scrolling state"sv;
case WheelRoutingAdmission::BlockingWheelEventListeners:
return "blocking wheel event listeners"sv;
case WheelRoutingAdmission::NoViewportScrollNode:
return "no viewport scroll node"sv;
case WheelRoutingAdmission::StaleWheelEventListeners:
return "stale wheel event listeners"sv;
}
VERIFY_NOT_REACHED();
}
bool blocks_wheel_event_at_position(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position)
{
if (async_scrolling_state.has_blocking_wheel_event_region_covering_viewport)
return true;
// If a caller knows blocking wheel listeners exist but cannot provide a display list for visual-context hit
// testing, async scrolling must fail closed. Sending the input to the main thread is slower, but it preserves
// cancelability.
if (!display_list)
return async_scrolling_state.has_blocking_wheel_event_listeners;
auto const& visual_context_tree = display_list->visual_context_tree();
for (auto const& region : async_scrolling_state.blocking_wheel_event_regions) {
auto position_in_context = visual_context_tree.transform_point_for_hit_test(region.visual_context_index, position, scroll_state_snapshot);
if (position_in_context.has_value() && region.rect.contains(*position_in_context))
return true;
}
return false;
}
bool requires_main_thread_wheel_event_at_position(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position)
{
if (async_scrolling_state.main_thread_wheel_event_regions.is_empty())
return false;
if (!display_list)
return true;
auto const& visual_context_tree = display_list->visual_context_tree();
for (auto const& region : async_scrolling_state.main_thread_wheel_event_regions) {
auto position_in_context = visual_context_tree.transform_point_for_hit_test(region.visual_context_index, position, scroll_state_snapshot);
if (position_in_context.has_value() && region.rect.contains(*position_in_context))
return true;
}
return false;
}
static bool scroll_node_can_scroll_by_delta(AsyncScrollNode const& node, Gfx::FloatPoint delta)
{
if (delta.x() < 0 && node.scroll_offset.x() > 0)
return true;
if (delta.x() > 0 && node.scroll_offset.x() < node.max_scroll_offset.x())
return true;
if (delta.y() < 0 && node.scroll_offset.y() > 0)
return true;
if (delta.y() > 0 && node.scroll_offset.y() < node.max_scroll_offset.y())
return true;
return false;
}
static bool has_scroll_node_at_position(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position, Gfx::FloatPoint delta)
{
if (!display_list)
return false;
auto const& visual_context_tree = display_list->visual_context_tree();
for (auto const& node : async_scrolling_state.scroll_nodes) {
if (!scroll_node_can_scroll_by_delta(node, delta))
continue;
auto scrollport_rect = node.is_viewport
? Gfx::FloatRect { {}, async_scrolling_state.viewport_rect.size().to_type<float>() }
: visual_context_tree.transform_rect_to_viewport(node.hit_test_visual_context_index, node.scrollport_rect.to_type<float>(), scroll_state_snapshot);
if (scrollport_rect.contains(position))
return true;
}
return false;
}
WheelScrollAdmission admit_wheel_scroll(AsyncScrollingState const& async_scrolling_state, RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot, Gfx::FloatPoint position, Gfx::FloatPoint delta, bool has_blocking_wheel_event_listeners, bool blocking_wheel_event_regions_are_current)
{
if (requires_main_thread_wheel_event_at_position(async_scrolling_state, display_list, scroll_state_snapshot, position))
return WheelScrollAdmission::BlockedByMainThreadRegion;
// Async scrolling may only start when the snapshot can prove that the wheel event cannot be canceled by script
// at this position. Stale or missing blocker information sends the event back to the main thread.
if (has_blocking_wheel_event_listeners) {
if (!blocking_wheel_event_regions_are_current)
return WheelScrollAdmission::StaleBlockingWheelEventRegions;
if (blocks_wheel_event_at_position(async_scrolling_state, display_list, scroll_state_snapshot, position))
return WheelScrollAdmission::BlockedByWheelEventRegion;
}
if (!has_scroll_node_at_position(async_scrolling_state, display_list, scroll_state_snapshot, position, delta))
return WheelScrollAdmission::NoScrollableTarget;
return WheelScrollAdmission::Accepted;
}
}