Skip to content

Commit 852c30b

Browse files
committed
Rework IntersectionObserver
1 - Always fire the callback on the next tick. This is probably the most important change, as frameworks like React don't react well if the callback is fired immediately (they expect to continue processing the page in its current state, not in the mutated state from the callback) 2 - Always fire the callback for observed elements with a parent, whether or not those intersect or are connected. From MDN, the callback is fired "The first time the observer is initially asked to watch a target element." 3 - Add a mutation observer so that if a node is added to the root (or removed) the callback is fired. This, I think, is the best we can currently do for "intersection".
1 parent dc85c65 commit 852c30b

File tree

4 files changed

+471
-262
lines changed

4 files changed

+471
-262
lines changed
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)