LibWeb: Allow viewport async scroll with nested scrollers

Allow compositor wheel routing to stay enabled when the scroll tree also
contains non-viewport scroll nodes. The compositor still rejects an
individual wheel whose hit-test target is not the viewport, so element
scrollers continue to fall back to the main thread until their scroll
offsets can be adopted safely.

Move the routing admission helper next to the async scrolling state and
expose test-only internals for the routing result and wheel target. Add
coverage for a page that has both a viewport scroller and a nested
element scroller.
This commit is contained in:
Andreas Kling 2026-05-11 23:51:57 +02:00 committed by Andreas Kling
parent ce1521441f
commit d36d336c82
10 changed files with 171 additions and 53 deletions

View file

@ -306,4 +306,10 @@ Optional<AsyncScrollNodeID> AsyncScrollTree::hit_test_scroll_node_for_wheel(Gfx:
return scroll_target;
}
bool AsyncScrollTree::scroll_node_is_viewport(AsyncScrollNodeID node_id) const
{
auto const* node = scroll_node_for_id(node_id);
return node && node->is_viewport;
}
}

View file

@ -43,6 +43,7 @@ public:
Optional<Gfx::FloatPoint> scroll_offset_for_node(AsyncScrollNodeID) const;
Optional<AsyncScrollNodeID> hit_test_scroll_node_for_wheel(Gfx::FloatPoint position, Gfx::FloatPoint delta) const;
bool scroll_node_is_viewport(AsyncScrollNodeID) const;
bool apply_scroll_delta(AsyncScrollNodeID, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot&);
private:

View file

@ -194,6 +194,38 @@ AsyncScrollingState collect_async_scrolling_state(HTML::Navigable& navigable, Pa
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;
}
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)

View file

@ -7,6 +7,7 @@
#pragma once
#include <AK/Optional.h>
#include <AK/StringView.h>
#include <AK/Vector.h>
#include <LibGfx/Point.h>
#include <LibGfx/Rect.h>
@ -83,6 +84,13 @@ struct AsyncScrollingState {
bool has_blocking_wheel_event_region_covering_viewport { false };
};
enum class WheelRoutingAdmission {
Accepted,
NoAsyncScrollingState,
BlockingWheelEventListeners,
NoViewportScrollNode,
};
enum class WheelScrollAdmission {
Accepted,
NoScrollableTarget,
@ -92,6 +100,8 @@ enum class WheelScrollAdmission {
};
AsyncScrollingState collect_async_scrolling_state(HTML::Navigable&, Painting::ViewportPaintable&, Gfx::IntRect viewport_rect);
WheelRoutingAdmission wheel_routing_admission_for(AsyncScrollingState const&);
StringView wheel_routing_admission_to_string(WheelRoutingAdmission);
bool blocks_wheel_event_at_position(AsyncScrollingState const&, RefPtr<Painting::DisplayList> const&, Painting::ScrollStateSnapshot const&, Gfx::FloatPoint position);
bool requires_main_thread_wheel_event_at_position(AsyncScrollingState const&, RefPtr<Painting::DisplayList> const&, Painting::ScrollStateSnapshot const&, Gfx::FloatPoint position);
WheelScrollAdmission admit_wheel_scroll(AsyncScrollingState const&, RefPtr<Painting::DisplayList> const&, Painting::ScrollStateSnapshot const&, Gfx::FloatPoint position, Gfx::FloatPoint delta, bool has_blocking_wheel_event_listeners, bool blocking_wheel_event_regions_are_current);

View file

@ -89,47 +89,6 @@ static Optional<AsyncScrollNode> viewport_scroll_node(AsyncScrollingState const&
return {};
}
enum class WheelRoutingAdmission {
Accepted,
NoAsyncScrollingState,
BlockingWheelEventListeners,
NoViewportScrollNode,
HasNonViewportScrollNode,
};
[[maybe_unused]] static 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::HasNonViewportScrollNode:
return "has non-viewport scroll node"sv;
}
VERIFY_NOT_REACHED();
}
static 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)
return WheelRoutingAdmission::HasNonViewportScrollNode;
found_viewport_node = true;
}
if (!found_viewport_node)
return WheelRoutingAdmission::NoViewportScrollNode;
return WheelRoutingAdmission::Accepted;
}
struct BackingStorePair {
RefPtr<Gfx::PaintingSurface> front;
RefPtr<Gfx::PaintingSurface> back;
@ -296,6 +255,22 @@ public:
&& (m_is_rasterizing || m_has_deferred_async_scroll_present || m_queued_rasterization_tasks > 0);
}
struct ViewportWheelTarget {
Optional<AsyncScrollNodeID> node_id;
bool rejected_non_viewport_target { false };
};
ViewportWheelTarget hit_test_viewport_scroll_node_for_wheel(Gfx::FloatPoint position, Gfx::FloatPoint delta) const
{
Sync::MutexLocker const locker { m_async_scroll_tree_mutex };
auto scroll_target = m_async_scroll_tree.hit_test_scroll_node_for_wheel(position, delta);
if (!scroll_target.has_value())
return {};
if (!m_async_scroll_tree.scroll_node_is_viewport(*scroll_target))
return { {}, true };
return { scroll_target, false };
}
bool enqueue_async_scroll_by(Gfx::FloatPoint position, Gfx::FloatPoint delta, Gfx::IntRect viewport_rect)
{
if (!m_can_accept_async_wheel_events.load()) {
@ -307,11 +282,13 @@ public:
wheel_routing_admission_to_string(wheel_routing_admission));
return false;
}
auto scroll_target = [this, position, delta] {
Sync::MutexLocker const locker { m_async_scroll_tree_mutex };
return m_async_scroll_tree.hit_test_scroll_node_for_wheel(position, delta);
}();
if (!scroll_target.has_value()) {
auto scroll_target = hit_test_viewport_scroll_node_for_wheel(position, delta);
if (scroll_target.rejected_non_viewport_target) {
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Rejecting async scroll enqueue: non-viewport target at {},{} delta {},{}",
position.x(), position.y(), delta.x(), delta.y());
return false;
}
if (!scroll_target.node_id.has_value()) {
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Rejecting async scroll enqueue: no wheel target at {},{} for delta {},{}",
position.x(), position.y(), delta.x(), delta.y());
return false;
@ -319,7 +296,7 @@ public:
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Compositor accepted main-thread async scroll enqueue at {},{} delta {},{} viewport={}x{} at {},{}",
position.x(), position.y(), delta.x(), delta.y(), viewport_rect.width(), viewport_rect.height(), viewport_rect.x(), viewport_rect.y());
enqueue_command(AsyncScrollByCommand { position, delta, viewport_rect, *scroll_target });
enqueue_command(AsyncScrollByCommand { position, delta, viewport_rect, *scroll_target.node_id });
return true;
}
@ -339,11 +316,13 @@ public:
Sync::MutexLocker const locker { m_mutex };
return m_async_scrolling_viewport_rect;
}();
auto scroll_target = [this, position, delta] {
Sync::MutexLocker const locker { m_async_scroll_tree_mutex };
return m_async_scroll_tree.hit_test_scroll_node_for_wheel(position, delta);
}();
if (!scroll_target.has_value()) {
auto scroll_target = hit_test_viewport_scroll_node_for_wheel(position, delta);
if (scroll_target.rejected_non_viewport_target) {
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Rejecting async scroll enqueue: non-viewport target at {},{} delta {},{}",
position.x(), position.y(), delta.x(), delta.y());
return false;
}
if (!scroll_target.node_id.has_value()) {
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Rejecting async scroll enqueue: no wheel target at {},{} for delta {},{}",
position.x(), position.y(), delta.x(), delta.y());
return false;
@ -351,7 +330,7 @@ public:
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Compositor accepted async scroll enqueue at {},{} delta {},{} viewport={}x{} at {},{}",
position.x(), position.y(), delta.x(), delta.y(), viewport_rect.width(), viewport_rect.height(), viewport_rect.x(), viewport_rect.y());
enqueue_command(AsyncScrollByCommand { position, delta, viewport_rect, *scroll_target });
enqueue_command(AsyncScrollByCommand { position, delta, viewport_rect, *scroll_target.node_id });
return true;
}

View file

