LibWeb: Add compositor scroll state snapshots

Record the scroll geometry that CompositorThread can safely reason about
while the main thread is painting. The snapshot contains stable scroll
node IDs, parent links, scroll bounds, sticky inputs, and wheel blocker
regions in display-list coordinate space.

Add AsyncScrollingState and AsyncScrollTree under LibWeb/Compositor. The
state is the immutable main-thread snapshot; the tree is the mutable
compositor-side copy that can replay scroll deltas and sticky offsets
without touching DOM, layout, or paintables.

Expose the state through internals and add text tests for tree shape,
parent links, sticky areas, blocker hit testing, nested scrollers, and
admission decisions. Keep the directory skipped unless the feature is
enabled with --enable-async-scrolling.
This commit is contained in:
Andreas Kling 2026-05-11 21:27:11 +02:00 committed by Andreas Kling
parent 1b810a5e15
commit 191bd46cb8
25 changed files with 1262 additions and 0 deletions

View file

@ -30,6 +30,8 @@ set(SOURCES
Clipboard/ClipboardEvent.cpp
Clipboard/ClipboardItem.cpp
Clipboard/SystemClipboard.cpp
Compositor/AsyncScrollTree.cpp
Compositor/AsyncScrollingState.cpp
Compositor/CompositorThread.cpp
Compression/CompressionStream.cpp
Compression/DecompressionStream.cpp

View file

@ -0,0 +1,303 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#include <LibWeb/Compositor/AsyncScrollTree.h>
#include <LibWeb/Painting/DisplayList.h>
#include <AK/Debug.h>
namespace Web::Compositor {
void AsyncScrollTree::set_state(AsyncScrollingState&& state)
{
m_scroll_nodes = move(state.scroll_nodes);
m_sticky_areas = move(state.sticky_areas);
m_viewport_rect = state.viewport_rect;
m_wheel_scroll_targets.clear();
}
AsyncScrollNode* AsyncScrollTree::scroll_node_for_id(AsyncScrollNodeID node_id)
{
for (auto& node : m_scroll_nodes) {
if (node.node_id == node_id)
return &node;
}
return nullptr;
}
AsyncScrollNode const* AsyncScrollTree::scroll_node_for_id(AsyncScrollNodeID node_id) const
{
for (auto const& node : m_scroll_nodes) {
if (node.node_id == node_id)
return &node;
}
return nullptr;
}
AsyncStickyArea const* AsyncScrollTree::sticky_area_for_scroll_frame_index(Painting::ScrollFrameIndex scroll_frame_index) const
{
for (auto const& sticky_area : m_sticky_areas) {
if (sticky_area.scroll_frame_index == scroll_frame_index)
return &sticky_area;
}
return nullptr;
}
Gfx::FloatPoint AsyncScrollTree::clamp_scroll_offset_to_node(AsyncScrollNode const& node, Gfx::FloatPoint scroll_offset)
{
scroll_offset.set_x(max(0.0f, min(scroll_offset.x(), node.max_scroll_offset.x())));
scroll_offset.set_y(max(0.0f, min(scroll_offset.y(), node.max_scroll_offset.y())));
return scroll_offset;
}
bool AsyncScrollTree::can_scroll_node_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;
}
bool AsyncScrollTree::can_scroll_target_by_delta(WheelScrollTarget const& target, Gfx::FloatPoint delta)
{
if (delta.x() < 0 && target.can_scroll_left)
return true;
if (delta.x() > 0 && target.can_scroll_right)
return true;
if (delta.y() < 0 && target.can_scroll_up)
return true;
if (delta.y() > 0 && target.can_scroll_down)
return true;
return false;
}
bool AsyncScrollTree::has_non_zero_scroll_delta(Gfx::FloatPoint delta)
{
return delta.x() != 0 || delta.y() != 0;
}
Optional<AsyncScrollNodeID> AsyncScrollTree::scrollable_ancestor_for_node(AsyncScrollNodeID node_id, Gfx::FloatPoint delta) const
{
auto const* node = scroll_node_for_id(node_id);
if (!node)
return {};
auto parent_node_id = node->parent_node_id;
while (parent_node_id.has_value()) {
auto const* parent_node = scroll_node_for_id(*parent_node_id);
if (!parent_node)
return {};
if (can_scroll_node_by_delta(*parent_node, delta))
return *parent_node_id;
parent_node_id = parent_node->parent_node_id;
}
return {};
}
Optional<Painting::ScrollFrameIndex> AsyncScrollTree::parent_scroll_frame_index(Painting::ScrollFrameIndex scroll_frame_index) const
{
for (auto const& node : m_scroll_nodes) {
if (node.node_id.scroll_frame_index != scroll_frame_index)
continue;
if (!node.parent_node_id.has_value())
return {};
return node.parent_node_id->scroll_frame_index;
}
if (auto const* sticky_area = sticky_area_for_scroll_frame_index(scroll_frame_index))
return sticky_area->parent_scroll_frame_index;
return {};
}
Gfx::FloatPoint AsyncScrollTree::cumulative_device_offset_for_frame(Painting::ScrollFrameIndex scroll_frame_index, Painting::ScrollStateSnapshot const& scroll_state_snapshot) const
{
Gfx::FloatPoint offset;
for (auto index = scroll_frame_index; index.value();) {
offset.translate_by(scroll_state_snapshot.device_offset_for_index(index));
auto parent_index = parent_scroll_frame_index(index);
if (!parent_index.has_value())
break;
index = *parent_index;
}
return offset;
}
Gfx::FloatPoint AsyncScrollTree::apply_scroll_delta_to_node(AsyncScrollNode& node, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot& scroll_state_snapshot)
{
auto old_scroll_offset = node.scroll_offset;
auto new_scroll_offset = clamp_scroll_offset_to_node(node, old_scroll_offset.translated(delta));
if (new_scroll_offset == old_scroll_offset) {
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Async scroll node {} did not move for delta {},{} (offset={},{} max={},{})",
node.node_id.scroll_frame_index.value(), delta.x(), delta.y(), old_scroll_offset.x(), old_scroll_offset.y(), node.max_scroll_offset.x(), node.max_scroll_offset.y());
return delta;
}
node.scroll_offset = new_scroll_offset;
scroll_state_snapshot.set_device_offset_for_index(node.node_id.scroll_frame_index, { -new_scroll_offset.x(), -new_scroll_offset.y() });
Gfx::FloatPoint consumed_delta {
new_scroll_offset.x() - old_scroll_offset.x(),
new_scroll_offset.y() - old_scroll_offset.y()
};
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Async scroll node {} moved from {},{} to {},{} (consumed={},{} remaining={},{})",
node.node_id.scroll_frame_index.value(),
old_scroll_offset.x(), old_scroll_offset.y(),
new_scroll_offset.x(), new_scroll_offset.y(),
consumed_delta.x(), consumed_delta.y(),
delta.x() - consumed_delta.x(), delta.y() - consumed_delta.y());
return {
delta.x() - consumed_delta.x(),
delta.y() - consumed_delta.y()
};
}
void AsyncScrollTree::update_sticky_offsets(Painting::ScrollStateSnapshot& scroll_state_snapshot) const
{
// This mirrors ViewportPaintable::refresh_scroll_state(), but consumes the compositor's mutated
// ScrollStateSnapshot instead of live layout objects.
for (auto const& sticky_area : m_sticky_areas) {
if (!sticky_area.nearest_scrolling_ancestor_index.value())
continue;
Gfx::FloatPoint parent_sticky_offset;
if (sticky_area.parent_scroll_frame_index.value() && sticky_area_for_scroll_frame_index(sticky_area.parent_scroll_frame_index))
parent_sticky_offset = cumulative_device_offset_for_frame(sticky_area.parent_scroll_frame_index, scroll_state_snapshot);
auto sticky_position_in_ancestor = sticky_area.position_relative_to_scroll_ancestor.translated(parent_sticky_offset);
auto containing_block_region = sticky_area.containing_block_region;
if (sticky_area.needs_parent_offset_adjustment)
containing_block_region.translate_by(parent_sticky_offset);
auto min_offset_within_containing_block = containing_block_region.top_left();
Gfx::FloatPoint max_offset_within_containing_block {
containing_block_region.right() - sticky_area.border_box_size.width(),
containing_block_region.bottom() - sticky_area.border_box_size.height()
};
auto ancestor_device_offset = scroll_state_snapshot.device_offset_for_index(sticky_area.nearest_scrolling_ancestor_index);
Gfx::FloatPoint scroll_ancestor_scroll_offset { -ancestor_device_offset.x(), -ancestor_device_offset.y() };
Gfx::FloatRect scrollport_rect { scroll_ancestor_scroll_offset, sticky_area.scrollport_size };
Gfx::FloatPoint sticky_offset;
if (sticky_area.inset_top.has_value()) {
if (scrollport_rect.top() > sticky_position_in_ancestor.y() - *sticky_area.inset_top)
sticky_offset.set_y(min(scrollport_rect.top() + *sticky_area.inset_top, max_offset_within_containing_block.y()) - sticky_position_in_ancestor.y());
}
if (sticky_area.inset_left.has_value()) {
if (scrollport_rect.left() > sticky_position_in_ancestor.x() - *sticky_area.inset_left)
sticky_offset.set_x(min(scrollport_rect.left() + *sticky_area.inset_left, max_offset_within_containing_block.x()) - sticky_position_in_ancestor.x());
}
if (sticky_area.inset_bottom.has_value()) {
if (scrollport_rect.bottom() < sticky_position_in_ancestor.y() + sticky_area.border_box_size.height() + *sticky_area.inset_bottom)
sticky_offset.set_y(max(scrollport_rect.bottom() - sticky_area.border_box_size.height() - *sticky_area.inset_bottom, min_offset_within_containing_block.y()) - sticky_position_in_ancestor.y());
}
if (sticky_area.inset_right.has_value()) {
if (scrollport_rect.right() < sticky_position_in_ancestor.x() + sticky_area.border_box_size.width() + *sticky_area.inset_right)
sticky_offset.set_x(max(scrollport_rect.right() - sticky_area.border_box_size.width() - *sticky_area.inset_right, min_offset_within_containing_block.x()) - sticky_position_in_ancestor.x());
}
scroll_state_snapshot.set_device_offset_for_index(sticky_area.scroll_frame_index, sticky_offset);
}
}
bool AsyncScrollTree::apply_scroll_delta(AsyncScrollNodeID node_id, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot& scroll_state_snapshot)
{
// The compositor can advance only the scroll offsets it owns in this snapshot. When a target reaches an edge,
// the remaining wheel delta is handed to the nearest scrollable ancestor from the same immutable tree.
bool scrolled = false;
auto remaining_delta = delta;
for (size_t remaining_handoffs = m_scroll_nodes.size(); remaining_handoffs > 0 && has_non_zero_scroll_delta(remaining_delta); --remaining_handoffs) {
auto* node = scroll_node_for_id(node_id);
if (!node)
break;
auto delta_before_scroll = remaining_delta;
remaining_delta = apply_scroll_delta_to_node(*node, remaining_delta, scroll_state_snapshot);
if (remaining_delta != delta_before_scroll)
scrolled = true;
if (!has_non_zero_scroll_delta(remaining_delta))
break;
auto ancestor_node_id = scrollable_ancestor_for_node(node_id, remaining_delta);
if (!ancestor_node_id.has_value())
break;
node_id = *ancestor_node_id;
}
if (scrolled)
update_sticky_offsets(scroll_state_snapshot);
else
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Async scroll tree did not scroll any node for delta {},{}",
delta.x(), delta.y());
return scrolled;
}
void AsyncScrollTree::rebuild_wheel_scroll_targets(RefPtr<Painting::DisplayList> const& display_list, Painting::ScrollStateSnapshot const& scroll_state_snapshot)
{
m_wheel_scroll_targets.clear();
if (!display_list)
return;
auto const& visual_context_tree = display_list->visual_context_tree();
for (auto const& node : m_scroll_nodes) {
auto viewport_rect = node.is_viewport
? Gfx::FloatRect { {}, m_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);
m_wheel_scroll_targets.append({
.node_id = node.node_id,
.viewport_rect = viewport_rect,
.can_scroll_left = node.scroll_offset.x() > 0,
.can_scroll_right = node.scroll_offset.x() < node.max_scroll_offset.x(),
.can_scroll_up = node.scroll_offset.y() > 0,
.can_scroll_down = node.scroll_offset.y() < node.max_scroll_offset.y(),
});
}
}
void AsyncScrollTree::clear_wheel_scroll_targets()
{
m_wheel_scroll_targets.clear();
}
Optional<AsyncScrollNodeID> AsyncScrollTree::viewport_scroll_node_for_delta(Gfx::FloatPoint delta) const
{
for (auto const& node : m_scroll_nodes) {
if (node.is_viewport && can_scroll_node_by_delta(node, delta))
return node.node_id;
}
return {};
}
Optional<Gfx::FloatPoint> AsyncScrollTree::scroll_offset_for_node(AsyncScrollNodeID node_id) const
{
if (auto const* node = scroll_node_for_id(node_id))
return node->scroll_offset;
return {};
}
Optional<AsyncScrollNodeID> AsyncScrollTree::hit_test_scroll_node_for_wheel(Gfx::FloatPoint position, Gfx::FloatPoint delta) const
{
Optional<AsyncScrollNodeID> scroll_target;
for (auto const& target : m_wheel_scroll_targets) {
if (!target.viewport_rect.contains(position))
continue;
if (!can_scroll_target_by_delta(target, delta))
continue;
scroll_target = target.node_id;
}
return scroll_target;
}
}

