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
+Hidden
+
+
+
+
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", .{});
+}