Skip to content

Commit f6d77af

Browse files
authored
Merge pull request #1130 from lightpanda-io/intersection_observer
Rework IntersectionObserver
2 parents 1b462da + 75e0637 commit f6d77af

File tree

7 files changed

+503
-285
lines changed

7 files changed

+503
-285
lines changed

src/browser/Scheduler.zig

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ pub fn init(allocator: Allocator) Scheduler {
3737
}
3838

3939
pub fn reset(self: *Scheduler) void {
40-
self.high_priority.clearRetainingCapacity();
41-
self.low_priority.clearRetainingCapacity();
40+
// Our allocator is the page arena, it's been reset. We cannot use
41+
// clearAndRetainCapacity, since that space is no longer ours
42+
self.high_priority.clearAndFree();
43+
self.low_priority.clearAndFree();
4244
}
4345

4446
const AddOpts = struct {
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
// Copyright (C) 2023-2025 Lightpanda (Selecy SAS)
2+
//
3+
// Francis Bouvier <francis@lightpanda.io>
4+
// Pierre Tachoire <pierre@lightpanda.io>
5+
//
6+
// This program is free software: you can redistribute it and/or modify
7+
// it under the terms of the GNU Affero General Public License as
8+
// published by the Free Software Foundation, either version 3 of the
9+
// License, or (at your option) any later version.
10+
//
11+
// This program is distributed in the hope that it will be useful,
12+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
// GNU Affero General Public License for more details.
15+
//
16+
// You should have received a copy of the GNU Affero General Public License
17+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
19+
const std = @import("std");
20+
21+
const js = @import("../js/js.zig");
22+
const log = @import("../../log.zig");
23+
const parser = @import("../netsurf.zig");
24+
const Page = @import("../page.zig").Page;
25+
const Node = @import("node.zig").Node;
26+
const Element = @import("element.zig").Element;
27+
28+
pub const Interfaces = .{
29+
IntersectionObserver,
30+
Entry,
31+
};
32+
33+
// This implementation attempts to be as less wrong as possible. Since we don't
34+
// render, or know how things are positioned, our best guess isn't very good.
35+
const IntersectionObserver = @This();
36+
page: *Page,
37+
root: *parser.Node,
38+
callback: js.Function,
39+
event_node: parser.EventNode,
40+
observed_entries: std.ArrayList(Entry),
41+
pending_elements: std.ArrayList(*parser.Element),
42+
ready_elements: std.ArrayList(*parser.Element),
43+
44+
pub fn constructor(callback: js.Function, opts_: ?IntersectionObserverOptions, page: *Page) !*IntersectionObserver {
45+
const opts = opts_ orelse IntersectionObserverOptions{};
46+
47+
const self = try page.arena.create(IntersectionObserver);
48+
self.* = .{
49+
.page = page,
50+
.callback = callback,
51+
.ready_elements = .{},
52+
.observed_entries = .{},
53+
.pending_elements = .{},
54+
.event_node = .{ .func = mutationCallback },
55+
.root = opts.root orelse parser.documentToNode(parser.documentHTMLToDocument(page.window.document)),
56+
};
57+
58+
_ = try parser.eventTargetAddEventListener(
59+
parser.toEventTarget(parser.Node, self.root),
60+
"DOMNodeInserted",
61+
&self.event_node,
62+
false,
63+
);
64+
65+
_ = try parser.eventTargetAddEventListener(
66+
parser.toEventTarget(parser.Node, self.root),
67+
"DOMNodeRemoved",
68+
&self.event_node,
69+
false,
70+
);
71+
72+
return self;
73+
}
74+
75+
pub fn _disconnect(self: *IntersectionObserver) !void {
76+
// We don't free as it is on an arena
77+
self.ready_elements = .{};
78+
self.observed_entries = .{};
79+
self.pending_elements = .{};
80+
}
81+
82+
pub fn _observe(self: *IntersectionObserver, target_element: *parser.Element, page: *Page) !void {
83+
for (self.observed_entries.items) |*observer| {
84+
if (observer.target == target_element) {
85+
return; // Already observed
86+
}
87+
}
88+
89+
if (self.isPending(target_element)) {
90+
return; // Already pending
91+
}
92+
93+
for (self.ready_elements.items) |element| {
94+
if (element == target_element) {
95+
return; // Already primed
96+
}
97+
}
98+
99+
// We can never fire callbacks synchronously. Code like React expects any
100+
// callback to fire in the future (e.g. via microtasks).
101+
try self.ready_elements.append(self.page.arena, target_element);
102+
if (self.ready_elements.items.len == 1) {
103+
// this is our first ready entry, schedule a callback
104+
try page.scheduler.add(self, processReady, 0, .{
105+
.name = "intersection ready",
106+
});
107+
}
108+
}
109+
110+
pub fn _unobserve(self: *IntersectionObserver, target: *parser.Element) !void {
111+
if (self.removeObserved(target)) {
112+
return;
113+
}
114+
115+
for (self.ready_elements.items, 0..) |el, index| {
116+
if (el == target) {
117+
_ = self.ready_elements.swapRemove(index);
118+
return;
119+
}
120+
}
121+
122+
for (self.pending_elements.items, 0..) |el, index| {
123+
if (el == target) {
124+
_ = self.pending_elements.swapRemove(index);
125+
return;
126+
}
127+
}
128+
}
129+
130+
pub fn _takeRecords(self: *IntersectionObserver) []Entry {
131+
return self.observed_entries.items;
132+
}
133+
134+
fn processReady(ctx: *anyopaque) ?u32 {
135+
const self: *IntersectionObserver = @ptrCast(@alignCast(ctx));
136+
self._processReady() catch |err| {
137+
log.err(.web_api, "intersection ready", .{ .err = err });
138+
};
139+
return null;
140+
}
141+
142+
fn _processReady(self: *IntersectionObserver) !void {
143+
defer self.ready_elements.clearRetainingCapacity();
144+
for (self.ready_elements.items) |element| {
145+
// IntersectionObserver probably doesn't work like what your intuition
146+
// thinks. As long as a node has a parent, even if that parent isn't
147+
// connected and even if the two nodes don't intersect, it'll fire the
148+
// callback once.
149+
if (try Node.get_parentNode(@ptrCast(element)) == null) {
150+
if (!self.isPending(element)) {
151+
try self.pending_elements.append(self.page.arena, element);
152+
}
153+
continue;
154+
}
155+
try self.forceObserve(element);
156+
}
157+
}
158+
159+
fn isPending(self: *IntersectionObserver, element: *parser.Element) bool {
160+
for (self.pending_elements.items) |el| {
161+
if (el == element) {
162+
return true;
163+
}
164+
}
165+
return false;
166+
}
167+
168+
fn mutationCallback(en: *parser.EventNode, event: *parser.Event) void {
169+
const mutation_event = parser.eventToMutationEvent(event);
170+
const self: *IntersectionObserver = @fieldParentPtr("event_node", en);
171+
self._mutationCallback(mutation_event) catch |err| {
172+
log.err(.web_api, "mutation callback", .{ .err = err, .source = "intersection observer" });
173+
};
174+
}
175+
176+
fn _mutationCallback(self: *IntersectionObserver, event: *parser.MutationEvent) !void {
177+
const event_type = parser.eventType(@ptrCast(event));
178+
179+
if (std.mem.eql(u8, event_type, "DOMNodeInserted")) {
180+
const node = parser.mutationEventRelatedNode(event) catch return orelse return;
181+
if (parser.nodeType(node) != .element) {
182+
return;
183+
}
184+
const el: *parser.Element = @ptrCast(node);
185+
if (self.removePending(el)) {
186+
// It was pending (because it wasn't in the root), but now it is
187+
// we should observe it.
188+
try self.forceObserve(el);
189+
}
190+
return;
191+
}
192+
193+
if (std.mem.eql(u8, event_type, "DOMNodeRemoved")) {
194+
const node = parser.mutationEventRelatedNode(event) catch return orelse return;
195+
if (parser.nodeType(node) != .element) {
196+
return;
197+
}
198+
199+
const el: *parser.Element = @ptrCast(node);
200+
if (self.removeObserved(el)) {
201+
// It _was_ observed, it no longer is in our root, but if it was
202+
// to get re-added, it should be observed again (I think), so
203+
// we add it to our pending list
204+
try self.pending_elements.append(self.page.arena, el);
205+
}
206+
207+
return;
208+
}
209+
210+
// impossible event type
211+
unreachable;
212+
}
213+
214+
// Exists to skip the checks made _observe when called from a DOMNodeInserted
215+
// event. In such events, the event handler has alread done the necessary
216+
// checks.
217+
fn forceObserve(self: *IntersectionObserver, target: *parser.Element) !void {
218+
try self.observed_entries.append(self.page.arena, .{
219+
.page = self.page,
220+
.root = self.root,
221+
.target = target,
222+
});
223+
224+
var result: js.Function.Result = undefined;
225+
self.callback.tryCall(void, .{self.observed_entries.items}, &result) catch {
226+
log.debug(.user_script, "callback error", .{
227+
.err = result.exception,
228+
.stack = result.stack,
229+
.source = "intersection observer",
230+
});
231+
};
232+
}
233+
234+
fn removeObserved(self: *IntersectionObserver, target: *parser.Element) bool {
235+
for (self.observed_entries.items, 0..) |*observer, index| {
236+
if (observer.target == target) {
237+
_ = self.observed_entries.swapRemove(index);
238+
return true;
239+
}
240+
}
241+
return false;
242+
}
243+
244+
fn removePending(self: *IntersectionObserver, target: *parser.Element) bool {
245+
for (self.pending_elements.items, 0..) |el, index| {
246+
if (el == target) {
247+
_ = self.pending_elements.swapRemove(index);
248+
return true;
249+
}
250+
}
251+
return false;
252+
}
253+
254+
const IntersectionObserverOptions = struct {
255+
root: ?*parser.Node = null, // Element or Document
256+
rootMargin: ?[]const u8 = "0px 0px 0px 0px",
257+
threshold: ?Threshold = .{ .single = 0.0 },
258+
259+
const Threshold = union(enum) {
260+
single: f32,
261+
list: []const f32,
262+
};
263+
};
264+
265+
// https://developer.mozilla.org/en-US/docs/Web/API/Entry
266+
// https://w3c.github.io/IntersectionObserver/#intersection-observer-entry
267+
pub const Entry = struct {
268+
page: *Page,
269+
root: *parser.Node,
270+
target: *parser.Element,
271+
272+
// Returns the bounds rectangle of the target element as a DOMRectReadOnly. The bounds are computed as described in the documentation for Element.getBoundingClientRect().
273+
pub fn get_boundingClientRect(self: *const Entry) !Element.DOMRect {
274+
return Element._getBoundingClientRect(self.target, self.page);
275+
}
276+
277+
// Returns the ratio of the intersectionRect to the boundingClientRect.
278+
pub fn get_intersectionRatio(_: *const Entry) f32 {
279+
return 1.0;
280+
}
281+
282+
// Returns a DOMRectReadOnly representing the target's visible area.
283+
pub fn get_intersectionRect(self: *const Entry) !Element.DOMRect {
284+
return Element._getBoundingClientRect(self.target, self.page);
285+
}
286+
287+
// A Boolean value which is true if the target element intersects with the
288+
// intersection observer's root. If this is true, then, the
289+
// Entry describes a transition into a state of
290+
// intersection; if it's false, then you know the transition is from
291+
// intersecting to not-intersecting.
292+
pub fn get_isIntersecting(_: *const Entry) bool {
293+
return true;
294+
}
295+
296+
// Returns a DOMRectReadOnly for the intersection observer's root.
297+
pub fn get_rootBounds(self: *const Entry) !Element.DOMRect {
298+
const root = self.root;
299+
if (@intFromPtr(root) == @intFromPtr(self.page.window.document)) {
300+
return self.page.renderer.boundingRect();
301+
}
302+
303+
const root_type = parser.nodeType(root);
304+
305+
var element: *parser.Element = undefined;
306+
switch (root_type) {
307+
.element => element = parser.nodeToElement(root),
308+
.document => {
309+
const doc = parser.nodeToDocument(root);
310+
element = (try parser.documentGetDocumentElement(doc)).?;
311+
},
312+
else => return error.InvalidState,
313+
}
314+
315+
return Element._getBoundingClientRect(element, self.page);
316+
}
317+
318+
// The Element whose intersection with the root changed.
319+
pub fn get_target(self: *const Entry) *parser.Element {
320+
return self.target;
321+
}
322+
323+
// TODO: pub fn get_time(self: *const Entry)
324+
};
325+
326+
const testing = @import("../../testing.zig");
327+
test "Browser: DOM.IntersectionObserver" {
328+
try testing.htmlRunner("dom/intersection_observer.html");
329+
}

src/browser/dom/dom.zig

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ const NodeList = @import("nodelist.zig");
2525
const Node = @import("node.zig");
2626
const ResizeObserver = @import("resize_observer.zig");
2727
const MutationObserver = @import("mutation_observer.zig");
28-
const IntersectionObserver = @import("intersection_observer.zig");
2928
const DOMParser = @import("dom_parser.zig").DOMParser;
3029
const TreeWalker = @import("tree_walker.zig").TreeWalker;
3130
const NodeIterator = @import("node_iterator.zig").NodeIterator;
@@ -44,7 +43,6 @@ pub const Interfaces = .{
4443
Node.Interfaces,
4544
ResizeObserver.Interfaces,
4645
MutationObserver.Interfaces,
47-
IntersectionObserver.Interfaces,
4846
DOMParser,
4947
TreeWalker,
5048
NodeIterator,
@@ -54,4 +52,5 @@ pub const Interfaces = .{
5452
@import("range.zig").Interfaces,
5553
@import("Animation.zig"),
5654
@import("MessageChannel.zig").Interfaces,
55+
@import("IntersectionObserver.zig").Interfaces,
5756
};

0 commit comments

Comments
 (0)