Skip to content

cdp: wheel events scroll the viewport unless over a scroll container - #3479

Merged
karlseguin merged 4 commits into
mainfrom
wheel-scrolls-viewport
Sep 13, 2026
Merged

karlseguin merged 4 commits into
mainfrom
wheel-scrolls-viewport

Conversation

@arrufat

@arrufat arrufat commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Input.dispatchMouseEvent with type: mouseWheel (and the BiDi wheel action, which shares triggerMouseWheel) only ever moved the scroll position of the element under the cursor, and returned early when no element was there. window.scrollY reads a separate field that Window.scrollTo maintains, so a wheel never moved the page and never fired the document's scroll event. Every CDP driver's scroll-by-wheel API was a no-op: Playwright mouse.wheel, Puppeteer mouse.wheel, chromedp Input.dispatchMouseEvent, Selenium BiDi wheel actions, browser-use scroll(), Stagehand v3 mouse.wheel.

Now the nearest ancestor whose overflow is auto or scroll is scrolled as before (the #scrollbox case), and anything else scrolls the window through Window.scrollBy, which already schedules the trusted scroll and scrollend events. A wheel over empty space targets the root element instead of returning early.

Adds cdp.input tests for the scroll-container case, viewport scrolling with accumulation and a negative delta, and a wheel over empty space.

Evidence from the integrations matrix (https://github.com/lightpanda-io/integrations): with this branch, the scroll cell goes from ❌ to ✅ for Playwright, Puppeteer, chromedp, Selenium (BiDi), Browser Use CLI and Stagehand v3, with no other cell changing.

Input.dispatchMouseEvent mouseWheel (and the BiDi wheel action, which
shares triggerMouseWheel) only ever moved the scroll position of the element
under the cursor, and gave up when no element was there. window.scrollY
reads a separate field that Window.scrollTo maintains, so a wheel never moved
the page and never fired the document's scroll event; every CDP driver's
scroll-by-wheel API was a no-op.

Scroll the nearest ancestor whose overflow is auto or scroll, as before, and
otherwise scroll the window through Window.scrollBy, which already schedules
the trusted scroll and scrollend events. A wheel over empty space targets the
root element instead of returning early.
@arrufat
arrufat force-pushed the wheel-scrolls-viewport branch from 69d8c1e to f9eb4e4 Compare September 10, 2026 14:01
Comment thread src/browser/frame/user_input.zig Outdated
}

fn isScrollContainer(el: *Element, frame: *Frame) bool {
const style = frame.window.getComputedStyle(el, null, frame) catch return false;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will always return a CSSStyleProperties that has an empty _properties. But it'll cache the value inside of the Frame's _element_computed_styles and have extra ceremony to eventually just call style_manager.inlineStyleValue. I think you just just call style_manager.inlineStyleValue directly here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. It now reads style_manager.inlineStyleValue directly: the axis longhand (overflow-x/overflow-y) first, then the matching half of the overflow shorthand. The comment on isScrollContainer notes that only inline overflow is resolved, since that is all computed styles see today.

Comment thread src/browser/frame/user_input.zig Outdated
if (scrollContainerOf(target, frame)) |container| {
const new_left: i32 = @as(i32, @intCast(container.getScrollLeft(frame))) +| dx;
const new_top: i32 = @as(i32, @intCast(container.getScrollTop(frame))) +| dy;
try container.setScrollLeft(new_left, frame);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an issue in main also, but setScrollTop fires events (via the scheduleScrollEvents) and it'll have a different bubbles flag in most cases...and then we fire scroll a few lines down immediately.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. The container path goes through Element.scrollBy and the viewport path through Window.scrollBy, and the inline dispatch is gone, so only the scheduled scroll/scrollend fire, with the bubbles flag those tasks already pick. The scroll-container test waits for the scheduled event with runner.waitForScript instead of asserting it synchronously.

Comment thread src/browser/frame/user_input.zig Outdated
try target.setScrollTop(new_top, frame);
const dx = deltaToScroll(delta_x);
const dy = deltaToScroll(delta_y);
if (scrollContainerOf(target, frame)) |container| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be axis-aware. If you're scrolling the x and find a overflow-y: auto, then it isn't scrollable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Each axis resolves its own container: a delta along an axis whose overflow on the box is not auto/scroll falls through to the viewport. Added a case to the container test with overflow: hidden scroll and a diagonal wheel, where the y delta lands on the box and the x delta on the window.

@karlseguin

Copy link
Copy Markdown
Collaborator

In addition to the inline comments, github says it can be merged as-is, but it can't. I tried, compiler errors when applied to main.

Each wheel axis now looks for the nearest ancestor whose inline overflow
along that axis is auto or scroll, and scrolls it through Element.scrollBy,
or the viewport through Window.scrollBy, so the trusted scroll and
scrollend events are scheduled once instead of firing a second, bubbling
scroll inline. The lookup reads the inline style straight from the style
manager instead of building a computed-style object per ancestor.

Both scrollBy implementations saturate the addition, so an oversized delta
from CDP or from a script no longer overflows the i32 position.
@arrufat

arrufat commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Merged main in. The compile error was Window.scrollBy going private in the declarations sweep (2e6999f); it is pub again since the wheel path now calls it. While in there, both scrollBy implementations use a saturating add, so an oversized delta from CDP or a script no longer overflows the i32 position (the wheel path used to clamp before writing, and now relies on scrollBy).

@karlseguin
karlseguin merged commit 799dc3e into main Sep 13, 2026
26 checks passed
@karlseguin
karlseguin deleted the wheel-scrolls-viewport branch September 13, 2026 01:06
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 13, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants