Files
browser/src/browser/webapi/IntersectionObserver.zig
Karl Seguin 25d8b7d5c7 mem: add local_arena
The `call_arena` is currently our shortest-lived arena. Its promise is that it
will be valid for at least 1 v8 -> zig -> v8 function call, which makes it ideal
for getting values from v8 into zig, temporary work, and getting values from
zig to v8.

VBut `call_arena`'s lifetime is actually much longer. It's only reset when the
call_depth reaches 0. And the reason for that are Zig functions that invoke
v8 callbacks. If you just do it when a function ends, then if you allocate in
ZigA and then call CallbackA which calls ZigB, then when ZigB ends, your ZigA
allocation is cleared. This is something we could solve by reaching into
Zig's ArenaAllocator to capture a position to rollback from.

So, `call_arena` can end up living relatively long and accumulating quite a bit
of memory. But most calls _don't_ invoke callbacks. Hence, `local_arena` IS
reset at the end of every function.

Using `local_arena` _is_ dangerous, mostly for the case where some of our APIs
receive a *Frame (or *Execution) and thus might use a `local_arena` because they
know they aren't invoking a JS callback. BUT, those APIs might not know that
they're also invoked by some other Zig code that _could_ be invoking JS.

Dangerous? Sure. But, github has code that looks like:

```js
function onDelegatedClick(e) {
  for (const el of document.querySelectorAll('[data-action]')) {
    if (el.matches('.menu > .item:not(.disabled) a[href]')) {
      handle(el);
    }
    el.closest('.panel');
  }
}
```

