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.
This commit is contained in:
Karl Seguin
2026-07-20 20:02:02 +08:00
parent 4e14331046
commit 61fd04d3a5
7 changed files with 499 additions and 30 deletions

View File

@@ -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) };

View File

@@ -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;

View File

@@ -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 {

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="target" style="width: 100px; height: 60px;">Target Element</div>
<div id="hidden" style="width: 50px; display: none;">Hidden</div>
<script id="initial">
{
const target = document.getElementById('target');
let count = 0;
let entries = null;
const observer = new ResizeObserver((observerEntries, obs) => {
count += 1;
entries = observerEntries;
});
observer.observe(target);
testing.onload(() => {
// observe() delivers a single initial entry
testing.expectEqual(1, count);
testing.expectEqual(1, entries.length);
const entry = entries[0];
testing.expectEqual(target, entry.target);
testing.expectEqual('object', typeof entry.contentRect);
testing.expectEqual(100, entry.contentRect.width);
testing.expectEqual(60, entry.contentRect.height);
// content-box / border-box / device-pixel are the same for us
testing.expectEqual(1, entry.contentBoxSize.length);
testing.expectEqual(100, entry.contentBoxSize[0].inlineSize);
testing.expectEqual(60, entry.contentBoxSize[0].blockSize);
testing.expectEqual(100, entry.borderBoxSize[0].inlineSize);
testing.expectEqual(100, entry.devicePixelContentBoxSize[0].inlineSize);
observer.disconnect();
});
}
</script>
<script id="no_initial_for_zero_size">
{
// A display:none element has no box, so observe() delivers nothing.
const hidden = document.getElementById('hidden');
let hiddenCount = 0;
const hiddenObserver = new ResizeObserver(() => { hiddenCount += 1; });
hiddenObserver.observe(hidden);
testing.onload(() => {
testing.expectEqual(0, hiddenCount);
hiddenObserver.disconnect();
});
}
</script>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="anchor">anchor</div>
<script id="style_change_delivers" type=module>
// Changing an element's size delivers a fresh notification.
const state = await testing.async();
const t = document.createElement('div');
t.style.width = '100px';
t.style.height = '50px';
document.body.appendChild(t);
let step = 0;
const observer = new ResizeObserver((entries) => {
step += 1;
if (step === 1) {
testing.expectEqual(100, entries[0].contentRect.width);
t.style.width = '25px';
} else if (step === 2) {
testing.expectEqual(25, entries[0].contentRect.width);
observer.disconnect();
state.resolve();
}
});
observer.observe(t);
await state.done(() => {
testing.expectEqual(2, step);
});
</script>
<script id="disconnect_from_dom" type=module>
// Removing the target from the DOM collapses its size to 0 and notifies.
const state = await testing.async();
const t = document.createElement('div');
t.style.width = '80px';
document.body.appendChild(t);
const widths = [];
const observer = new ResizeObserver((entries) => {
widths.push(entries[0].contentRect.width);
if (widths.length === 1) {
t.remove();
} else if (widths.length === 2) {
observer.disconnect();
state.resolve();
}
});
observer.observe(t);
await state.done(() => {
testing.expectEqual(80, widths[0]);
testing.expectEqual(0, widths[1]);
});
</script>

View File

@@ -0,0 +1,64 @@
<!DOCTYPE html>
<script src="../testing.js"></script>
<div id="anchor">anchor</div>
<script id="unobserve_before_delivery">
{
const t = document.createElement('div');
t.style.width = '100px';
document.body.appendChild(t);
let count = 0;
const observer = new ResizeObserver(() => { count += 1; });
observer.observe(t);
observer.unobserve(t);
// unobserving a target we never observed is a no-op
observer.unobserve(document.body);
testing.onload(() => {
testing.expectEqual(0, count);
observer.disconnect();
});
}
</script>
<script id="disconnect_stops_notifications">
{
const t1 = document.createElement('div');
const t2 = document.createElement('div');
t1.style.width = '100px';
t2.style.width = '100px';
document.body.appendChild(t1);
document.body.appendChild(t2);
let count = 0;
const observer = new ResizeObserver(() => { count += 1; });
observer.observe(t1);
observer.observe(t2);
observer.disconnect();
t1.style.width = '30px';
testing.onload(() => {
testing.expectEqual(0, count);
});
}
</script>
<script id="observe_twice_is_one">
{
const t = document.createElement('div');
t.style.width = '100px';
document.body.appendChild(t);
let entryCount = 0;
const observer = new ResizeObserver((entries) => { entryCount += entries.length; });
observer.observe(t);
observer.observe(t);
testing.onload(() => {
// observing the same target twice still yields a single entry
testing.expectEqual(1, entryCount);
observer.disconnect();
});
}
</script>

View File

@@ -1,4 +1,4 @@
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
@@ -16,39 +16,238 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// 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", .{});
}