From bf56adeb1615a7a846650ae8808e101e2e50132e Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 23:09:29 +0200 Subject: [PATCH 1/7] webapi: documents report the navigation response's content type Fixes 9 failing files under /dom/nodes/Document-contentType/contentType/ (bmp, css, gif, jpg, mimeheader_01, png, txt, xml, and keeps the rest green): a document created from a non-HTML response (the synthesized image/text/raw documents) reported the text/html default from document.contentType instead of the response's MIME type. The frame records the response's MIME essence (type/subtype, lowercased, parameters stripped) when the first chunk arrives, and applies it to the new document's _content_type override once the navigation commits. text/html responses keep the default. The remaining file in the directory (contenttype_javascripturi) needs iframes to navigate to javascript: URLs and document their string result, which is a different mechanism. Coverage: /dom/nodes/Document-contentType/: 14/15 files passing (9 newly passing, 1 subtest each). Co-Authored-By: Claude Fable 5 --- src/browser/Frame.zig | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 87ab3131c..33d391c56 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -236,6 +236,10 @@ _factory: *Factory, _load_state: LoadState = .waiting, +// The navigation response's MIME essence, recorded when the first chunk +// arrives and applied to the new document (document.contentType) once the +// navigation commits. null means the text/html default. +_pending_content_type: ?[]const u8 = null, _parse_state: ParseState = .pre, /// `frameErrorCallback` swallows the failure into a placeholder page; @@ -1427,6 +1431,19 @@ fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { }); } + // Record the response's MIME essence so the resulting document + // reports it (document.contentType); text/html stays the default. + self._pending_content_type = null; + if (transfer.contentType()) |ct| { + const end = std.mem.indexOfScalar(u8, ct, ';') orelse ct.len; + const essence = std.mem.trim(u8, ct[0..end], " \t"); + if (essence.len > 0 and !std.ascii.eqlIgnoreCase(essence, "text/html")) { + const lower = try self.arena.dupe(u8, essence); + _ = std.ascii.lowerString(lower, essence); + self._pending_content_type = lower; + } + } + switch (mime.content_type) { .text_html => { // Normalize and store the charset using encoding_rs canonical names @@ -1501,6 +1518,11 @@ fn frameDoneCallback(ctx: *anyopaque) !void { //We need to handle different navigation types differently. try self._session.navigation.commitNavigation(self); + if (self._pending_content_type) |content_type| { + self.document._content_type = content_type; + self._pending_content_type = null; + } + defer if (comptime IS_DEBUG) { log.debug(.frame, "frame load complete", .{ .url = self.url, From 5f8e910312588dfb5e4c7c9ef21af3545ec7eb77 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sat, 11 Jul 2026 23:23:22 +0200 Subject: [PATCH 2/7] 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 26ecce962..1ad217f77 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); @@ -1344,7 +1347,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 { @@ -1357,7 +1364,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 { @@ -1647,6 +1658,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)); @@ -1657,6 +1670,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. @@ -1672,8 +1688,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()); From d5c57479a86c35a4e4360ffc0e085ae3acad3ef9 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sun, 12 Jul 2026 11:45:10 +0200 Subject: [PATCH 3/7] webapi: implement the translate IDL attribute Fixes 7 failing files under /html/dom/elements/global-attributes/ (the-translate-attribute-007 through -012 and translate-enumerated-ascii-case-insensitive, 1 subtest each): the HTMLElement.translate property was missing entirely. The getter computes the element's translation mode per the HTML spec: translate="yes" or "" enables it, "no" disables it (both ASCII case-insensitively), anything else inherits from the closest ancestor with a valid value, defaulting to enabled. The setter reflects back as "yes"/"no". Coverage: the 7 files above each 0/1 -> 1/1 (fully green). Co-Authored-By: Claude Fable 5 --- src/browser/webapi/element/Html.zig | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 4cd12b702..67924b0c5 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -345,6 +345,28 @@ pub fn setHidden(self: *HtmlElement, hidden: bool, frame: *Frame) !void { } } +// The translate IDL attribute reflects the element's translation mode: +// translate="yes"/"" enables it, "no" disables it, anything else (or no +// attribute) inherits from the parent, defaulting to enabled. +pub fn getTranslate(self: *HtmlElement) bool { + var node: ?*Node = self.asElement().asNode(); + while (node) |n| : (node = n.parentNode()) { + const el = n.is(Element) orelse continue; + const value = el.getAttributeSafe(comptime .wrap("translate")) orelse continue; + if (value.len == 0 or std.ascii.eqlIgnoreCase(value, "yes")) { + return true; + } + if (std.ascii.eqlIgnoreCase(value, "no")) { + return false; + } + } + return true; +} + +pub fn setTranslate(self: *HtmlElement, translate: bool, frame: *Frame) !void { + try self.asElement().setAttributeSafe(comptime .wrap("translate"), .wrap(if (translate) "yes" else "no"), frame); +} + pub fn getPopover(self: *HtmlElement) ?[]const u8 { const s = popover.getState(self.asElement()) orelse return null; return @tagName(s); @@ -1707,6 +1729,7 @@ pub const JsApi = struct { pub const autofocus = bridge.accessor(HtmlElement.getAutofocus, HtmlElement.setAutofocus, .{ .ce_reactions = true }); pub const dir = bridge.accessor(HtmlElement.getDir, HtmlElement.setDir, .{ .ce_reactions = true }); pub const hidden = bridge.accessor(HtmlElement.getHidden, HtmlElement.setHidden, .{ .ce_reactions = true }); + pub const translate = bridge.accessor(HtmlElement.getTranslate, HtmlElement.setTranslate, .{ .ce_reactions = true }); pub const popover = bridge.accessor(HtmlElement.getPopover, HtmlElement.setPopover, .{ .ce_reactions = true }); pub const showPopover = bridge.function(HtmlElement.showPopover, .{}); pub const hidePopover = bridge.function(HtmlElement.hidePopover, .{}); From 4bd52eb32c0051c3d3f34c5596abef7b46451955 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Sun, 12 Jul 2026 11:56:33 +0200 Subject: [PATCH 4/7] webapi: SVGAElement exposes relList Fixes the last failing subtest of WPT /dom/lists/DOMTokenList-coverage-for-attributes.html (174/175 -> 175/175): an element in the SVG namespace must expose relList as a DOMTokenList (while SVG / have none). Main now provides the full SVGAElement type (SVGGraphicsElement hierarchy, href as SVGAnimatedString); this commit only adds the relList accessor, reflecting the rel attribute through the shared Element.getRelList. (As originally written, this commit also created the SVGAElement type and its factory/tag wiring; the rebase keeps main's implementation.) Coverage: /dom/lists/DOMTokenList-coverage-for-attributes.html 174/175 -> 175/175 (fully green). Co-Authored-By: Claude Fable 5 --- src/browser/webapi/element/svg/A.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/browser/webapi/element/svg/A.zig b/src/browser/webapi/element/svg/A.zig index da5cad8dd..ed068c504 100644 --- a/src/browser/webapi/element/svg/A.zig +++ b/src/browser/webapi/element/svg/A.zig @@ -48,4 +48,9 @@ pub const JsApi = struct { fn _href(self: *A, frame: *Frame) !*AnimatedString { return AnimatedString.getOrCreate(self.asElement(), .href, frame); } + + pub const relList = bridge.accessor(_getRelList, null, .{}); + fn _getRelList(self: *A, frame: *Frame) !*@import("../../collections.zig").DOMTokenList { + return self.asElement().getRelList(frame); + } }; From 1a51a2ec1d3f1e9d9990a0599746d53efcf420f9 Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Tue, 14 Jul 2026 15:43:02 +0200 Subject: [PATCH 5/7] webapi: element scroll state lives in the element's own frame Review pattern from #2944: an injected `frame` parameter is the frame of the calling realm, not the frame owning the element. The element scroll positions map, the scheduled scroll/scrollend events and the scrolling-element (root) comparison all used the caller's frame, so a same-origin script scrolling an element inside an iframe stored the position in the wrong frame's map (the iframe's own reads returned 0), fired the events through the wrong frame, and compared against the wrong document's root. All scroll accessors (scrollTop/scrollLeft getters and setters, scrollTo, scrollBy) now resolve Element.ownerFrame first; scheduleScrollEvents and the ScrollEventTask receive that owner frame. Co-Authored-By: Karl Seguin --- src/browser/webapi/Element.zig | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 1ad217f77..11d59f77f 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1337,37 +1337,46 @@ pub fn getClientRects(self: *Element, frame: *Frame) ![]*DOMRect { return rects; } +// Scroll positions live in the map of the element's own frame — not the +// caller's, which differs when a same-origin script scrolls an element in +// another frame (e.g. inside an iframe). All scroll accessors resolve the +// owner frame first so the state, the fired events and the document +// comparison stay in the element's frame. pub fn getScrollTop(self: *Element, frame: *Frame) u32 { - const pos = frame._element_scroll_positions.get(self) orelse return 0; + const owner = self.ownerFrame(frame); + const pos = owner._element_scroll_positions.get(self) orelse return 0; return pos.y; } pub fn setScrollTop(self: *Element, value: i32, frame: *Frame) !void { - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.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(frame); + try self.scheduleScrollEvents(owner); } } pub fn getScrollLeft(self: *Element, frame: *Frame) u32 { - const pos = frame._element_scroll_positions.get(self) orelse return 0; + const owner = self.ownerFrame(frame); + const pos = owner._element_scroll_positions.get(self) orelse return 0; return pos.x; } pub fn setScrollLeft(self: *Element, value: i32, frame: *Frame) !void { - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.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(frame); + try self.scheduleScrollEvents(owner); } } @@ -1654,7 +1663,8 @@ const ScrollToOpts = union(enum) { pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !void { const o = opts orelse return; - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } @@ -1671,14 +1681,15 @@ pub fn scrollTo(self: *Element, opts: ?ScrollToOpts, y: ?i32, frame: *Frame) !vo }, } if (gop.value_ptr.x != old_x or gop.value_ptr.y != old_y) { - try self.scheduleScrollEvents(frame); + try self.scheduleScrollEvents(owner); } } // 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; - const gop = try frame._element_scroll_positions.getOrPut(frame.arena, self); + const owner = self.ownerFrame(frame); + const gop = try owner._element_scroll_positions.getOrPut(owner.arena, self); if (!gop.found_existing) { gop.value_ptr.* = .{}; } @@ -1689,13 +1700,14 @@ 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); + try self.scheduleScrollEvents(owner); } } // 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._element_scroll_positions.getOrPut(frame.arena, self); if (!gop.found_existing) { From 3fd91499178ed852f7567da30a0638167f1e41bc Mon Sep 17 00:00:00 2001 From: Francis Bouvier Date: Wed, 15 Jul 2026 12:44:13 +0200 Subject: [PATCH 6/7] webapi: move SVGAElement.getRelList out of the JsApi bridge block Review pattern: the JsApi struct is for JS/v8 bridging only; the relList getter is plain delegation with no v8 adaptation, so it moves to the type and the bridge accessor references it. Co-Authored-By: Karl Seguin --- src/browser/webapi/element/svg/A.zig | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/element/svg/A.zig b/src/browser/webapi/element/svg/A.zig index ed068c504..9cad91395 100644 --- a/src/browser/webapi/element/svg/A.zig +++ b/src/browser/webapi/element/svg/A.zig @@ -22,6 +22,7 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); const AnimatedString = @import("../../svg/AnimatedString.zig"); +const DOMTokenList = @import("../../collections.zig").DOMTokenList; const Graphics = @import("Graphics.zig"); @@ -35,6 +36,10 @@ pub fn asNode(self: *A) *Node { return self.asElement().asNode(); } +pub fn getRelList(self: *A, frame: *Frame) !*DOMTokenList { + return self.asElement().getRelList(frame); +} + pub const JsApi = struct { pub const bridge = js.Bridge(A); @@ -49,8 +54,5 @@ pub const JsApi = struct { return AnimatedString.getOrCreate(self.asElement(), .href, frame); } - pub const relList = bridge.accessor(_getRelList, null, .{}); - fn _getRelList(self: *A, frame: *Frame) !*@import("../../collections.zig").DOMTokenList { - return self.asElement().getRelList(frame); - } + pub const relList = bridge.accessor(A.getRelList, null, .{}); }; From 40c18f6142996274399dba87c3237f07dcb0e202 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 16 Jul 2026 17:23:33 +0800 Subject: [PATCH 7/7] Fix UAF on ScrollTask Free ScrollTask on shutdown / when complete. Skip content_type logic when type is known to be html. --- src/browser/Frame.zig | 19 ++++--- src/browser/webapi/Element.zig | 100 ++++++++++++++++++++++----------- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 33d391c56..7e30b45d8 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1432,15 +1432,18 @@ fn frameDataCallback(transfer: *HttpClient.Transfer, data: []const u8) !void { } // Record the response's MIME essence so the resulting document - // reports it (document.contentType); text/html stays the default. + // reports it (document.contentType). text/html keeps the default, so an + // HTML response (parsed from the header, or sniffed when there's no + // header) never needs an override; inside this branch the essence can + // no longer be text/html. self._pending_content_type = null; - if (transfer.contentType()) |ct| { - const end = std.mem.indexOfScalar(u8, ct, ';') orelse ct.len; - const essence = std.mem.trim(u8, ct[0..end], " \t"); - if (essence.len > 0 and !std.ascii.eqlIgnoreCase(essence, "text/html")) { - const lower = try self.arena.dupe(u8, essence); - _ = std.ascii.lowerString(lower, essence); - self._pending_content_type = lower; + if (mime.content_type != .text_html) { + if (transfer.contentType()) |ct| { + const end = std.mem.indexOfScalarPos(u8, ct, 0, ';') orelse ct.len; + const essence = std.mem.trim(u8, ct[0..end], " \t"); + if (essence.len > 0) { + self._pending_content_type = try std.ascii.allocLowerString(self.arena, essence); + } } } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 11d59f77f..118c4491b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1715,17 +1715,79 @@ fn scheduleScrollEvents(self: *Element, frame: *Frame) !void { } gop.value_ptr.state = .scroll; - const task = try frame.arena.create(ScrollEventTask); - task.* = .{ .frame = frame, .element = self }; + const task = try frame._factory.create(ScrollEventTask{ .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 }); + try frame.js.scheduler.add(task, ScrollEventTask.dispatchScrollEnd, 20, .{ .low_priority = true, .finalizer = ScrollEventTask.cancelled }); } const ScrollEventTask = struct { frame: *Frame, element: *Element, - fn eventTarget(self: *ScrollEventTask) *@import("EventTarget.zig") { + fn cancelled(ctx: *anyopaque) void { + const self: *ScrollEventTask = @ptrCast(@alignCast(ctx)); + self.deinit(); + } + + fn deinit(self: *ScrollEventTask) void { + self.frame._factory.destroy(self); + } + + fn dispatchScroll(ctx: *anyopaque) anyerror!?u32 { + const self: *ScrollEventTask = @ptrCast(@alignCast(ctx)); + 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); + + // can't use gop on _element_scroll_positions above, since the JS callback + // can mutate _element_scroll_positions and invalidate any pointers + if (f._element_scroll_positions.getPtr(self.element)) |p| { + p.state = .end; + } + return null; + } + + fn dispatchScrollEnd(ctx: *anyopaque) anyerror!?u32 { + const self: *ScrollEventTask = @ptrCast(@alignCast(ctx)); + + const f = self.frame; + const pos = f._element_scroll_positions.getPtr(self.element) orelse { + self.deinit(); + return null; + }; + + switch (pos.state) { + // The scroll event is still pending; retry in 10ms. Must not destroy + // the task here — the entry is re-scheduled and runs again. + .scroll => return 10, + .end => {}, + .done => { + self.deinit(); + return null; + }, + } + + defer self.deinit(); + 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); + + // can't use gop on _element_scroll_positions above, since the JS callback + // can mutate _element_scroll_positions and invalidate any pointers + if (f._element_scroll_positions.getPtr(self.element)) |p| { + p.state = .done; + } + + return null; + } + + fn eventTarget(self: *ScrollEventTask) *EventTarget { if (self.frame.document.getDocumentElement() == self.element) { return self.frame.document.asEventTarget(); } @@ -1737,36 +1799,6 @@ const ScrollEventTask = struct { 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 {