View file

@ -0,0 +1,65 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Optional.h>
#include <AK/RefPtr.h>
#include <AK/Vector.h>
#include <LibGfx/Point.h>
#include <LibGfx/Rect.h>
#include <LibWeb/Compositor/AsyncScrollingState.h>
#include <LibWeb/Forward.h>
#include <LibWeb/Painting/ScrollState.h>
namespace Web::Compositor {
// Viewport-space cache used for wheel hit-testing, rebuilt from AsyncScrollNode whenever scroll offsets change.
struct WheelScrollTarget {
AsyncScrollNodeID node_id;
Gfx::FloatRect viewport_rect;
bool can_scroll_left { false };
bool can_scroll_right { false };
bool can_scroll_up { false };
bool can_scroll_down { false };
};
// Mutable compositor-side copy of AsyncScrollingState. The main thread owns the source snapshot; this tree owns only
// compositor scroll offsets and derived hit-test targets.
class AsyncScrollTree {
public:
void set_state(AsyncScrollingState&&);
void rebuild_wheel_scroll_targets(RefPtr<Painting::DisplayList> const&, Painting::ScrollStateSnapshot const&);
void clear_wheel_scroll_targets();
Optional<AsyncScrollNodeID> viewport_scroll_node_for_delta(Gfx::FloatPoint delta) const;
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 apply_scroll_delta(AsyncScrollNodeID, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot&);
private:
static Gfx::FloatPoint clamp_scroll_offset_to_node(AsyncScrollNode const&, Gfx::FloatPoint);
static bool can_scroll_node_by_delta(AsyncScrollNode const&, Gfx::FloatPoint);
static bool can_scroll_target_by_delta(WheelScrollTarget const&, Gfx::FloatPoint);
static bool has_non_zero_scroll_delta(Gfx::FloatPoint);
AsyncScrollNode* scroll_node_for_id(AsyncScrollNodeID);
AsyncScrollNode const* scroll_node_for_id(AsyncScrollNodeID) const;
AsyncStickyArea const* sticky_area_for_scroll_frame_index(Painting::ScrollFrameIndex) const;
Optional<AsyncScrollNodeID> scrollable_ancestor_for_node(AsyncScrollNodeID, Gfx::FloatPoint delta) const;
Optional<Painting::ScrollFrameIndex> parent_scroll_frame_index(Painting::ScrollFrameIndex) const;
Gfx::FloatPoint cumulative_device_offset_for_frame(Painting::ScrollFrameIndex, Painting::ScrollStateSnapshot const&) const;
Gfx::FloatPoint apply_scroll_delta_to_node(AsyncScrollNode&, Gfx::FloatPoint delta, Painting::ScrollStateSnapshot&);
void update_sticky_offsets(Painting::ScrollStateSnapshot&) const;
Vector<AsyncScrollNode> m_scroll_nodes;
Vector<AsyncStickyArea> m_sticky_areas;
Vector<WheelScrollTarget> m_wheel_scroll_targets;
Gfx::IntRect m_viewport_rect;
};
}

View file

@ -0,0 +1,257 @@
/*
* 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/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;
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),
});
}
}
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;
}
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;
}
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)
{
// 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;
}
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2026-present, the Ladybird developers.
*
* SPDX-License-Identifier: BSD-2-Clause
*/
#pragma once
#include <AK/Optional.h>
#include <AK/Vector.h>
#include <LibGfx/Point.h>
#include <LibGfx/Rect.h>
#include <LibWeb/Forward.h>
#include <LibWeb/Painting/AccumulatedVisualContext.h>
#include <LibWeb/Painting/ScrollFrame.h>
namespace Web::Compositor {
// Stable identifier for a scroll frame in a document; the frame index alone is not unique across nested documents.
struct AsyncScrollNodeID {
UniqueNodeID document_id;
Painting::ScrollFrameIndex scroll_frame_index;
bool operator==(AsyncScrollNodeID const&) const = default;
};
// One scrollable area from the paint snapshot. Non-viewport scrollports are stored in hit_test_visual_context_index
// coordinates and transformed to viewport coordinates when the compositor rebuilds wheel targets.
struct AsyncScrollNode {
AsyncScrollNodeID node_id;
Optional<AsyncScrollNodeID> parent_node_id;
Painting::VisualContextIndex hit_test_visual_context_index;
Gfx::IntRect scrollport_rect;
Gfx::FloatPoint scroll_offset;
Gfx::FloatPoint max_scroll_offset;
bool is_viewport { false };
};
// Sticky elements are represented as scroll frames whose offset is derived from ancestor scroll offsets. Keep only
// the precomputed geometry needed to replay that calculation on the compositor thread after an async scroll mutation.
struct AsyncStickyArea {
UniqueNodeID document_id;
Painting::ScrollFrameIndex scroll_frame_index;
Painting::ScrollFrameIndex parent_scroll_frame_index;
Painting::ScrollFrameIndex nearest_scrolling_ancestor_index;
Gfx::FloatPoint position_relative_to_scroll_ancestor;
Gfx::FloatSize border_box_size;
Gfx::FloatSize scrollport_size;
Gfx::FloatRect containing_block_region;
bool needs_parent_offset_adjustment { false };
Optional<float> inset_top;
Optional<float> inset_right;
Optional<float> inset_bottom;
Optional<float> inset_left;
};
// A region with a non-passive wheel listener. Wheels inside it must stay on the main thread because script may cancel.
struct BlockingWheelEventRegion {
Painting::VisualContextIndex visual_context_index;
Gfx::FloatRect rect;
};
// Snapshot of scroll-related paint data that the compositor can read without touching DOM, layout, or paintables.
// It is immutable once collected on the main thread; AsyncScrollTree keeps the mutable compositor-side scroll offsets.
struct AsyncScrollingState {
Vector<AsyncScrollNode> scroll_nodes;
Vector<AsyncStickyArea> sticky_areas;
// Non-passive wheel listeners can cancel scrolling, so async scrolling must treat them as hard barriers.
// Viewport-wide barriers cover listeners on the root targets; element regions let input hit-testing accept
// async scrolling elsewhere.
Vector<BlockingWheelEventRegion> blocking_wheel_event_regions;
Gfx::IntRect viewport_rect;
bool has_blocking_wheel_event_listeners { false };
bool blocking_wheel_event_regions_are_current { false };
bool has_blocking_wheel_event_region_covering_viewport { false };
};
enum class WheelScrollAdmission {
Accepted,
NoScrollableTarget,
StaleBlockingWheelEventRegions,
BlockedByWheelEventRegion,
};
AsyncScrollingState collect_async_scrolling_state(HTML::Navigable&, Painting::ViewportPaintable&, Gfx::IntRect viewport_rect);
bool blocks_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

@ -16,6 +16,7 @@
#include <LibWeb/Bindings/Internals.h>
#include <LibWeb/Bindings/Intrinsics.h>
#include <LibWeb/Bindings/MainThreadVM.h>
#include <LibWeb/Compositor/AsyncScrollingState.h>
#include <LibWeb/DOM/Document.h>
#include <LibWeb/DOM/Event.h>
#include <LibWeb/DOM/EventTarget.h>
@ -38,6 +39,7 @@
#include <LibWeb/Page/InputEvent.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/Painting/PaintableBox.h>
#include <LibWeb/Painting/ViewportPaintable.h>
#include <LibWeb/WebIDL/Promise.h>
namespace Web::Internals {
@ -621,4 +623,130 @@ void Internals::reset_style_invalidation_counters()
window().associated_document().reset_style_invalidation_counters();
}
JS::Object* Internals::async_scrolling_state()
{
auto& document = window().associated_document();
document.update_layout(DOM::UpdateLayoutReason::InternalsHitTest);
auto object = JS::Object::create(realm(), nullptr);
auto navigable = document.navigable();
auto document_paintable = document.paintable();
if (!navigable || !document_paintable) {
auto scroll_nodes = MUST(JS::Array::create(realm(), 0));
auto sticky_areas = MUST(JS::Array::create(realm(), 0));
object->define_direct_property("scrollNodeCount"_utf16_fly_string, JS::Value(0), JS::default_attributes);
object->define_direct_property("scrollNodes"_utf16_fly_string, scroll_nodes, JS::default_attributes);
object->define_direct_property("stickyAreaCount"_utf16_fly_string, JS::Value(0), JS::default_attributes);
object->define_direct_property("stickyAreas"_utf16_fly_string, sticky_areas, JS::default_attributes);
object->define_direct_property("hasBlockingWheelEventListeners"_utf16_fly_string, JS::Value(false), JS::default_attributes);
object->define_direct_property("blockingWheelEventRegionCount"_utf16_fly_string, JS::Value(0), JS::default_attributes);
object->define_direct_property("blockingWheelEventRegionsAreCurrent"_utf16_fly_string, JS::Value(false), JS::default_attributes);
object->define_direct_property("hasBlockingWheelEventRegionCoveringViewport"_utf16_fly_string, JS::Value(false), JS::default_attributes);
return object;
}
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);
auto scroll_nodes = MUST(JS::Array::create(realm(), state.scroll_nodes.size()));
for (size_t i = 0; i < state.scroll_nodes.size(); ++i) {
auto const& scroll_node = state.scroll_nodes[i];
auto node = JS::Object::create(realm(), nullptr);
node->define_direct_property("documentID"_utf16_fly_string, JS::Value(static_cast<double>(scroll_node.node_id.document_id.value())), JS::default_attributes);
node->define_direct_property("scrollFrameIndex"_utf16_fly_string, JS::Value(scroll_node.node_id.scroll_frame_index.value()), JS::default_attributes);
node->define_direct_property("parentDocumentID"_utf16_fly_string, JS::Value(scroll_node.parent_node_id.has_value() ? static_cast<double>(scroll_node.parent_node_id->document_id.value()) : 0), JS::default_attributes);
node->define_direct_property("parentScrollFrameIndex"_utf16_fly_string, JS::Value(scroll_node.parent_node_id.has_value() ? scroll_node.parent_node_id->scroll_frame_index.value() : 0), JS::default_attributes);
node->define_direct_property("isViewport"_utf16_fly_string, JS::Value(scroll_node.is_viewport), JS::default_attributes);
MUST(scroll_nodes->create_data_property_or_throw(i, node));
}
auto sticky_areas = MUST(JS::Array::create(realm(), state.sticky_areas.size()));
for (size_t i = 0; i < state.sticky_areas.size(); ++i) {
auto const& sticky_area = state.sticky_areas[i];
auto area = JS::Object::create(realm(), nullptr);
area->define_direct_property("documentID"_utf16_fly_string, JS::Value(static_cast<double>(sticky_area.document_id.value())), JS::default_attributes);
area->define_direct_property("scrollFrameIndex"_utf16_fly_string, JS::Value(sticky_area.scroll_frame_index.value()), JS::default_attributes);
area->define_direct_property("parentScrollFrameIndex"_utf16_fly_string, JS::Value(sticky_area.parent_scroll_frame_index.value()), JS::default_attributes);
area->define_direct_property("nearestScrollingAncestorIndex"_utf16_fly_string, JS::Value(sticky_area.nearest_scrolling_ancestor_index.value()), JS::default_attributes);
area->define_direct_property("hasTopInset"_utf16_fly_string, JS::Value(sticky_area.inset_top.has_value()), JS::default_attributes);
area->define_direct_property("hasRightInset"_utf16_fly_string, JS::Value(sticky_area.inset_right.has_value()), JS::default_attributes);
area->define_direct_property("hasBottomInset"_utf16_fly_string, JS::Value(sticky_area.inset_bottom.has_value()), JS::default_attributes);
area->define_direct_property("hasLeftInset"_utf16_fly_string, JS::Value(sticky_area.inset_left.has_value()), JS::default_attributes);
MUST(sticky_areas->create_data_property_or_throw(i, area));
}
object->define_direct_property("scrollNodeCount"_utf16_fly_string, JS::Value(state.scroll_nodes.size()), JS::default_attributes);
object->define_direct_property("scrollNodes"_utf16_fly_string, scroll_nodes, JS::default_attributes);
object->define_direct_property("stickyAreaCount"_utf16_fly_string, JS::Value(state.sticky_areas.size()), JS::default_attributes);
object->define_direct_property("stickyAreas"_utf16_fly_string, sticky_areas, JS::default_attributes);
object->define_direct_property("hasBlockingWheelEventListeners"_utf16_fly_string, JS::Value(state.has_blocking_wheel_event_listeners), JS::default_attributes);
object->define_direct_property("blockingWheelEventRegionCount"_utf16_fly_string, JS::Value(state.blocking_wheel_event_regions.size()), JS::default_attributes);
object->define_direct_property("blockingWheelEventRegionsAreCurrent"_utf16_fly_string, JS::Value(state.blocking_wheel_event_regions_are_current), JS::default_attributes);
object->define_direct_property("hasBlockingWheelEventRegionCoveringViewport"_utf16_fly_string, JS::Value(state.has_blocking_wheel_event_region_covering_viewport), JS::default_attributes);
return object;
}
bool Internals::async_scrolling_state_blocks_wheel_event_at(double x, double 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 false;
auto display_list = document.record_display_list(HTML::PaintConfig {});
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 Compositor::blocks_wheel_event_at_position(state, display_list, document_paintable->scroll_state_snapshot(), { static_cast<float>(x), static_cast<float>(y) });
}
bool Internals::async_scrolling_state_can_wheel_scroll_at(double x, double y, double delta_x, double delta_y, bool force_stale_wheel_event_regions)
{
return async_scrolling_state_wheel_scroll_admission_at(x, y, delta_x, delta_y, force_stale_wheel_event_regions) == "accepted"sv;
}
static String wheel_scroll_admission_to_string(Compositor::WheelScrollAdmission admission)
{
switch (admission) {
case Compositor::WheelScrollAdmission::Accepted:
return "accepted"_string;
case Compositor::WheelScrollAdmission::NoScrollableTarget:
return "no-scrollable-target"_string;
case Compositor::WheelScrollAdmission::StaleBlockingWheelEventRegions:
return "stale-blocking-wheel-event-regions"_string;
case Compositor::WheelScrollAdmission::BlockedByWheelEventRegion:
return "blocked-by-wheel-event-region"_string;
}
VERIFY_NOT_REACHED();
}
String Internals::async_scrolling_state_wheel_scroll_admission_at(double x, double y, double delta_x, double delta_y, bool force_stale_wheel_event_regions)
{
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 "no-scrollable-target"_string;
auto display_list = document.record_display_list(HTML::PaintConfig {});
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);
auto admission = Compositor::admit_wheel_scroll(
state,
display_list,
document_paintable->scroll_state_snapshot(),
{ static_cast<float>(x), static_cast<float>(y) },
{ static_cast<float>(delta_x), static_cast<float>(delta_y) },
state.has_blocking_wheel_event_listeners,
state.blocking_wheel_event_regions_are_current && !force_stale_wheel_event_regions);
return wheel_scroll_admission_to_string(admission);
}
}

