From d3f40ede97cc59ade873dddfea38302455fff677 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 23:23:22 +0200 Subject: [PATCH] webapi: element scrolls fire scroll and scrollend events Fixes 16 failing files under /dom/events/scrolling/: all 8 variants of scrollend-event-fired-for-programmatic-scroll.html and all 8 variants of scrollend-event-fired-for-scroll-attr-change.html. Window scrolls already dispatched throttled scroll + scrollend events, but Element.scrollTo/scrollBy and the scrollTop/scrollLeft setters only updated the stored position. They now schedule the same scroll-then-scrollend pair (10ms/20ms, throttled through a per-element state on the scroll position entry) when the position actually changes: - events fired at the element don't bubble, per the UI Events cancelability rules the tests assert ("Event targeting element does not bubble"); - scrolling the document's scrolling element dispatches at the document instead, where the events do bubble. The remaining files in the directory need real layout (scrollIntoView offsets, scroll snapping) or gesture scrolling. Coverage: /dom/events/scrolling/: 20 files passing (16 newly), no regressions. Co-Authored-By: Claude Fable 5 --- src/browser/webapi/Element.zig | 87 +++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 2 deletions(-) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index d0be282fd..b5077e76a 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -56,6 +56,9 @@ pub const NamespaceUriLookup = std.AutoHashMapUnmanaged(*Element, []const u8); pub const ScrollPosition = struct { x: u32 = 0, y: u32 = 0, + // Throttle state for the async scroll/scrollend dispatch (mirrors + // Window._scroll_pos.state). + state: enum { scroll, end, done } = .done, }; pub const ScrollPositionLookup = std.AutoHashMapUnmanaged(*Element, ScrollPosition); @@ -1324,7 +1327,11 @@ pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { if (!gop.found_existing) { gop.value_ptr.* = .{}; } - gop.value_ptr.y = @intCast(@max(0, value)); + const new_y: u32 = @intCast(@max(0, value)); + if (gop.value_ptr.y != new_y) { + gop.value_ptr.y = new_y; + try self.scheduleScrollEvents(frame); + } } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { @@ -1337,7 +1344,11 @@ pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { if (!gop.found_existing) { gop.value_ptr.* = .{}; } - gop.value_ptr.x = @intCast(@max(0, value)); + const new_x: u32 = @intCast(@max(0, value)); + if (gop.value_ptr.x != new_x) { + gop.value_ptr.x = new_x; + try self.scheduleScrollEvents(frame); + } } pub fn getScrollHeight(self: *Element, frame: *Frame) f64 { @@ -1627,6 +1638,8 @@ pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo if (!gop.found_existing) { gop.value_ptr.* = .{}; } + const old_x = gop.value_ptr.x; + const old_y = gop.value_ptr.y; switch (o) { .x => |x| { gop.value_ptr.x = @intCast(@max(0, x)); @@ -1637,6 +1650,9 @@ pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo if (dict.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(frame); + } } // scrollBy(): like scrollTo() but relative to the current position. @@ -1652,8 +1668,75 @@ pub fn scrollBy(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo }; gop.value_ptr.x = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.x)) + dx)); gop.value_ptr.y = @intCast(@max(0, @as(i32, @intCast(gop.value_ptr.y)) + dy)); + if (dx != 0 or dy != 0) { + try self.scheduleScrollEvents(frame); + } } +// 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. +fn scheduleScrollEvents(self: *Element, frame: *Frame) !void { + const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + if (!gop.found_existing) { + gop.value_ptr.* = .{}; + } + gop.value_ptr.state = .scroll; + + const task = try frame.arena.create(ScrollEventTask); + task.* = .{ .frame = frame, .element = self }; + try frame.js.scheduler.add(task, ScrollEventTask.dispatchScroll, 10, .{ .low_priority = true }); + try frame.js.scheduler.add(task, ScrollEventTask.dispatchScrollEnd, 20, .{ .low_priority = true }); +} + +const ScrollEventTask = struct { + frame: *Frame, + element: *Element, + + fn eventTarget(self: *ScrollEventTask) *@import("EventTarget.zig") { + if (self.frame.document.getDocumentElement() == self.element) { + return self.frame.document.asEventTarget(); + } + return self.element.asEventTarget(); + } + + // Scroll events fired at an element don't bubble; only document-level + // scrolls (the scrolling element, dispatched at the document) do. + fn bubbles(self: *ScrollEventTask) bool { + return self.frame.document.getDocumentElement() == self.element; + } + + fn dispatchScroll(ptr: *anyopaque) anyerror!?u32 { + const self: *ScrollEventTask = @ptrCast(@alignCast(ptr)); + const f = self.frame; + const pos = f._element_scroll_positions.getPtr(self.element) orelse return null; + if (pos.state != .scroll) { + return null; + } + const Event = @import("Event.zig"); + const event = try Event.initTrusted(comptime .wrap("scroll"), .{ .bubbles = self.bubbles() }, f._page); + try f._event_manager.dispatch(self.eventTarget(), event); + pos.state = .end; + return null; + } + + fn dispatchScrollEnd(ptr: *anyopaque) anyerror!?u32 { + const self: *ScrollEventTask = @ptrCast(@alignCast(ptr)); + const f = self.frame; + const pos = f._element_scroll_positions.getPtr(self.element) orelse return null; + switch (pos.state) { + .scroll => return 10, + .end => {}, + .done => return null, + } + const Event = @import("Event.zig"); + const event = try Event.initTrusted(comptime .wrap("scrollend"), .{ .bubbles = self.bubbles() }, f._page); + try f._event_manager.dispatch(self.eventTarget(), event); + pos.state = .done; + return null; + } +}; + pub fn format(self: *Element, writer: *std.Io.Writer) !void { try writer.writeByte('<'); try writer.writeAll(self.getTagNameDump());