From 61fd04d3a52b67a97f612a1ea2b0e0a3a087204a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 20 Jul 2026 20:02:02 +0800 Subject: [PATCH] webapi: Improve ResizeObserver This gives ResizeObserver a touch up that brings it on par with how the more flushed out IntersectionObserver behaves. While it's impossible to fully implement this in a headless world, we can correctly emit ResizeObserverEntry in a number of important cases, e.g. visibility change, and style width/height changes. Most importantly, we not execute the callback on the initial observe, which can unlock some cases. --- src/browser/Frame.zig | 15 +- src/browser/frame/observers.zig | 87 ++++++- src/browser/js/Context.zig | 11 + src/browser/tests/resize_observer/basic.html | 54 ++++ src/browser/tests/resize_observer/resize.html | 55 ++++ .../tests/resize_observer/unobserve.html | 64 +++++ src/browser/webapi/ResizeObserver.zig | 243 ++++++++++++++++-- 7 files changed, 499 insertions(+), 30 deletions(-) create mode 100644 src/browser/tests/resize_observer/basic.html create mode 100644 src/browser/tests/resize_observer/resize.html create mode 100644 src/browser/tests/resize_observer/unobserve.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 59841f2b7..8ab70b904 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -200,7 +200,8 @@ _broadcast_channels: std.DoublyLinkedList = .{}, // List of MessagePorts living in this frame's context. _message_ports: std.DoublyLinkedList = .{}, -// MutationObserver / IntersectionObserver bookkeeping. See frame/observers.zig. +// See frame/observers.zig. +_resize: observers.Resize = .{}, _mutation: observers.Mutation = .{}, _intersection: observers.Intersection = .{}, @@ -1944,14 +1945,10 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame { pub fn domChanged(self: *Frame) void { self._page.dom_version += 1; - if (self._intersection.check_scheduled) { - return; - } - - self._intersection.check_scheduled = true; - self.js.queueIntersectionChecks() catch |err| { - log.err(.frame, "frame.schedIntersectChecks", .{ .err = err, .type = self._type, .url = self.url }); - }; + // A DOM change is our "rendering opportunity": re-evaluate the layout + // observers. Both are no-ops unless something they track actually changed. + observers.scheduleIntersectionChecks(self); + observers.scheduleResizeDelivery(self); } const ElementIdMaps = struct { lookup: *std.StringHashMapUnmanaged(*Element), removed_ids: *std.StringHashMapUnmanaged(void) }; diff --git a/src/browser/frame/observers.zig b/src/browser/frame/observers.zig index d27e5c0d6..de9defed5 100644 --- a/src/browser/frame/observers.zig +++ b/src/browser/frame/observers.zig @@ -30,6 +30,7 @@ const Page = @import("../Page.zig"); const Node = @import("../webapi/Node.zig"); const Event = @import("../webapi/Event.zig"); const Element = @import("../webapi/Element.zig"); +const ResizeObserver = @import("../webapi/ResizeObserver.zig"); const MutationObserver = @import("../webapi/MutationObserver.zig"); const IntersectionObserver = @import("../webapi/IntersectionObserver.zig"); @@ -52,6 +53,14 @@ pub const Intersection = struct { delivery_scheduled: bool = false, }; +// ResizeObserver bookkeeping for a frame. +pub const Resize = struct { + // List of active ResizeObservers (i.e. those with >= 1 observation) + observers: std.ArrayList(*ResizeObserver) = .{}, + delivery_scheduled: bool = false, + delivery_depth: u32 = 0, +}; + // Releases the frame's references to its registered observers. Called from // Frame.deinit. pub fn deinit(frame: *Frame, page: *Page) void { @@ -65,6 +74,10 @@ pub fn deinit(frame: *Frame, page: *Page) void { for (frame._intersection.observers.items) |observer| { observer.releaseRef(page); } + + for (frame._resize.observers.items) |observer| { + observer.releaseRef(page); + } } pub fn registerMutationObserver(frame: *Frame, observer: *MutationObserver) !void { @@ -92,6 +105,21 @@ pub fn unregisterIntersectionObserver(frame: *Frame, observer: *IntersectionObse } } +pub fn registerResizeObserver(frame: *Frame, observer: *ResizeObserver) !void { + observer.acquireRef(); + try frame._resize.observers.append(frame.arena, observer); +} + +pub fn unregisterResizeObserver(frame: *Frame, observer: *ResizeObserver) void { + for (frame._resize.observers.items, 0..) |obs, i| { + if (obs == observer) { + observer.releaseRef(frame._page); + _ = frame._resize.observers.swapRemove(i); + return; + } + } +} + pub fn hasMutationObservers(frame: *const Frame) bool { return frame._mutation.observers.first != null; } @@ -118,6 +146,63 @@ pub fn scheduleIntersectionDelivery(frame: *Frame) !void { try frame.js.queueIntersectionDelivery(); } +pub fn scheduleIntersectionChecks(frame: *Frame) void { + if (frame._intersection.check_scheduled) { + return; + } + frame._intersection.check_scheduled = true; + frame.js.queueIntersectionChecks() catch |err| { + frame._intersection.check_scheduled = false; + log.err(.frame, "frame.scheduleIntersectionChecks", .{ .err = err, .type = frame._type, .url = frame.url }); + }; +} + +pub fn scheduleResizeDelivery(frame: *Frame) void { + if (frame._resize.observers.items.len == 0) { + return; + } + if (frame._resize.delivery_scheduled) { + return; + } + frame._resize.delivery_scheduled = true; + frame.js.queueResizeDelivery() catch |err| { + frame._resize.delivery_scheduled = false; + log.err(.frame, "frame.scheduleResizeDelivery", .{ .err = err, .type = frame._type, .url = frame.url }); + }; +} + +pub fn deliverResizes(frame: *Frame) void { + if (!frame._resize.delivery_scheduled) { + return; + } + frame._resize.delivery_scheduled = false; + + // guard against a callback that keeps mutating the layout, and thus causes + // an endless stram of deliveries. + frame._resize.delivery_depth += 1; + defer if (!frame._resize.delivery_scheduled) { + frame._resize.delivery_depth = 0; + }; + if (frame._resize.delivery_depth > 50) { + log.warn(.frame, "frame.ResizeLimit", .{ .type = frame._type, .url = frame.url }); + frame._resize.delivery_depth = 0; + return; + } + + // Iterate backwards so an observer disconnecting during its callback is safe. + var i = frame._resize.observers.items.len; + while (i > 0) { + i -= 1; + if (i >= frame._resize.observers.items.len) { + continue; + } + const observer = frame._resize.observers.items[i]; + observer.deliverEntries(frame) catch |err| { + log.err(.frame, "frame.deliverResizes", .{ .err = err, .type = frame._type, .url = frame.url }); + }; + } +} + pub fn performScheduledIntersectionChecks(frame: *Frame) void { if (!frame._intersection.check_scheduled) { return; @@ -157,7 +242,7 @@ pub fn deliverMutations(frame: *Frame) void { frame._mutation.delivery_depth = 0; }; - if (frame._mutation.delivery_depth > 100) { + if (frame._mutation.delivery_depth > 50) { log.err(.frame, "frame.MutationLimit", .{ .type = frame._type, .url = frame.url }); frame._mutation.delivery_depth = 0; return; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index a8b341dfe..f8c5163e8 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -1093,6 +1093,17 @@ pub fn queueIntersectionDelivery(self: *Context) !void { }.run); } +pub fn queueResizeDelivery(self: *Context) !void { + self.enqueueMicrotask(struct { + fn run(ctx: *Context) void { + switch (ctx.global) { + .frame => |frame| Frame.observers.deliverResizes(frame), + .worker => unreachable, + } + } + }.run); +} + pub fn queueCustomElementBackupDrain(self: *Context) !void { self.enqueueMicrotask(struct { fn run(ctx: *Context) void { diff --git a/src/browser/tests/resize_observer/basic.html b/src/browser/tests/resize_observer/basic.html new file mode 100644 index 000000000..4d8d8a2c1 --- /dev/null +++ b/src/browser/tests/resize_observer/basic.html @@ -0,0 +1,54 @@ + + +
Target Element
+ + + + + diff --git a/src/browser/tests/resize_observer/resize.html b/src/browser/tests/resize_observer/resize.html new file mode 100644 index 000000000..ed437249e --- /dev/null +++ b/src/browser/tests/resize_observer/resize.html @@ -0,0 +1,55 @@ + + +
anchor
+ + + + diff --git a/src/browser/tests/resize_observer/unobserve.html b/src/browser/tests/resize_observer/unobserve.html new file mode 100644 index 000000000..6c4155a07 --- /dev/null +++ b/src/browser/tests/resize_observer/unobserve.html @@ -0,0 +1,64 @@ + + +
anchor
+ + + + + + diff --git a/src/browser/webapi/ResizeObserver.zig b/src/browser/webapi/ResizeObserver.zig index 29c99003f..32fd0ebe4 100644 --- a/src/browser/webapi/ResizeObserver.zig +++ b/src/browser/webapi/ResizeObserver.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -16,39 +16,238 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +// We're a headless browser, so this is never goig to be perfect, but we CAN +// correctly deliver some effects, e.g. the initial entry when observe() is +// called and some changes to display or style's width/height. + +const std = @import("std"); +const lp = @import("lightpanda"); + const js = @import("../js/js.zig"); + +const Page = @import("../Page.zig"); +const Frame = @import("../Frame.zig"); + const Element = @import("Element.zig"); +const DOMRect = @import("DOMRect.zig"); +const Factory = @import("../Factory.zig"); -pub const ResizeObserver = @This(); +const log = lp.log; +const Allocator = std.mem.Allocator; -// Padding to avoid zero-size struct, which causes identity_map pointer collisions. -_pad: bool = false, - -fn init(cbk: js.Function) ResizeObserver { - _ = cbk; - return .{}; +pub fn registerTypes() []const type { + return &.{ + ResizeObserver, + ResizeObserverEntry, + ResizeObserverSize, + }; } +const ResizeObserver = @This(); + +_rc: lp.RC(u8) = .{}, +_arena: Allocator, +_callback: js.Function.Global, +_observations: std.ArrayList(Observation) = .{}, + +const Observation = struct { + target: *Element, + last_width: f64 = 0, + last_height: f64 = 0, +}; + const Options = struct { box: []const u8 = "content-box", }; -pub fn observe(self: *const ResizeObserver, element: *Element, options_: ?Options) void { - _ = self; - _ = element; - _ = options_; - return; + +pub fn init(callback: js.Function.Global, frame: *Frame) !*ResizeObserver { + const arena = try frame.getArena(.small, "ResizeObserver"); + errdefer frame.releaseArena(arena); + + const self = try arena.create(ResizeObserver); + self.* = .{ + ._arena = arena, + ._callback = callback, + }; + return self; } -pub fn unobserve(self: *const ResizeObserver, element: *Element) void { - _ = self; - _ = element; - return; +pub fn deinit(self: *ResizeObserver, page: *Page) void { + self._callback.release(); + page.releaseArena(self._arena); } -pub fn disconnect(self: *const ResizeObserver) void { - _ = self; +pub fn acquireRef(self: *ResizeObserver) void { + self._rc.acquire(); } +pub fn releaseRef(self: *ResizeObserver, page: *Page) void { + self._rc.release(self, page); +} + +pub fn observe(self: *ResizeObserver, target: *Element, options_: ?Options, frame: *Frame) !void { + _ = options_; // Can't make use of this + + for (self._observations.items) |obs| { + if (obs.target == target) { + return; + } + } + + try self._observations.append(self._arena, .{ .target = target }); + if (self._observations.items.len == 1) { + try Frame.observers.registerResizeObserver(frame, self); + } + + Frame.observers.scheduleResizeDelivery(frame); +} + +pub fn unobserve(self: *ResizeObserver, target: *Element, frame: *Frame) void { + for (self._observations.items, 0..) |obs, i| { + if (obs.target == target) { + _ = self._observations.swapRemove(i); + break; + } + } + + if (self._observations.items.len == 0) { + Frame.observers.unregisterResizeObserver(frame, self); + } +} + +pub fn disconnect(self: *ResizeObserver, frame: *Frame) void { + if (self._observations.items.len == 0) { + return; + } + self._observations.clearRetainingCapacity(); + Frame.observers.unregisterResizeObserver(frame, self); +} + +// Gather the observations whose size changed since the last delivery and, if +// any, invoke the callback. +pub fn deliverEntries(self: *ResizeObserver, frame: *Frame) !void { + var entries: std.ArrayList(*ResizeObserverEntry) = .empty; + for (self._observations.items) |*obs| { + const target = obs.target; + + const width, const height = blk: { + if (obs.target.asNode().isConnected() == false) { + break :blk .{ 0, 0 }; + } + break :blk .{ target.getClientWidth(frame), target.getClientHeight(frame) }; + }; + + if (width == obs.last_width and height == obs.last_height) { + continue; + } + obs.last_width = width; + obs.last_height = height; + + const entry = try ResizeObserverEntry.create(obs.target, width, height, frame._factory); + try entries.append(frame.call_arena, entry); + } + + if (entries.items.len == 0) { + return; + } + + var caught: js.TryCatch.Caught = undefined; + + var ls: js.Local.Scope = undefined; + frame.js.localScope(&ls); + defer ls.deinit(); + + ls.toLocal(self._callback).tryCall(void, .{ entries.items, self }, &caught) catch |err| { + log.err(.frame, "ResizeObserver.deliverEntries", .{ .err = err, .caught = caught }); + return err; + }; +} + +pub const ResizeObserverEntry = struct { + _target: *Element, + _content_rect: *DOMRect, + _box_size: [1]*ResizeObserverSize, + + pub fn create(target: *Element, width: f64, height: f64, factory: *Factory) !*ResizeObserverEntry { + const content_rect = try DOMRect.create(.{ .width = width, .height = height }, factory); + const size = try ResizeObserverSize.create(width, height, factory); + return factory.create(ResizeObserverEntry{ + ._target = target, + ._content_rect = content_rect, + ._box_size = .{size}, + }); + } + + pub fn getTarget(self: *const ResizeObserverEntry) *Element { + return self._target; + } + + pub fn getContentRect(self: *const ResizeObserverEntry) *DOMRect { + return self._content_rect; + } + + pub fn getBorderBoxSize(self: *const ResizeObserverEntry) []const *ResizeObserverSize { + return &self._box_size; + } + + pub fn getContentBoxSize(self: *const ResizeObserverEntry) []const *ResizeObserverSize { + return &self._box_size; + } + + pub fn getDevicePixelContentBoxSize(self: *const ResizeObserverEntry) []const *ResizeObserverSize { + return &self._box_size; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(ResizeObserverEntry); + + pub const Meta = struct { + pub const name = "ResizeObserverEntry"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const target = bridge.accessor(ResizeObserverEntry.getTarget, null, .{}); + pub const contentRect = bridge.accessor(ResizeObserverEntry.getContentRect, null, .{}); + pub const borderBoxSize = bridge.accessor(ResizeObserverEntry.getBorderBoxSize, null, .{}); + pub const contentBoxSize = bridge.accessor(ResizeObserverEntry.getContentBoxSize, null, .{}); + pub const devicePixelContentBoxSize = bridge.accessor(ResizeObserverEntry.getDevicePixelContentBoxSize, null, .{}); + }; +}; + +pub const ResizeObserverSize = struct { + _inline_size: f64, + _block_size: f64, + + pub fn create(inline_size: f64, block_size: f64, factory: *Factory) !*ResizeObserverSize { + return factory.create(ResizeObserverSize{ + ._inline_size = inline_size, + ._block_size = block_size, + }); + } + + pub fn getInlineSize(self: *const ResizeObserverSize) f64 { + return self._inline_size; + } + + pub fn getBlockSize(self: *const ResizeObserverSize) f64 { + return self._block_size; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(ResizeObserverSize); + + pub const Meta = struct { + pub const name = "ResizeObserverSize"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const inlineSize = bridge.accessor(ResizeObserverSize.getInlineSize, null, .{}); + pub const blockSize = bridge.accessor(ResizeObserverSize.getBlockSize, null, .{}); + }; +}; + pub const JsApi = struct { pub const bridge = js.Bridge(ResizeObserver); @@ -56,7 +255,6 @@ pub const JsApi = struct { pub const name = "ResizeObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const empty_with_no_proto = true; }; pub const constructor = bridge.constructor(ResizeObserver.init, .{}); @@ -64,3 +262,8 @@ pub const JsApi = struct { pub const unobserve = bridge.function(ResizeObserver.unobserve, .{}); pub const disconnect = bridge.function(ResizeObserver.disconnect, .{}); }; + +const testing = @import("../../testing.zig"); +test "WebApi: ResizeObserver" { + try testing.htmlRunner("resize_observer", .{}); +}