View file

@ -111,6 +111,10 @@ public:
JS::Object* get_style_invalidation_counters();
void reset_style_invalidation_counters();
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_scroll_admission_at(double x, double y, double delta_x, double delta_y, bool force_stale_wheel_event_regions);
private:
explicit Internals(JS::Realm&);

View file

@ -90,4 +90,9 @@ interface Internals {
object getStyleInvalidationCounters();
undefined resetStyleInvalidationCounters();
object asyncScrollingState();
boolean asyncScrollingStateBlocksWheelEventAt(double x, double y);
boolean asyncScrollingStateCanWheelScrollAt(double x, double y, double deltaX, double deltaY, optional boolean forceStaleWheelEventRegions = false);
DOMString asyncScrollingStateWheelScrollAdmissionAt(double x, double y, double deltaX, double deltaY, optional boolean forceStaleWheelEventRegions = false);
};

View file

@ -22,6 +22,13 @@ public:
return m_device_offsets[index.value()];
}
void set_device_offset_for_index(ScrollFrameIndex index, Gfx::FloatPoint offset)
{
if (index.value() >= m_device_offsets.size())
m_device_offsets.resize(index.value() + 1);
m_device_offsets[index.value()] = offset;
}
private:
Vector<Gfx::FloatPoint> m_device_offsets;
};

View file

@ -162,6 +162,11 @@ enum class PaintViewportScrollbars {
No,
};
enum class EnableAsyncScrolling {
No,
Yes,
};
enum class FileSchemeUrlsHaveTupleOrigins {
No,
Yes,
@ -183,6 +188,7 @@ struct WebContentOptions {
CollectGarbageOnEveryAllocation collect_garbage_on_every_allocation { CollectGarbageOnEveryAllocation::No };
Optional<u16> echo_server_port {};
PaintViewportScrollbars paint_viewport_scrollbars { PaintViewportScrollbars::Yes };
EnableAsyncScrolling enable_async_scrolling { EnableAsyncScrolling::No };
FileSchemeUrlsHaveTupleOrigins file_scheme_urls_have_tuple_origins { FileSchemeUrlsHaveTupleOrigins::No };
Optional<StringView> default_time_zone {};
Optional<u64> style_invalidation_counter_dump_interval {};

View file

@ -0,0 +1,5 @@
blocking regions: 2
transformed visual inside: true
transformed original position: false
clipped visible inside: true
clipped overflow outside clip: false

View file

@ -0,0 +1,24 @@
initial:
regions current: false
viewport blocked: false
blocking regions: 0
passive element listener:
regions current: false
viewport blocked: false
blocking regions: 0
blocking element listener:
regions current: true
viewport blocked: false
blocking regions: 1
removed element listener:
regions current: false
viewport blocked: false
blocking regions: 0
blocking window listener:
regions current: true
viewport blocked: true
blocking regions: 0
removed window listener:
regions current: false
viewport blocked: false
blocking regions: 0

View file

@ -0,0 +1,2 @@
scroller scrollTop: 100
window scrollY: 0

View file

@ -0,0 +1,4 @@
scroll nodes: 2
child nodes: 1
dangling parent nodes: 0
child parent is viewport: true

View file

@ -0,0 +1,7 @@
scroll node count: 2
scroll nodes: 2
has viewport node: true
viewport parent: 0
child nodes: 1
child has same document as viewport: true
child parent is viewport: true

View file

@ -0,0 +1,8 @@
scroll nodes: 1
sticky areas: 2
outer parent is viewport: true
inner parent is outer: true
outer ancestor is viewport: true
inner ancestor is viewport: true
outer has top inset only: true
inner has top inset only: true

View file

@ -0,0 +1,8 @@
no listener over scroller: true
no scrollable target: false
passive listener over target: true
blocking element listener inside: false
blocking element listener outside: true
stale blocking element regions: false
blocking window listener: false
stale blocking window regions: false

View file

@ -0,0 +1,51 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#transformed {
position: absolute;
left: 20px;
top: 20px;
width: 100px;
height: 100px;
transform-origin: top left;
transform: translateX(100px);
}
#clip {
position: absolute;
left: 20px;
top: 160px;
width: 100px;
height: 100px;
overflow: hidden;
}
#clipped-target {
width: 200px;
height: 100px;
}
</style>
<div id="transformed"></div>
<div id="clip">
<div id="clipped-target"></div>
</div>
<script>
function dump(label, x, y) {
println(`${label}: ${internals.asyncScrollingStateBlocksWheelEventAt(x, y)}`);
}
test(() => {
transformed.addEventListener("wheel", event => {}, { passive: false });
document.getElementById("clipped-target").addEventListener("wheel", event => {}, { passive: false });
println(`blocking regions: ${internals.asyncScrollingState().blockingWheelEventRegionCount}`);
dump("transformed visual inside", 130, 30);
dump("transformed original position", 30, 30);
dump("clipped visible inside", 30, 170);
dump("clipped overflow outside clip", 150, 170);
});
</script>

View file

@ -0,0 +1,58 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#scroller {
width: 200px;
height: 200px;
overflow: scroll;
}
#target {
width: 100px;
height: 100px;
}
#spacer {
width: 1000px;
height: 1000px;
}
</style>
<div id="scroller">
<div id="target"></div>
<div id="spacer"></div>
</div>
<script>
function dump(label) {
const state = internals.asyncScrollingState();
println(`${label}:`);
println(` regions current: ${state.blockingWheelEventRegionsAreCurrent}`);
println(` viewport blocked: ${state.hasBlockingWheelEventRegionCoveringViewport}`);
println(` blocking regions: ${state.blockingWheelEventRegionCount}`);
}
test(() => {
dump("initial");
const passiveElementListener = event => {};
target.addEventListener("wheel", passiveElementListener, { passive: true });
dump("passive element listener");
const blockingElementListener = event => {};
target.addEventListener("wheel", blockingElementListener, { passive: false });
dump("blocking element listener");
target.removeEventListener("wheel", blockingElementListener);
dump("removed element listener");
const blockingWindowListener = event => {};
window.addEventListener("wheel", blockingWindowListener, { passive: false });
dump("blocking window listener");
window.removeEventListener("wheel", blockingWindowListener);
dump("removed window listener");
});
</script>

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#scroller {
width: 100px;
height: 100px;
overflow: scroll;
}
#spacer {
height: 500px;
}
#page-spacer {
height: 2000px;
}
</style>
<div id="scroller"><div id="spacer"></div></div>
<div id="page-spacer"></div>
<script>
promiseTest(async () => {
internals.wheel(50, 50, 0, 100);
await animationFrame();
println(`scroller scrollTop: ${scroller.scrollTop}`);
println(`window scrollY: ${window.scrollY}`);
});
</script>

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#sticky {
position: sticky;
top: 0;
}
#scroller {
width: 100px;
height: 100px;
overflow: scroll;
}
#spacer {
width: 500px;
height: 500px;
}
</style>
<div id="sticky">
<div id="scroller"><div id="spacer"></div></div>
</div>
<div style="height: 2000px"></div>
<script>
test(() => {
const state = internals.asyncScrollingState();
const scrollNodeIDs = new Set(state.scrollNodes.map(node => `${node.documentID}:${node.scrollFrameIndex}`));
const viewportNode = state.scrollNodes.find(node => node.isViewport);
const childNodes = state.scrollNodes.filter(node => !node.isViewport);
const danglingParentNodes = state.scrollNodes.filter(node => node.parentScrollFrameIndex !== 0 && !scrollNodeIDs.has(`${node.parentDocumentID}:${node.parentScrollFrameIndex}`));
println(`scroll nodes: ${state.scrollNodes.length}`);
println(`child nodes: ${childNodes.length}`);
println(`dangling parent nodes: ${danglingParentNodes.length}`);
for (const node of childNodes)
println(`child parent is viewport: ${node.parentScrollFrameIndex === viewportNode.scrollFrameIndex}`);
});
</script>

