From 9b5434e25a97e3b98f5bacf339b58278f2d1b1d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 21 Sep 2026 17:37:26 +0200 Subject: [PATCH 1/5] scroll: clamp offsets to the scrollable extent Every scroll write clamped at zero and nothing else, so an offset could exceed the scrollable extent without limit and a page probing `scrollTop >= scrollHeight - clientHeight` got a number Chrome would never produce. Element.scrollExtent is that bound, and setScrollTop/setScrollLeft/ scrollTo/scrollBy now share one writer that applies it. The extent is optional and null means unbounded: without a layout engine there is no honest box for an element sized by a stylesheet (getElementAxis reads only inline width/height) or one holding text (contentAxis sums element children), and refusing a scroll we can't prove impossible is worse than allowing one too many. html and body are excluded outright, so the viewport keeps its fabricated extent and stays unbounded. Chrome clamps all three, so the HTML fixture asserts the limit relationally - a real browser reserves scrollbar space in clientHeight and lands a few px lower. The gap we keep is pinned in a Zig test instead. The write path also does its arithmetic in i64: the old relative path could panic on an offset stored above maxInt(i32). --- src/browser/tests/element/position.html | 29 ++++ src/browser/webapi/Element.zig | 191 +++++++++++++++++------- src/browser/webapi/Window.zig | 6 +- 3 files changed, 171 insertions(+), 55 deletions(-) diff --git a/src/browser/tests/element/position.html b/src/browser/tests/element/position.html index 291bf60f3..e6821653f 100644 --- a/src/browser/tests/element/position.html +++ b/src/browser/tests/element/position.html @@ -600,3 +600,32 @@ testing.expectEqual(targetY, window.scrollY); } + +
+
content
+
+ + diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index f3a644b45..d8546f79e 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1578,16 +1578,7 @@ pub fn getScrollTop(self: *Element, frame: *Frame) u32 { } pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { - const owner = self.ownerFrame(frame) orelse return; - const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const new_y: u32 = @intCast(@max(0, value)); - if (gop.value_ptr.y != new_y) { - gop.value_ptr.y = new_y; - try self.scheduleScrollEvents(owner); - } + return self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame); } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { @@ -1597,16 +1588,7 @@ pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { } pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { - const owner = self.ownerFrame(frame) orelse return; - const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const new_x: u32 = @intCast(@max(0, value)); - if (gop.value_ptr.x != new_x) { - gop.value_ptr.x = new_x; - try self.scheduleScrollEvents(owner); - } + return self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame); } pub const ScrollAxes = struct { x: bool = false, y: bool = false }; @@ -1616,14 +1598,6 @@ pub const ScrollAxes = struct { x: bool = false, y: bool = false }; const ScrollTarget = union(enum) { viewport, container: *Element, - - pub fn scrollBy(self: ScrollTarget, left: i32, top: i32, frame: *Frame) !void { - const opts: ScrollToOpts = .{ .opts = .{ .left = left, .top = top } }; - return switch (self) { - .container => |el| el.scrollBy(opts, null, frame), - .viewport => frame.window.scrollBy(opts, null, frame), - }; - } }; /// Nearest ancestor-or-self scroll container along any of `axes`. The walk @@ -1643,6 +1617,14 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTa return .viewport; } +/// Whether the element's own overscroll-behavior keeps a scroll from chaining +/// out of it along any of `axes`. +pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool { + const owner = self.ownerFrame(frame) orelse return false; + const contains = owner._style_manager.overscrollContainAxes(self); + return (axes.x and contains.x) or (axes.y and contains.y); +} + fn scrollsViewport(self: *const Element) bool { return switch (self.getTag()) { .html, .body => true, @@ -1685,6 +1667,29 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 { return @max(width, self.contentAxis(frame, .width)); } +/// The furthest offset a scroll along `axis` may reach, or null when there is +/// no box to measure against. Without an explicit size the client and the +/// content measurements collapse onto the same sum, so nothing can overflow: +/// an element sized by a stylesheet or holding only text stays unbounded, as +/// every scroll write was before there was an extent at all. Refusing a scroll +/// we can't prove impossible is worse than allowing one too many. html and +/// body are out too: their artificial giant defaults would fabricate an extent +/// against the real viewport. +fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 { + if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) { + return null; + } + const client = self.clientAxis(frame, axis); + const content = switch (axis) { + .width => self.getScrollWidth(frame), + .height => self.getScrollHeight(frame), + }; + if (content <= client) { + return null; + } + return content - client; +} + // One axis of the direct child elements' size: laid end to end on a single // row for the width, stacked for the height. // @@ -2014,23 +2019,64 @@ pub const ScrollToOpts = union(enum) { pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); - const owner = self.ownerFrame(frame) orelse return; - const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const old_x = gop.value_ptr.x; - const old_y = gop.value_ptr.y; - if (o.left) |left| gop.value_ptr.x = @intCast(@max(0, left)); - if (o.top) |top| gop.value_ptr.y = @intCast(@max(0, top)); - if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(owner); - } + return self.writeScroll(.{ .to = o }, frame); } // scrollBy(): like scrollTo() but relative to the current position. pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); + return self.writeScroll(.{ .by = o }, frame); +} + +/// Scrolls one axis by `delta`. +pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !void { + return self.writeScroll(.{ .by = switch (axis) { + .width => .{ .left = delta, .top = null }, + .height => .{ .left = null, .top = delta }, + } }, frame); +} + +/// Whether `delta` can move this container along `axis` at all. A wheel latches +/// to the nearest container for which this holds; an unmeasurable box has no +/// end to be at, so it always takes the delta. +pub fn canScrollAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) bool { + const offset: i64 = switch (axis) { + .width => self.getScrollLeft(frame), + .height => self.getScrollTop(frame), + }; + if (delta < 0) { + return offset > 0; + } + const extent = self.scrollExtent(frame, axis) orelse return true; + const max: i64 = @floor(extent); + return offset < max; +} + +// Where a write puts the offsets: at an absolute position, or that much from +// wherever they are. +const ScrollWrite = union(enum) { + to: ScrollToOpts.Offsets, + by: ScrollToOpts.Offsets, + + // The absolute target for one axis, null when the write leaves it alone. + fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 { + const offsets = switch (self) { + inline else => |o| o, + }; + const value = switch (axis) { + .width => offsets.left, + .height => offsets.top, + } orelse return null; + return switch (self) { + .to => value, + .by => @as(i64, current) + value, + }; + } +}; + +/// The single scroll write: clamps both axes, stores, and schedules the events +/// once for the pair. +fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !void { const owner = self.ownerFrame(frame) orelse return; const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); if (!gop.found_existing) { @@ -2038,31 +2084,46 @@ pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo } const old_x = gop.value_ptr.x; const old_y = gop.value_ptr.y; - gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) +| (o.left orelse 0))); - gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) +| (o.top orelse 0))); - if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(owner); + + if (write.target(.width, old_x)) |target| { + gop.value_ptr.x = self.clampScroll(frame, .width, target); } + if (write.target(.height, old_y)) |target| { + gop.value_ptr.y = self.clampScroll(frame, .height, target); + } + + if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { + try self.scheduleScrollEvents(gop.value_ptr, owner); + } +} + +/// `target` brought into [0, scrollExtent]. +fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 { + var clamped = target; + if (clamped < 0) { + clamped = 0; + } else if (self.scrollExtent(frame, axis)) |extent| { + const max: i64 = @floor(extent); + clamped = @min(clamped, max); + } + return @intCast(@min(clamped, std.math.maxInt(u32))); } // Scrolling an element fires a scroll event and then a scrollend event, // asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the // scrolling element (the root) are fired at the document instead. -// `frame` is the element's owner frame (resolved by the public accessors). -fn scheduleScrollEvents(self: *Element, frame: *Frame) !void { - const gop = try frame.page.element_scroll_positions.getOrPut(frame.page.frame_arena, self); - if (!gop.found_existing) { - gop.value_ptr.* = .{}; - } - const task_pending = gop.value_ptr.state != .done; - gop.value_ptr.state = .scroll; +// `frame` is the element's owner frame, `pos` its entry in that frame's +// positions (both resolved by writeScroll). +fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void { + const task_pending = pos.state != .done; + pos.state = .scroll; if (task_pending) { return; } const task = try frame._factory.create(ScrollEventTask{ .frame = frame, .element = self }); errdefer { - gop.value_ptr.state = .done; + pos.state = .done; frame._factory.destroy(task); } try frame.js.scheduler.add(task, ScrollEventTask.run, 10, .{ @@ -2722,6 +2783,30 @@ test "WebApi: Element" { try testing.htmlRunner("element", .{}); } +test "Element: scroll extent" { + const frame = try testing.createFrame(); + defer testing.test_session.closeAllPages(); + + const root = try frame.window._document.createElement("div", null, frame); + try Frame.parse.htmlAsChildren(frame, root.asNode(), + \\
+ \\
text, and no element child to measure
+ ); + const box = root.asNode().firstChild().?.as(Element); + const text = box.nextElementSibling().?; + + try box.setScrollTop(9999, frame); + try testing.expectEqual(400, box.getScrollTop(frame)); + try box.setScrollTop(-1, frame); + try testing.expectEqual(0, box.getScrollTop(frame)); + + // A real browser clamps this one too, to the height of its text. contentAxis + // sums element children only, so we have no extent to clamp against and the + // offset stays unbounded. + try text.setScrollTop(9999, frame); + try testing.expectEqual(9999, text.getScrollTop(frame)); +} + test "Element: div chain slot size" { // Guard against accidental growth: new Element fields (e.g. _flags) must // fit in existing padding. Debug is larger from the _proto_canary fields. diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 5118fb774..c60f48b4c 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -1016,8 +1016,10 @@ pub fn scrollTo(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Fram pub fn scrollBy(self: *Window, opts: Element.ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = opts.offsets(y); - const absx = @as(i32, @intCast(self._scroll_pos.x)) +| (o.left orelse 0); - const absy = @as(i32, @intCast(self._scroll_pos.y)) +| (o.top orelse 0); + // The viewport has no honest extent, so a stored offset can sit above + // maxInt(i32): widen before saturating back down. + const absx: i32 = @intCast(@min(@as(i64, self._scroll_pos.x) + (o.left orelse 0), std.math.maxInt(i32))); + const absy: i32 = @intCast(@min(@as(i64, self._scroll_pos.y) + (o.top orelse 0), std.math.maxInt(i32))); return self.scrollTo(.{ .x = absx }, absy, frame); } From e4df45a967ca506a7b87e8d2b9cb9e981ac68c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 21 Sep 2026 17:37:26 +0200 Subject: [PATCH 2/5] StyleManager: track overscroll-behavior Chaining a wheel out of a saturated container is exactly what sites use `overscroll-behavior: contain` to prevent, so the cascade needs to know about it before the wheel can chain. Two flags follow the overflow-x/overflow-y pattern: a shorthand arm in Slots.apply covers both fold paths, and overscrollContainAxes is the probe. `contain` and `none` both stop propagation, only `auto` lets it through. Props was exactly full at u8. splitOverflow serves two shorthands now, so it is splitAxisPair. --- src/browser/StyleManager.zig | 57 ++++++++++++++++--- src/browser/css/Parser.zig | 9 +-- .../webapi/css/CSSStyleDeclaration.zig | 4 +- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index 894a73913..d3f529d9c 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -654,7 +654,7 @@ fn rebuildIfDirty(self: *StyleManager) !void { /// Own-element cascade result, resolved for every property at once so one /// entry serves any probe. -const Props = packed struct(u8) { +const Props = packed struct(u10) { // Author value (inline or sheet). Without `author_display` it's the UA // fallback: .none when matchesUaDisplayNoneRule, else .other. display: Display = .other, @@ -664,6 +664,8 @@ const Props = packed struct(u8) { pointer_events_none: bool = false, overflow_x_scrolls: bool = false, overflow_y_scrolls: bool = false, + overscroll_x_contains: bool = false, + overscroll_y_contains: bool = false, fn probe(self: Props, comptime what: Probe, options: CheckVisibilityOptions) bool { return switch (what) { @@ -737,6 +739,15 @@ pub fn overflowAxes(self: *StyleManager, el: *Element) Element.ScrollAxes { return .{ .x = p.overflow_x_scrolls, .y = p.overflow_y_scrolls }; } +/// The axes along which `el` keeps a scroll from chaining out of it: its own +/// computed overscroll-behavior on that axis is contain or none. No ancestor +/// walk. +pub fn overscrollContainAxes(self: *StyleManager, el: *Element) Element.ScrollAxes { + self.rebuildIfDirty() catch return .{}; + const p = self.ownProps(el); + return .{ .x = p.overscroll_x_contains, .y = p.overscroll_y_contains }; +} + fn anyInChain(self: *StyleManager, el: *Element, comptime what: Probe, options: CheckVisibilityOptions) bool { var current: ?*Element = el; while (current) |elem| : (current = elem.parentElement()) { @@ -776,6 +787,8 @@ const Priorities = struct { pointer_events_none: u64 = 0, overflow_x_scrolls: u64 = 0, overflow_y_scrolls: u64 = 0, + overscroll_x_contains: u64 = 0, + overscroll_y_contains: u64 = 0, }; fn compute(self: *StyleManager, el: *Element) Props { @@ -1020,7 +1033,7 @@ fn getBucketKey(compound: Selector.Compound) ?BucketKey { } // The declaration names behind TrackedProperties, in field order. -const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y" }; +const property_names = [_][]const u8{ "display", "visibility", "opacity", "pointer-events", "overflow-x", "overflow-y", "overscroll-behavior-x", "overscroll-behavior-y" }; /// Extracts the tracked properties from a style declaration. The object holds /// one entry per name in first-declared order, so folding it in order gives a @@ -1113,6 +1126,8 @@ const TrackedProperties = struct { pointer_events_none: ?bool = null, overflow_x_scrolls: ?bool = null, overflow_y_scrolls: ?bool = null, + overscroll_x_contains: ?bool = null, + overscroll_y_contains: ?bool = null, fn apply(self: *TrackedProperties, name: []const u8, value: []const u8) void { if (std.ascii.eqlIgnoreCase(name, "display")) { @@ -1127,6 +1142,10 @@ const TrackedProperties = struct { self.overflow_x_scrolls = overflowScrolls(value); } else if (std.ascii.eqlIgnoreCase(name, "overflow-y")) { self.overflow_y_scrolls = overflowScrolls(value); + } else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-x")) { + self.overscroll_x_contains = overscrollContains(value); + } else if (std.ascii.eqlIgnoreCase(name, "overscroll-behavior-y")) { + self.overscroll_y_contains = overscrollContains(value); } } @@ -1137,6 +1156,13 @@ const TrackedProperties = struct { std.ascii.eqlIgnoreCase(value, "overlay"); } + // `contain` keeps the scroll in the box, `none` also kills the bounce we + // don't render anyway; only `auto` lets a scroll chain outward. + fn overscrollContains(value: []const u8) bool { + return std.ascii.eqlIgnoreCase(value, "contain") or + std.ascii.eqlIgnoreCase(value, "none"); + } + fn isRelevant(self: TrackedProperties) bool { inline for (property_fields) |field| { if (@field(self, field) != null) { @@ -1277,6 +1303,12 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties return slots.props(); } +// The ` []` shorthands the cascade expands into the tracked longhands. +const axis_shorthands = [_]struct { name: []const u8, x: []const u8, y: []const u8 }{ + .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, + .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, +}; + /// One block's winning value per tracked property, folded in declaration /// order. const Slots = struct { @@ -1300,11 +1332,13 @@ const Slots = struct { slots: [property_names.len]Slot = @splat(.{}), fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void { - if (std.ascii.eqlIgnoreCase(name, "overflow")) { - const values = CssParser.splitOverflow(value) orelse return; - self.apply("overflow-x", values.x, important); - self.apply("overflow-y", values.y, important); - return; + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { + const values = CssParser.splitAxisPair(value) orelse return; + self.apply(shorthand.x, values.x, important); + self.apply(shorthand.y, values.y, important); + return; + } } for (property_names, &self.slots) |tracked, *slot| { if (std.ascii.eqlIgnoreCase(name, tracked)) { @@ -1752,6 +1786,15 @@ test "StyleManager: memo: reuse and invalidation" { try (try b.getOrCreateStyle(frame)).asCSSStyleDeclaration().setProperty("overflow", "hidden", null, frame); try testing.expectEqual(Element.ScrollAxes{}, sm.overflowAxes(b)); + // overscroll-behavior expands the same way; only `auto` chains outward. + try b.setStyle("overscroll-behavior: contain auto", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = true, .y = false }, sm.overscrollContainAxes(b)); + try testing.expectEqual(Element.ScrollAxes{}, sm.overscrollContainAxes(p)); + try b.setStyle("overscroll-behavior-y: none", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b)); + try b.setStyle("overscroll-behavior: contain; overscroll-behavior-x: auto", frame); + try testing.expectEqual(Element.ScrollAxes{ .x = false, .y = true }, sm.overscrollContainAxes(b)); + // A stylesheet change resets the memo sm.sheetModified(); try testing.expectEqual(false, sm.isHidden(p, .{})); diff --git a/src/browser/css/Parser.zig b/src/browser/css/Parser.zig index dc8806dae..8cda3dcc2 100644 --- a/src/browser/css/Parser.zig +++ b/src/browser/css/Parser.zig @@ -25,11 +25,12 @@ pub const Declaration = struct { important: bool, }; -pub const OverflowValues = struct { x: []const u8, y: []const u8 }; +pub const AxisPair = struct { x: []const u8, y: []const u8 }; -/// `overflow: []`; a single value applies to both axes. More than two -/// values is invalid and null, as is an empty declaration. -pub fn splitOverflow(value: []const u8) ?OverflowValues { +/// An ` []` axis shorthand such as `overflow` or `overscroll-behavior`; +/// a single value applies to both axes. More than two values is invalid and +/// null, as is an empty declaration. +pub fn splitAxisPair(value: []const u8) ?AxisPair { var it = std.mem.tokenizeAny(u8, value, &std.ascii.whitespace); const x = it.next() orelse return null; const y = it.next() orelse x; diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 1eb09385a..7b0abf2b7 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -241,7 +241,7 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value: fn applyParsedDeclaration(self: *CSSStyleDeclaration, declaration: CssParser.Declaration, frame: *Frame) !void { const normalized = normalizePropertyName(declaration.name, &frame.buf); if (overflow_shorthand.eqlSlice(normalized)) { - const values = CssParser.splitOverflow(declaration.value) orelse return; + const values = CssParser.splitAxisPair(declaration.value) orelse return; try self.applyParsedDeclaration(.{ .name = "overflow-x", .value = values.x, .important = declaration.important }, frame); try self.applyParsedDeclaration(.{ .name = "overflow-y", .value = values.y, .important = declaration.important }, frame); return; @@ -267,7 +267,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized = normalizePropertyName(property_name, &frame.buf); if (overflow_shorthand.eqlSlice(normalized)) { - const values = CssParser.splitOverflow(value) orelse return false; + const values = CssParser.splitAxisPair(value) orelse return false; const x = try self.setPropertyImpl("overflow-x", values.x, important, frame); const y = try self.setPropertyImpl("overflow-y", values.y, important, frame); return x or y; From 689045d72aa0c6381ce3e25fd1015e9eb387a98a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 21 Sep 2026 17:37:26 +0200 Subject: [PATCH 3/5] user_input: latch a wheel to one scroll container wheelScroll handed the whole delta to the nearest scroll container on each axis, whatever state it was in, so a saturated inner scroller trapped the wheel and the page never moved. scrollAxis walks outward per axis and gives the whole delta to the first container that can still move along it. A delta is never split: a container that can only take part of it keeps the rest, and the page moves on the next wheel. That is Chrome's rule in FindNodeToLatch (cc/input/input_handler.cc), confirmed against Chrome 153 - one wheel of 1000px over a container with 416px of travel leaves window.scrollY at 0. A container whose overscroll-behavior doesn't propagate takes the latch even when it can't move, which ends the walk. --- src/browser/frame/user_input.zig | 46 ++++++++++++++++++---- src/server/cdp/domains/input.zig | 66 ++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 961a47ff0..5e6b621ac 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -487,16 +487,48 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt } // Deltas come from the wire, so guard NaN and saturate the addition. - try wheelScroll(target, deltaToScroll(delta_x), deltaToScroll(delta_y), owner); + try scrollAxis(target, .width, deltaToScroll(delta_x), owner); + try scrollAxis(target, .height, deltaToScroll(delta_y), owner); } -/// Each axis scrolls the nearest ancestor-or-self scroll container along it, -/// else the viewport. Relative deltas may land on different scrollers per +/// One axis' delta goes to the nearest ancestor-or-self scroll container that +/// can still move along it, and to that one alone: a wheel latches to a single +/// scroller and a delta is never split across two, matching Chrome's +/// FindNodeToLatch (cc/input/input_handler.cc). A container whose +/// overscroll-behavior doesn't propagate takes the latch even when it can't +/// move, which ends the walk. The viewport terminates it otherwise. +/// +/// Each axis walks on its own, so a wheel may latch to a different scroller per /// axis, unlike an absolute position. -fn wheelScroll(target: *Element, delta_x: i32, delta_y: i32, frame: *Frame) !void { - // A zero delta resolves to .viewport and scrolls it by nothing. - try target.scrollContainer(.{ .x = delta_x != 0 }, frame).scrollBy(delta_x, 0, frame); - try target.scrollContainer(.{ .y = delta_y != 0 }, frame).scrollBy(0, delta_y, frame); +fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void { + if (delta == 0) { + return; + } + const axes: Element.ScrollAxes = switch (axis) { + .width => .{ .x = true }, + .height => .{ .y = true }, + }; + + var current: ?*Element = target; + while (current) |el| { + const container = switch (el.scrollContainer(axes, frame)) { + .container => |c| c, + .viewport => break, + }; + if (container.canScrollAxis(axis, delta, frame)) { + return container.scrollByAxis(axis, delta, frame); + } + if (container.containsOverscroll(axes, frame)) { + return; + } + current = container.parentElement(); + } + + const opts: Element.ScrollToOpts = switch (axis) { + .width => .{ .opts = .{ .left = delta } }, + .height => .{ .opts = .{ .top = delta } }, + }; + return frame.window.scrollBy(opts, null, frame); } fn deltaToScroll(d: f64) i32 { diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig index 492d604de..5dcdfb190 100644 --- a/src/server/cdp/domains/input.zig +++ b/src/server/cdp/domains/input.zig @@ -514,6 +514,72 @@ test "cdp.input: dispatchMouseEvent mouseWheel scrolls a scroll container, not t try runner.waitForScript(frame._frame_id, "window.sheetScrolled === true", 1000); } +test "cdp.input: dispatchMouseEvent mouseWheel chains once the container is saturated" { + var ctx = try testing.context(); + defer ctx.deinit(); + + const bc = try ctx.loadBrowserContext(.{}); + const page = try bc.session.createPage(); + const frame = page.frame().?; + + const url = "http://localhost:9582/src/browser/tests/mcp_actions.html"; + try frame.navigate(url, .{ .reason = .address_bar, .kind = .{ .push = null } }); + try testing.waitForPage(bc); + + var ls: lp.js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + var try_catch: lp.js.TryCatch = undefined; + try_catch.init(&ls.local); + defer try_catch.deinit(); + + const leaf_x = try (try ls.local.compileAndRun("document.getElementById('innerleaf').getBoundingClientRect().x", null)).toF64(); + const leaf_y = try (try ls.local.compileAndRun("document.getElementById('innerleaf').getBoundingClientRect().y", null)).toF64(); + + // #outerscroll is a 100px box over 500px of content. A wheel latches to one + // scroller: the container takes the whole delta and keeps what doesn't fit, + // rather than passing the rest on. + try ctx.processMessage(.{ + .id = 1, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 1000 }, + }); + const latched = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 0", null); + try testing.expect(latched.isTrue()); + + // Saturated now, so the next wheel latches to the viewport instead. + try ctx.processMessage(.{ + .id = 2, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 100 }, + }); + const chained = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 100", null); + try testing.expect(chained.isTrue()); + + // overscroll-behavior keeps the latch on a container that can't move, so + // nothing scrolls at all. + _ = try ls.local.compileAndRun("document.getElementById('outerscroll').style.overscrollBehavior = 'contain'", null); + try ctx.processMessage(.{ + .id = 3, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = 100 }, + }); + const contained = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 400 && window.scrollY === 100", null); + try testing.expect(contained.isTrue()); + + // Reversing direction latches back to the container, which can move again. + // The 100 it can't give back stays unscrolled: no split here either. + _ = try ls.local.compileAndRun("document.getElementById('outerscroll').style.overscrollBehavior = 'auto'", null); + try ctx.processMessage(.{ + .id = 4, + .method = "Input.dispatchMouseEvent", + .params = .{ .type = "mouseWheel", .x = leaf_x, .y = leaf_y, .deltaY = -500 }, + }); + const upward = try ls.local.compileAndRun("document.getElementById('outerscroll').scrollTop === 0 && window.scrollY === 100", null); + try testing.expect(upward.isTrue()); +} + test "cdp.input: dispatchMouseEvent mouseWheel on page content scrolls the viewport" { var ctx = try testing.context(); defer ctx.deinit(); From fdee7d558c123fb91226a611ea00db4d048ea4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 22 Sep 2026 16:39:13 +0200 Subject: [PATCH 4/5] scroll: write first, then report whether it moved writeScroll held the map entry across the clamp, which reads styles and walks children, and it created an entry even for a write that changed nothing. It now clamps both axes against a plain lookup and only takes the entry when an offset actually moves. That makes the write itself the answer to "can this container move?", so the wheel walk asks by writing instead of recomputing the extent first, and canScrollAxis is gone. --- src/browser/frame/user_input.zig | 18 +++---- src/browser/webapi/Element.zig | 91 +++++++++++++------------------- 2 files changed, 44 insertions(+), 65 deletions(-) diff --git a/src/browser/frame/user_input.zig b/src/browser/frame/user_input.zig index 5e6b621ac..a8dd0308c 100644 --- a/src/browser/frame/user_input.zig +++ b/src/browser/frame/user_input.zig @@ -491,15 +491,11 @@ pub fn wheel(frame: *Frame, target: *Element, x: f64, y: f64, delta_x: f64, delt try scrollAxis(target, .height, deltaToScroll(delta_y), owner); } -/// One axis' delta goes to the nearest ancestor-or-self scroll container that -/// can still move along it, and to that one alone: a wheel latches to a single -/// scroller and a delta is never split across two, matching Chrome's -/// FindNodeToLatch (cc/input/input_handler.cc). A container whose -/// overscroll-behavior doesn't propagate takes the latch even when it can't -/// move, which ends the walk. The viewport terminates it otherwise. -/// -/// Each axis walks on its own, so a wheel may latch to a different scroller per -/// axis, unlike an absolute position. +/// A wheel latches to a single scroller and a delta is never split across two, +/// as in Chrome's FindNodeToLatch (cc/input/input_handler.cc): the whole delta +/// goes to the nearest ancestor-or-self container that can still move along +/// this axis. One whose overscroll-behavior doesn't propagate takes the latch +/// even when it can't move, which ends the walk. fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: *Frame) !void { if (delta == 0) { return; @@ -515,8 +511,8 @@ fn scrollAxis(target: *Element, comptime axis: Element.Axis, delta: i32, frame: .container => |c| c, .viewport => break, }; - if (container.canScrollAxis(axis, delta, frame)) { - return container.scrollByAxis(axis, delta, frame); + if (try container.scrollByAxis(axis, delta, frame)) { + return; } if (container.containsOverscroll(axes, frame)) { return; diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index d8546f79e..2d2507bc7 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1578,7 +1578,7 @@ pub fn getScrollTop(self: *Element, frame: *Frame) u32 { } pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { - return self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame); + _ = try self.writeScroll(.{ .to = .{ .left = null, .top = value } }, frame); } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { @@ -1588,7 +1588,7 @@ pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { } pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { - return self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame); + _ = try self.writeScroll(.{ .to = .{ .left = value, .top = null } }, frame); } pub const ScrollAxes = struct { x: bool = false, y: bool = false }; @@ -1618,7 +1618,7 @@ pub fn scrollContainer(self: *Element, axes: ScrollAxes, frame: *Frame) ScrollTa } /// Whether the element's own overscroll-behavior keeps a scroll from chaining -/// out of it along any of `axes`. +/// out of it. pub fn containsOverscroll(self: *Element, axes: ScrollAxes, frame: *Frame) bool { const owner = self.ownerFrame(frame) orelse return false; const contains = owner._style_manager.overscrollContainAxes(self); @@ -1667,14 +1667,11 @@ pub fn getScrollWidth(self: *Element, frame: *Frame) f64 { return @max(width, self.contentAxis(frame, .width)); } -/// The furthest offset a scroll along `axis` may reach, or null when there is -/// no box to measure against. Without an explicit size the client and the -/// content measurements collapse onto the same sum, so nothing can overflow: -/// an element sized by a stylesheet or holding only text stays unbounded, as -/// every scroll write was before there was an extent at all. Refusing a scroll -/// we can't prove impossible is worse than allowing one too many. html and -/// body are out too: their artificial giant defaults would fabricate an extent -/// against the real viewport. +/// Null where we can't prove a limit, which leaves the offset unbounded: +/// without an explicit size the client and content measurements collapse onto +/// the same sum, and html and body carry giant defaults that would fabricate +/// an extent against the real viewport. Refusing a scroll we can't prove +/// impossible is worse than allowing one too many. fn scrollExtent(self: *Element, frame: *Frame, comptime axis: Axis) ?f64 { if (self.scrollsViewport() or !self.getElementAxis(frame, axis).explicit) { return null; @@ -2019,46 +2016,28 @@ pub const ScrollToOpts = union(enum) { pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); - return self.writeScroll(.{ .to = o }, frame); + _ = try self.writeScroll(.{ .to = o }, frame); } // scrollBy(): like scrollTo() but relative to the current position. pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = (opts orelse return).offsets(y); - return self.writeScroll(.{ .by = o }, frame); + _ = try self.writeScroll(.{ .by = o }, frame); } -/// Scrolls one axis by `delta`. -pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !void { +/// Reports whether the container moved: a wheel walks outward until one does. +pub fn scrollByAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) !bool { return self.writeScroll(.{ .by = switch (axis) { .width => .{ .left = delta, .top = null }, .height => .{ .left = null, .top = delta }, } }, frame); } -/// Whether `delta` can move this container along `axis` at all. A wheel latches -/// to the nearest container for which this holds; an unmeasurable box has no -/// end to be at, so it always takes the delta. -pub fn canScrollAxis(self: *Element, comptime axis: Axis, delta: i32, frame: *Frame) bool { - const offset: i64 = switch (axis) { - .width => self.getScrollLeft(frame), - .height => self.getScrollTop(frame), - }; - if (delta < 0) { - return offset > 0; - } - const extent = self.scrollExtent(frame, axis) orelse return true; - const max: i64 = @floor(extent); - return offset < max; -} - -// Where a write puts the offsets: at an absolute position, or that much from -// wherever they are. const ScrollWrite = union(enum) { to: ScrollToOpts.Offsets, by: ScrollToOpts.Offsets, - // The absolute target for one axis, null when the write leaves it alone. + // Null leaves that axis alone. fn target(self: ScrollWrite, comptime axis: Axis, current: u32) ?i64 { const offsets = switch (self) { inline else => |o| o, @@ -2074,30 +2053,34 @@ const ScrollWrite = union(enum) { } }; -/// The single scroll write: clamps both axes, stores, and schedules the events -/// once for the pair. -fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !void { - const owner = self.ownerFrame(frame) orelse return; +/// Every scroll write goes through here. Reports whether anything moved; one +/// that lands where the offsets already are doesn't even take a map entry. +fn writeScroll(self: *Element, write: ScrollWrite, frame: *Frame) !bool { + const owner = self.ownerFrame(frame) orelse return false; + const current: ScrollPosition = owner.page.element_scroll_positions.get(self) orelse .{}; + + var x = current.x; + var y = current.y; + if (write.target(.width, current.x)) |target| { + x = self.clampScroll(frame, .width, target); + } + if (write.target(.height, current.y)) |target| { + y = self.clampScroll(frame, .height, target); + } + if (x == current.x and y == current.y) { + return false; + } + const gop = try owner.page.element_scroll_positions.getOrPut(owner.page.frame_arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } - const old_x = gop.value_ptr.x; - const old_y = gop.value_ptr.y; - - if (write.target(.width, old_x)) |target| { - gop.value_ptr.x = self.clampScroll(frame, .width, target); - } - if (write.target(.height, old_y)) |target| { - gop.value_ptr.y = self.clampScroll(frame, .height, target); - } - - if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(gop.value_ptr, owner); - } + gop.value_ptr.x = x; + gop.value_ptr.y = y; + try self.scheduleScrollEvents(gop.value_ptr, owner); + return true; } -/// `target` brought into [0, scrollExtent]. fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) u32 { var clamped = target; if (clamped < 0) { @@ -2112,8 +2095,8 @@ fn clampScroll(self: *Element, frame: *Frame, comptime axis: Axis, target: i64) // Scrolling an element fires a scroll event and then a scrollend event, // asynchronously and throttled, mirroring Window.scrollTo. Scrolls of the // scrolling element (the root) are fired at the document instead. -// `frame` is the element's owner frame, `pos` its entry in that frame's -// positions (both resolved by writeScroll). +// `frame` is the element's owner frame and `pos` its entry there, both +// resolved by writeScroll. fn scheduleScrollEvents(self: *Element, pos: *ScrollPosition, frame: *Frame) !void { const task_pending = pos.state != .done; pos.state = .scroll; From 93c551bed6e96d211112182d4a46f14f084ec6bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Tue, 22 Sep 2026 16:39:13 +0200 Subject: [PATCH 5/5] css: share the axis shorthand table with the CSSOM The cascade expanded overscroll-behavior into longhands but CSSStyleDeclaration didn't, so setting the shorthand left overscrollBehaviorX reading empty and a style= block round-tripped through the object lost it. CssParser.axis_shorthands is now the one list, with axisShorthand and axisLonghand as the lookups both sides use: the CSSOM's overflow-only special cases (set, apply, remove, priority, serialize) became that lookup, and OverflowPair became AxisPair. Verified against Chrome 153: `overscroll-behavior: contain auto` reads back per axis, collapses to `contain` when both match, and serializes as one declaration. --- src/browser/StyleManager.zig | 18 ++--- src/browser/css/Parser.zig | 47 +++++++++++ src/browser/tests/element/styles.html | 19 +++++ .../webapi/css/CSSStyleDeclaration.zig | 79 +++++++++---------- 4 files changed, 107 insertions(+), 56 deletions(-) diff --git a/src/browser/StyleManager.zig b/src/browser/StyleManager.zig index d3f529d9c..a5ed6f4e7 100644 --- a/src/browser/StyleManager.zig +++ b/src/browser/StyleManager.zig @@ -1303,12 +1303,6 @@ fn foldDeclarations(block: []const u8, customs: ?*CustomSink) !TrackedProperties return slots.props(); } -// The ` []` shorthands the cascade expands into the tracked longhands. -const axis_shorthands = [_]struct { name: []const u8, x: []const u8, y: []const u8 }{ - .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, - .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, -}; - /// One block's winning value per tracked property, folded in declaration /// order. const Slots = struct { @@ -1332,13 +1326,11 @@ const Slots = struct { slots: [property_names.len]Slot = @splat(.{}), fn apply(self: *Slots, name: []const u8, value: []const u8, important: bool) void { - for (axis_shorthands) |shorthand| { - if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { - const values = CssParser.splitAxisPair(value) orelse return; - self.apply(shorthand.x, values.x, important); - self.apply(shorthand.y, values.y, important); - return; - } + if (CssParser.axisShorthand(name)) |shorthand| { + const values = CssParser.splitAxisPair(value) orelse return; + self.apply(shorthand.x, values.x, important); + self.apply(shorthand.y, values.y, important); + return; } for (property_names, &self.slots) |tracked, *slot| { if (std.ascii.eqlIgnoreCase(name, tracked)) { diff --git a/src/browser/css/Parser.zig b/src/browser/css/Parser.zig index 8cda3dcc2..5e7f5c12e 100644 --- a/src/browser/css/Parser.zig +++ b/src/browser/css/Parser.zig @@ -27,6 +27,53 @@ pub const Declaration = struct { pub const AxisPair = struct { x: []const u8, y: []const u8 }; +pub const AxisShorthand = struct { + name: []const u8, + x: []const u8, + y: []const u8, +}; + +// The ` []` shorthands whose longhands the style cascade tracks. Both the +// CSSOM object and the cascade store these expanded: setting one sets both +// longhands, reading or serializing it recombines them. +pub const axis_shorthands = [_]AxisShorthand{ + .{ .name = "overflow", .x = "overflow-x", .y = "overflow-y" }, + .{ .name = "overscroll-behavior", .x = "overscroll-behavior-x", .y = "overscroll-behavior-y" }, +}; + +/// The axis shorthand `name` names, if it names one. +pub fn axisShorthand(name: []const u8) ?AxisShorthand { + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.name)) { + return shorthand; + } + } + return null; +} + +pub const AxisLonghand = struct { + shorthand: AxisShorthand, + is_x: bool, + + /// The longhand on the other axis. + pub fn partner(self: AxisLonghand) []const u8 { + return if (self.is_x) self.shorthand.y else self.shorthand.x; + } +}; + +/// The axis shorthand `name` is a longhand of, if it is one. +pub fn axisLonghand(name: []const u8) ?AxisLonghand { + for (axis_shorthands) |shorthand| { + if (std.ascii.eqlIgnoreCase(name, shorthand.x)) { + return .{ .shorthand = shorthand, .is_x = true }; + } + if (std.ascii.eqlIgnoreCase(name, shorthand.y)) { + return .{ .shorthand = shorthand, .is_x = false }; + } + } + return null; +} + /// An ` []` axis shorthand such as `overflow` or `overscroll-behavior`; /// a single value applies to both axes. More than two values is invalid and /// null, as is an empty declaration. diff --git a/src/browser/tests/element/styles.html b/src/browser/tests/element/styles.html index 043c3c02c..1c30d525d 100644 --- a/src/browser/tests/element/styles.html +++ b/src/browser/tests/element/styles.html @@ -113,6 +113,25 @@ } + +