tweak timing of intersection observer and how it handles disconnected nodes

This commit is contained in:
Karl Seguin
2025-12-13 20:33:43 +08:00
parent c9b4067686
commit 0d3055716e
5 changed files with 75 additions and 18 deletions

View File

@@ -106,6 +106,7 @@ _mutation_delivery_depth: u32 = 0,
// List of active IntersectionObservers
_intersection_observers: std.ArrayList(*IntersectionObserver) = .{},
_intersection_check_scheduled: bool = false,
_intersection_delivery_scheduled: bool = false,
// Lookup for customized built-in elements. Maps element pointer to definition.
@@ -250,6 +251,7 @@ fn reset(self: *Page, comptime initializing: bool) !void {
self._mutation_delivery_scheduled = false;
self._mutation_delivery_depth = 0;
self._intersection_observers = .{};
self._intersection_check_scheduled = false;
self._intersection_delivery_scheduled = false;
self._customized_builtin_definitions = .{};
self._customized_builtin_connected_callback_invoked = .{};
@@ -781,6 +783,15 @@ pub fn scriptAddedCallback(self: *Page, script: *HtmlScript) !void {
pub fn domChanged(self: *Page) void {
self.version += 1;
if (self._intersection_check_scheduled) {
return;
}
self._intersection_check_scheduled = true;
self.js.queueIntersectionChecks() catch |err| {
log.err(.page, "page.schedIntersectChecks", .{ .err = err });
};
}
pub fn getElementIdMap(page: *Page, node: *Node) *std.StringHashMapUnmanaged(*Element) {
@@ -849,27 +860,31 @@ pub fn checkIntersections(self: *Page) !void {
}
pub fn scheduleMutationDelivery(self: *Page) !void {
// Only queue if not already scheduled
if (self._mutation_delivery_scheduled) {
return;
}
self._mutation_delivery_scheduled = true;
// Queue mutation delivery as a microtask
try self.js.queueMutationDelivery();
}
pub fn scheduleIntersectionDelivery(self: *Page) !void {
// Only queue if not already scheduled
if (self._intersection_delivery_scheduled) {
return;
}
self._intersection_delivery_scheduled = true;
// Queue intersection delivery as a microtask
try self.js.queueIntersectionDelivery();
}
pub fn performScheduledIntersectionChecks(self: *Page) void {
if (!self._intersection_check_scheduled) {
return;
}
self._intersection_check_scheduled = false;
self.checkIntersections() catch |err| {
log.err(.page, "page.schedIntersectChecks", .{ .err = err });
};
}
pub fn deliverIntersections(self: *Page) void {
if (!self._intersection_delivery_scheduled) {
return;

View File

@@ -1973,6 +1973,15 @@ pub fn queueMutationDelivery(self: *Context) !void {
}.run, self.page);
}
pub fn queueIntersectionChecks(self: *Context) !void {
self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void {
const page: *Page = @ptrCast(@alignCast(data.?));
page.performScheduledIntersectionChecks();
}
}.run, self.page);
}
pub fn queueIntersectionDelivery(self: *Context) !void {
self.isolate.enqueueMicrotask(struct {
fn run(data: ?*anyopaque) callconv(.c) void {

View File

@@ -29,3 +29,33 @@
observer.disconnect();
});
</script>
<script id=detached>
{
// never attached
let count = 0;
const div = document.createElement('div');
new IntersectionObserver((entries) => {
count += 1;
}).observe(div);
testing.eventually(() => {
testing.expectEqual(0, count);
});
}
{
// not connected, but has parent
let count = 0;
const div1 = document.createElement('div');
const div2 = document.createElement('div');
new IntersectionObserver((entries) => {
count += 1;
}).observe(div1);
div2.appendChild(div1);
testing.eventually(() => {
testing.expectEqual(1, count);
});
}
</script>

View File

@@ -22,7 +22,6 @@
const div1 = document.createElement('div');
const div2 = document.createElement('div');
new IntersectionObserver((entries) => {
console.log(entries[0]);
count += 1;
}).observe(div1);
@@ -33,7 +32,7 @@
}
</script>
<script id=reobserve>
<!-- <script id=reobserve>
{
let count = 0;
let observer = new IntersectionObserver(entries => {
@@ -160,4 +159,4 @@
], capture)
});
}
</script>
</script> -->

View File

@@ -161,11 +161,13 @@ fn calculateIntersection(
// 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.
const is_intersecting = true;
const intersection_ratio: f64 = 1.0;
// However, elements without a parent cannot intersect (they have no containing block).
const has_parent = target.asNode().parentNode() != null;
const is_intersecting = has_parent;
const intersection_ratio: f64 = if (has_parent) 1.0 else 0.0;
// Intersection rect is the same as the target rect (fully visible)
const intersection_rect = target_rect;
// 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 = is_intersecting,
@@ -199,11 +201,10 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page)
const is_now_intersecting = data.is_intersecting and self.meetsThreshold(data.intersection_ratio);
// Create entry if:
// 1. First time observing this target (was_intersecting_opt == null)
// 1. First time observing this target AND it's intersecting
// 2. State changed
// 3. Currently intersecting
const should_report = was_intersecting_opt == null or
was_intersecting_opt.? != is_now_intersecting;
const should_report = (was_intersecting_opt == null and is_now_intersecting) or
(was_intersecting_opt != null and was_intersecting_opt.? != is_now_intersecting);
if (should_report) {
const entry = try page.arena.create(IntersectionObserverEntry);
@@ -218,8 +219,11 @@ fn checkIntersection(self: *IntersectionObserver, target: *Element, page: *Page)
};
try self._pending_entries.append(page.arena, entry);
try self._previous_states.put(page.arena, target, is_now_intersecting);
}
// Always update the previous state, even if we didn't report
// This ensures we can detect state changes on subsequent checks
try self._previous_states.put(page.arena, target, is_now_intersecting);
}
pub fn checkIntersections(self: *IntersectionObserver, page: *Page) !void {