View file

@ -0,0 +1,38 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#scroller {
width: 100px;
height: 100px;
overflow: scroll;
}
#spacer {
width: 500px;
height: 500px;
}
</style>
<div id="scroller"><div id="spacer"></div></div>
<div style="height: 2000px"></div>
<script>
test(() => {
const state = internals.asyncScrollingState();
println(`scroll node count: ${state.scrollNodeCount}`);
println(`scroll nodes: ${state.scrollNodes.length}`);
const viewportNode = state.scrollNodes.find(node => node.isViewport);
println(`has viewport node: ${!!viewportNode}`);
println(`viewport parent: ${viewportNode?.parentScrollFrameIndex ?? "missing"}`);
const childNodes = state.scrollNodes.filter(node => !node.isViewport);
println(`child nodes: ${childNodes.length}`);
for (const node of childNodes) {
println(`child has same document as viewport: ${node.documentID === viewportNode.documentID}`);
println(`child parent is viewport: ${node.parentScrollFrameIndex === viewportNode.scrollFrameIndex}`);
}
});
</script>

View file

@ -0,0 +1,42 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#outer {
position: sticky;
top: 0;
}
#inner {
position: sticky;
top: 20px;
}
#spacer {
height: 2000px;
}
</style>
<div id="outer">
<div id="inner">sticky</div>
</div>
<div id="spacer"></div>
<script>
test(() => {
const state = internals.asyncScrollingState();
const viewportNode = state.scrollNodes.find(node => node.isViewport);
const outerArea = state.stickyAreas.find(area => area.parentScrollFrameIndex === viewportNode.scrollFrameIndex);
const innerArea = state.stickyAreas.find(area => area.parentScrollFrameIndex === outerArea.scrollFrameIndex);
println(`scroll nodes: ${state.scrollNodeCount}`);
println(`sticky areas: ${state.stickyAreaCount}`);
println(`outer parent is viewport: ${outerArea.parentScrollFrameIndex === viewportNode.scrollFrameIndex}`);
println(`inner parent is outer: ${innerArea.parentScrollFrameIndex === outerArea.scrollFrameIndex}`);
println(`outer ancestor is viewport: ${outerArea.nearestScrollingAncestorIndex === viewportNode.scrollFrameIndex}`);
println(`inner ancestor is viewport: ${innerArea.nearestScrollingAncestorIndex === viewportNode.scrollFrameIndex}`);
println(`outer has top inset only: ${outerArea.hasTopInset && !outerArea.hasRightInset && !outerArea.hasBottomInset && !outerArea.hasLeftInset}`);
println(`inner has top inset only: ${innerArea.hasTopInset && !innerArea.hasRightInset && !innerArea.hasBottomInset && !innerArea.hasLeftInset}`);
});
</script>