Anything allocated in the `call_arena` will only be freed when the caller of
`onDelegatedClick` ends. The `querySelectorAll` returns hundreds of elements
and thus builds hundreds of parsed CSS and other scrap (x2 for `matches` and
closest`). `call_arena` peaks at 15MB. With the local_arena? 1MB. If 10x more
elements were returned, the peak would be 10x higher. With local_arena, it
stays 1MB.
2026-07-02 21:06:51 +08:00

415 lines
14 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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/>.
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 Node = @import("Node.zig");
const Element = @import("Element.zig");
const DOMRect = @import("DOMRect.zig");
const log = lp.log;
const Allocator = std.mem.Allocator;
pub fn registerTypes() []const type {
return &.{
IntersectionObserver,
IntersectionObserverEntry,
};
}
const IntersectionObserver = @This();
_rc: lp.RC(u8) = .{},
_arena: Allocator,
_callback: js.Function.Temp,
_observing: std.ArrayList(*Element) = .{},
_root: ?*Element = null,
_root_margin: []const u8 = "0px",
_threshold: []const f64 = &.{0.0},
_pending_entries: std.ArrayList(*IntersectionObserverEntry) = .{},
// tracked targets that aren't reported yet
_tracked: std.AutoHashMapUnmanaged(*Element, void) = .{},
// Shared zero DOMRect to avoid repeated allocations for non-intersecting elements
var zero_rect: DOMRect = .{
._x = 0.0,
._y = 0.0,
._width = 0.0,
._height = 0.0,
};
pub const ObserverInit = struct {
root: ?*Node = null,
rootMargin: ?[]const u8 = null,
threshold: Threshold = .{ .scalar = 0.0 },
const Threshold = union(enum) {
scalar: f64,
array: []const f64,
};
};
pub fn init(callback: js.Function.Temp, options: ?ObserverInit, frame: *Frame) !*IntersectionObserver {
const arena = try frame.getArena(.small, "IntersectionObserver");
errdefer frame.releaseArena(arena);
const opts = options orelse ObserverInit{};
const root_margin = if (opts.rootMargin) |rm| try arena.dupe(u8, rm) else "0px";
const threshold = switch (opts.threshold) {
.scalar => |s| blk: {
const arr = try arena.alloc(f64, 1);
arr[0] = s;
break :blk arr;
},
.array => |arr| try arena.dupe(f64, arr),
};
const root: ?*Element = blk: {
const root_opt = opts.root orelse break :blk null;
switch (root_opt._type) {
.element => |el| break :blk el,
.document => {
// not strictly correct, `null` means the viewport, not the
// entire document, but since we don't render anything, this
// should be fine.
break :blk null;
},
else => return error.TypeError,
}
};
const self = try arena.create(IntersectionObserver);
self.* = .{
._arena = arena,
._callback = callback,
._root = root,
._root_margin = root_margin,
._threshold = threshold,
};
return self;
}
pub fn deinit(self: *IntersectionObserver, page: *Page) void {
self._callback.release();
for (self._pending_entries.items) |entry| {
// These were never handed to v8, they do not have a corresponding
// FinalizerCallback. We 100% own them.
entry.deinit(page);
}
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *IntersectionObserver, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *IntersectionObserver) void {
self._rc.acquire();
}
pub fn observe(self: *IntersectionObserver, target: *Element, frame: *Frame) !void {
// Check if already observing this target
for (self._observing.items) |elem| {
if (elem == target) {
return;
}
}
try self._observing.append(self._arena, target);
if (self._observing.items.len == 1) {
try Frame.observers.registerIntersectionObserver(frame, self);
}
try self._tracked.put(self._arena, target, {});
// Check intersection for this new target and schedule delivery
try self.checkIntersection(target, frame);
if (self._pending_entries.items.len > 0) {
try Frame.observers.scheduleIntersectionDelivery(frame);
}
}
pub fn unobserve(self: *IntersectionObserver, target: *Element, frame: *Frame) void {
const original_length = self._observing.items.len;
for (self._observing.items, 0..) |elem, i| {
if (elem == target) {
_ = self._observing.swapRemove(i);
_ = self._tracked.remove(target);
// Remove any pending entries for this target.
// Entries will be cleaned up by V8 GC via the finalizer.
var j: usize = 0;
while (j < self._pending_entries.items.len) {
if (self._pending_entries.items[j]._target == target) {
const entry = self._pending_entries.swapRemove(j);
entry.deinit(frame._page);
} else {
j += 1;
}
}
break;
}
}
if (original_length > 0 and self._observing.items.len == 0) {
Frame.observers.unregisterIntersectionObserver(frame, self);
}
}
pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void {
for (self._pending_entries.items) |entry| {
entry.deinit(frame._page);
}
self._pending_entries.clearRetainingCapacity();
self._tracked.clearRetainingCapacity();
if (self._observing.items.len > 0) {
Frame.observers.unregisterIntersectionObserver(frame, self);
}
self._observing.clearRetainingCapacity();
}
pub fn takeRecords(self: *IntersectionObserver, frame: *Frame) ![]*IntersectionObserverEntry {
const entries = try frame.local_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items);
self._pending_entries.clearRetainingCapacity();
return entries;
}
fn calculateIntersection(
self: *IntersectionObserver,
target: *Element,
has_parent: bool,
frame: *Frame,
) !IntersectionData {
const target_rect = target.getBoundingClientRect(frame);
// Use root element's rect or the faux-layout viewport.
const root_rect = if (self._root) |root|
root.getBoundingClientRect(frame)
else blk: {
const viewport = frame._page.getViewport();
break :blk DOMRect{
._x = 0.0,
._y = 0.0,
._width = @floatFromInt(viewport.width),
._height = @floatFromInt(viewport.height),
};
};
// For a headless browser without real layout, we treat all elements as fully visible.
// This avoids fingerprinting issues (massive viewports) and matches the behavior
// scripts expect when querying element visibility.
// However, elements without a parent cannot intersect (they have no containing block).
const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0;
// Intersection rect is the same as the target rect if visible, otherwise zero rect
const intersection_rect = if (has_parent) target_rect else zero_rect;
return .{
.is_intersecting = has_parent,
.intersection_ratio = intersection_ratio,
.intersection_rect = intersection_rect,
.bounding_client_rect = target_rect,
.root_bounds = root_rect,
};
}
const IntersectionData = struct {
is_intersecting: bool,
intersection_ratio: f64,
intersection_rect: DOMRect,
bounding_client_rect: DOMRect,
root_bounds: DOMRect,
};
fn meetsThreshold(self: *IntersectionObserver, ratio: f64) bool {
for (self._threshold) |threshold| {
if (ratio >= threshold) {
return true;
}
}
return false;
}
fn checkIntersection(self: *IntersectionObserver, target: *Element, frame: *Frame) !void {
const tracked = self._tracked.getEntry(target) orelse {
// If target isn't tracked then it was already reported. This is an
// important optimization which lets us skip the heavy work below.
// This function can be called a lot.
return;
};
const has_parent = target.asNode().parentNode() != null;
const is_now_intersecting = has_parent and self.meetsThreshold(1.0);
if (!is_now_intersecting) {
// Not intersecting yet (e.g. observed while still orphaned) — keep
// tracking so a later attach is reported.
return;
}
// Building the entry is the expensive part — getBoundingClientRect walks the
// document to fake a position (O(node count)) — so it only runs here.
const data = try self.calculateIntersection(target, has_parent, frame);
const arena = try frame.getArena(.tiny, "IntersectionObserverEntry");
errdefer frame.releaseArena(arena);
const entry = try arena.create(IntersectionObserverEntry);
entry.* = .{
._arena = arena,
._target = target,
._time = frame.window._performance.now(),
._is_intersecting = is_now_intersecting,
._root_bounds = try frame._factory.create(data.root_bounds),
._intersection_rect = try frame._factory.create(data.intersection_rect),
._bounding_client_rect = try frame._factory.create(data.bounding_client_rect),
._intersection_ratio = data.intersection_ratio,
};
try self._pending_entries.append(self._arena, entry);
_ = self._tracked.removeByPtr(tracked.key_ptr);
}
pub fn checkIntersections(self: *IntersectionObserver, frame: *Frame) !void {
if (self._observing.items.len == 0) {
return;
}
for (self._observing.items) |target| {
try self.checkIntersection(target, frame);
}
if (self._pending_entries.items.len > 0) {
try Frame.observers.scheduleIntersectionDelivery(frame);
}
}
pub fn deliverEntries(self: *IntersectionObserver, frame: *Frame) !void {
if (self._pending_entries.items.len == 0) {
return;
}
const entries = try self.takeRecords(frame);
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, self }, &caught) catch |err| {
log.err(.frame, "IntsctObserver.deliverEntries", .{ .err = err, .caught = caught });
return err;
};
}
pub const IntersectionObserverEntry = struct {
_rc: lp.RC(u8) = .{},
_arena: Allocator,
_time: f64,
_target: *Element,
_bounding_client_rect: *DOMRect,
_intersection_rect: *DOMRect,
_root_bounds: *DOMRect,
_intersection_ratio: f64,
_is_intersecting: bool,
pub fn deinit(self: *IntersectionObserverEntry, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn releaseRef(self: *IntersectionObserverEntry, page: *Page) void {
self._rc.release(self, page);
}
pub fn acquireRef(self: *IntersectionObserverEntry) void {
self._rc.acquire();
}
pub fn getTarget(self: *const IntersectionObserverEntry) *Element {
return self._target;
}
pub fn getTime(self: *const IntersectionObserverEntry) f64 {
return self._time;
}
pub fn getBoundingClientRect(self: *const IntersectionObserverEntry) *DOMRect {
return self._bounding_client_rect;
}
pub fn getIntersectionRect(self: *const IntersectionObserverEntry) *DOMRect {
return self._intersection_rect;
}
pub fn getRootBounds(self: *const IntersectionObserverEntry) ?*DOMRect {
return self._root_bounds;
}
pub fn getIntersectionRatio(self: *const IntersectionObserverEntry) f64 {
return self._intersection_ratio;
}
pub fn getIsIntersecting(self: *const IntersectionObserverEntry) bool {
return self._is_intersecting;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(IntersectionObserverEntry);
pub const Meta = struct {
pub const name = "IntersectionObserverEntry";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const target = bridge.accessor(IntersectionObserverEntry.getTarget, null, .{});
pub const time = bridge.accessor(IntersectionObserverEntry.getTime, null, .{});
pub const boundingClientRect = bridge.accessor(IntersectionObserverEntry.getBoundingClientRect, null, .{});
pub const intersectionRect = bridge.accessor(IntersectionObserverEntry.getIntersectionRect, null, .{});
pub const rootBounds = bridge.accessor(IntersectionObserverEntry.getRootBounds, null, .{});
pub const intersectionRatio = bridge.accessor(IntersectionObserverEntry.getIntersectionRatio, null, .{});
pub const isIntersecting = bridge.accessor(IntersectionObserverEntry.getIsIntersecting, null, .{});
};
};
pub const JsApi = struct {
pub const bridge = js.Bridge(IntersectionObserver);
pub const Meta = struct {
pub const name = "IntersectionObserver";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(init, .{});
pub const observe = bridge.function(IntersectionObserver.observe, .{});
pub const unobserve = bridge.function(IntersectionObserver.unobserve, .{});
pub const disconnect = bridge.function(IntersectionObserver.disconnect, .{});
pub const takeRecords = bridge.function(IntersectionObserver.takeRecords, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: IntersectionObserver" {
try testing.htmlRunner("intersection_observer", .{});
}