@ -16,6 +16,7 @@
#include <LibWeb/Bindings/Internals.h>
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/Bindings/MainThreadVM.h>
#include <LibWeb/Compositor/AsyncScrollTree.h>
#include <LibWeb/Compositor/AsyncScrollingState.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Event.h>
@ -711,6 +712,24 @@ bool Internals::async_scrolling_state_can_wheel_scroll_at(double x, double y, do
return async_scrolling_state_wheel_scroll_admission_at(x, y, delta_x, delta_y, force_stale_wheel_event_regions) == "accepted"sv;
}
String Internals::async_scrolling_state_wheel_routing_admission()
{
auto& document = window().associated_document();
document.update_layout(DOM::UpdateLayoutReason::InternalsHitTest);
auto navigable = document.navigable();
auto document_paintable = document.paintable();
if (!navigable || !document_paintable)
return String::from_utf8_without_validation(
Compositor::wheel_routing_admission_to_string(Compositor::WheelRoutingAdmission::NoAsyncScrollingState).bytes());
document_paintable->refresh_scroll_state();
auto viewport_rect = page().css_to_device_rect(navigable->viewport_rect()).to_type<int>();
auto state = Compositor::collect_async_scrolling_state(*navigable, *document_paintable, viewport_rect);
return String::from_utf8_without_validation(
Compositor::wheel_routing_admission_to_string(Compositor::wheel_routing_admission_for(state)).bytes());
}
static String wheel_scroll_admission_to_string(Compositor::WheelScrollAdmission admission)
{
switch (admission) {
@ -753,4 +772,34 @@ String Internals::async_scrolling_state_wheel_scroll_admission_at(double x, doub
return wheel_scroll_admission_to_string(admission);
}
String Internals::async_scrolling_state_wheel_target_at(double x, double y, double delta_x, double delta_y)
{
auto& document = window().associated_document();
document.update_layout(DOM::UpdateLayoutReason::InternalsHitTest);
auto navigable = document.navigable();
auto document_paintable = document.paintable();
if (!navigable || !document_paintable)
return "none"_string;
auto display_list = document.record_display_list(HTML::PaintConfig {});
document_paintable->refresh_scroll_state();
auto scroll_state_snapshot = document_paintable->scroll_state_snapshot();
auto viewport_rect = page().css_to_device_rect(navigable->viewport_rect()).to_type<int>();
auto state = Compositor::collect_async_scrolling_state(*navigable, *document_paintable, viewport_rect);
Compositor::AsyncScrollTree scroll_tree;
scroll_tree.set_state(move(state));
scroll_tree.rebuild_wheel_scroll_targets(display_list, scroll_state_snapshot);
auto target = scroll_tree.hit_test_scroll_node_for_wheel(
{ static_cast<float>(x), static_cast<float>(y) },
{ static_cast<float>(delta_x), static_cast<float>(delta_y) });
if (!target.has_value())
return "none"_string;
if (scroll_tree.scroll_node_is_viewport(*target))
return "viewport"_string;
return "non-viewport"_string;
}
}

View file

@ -114,7 +114,9 @@ public:
JS::Object* async_scrolling_state();
bool async_scrolling_state_blocks_wheel_event_at(double x, double y);
bool async_scrolling_state_can_wheel_scroll_at(double x, double y, double delta_x, double delta_y, bool force_stale_wheel_event_regions);
String async_scrolling_state_wheel_routing_admission();
String async_scrolling_state_wheel_scroll_admission_at(double x, double y, double delta_x, double delta_y, bool force_stale_wheel_event_regions);
String async_scrolling_state_wheel_target_at(double x, double y, double delta_x, double delta_y);
private:
explicit Internals(JS::Realm&);

View file

@ -93,6 +93,8 @@ interface Internals {
object asyncScrollingState();
boolean asyncScrollingStateBlocksWheelEventAt(double x, double y);
boolean asyncScrollingStateCanWheelScrollAt(double x, double y, double deltaX, double deltaY, optional boolean forceStaleWheelEventRegions = false);
DOMString asyncScrollingStateWheelRoutingAdmission();
DOMString asyncScrollingStateWheelScrollAdmissionAt(double x, double y, double deltaX, double deltaY, optional boolean forceStaleWheelEventRegions = false);
DOMString asyncScrollingStateWheelTargetAt(double x, double y, double deltaX, double deltaY);
};

View file

@ -0,0 +1,4 @@
scroll nodes: 2
wheel routing admission: accepted
viewport wheel target: viewport
scroller wheel target: non-viewport

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#scroller {
width: 100px;
height: 100px;
overflow: scroll;
}
#scroller-spacer {
height: 500px;
}
#page-spacer {
height: 2000px;
}
</style>
<div id="scroller"><div id="scroller-spacer"></div></div>
<div id="page-spacer"></div>
<script>
test(() => {
const state = internals.asyncScrollingState();
println(`scroll nodes: ${state.scrollNodeCount}`);
println(`wheel routing admission: ${internals.asyncScrollingStateWheelRoutingAdmission()}`);
println(`viewport wheel target: ${internals.asyncScrollingStateWheelTargetAt(150, 150, 0, 100)}`);
println(`scroller wheel target: ${internals.asyncScrollingStateWheelTargetAt(50, 50, 0, 100)}`);
});
</script>