View file

@ -0,0 +1,62 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#scroller {
width: 200px;
height: 200px;
overflow: scroll;
}
#target {
width: 100px;
height: 100px;
}
#spacer {
width: 1000px;
height: 1000px;
}
#not-scrollable {
position: absolute;
left: 250px;
top: 0;
width: 100px;
height: 100px;
}
</style>
<div id="scroller">
<div id="target"></div>
<div id="spacer"></div>
</div>
<div id="not-scrollable"></div>
<script>
function canWheel(label, x, y, forceStaleWheelEventRegions = false) {
const canScroll = internals.asyncScrollingStateCanWheelScrollAt(x, y, 0, 100, forceStaleWheelEventRegions);
println(`${label}: ${canScroll}`);
}
test(() => {
canWheel("no listener over scroller", 150, 150);
canWheel("no scrollable target", 275, 50);
target.addEventListener("wheel", event => {}, { passive: true });
canWheel("passive listener over target", 50, 50);
const blockingElementListener = event => {};
target.addEventListener("wheel", blockingElementListener, { passive: false });
canWheel("blocking element listener inside", 50, 50);
canWheel("blocking element listener outside", 150, 150);
canWheel("stale blocking element regions", 150, 150, true);
target.removeEventListener("wheel", blockingElementListener);
const blockingWindowListener = event => {};
window.addEventListener("wheel", blockingWindowListener, { passive: false });
canWheel("blocking window listener", 150, 150);
canWheel("stale blocking window regions", 150, 150, true);
});
</script>

View file

@ -146,6 +146,17 @@ static ErrorOr<void> load_test_config(StringView test_root_path)
return {};
}
static ErrorOr<void> skip_async_scrolling_tests_unless_enabled(Application const& app)
{
if (WebView::Application::web_content_options().enable_async_scrolling == WebView::EnableAsyncScrolling::Yes)
return {};
auto path = LexicalPath::join(app.test_root_path, "Text/input/async-scrolling/"sv).string();
if (!FileSystem::exists(path))
return {};
return enumerate_test_files_recursively(path, s_skipped_tests);
}
static ErrorOr<void> collect_dump_tests(Application const& app, Vector<Test>& tests, StringView path, StringView trail, TestMode mode)
{
Core::DirIterator it(ByteString::formatted("{}/input/{}", path, trail), Core::DirIterator::Flags::SkipDots);
@ -1064,6 +1075,7 @@ static ErrorOr<int> run_tests(Core::AnonymousBuffer const& theme, Web::DevicePix
auto& display = Display::the();
TRY(load_test_config(app.test_root_path));
TRY(skip_async_scrolling_tests_unless_enabled(app));
Vector<Test> tests;