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.
This commit is contained in:
Andreas Kling 2026-05-12 00:19:00 +02:00 committed by Andreas Kling
parent d36d336c82
commit b57cc2bb7a
10 changed files with 98 additions and 2 deletions

View file

@ -124,6 +124,7 @@ AsyncScrollingState collect_async_scrolling_state(HTML::Navigable& navigable, Pa
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) {
@ -222,6 +223,8 @@ StringView wheel_routing_admission_to_string(WheelRoutingAdmission admission)
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();
}

View file

@ -79,6 +79,11 @@ struct AsyncScrollingState {
// async scrolling elsewhere.
Vector<BlockingWheelEventRegion> blocking_wheel_event_regions;
Gfx::IntRect viewport_rect;
// Bumped whenever wheel listener state changes so queued compositor snapshots
// cannot re-enable async wheel routing after a non-passive listener has been
// added.
u64 wheel_event_listener_state_generation { 0 };
bool has_blocking_wheel_event_listeners { false };
bool blocking_wheel_event_regions_are_current { false };
bool has_blocking_wheel_event_region_covering_viewport { false };
@ -89,6 +94,7 @@ enum class WheelRoutingAdmission {
NoAsyncScrollingState,
BlockingWheelEventListeners,
NoViewportScrollNode,
StaleWheelEventListeners,
};
enum class WheelScrollAdmission {

View file

@ -255,6 +255,15 @@ public:
&& (m_is_rasterizing || m_has_deferred_async_scroll_present || m_queued_rasterization_tasks > 0);
}
void invalidate_wheel_event_listener_state(u64 generation)
{
Sync::MutexLocker const locker { m_mutex };
m_wheel_event_listener_state_generation = max(m_wheel_event_listener_state_generation, generation);
m_wheel_routing_admission = WheelRoutingAdmission::StaleWheelEventListeners;
m_can_accept_async_wheel_events = false;
dbgln_if(COMPOSITOR_DEBUG, "[Compositor] Invalidated compositor wheel listener state (generation={})", generation);
}
struct ViewportWheelTarget {
Optional<AsyncScrollNodeID> node_id;
bool rejected_non_viewport_target { false };
@ -373,9 +382,14 @@ public:
m_cached_scroll_state_snapshot = move(cmd.scroll_state_snapshot);
if (cmd.async_scrolling_state.has_value()) {
auto async_scrolling_state = cmd.async_scrolling_state.release_value();
auto const wheel_event_listener_state_generation = async_scrolling_state.wheel_event_listener_state_generation;
auto wheel_routing_admission = wheel_routing_admission_for(async_scrolling_state);
{
Sync::MutexLocker const locker { m_mutex };
if (wheel_event_listener_state_generation < m_wheel_event_listener_state_generation)
wheel_routing_admission = WheelRoutingAdmission::StaleWheelEventListeners;
else
m_wheel_event_listener_state_generation = wheel_event_listener_state_generation;
m_wheel_routing_admission = wheel_routing_admission;
m_async_scrolling_viewport_rect = async_scrolling_state.viewport_rect;
}
@ -716,6 +730,7 @@ private:
Gfx::IntRect m_async_scrolling_viewport_rect;
Atomic<bool> m_has_async_scrolling_state { false };
Atomic<bool> m_can_accept_async_wheel_events { false };
u64 m_wheel_event_listener_state_generation { 0 };
WheelRoutingAdmission m_wheel_routing_admission { WheelRoutingAdmission::NoAsyncScrollingState };
public:
@ -1004,6 +1019,11 @@ void CompositorThread::update_display_list_and_async_scrolling_state(NonnullRefP
m_thread_data->enqueue_command(UpdateDisplayListCommand { move(display_list), move(scroll_state_snapshot), Optional<AsyncScrollingState> { move(async_scrolling_state) } });
}
void CompositorThread::invalidate_wheel_event_listener_state(u64 generation)
{
m_thread_data->invalidate_wheel_event_listener_state(generation);
}
bool CompositorThread::async_scroll_by(Gfx::FloatPoint position, Gfx::FloatPoint delta, Gfx::IntRect viewport_rect)
{
return m_thread_data->enqueue_async_scroll_by(position, delta, viewport_rect);

View file

@ -60,6 +60,7 @@ public:
void update_display_list(NonnullRefPtr<Painting::DisplayList>, Painting::ScrollStateSnapshot&&);
void update_scroll_state(Painting::ScrollStateSnapshot&&);
void update_display_list_and_async_scrolling_state(NonnullRefPtr<Painting::DisplayList>, Painting::ScrollStateSnapshot&&, AsyncScrollingState&&);
void invalidate_wheel_event_listener_state(u64 generation);
bool async_scroll_by(Gfx::FloatPoint position, Gfx::FloatPoint delta, Gfx::IntRect viewport_rect);
Optional<Gfx::FloatPoint> pending_async_viewport_scroll_offset() const;
bool should_defer_async_viewport_scroll_offset_adoption() const;

View file

@ -39,6 +39,7 @@
#include <LibWeb/HTML/HTMLFrameSetElement.h>
#include <LibWeb/HTML/Window.h>
#include <LibWeb/HighResolutionTime/TimeOrigin.h>
#include <LibWeb/Page/Page.h>
#include <LibWeb/UIEvents/EventNames.h>
#include <LibWeb/UIEvents/KeyCode.h>
#include <LibWeb/UIEvents/KeyboardEvent.h>
@ -186,6 +187,25 @@ static bool default_passive_value(FlyString const& type, EventTarget* event_targ
return false;
}
static bool is_blocking_wheel_event_listener(DOMEventListener const& listener)
{
return AK::first_is_one_of(listener.type, "wheel"sv, "mousewheel"sv) && listener.passive != true;
}
static void invalidate_compositor_wheel_event_listener_state(EventTarget& event_target, DOMEventListener const& listener)
{
if (!is_blocking_wheel_event_listener(listener))
return;
if (auto* window = as_if<HTML::Window>(event_target)) {
window->associated_document().page().invalidate_compositor_wheel_event_listener_state();
return;
}
if (auto* node = as_if<Node>(event_target))
node->document().page().invalidate_compositor_wheel_event_listener_state();
}
// https://dom.spec.whatwg.org/#dom-eventtarget-addeventlistener
void EventTarget::add_event_listener(FlyString const& type, IDLEventListener* callback, Variant<Bindings::AddEventListenerOptions, bool> const& options)
{
@ -239,8 +259,10 @@ void EventTarget::add_an_event_listener(DOMEventListener& listener)
&& entry->callback->callback().callback == listener.callback->callback().callback
&& entry->capture == listener.capture;
});
if (it == event_listener_list.end())
if (it == event_listener_list.end()) {
event_listener_list.append(listener);
invalidate_compositor_wheel_event_listener_state(*this, listener);
}
// 6. If listeners signal is not null, then add the following abort steps to it:
if (listener.signal) {
@ -292,7 +314,9 @@ void EventTarget::remove_an_event_listener(DOMEventListener& listener)
// 2. Set listeners removed to true and remove listener from eventTargets event listener list.
listener.removed = true;
VERIFY(m_data);
m_data->event_listener_list.remove_first_matching([&](auto& entry) { return entry.ptr() == &listener; });
auto did_remove = m_data->event_listener_list.remove_first_matching([&](auto& entry) { return entry.ptr() == &listener; });
if (did_remove)
invalidate_compositor_wheel_event_listener_state(*this, listener);
}
// https://dom.spec.whatwg.org/#dom-eventtarget-dispatchevent

View file

@ -642,6 +642,7 @@ JS::Object* Internals::async_scrolling_state()
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("mainThreadWheelEventRegionCount"_utf16_fly_string, JS::Value(0), JS::default_attributes);
object->define_direct_property("wheelEventListenerStateGeneration"_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;
@ -685,6 +686,7 @@ JS::Object* Internals::async_scrolling_state()
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("mainThreadWheelEventRegionCount"_utf16_fly_string, JS::Value(state.main_thread_wheel_event_regions.size()), JS::default_attributes);
object->define_direct_property("wheelEventListenerStateGeneration"_utf16_fly_string, JS::Value(state.wheel_event_listener_state_generation), 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;

View file

@ -297,6 +297,16 @@ void Page::handle_sdl_input_events()
top_level_traversable()->event_handler().handle_sdl_input_events();
}
void Page::invalidate_compositor_wheel_event_listener_state()
{
++m_wheel_event_listener_state_generation;
if (!m_async_scrolling_enabled || !top_level_traversable_is_initialized())
return;
top_level_traversable()->rendering_thread().invalidate_wheel_event_listener_state(m_wheel_event_listener_state_generation);
}
void Page::set_top_level_traversable(GC::Ref<HTML::TraversableNavigable> navigable)
{
VERIFY(!m_top_level_traversable); // Replacement is not allowed!

View file

@ -134,6 +134,8 @@ public:
bool async_scrolling_enabled() const { return m_async_scrolling_enabled; }
void set_async_scrolling_enabled(bool b) { m_async_scrolling_enabled = b; }
u64 wheel_event_listener_state_generation() const { return m_wheel_event_listener_state_generation; }
void invalidate_compositor_wheel_event_listener_state();
bool is_webdriver_active() const { return m_is_webdriver_active; }
void set_is_webdriver_active(bool b) { m_is_webdriver_active = b; }
@ -294,6 +296,7 @@ private:
bool m_should_block_pop_ups { true };
bool m_enable_autoscroll { true };
bool m_async_scrolling_enabled { false };
u64 m_wheel_event_listener_state_generation { 0 };
// https://w3c.github.io/webdriver/#dfn-webdriver-active-flag
// The webdriver-active flag is set to true when the user agent is under remote control. It is initially false.

View file

@ -0,0 +1,3 @@
before routing admission: accepted
listener generation increased: true
after routing admission: blocking wheel event listeners

View file

@ -0,0 +1,24 @@
<!DOCTYPE html>
<script src="../include.js"></script>
<style>
body {
margin: 0;
}
#spacer {
height: 2000px;
}
</style>
<div id="spacer"></div>
<script>
test(() => {
const before = internals.asyncScrollingState();
println(`before routing admission: ${internals.asyncScrollingStateWheelRoutingAdmission()}`);
document.addEventListener("wheel", event => event.preventDefault(), { passive: false });
const after = internals.asyncScrollingState();
println(`listener generation increased: ${after.wheelEventListenerStateGeneration > before.wheelEventListenerStateGeneration}`);
println(`after routing admission: ${internals.asyncScrollingStateWheelRoutingAdmission()}`);
});
</script>