Skip to content

Commit e880b18

Browse files
committed
Rework MutationObserver callback.
Previously, MutationObserver callbacks where called using the `jsCallScopeEnd` mechanism. This was slow and resulted in records split in a way that callers might not expect. `jsCallScopeEnd` has been removed. The new approach uses the loop.timeout mechanism, much like a window.setTimeout and only registers a timeout when events have been handled. It should perform much better. Exactly how MutationRecords are supposed to be grouped is still a mystery to me. This new grouping is still wrong in many cases (according to WPT), but appears slightly less wrong; I'm pretty hopeful clients don't really have hard-coded expectations for this though. Also implement the attributeFilter option of MutationObserver. (Github)
1 parent 300428d commit e880b18

File tree

3 files changed

+96
-69
lines changed

3 files changed

+96
-69
lines changed

src/browser/dom/mutation_observer.zig

Lines changed: 94 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const Allocator = std.mem.Allocator;
2222
const log = @import("../../log.zig");
2323
const parser = @import("../netsurf.zig");
2424
const Page = @import("../page.zig").Page;
25+
const Loop = @import("../../runtime/loop.zig").Loop;
2526

2627
const Env = @import("../env.zig").Env;
2728
const NodeList = @import("nodelist.zig").NodeList;
@@ -35,25 +36,37 @@ const Walker = @import("../dom/walker.zig").WalkerChildren;
3536

3637
// WEB IDL https://dom.spec.whatwg.org/#interface-mutationobserver
3738
pub const MutationObserver = struct {
39+
loop: *Loop,
3840
cbk: Env.Function,
3941
arena: Allocator,
42+
connected: bool,
43+
scheduled: bool,
44+
loop_node: Loop.CallbackNode,
4045

4146
// List of records which were observed. When the call scope ends, we need to
4247
// execute our callback with it.
43-
observed: std.ArrayListUnmanaged(*MutationRecord),
48+
observed: std.ArrayListUnmanaged(MutationRecord),
4449

4550
pub fn constructor(cbk: Env.Function, page: *Page) !MutationObserver {
4651
return .{
4752
.cbk = cbk,
53+
.loop = page.loop,
4854
.observed = .{},
55+
.connected = true,
56+
.scheduled = false,
4957
.arena = page.arena,
58+
.loop_node = .{.func = callback},
5059
};
5160
}
5261

53-
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?MutationObserverInit) !void {
54-
const options = options_ orelse MutationObserverInit{};
62+
pub fn _observe(self: *MutationObserver, node: *parser.Node, options_: ?Options) !void {
63+
const arena = self.arena;
64+
var options = options_ orelse Options{};
65+
if (options.attributeFilter.len > 0) {
66+
options.attributeFilter = try arena.dupe([]const u8, options.attributeFilter);
67+
}
5568

56-
const observer = try self.arena.create(Observer);
69+
const observer = try arena.create(Observer);
5770
observer.* = .{
5871
.node = node,
5972
.options = options,
@@ -102,30 +115,34 @@ pub const MutationObserver = struct {
102115
}
103116
}
104117

105-
pub fn jsCallScopeEnd(self: *MutationObserver) void {
106-
const record = self.observed.items;
107-
if (record.len == 0) {
118+
fn callback(node: *Loop.CallbackNode, _: *?u63) void {
119+
const self: *MutationObserver = @fieldParentPtr("loop_node", node);
120+
if (self.connected == false) {
121+
self.scheduled = true;
122+
return;
123+
}
124+
self.scheduled = false;
125+
126+
const records = self.observed.items;
127+
if (records.len == 0) {
108128
return;
109129
}
110130

111131
defer self.observed.clearRetainingCapacity();
112132

113-
for (record) |r| {
114-
const records = [_]MutationRecord{r.*};
115-
var result: Env.Function.Result = undefined;
116-
self.cbk.tryCall(void, .{records}, &result) catch {
117-
log.debug(.user_script, "callback error", .{
118-
.err = result.exception,
119-
.stack = result.stack,
120-
.source = "mutation observer",
121-
});
122-
};
123-
}
133+
var result: Env.Function.Result = undefined;
134+
self.cbk.tryCall(void, .{records}, &result) catch {
135+
log.debug(.user_script, "callback error", .{
136+
.err = result.exception,
137+
.stack = result.stack,
138+
.source = "mutation observer",
139+
});
140+
};
124141
}
125142

126143
// TODO
127-
pub fn _disconnect(_: *MutationObserver) !void {
128-
// TODO unregister listeners.
144+
pub fn _disconnect(self: *MutationObserver) !void {
145+
self.connected = false;
129146
}
130147

131148
// TODO
@@ -182,59 +199,65 @@ pub const MutationRecord = struct {
182199
}
183200
};
184201

185-
const MutationObserverInit = struct {
202+
const Options = struct {
186203
childList: bool = false,
187204
attributes: bool = false,
188205
characterData: bool = false,
189206
subtree: bool = false,
190207
attributeOldValue: bool = false,
191208
characterDataOldValue: bool = false,
192-
// TODO
193-
// attributeFilter: [][]const u8,
209+
attributeFilter: [][]const u8 = &.{},
194210

195-
fn attr(self: MutationObserverInit) bool {
196-
return self.attributes or self.attributeOldValue;
211+
fn attr(self: Options) bool {
212+
return self.attributes or self.attributeOldValue or self.attributeFilter.len > 0;
197213
}
198214

199-
fn cdata(self: MutationObserverInit) bool {
215+
fn cdata(self: Options) bool {
200216
return self.characterData or self.characterDataOldValue;
201217
}
202218
};
203219

204220
const Observer = struct {
205221
node: *parser.Node,
206-
options: MutationObserverInit,
207-
208-
// record of the mutation, all observed changes in 1 call are batched
209-
record: ?MutationRecord = null,
222+
options: Options,
210223

211224
// reference back to the MutationObserver so that we can access the arena
212225
// and batch the mutation records.
213226
mutation_observer: *MutationObserver,
214227

215228
event_node: parser.EventNode,
216229

217-
fn appliesTo(o: *const Observer, target: *parser.Node) bool {
230+
fn appliesTo(self: *const Observer, target: *parser.Node, event_type: MutationEventType, event: *parser.MutationEvent,) !bool {
231+
if (event_type == .DOMAttrModified and self.options.attributeFilter.len > 0) {
232+
const attribute_name = try parser.mutationEventAttributeName(event);
233+
for (self.options.attributeFilter) |needle| blk: {
234+
if (std.mem.eql(u8, attribute_name, needle)) {
235+
break :blk;
236+
}
237+
}
238+
return false;
239+
}
240+
218241
// mutation on any target is always ok.
219-
if (o.options.subtree) {
242+
if (self.options.subtree) {
220243
return true;
221244
}
222245

223246
// if target equals node, alway ok.
224-
if (target == o.node) {
247+
if (target == self.node) {
225248
return true;
226249
}
227250

228251
// no subtree, no same target and no childlist, always noky.
229-
if (!o.options.childList) {
252+
if (!self.options.childList) {
230253
return false;
231254
}
232255

233256
// target must be a child of o.node
234257
const walker = Walker{};
235258
var next: ?*parser.Node = null;
236259
while (true) {
237-
next = walker.get_next(o.node, next) catch break orelse break;
260+
next = walker.get_next(self.node, next) catch break orelse break;
238261
if (next.? == target) {
239262
return true;
240263
}
@@ -258,27 +281,22 @@ const Observer = struct {
258281
break :blk parser.eventTargetToNode(event_target);
259282
};
260283

261-
if (self.appliesTo(node) == false) {
262-
return;
263-
}
264-
284+
const mutation_event = parser.eventToMutationEvent(event);
265285
const event_type = blk: {
266286
const t = try parser.eventType(event);
267287
break :blk std.meta.stringToEnum(MutationEventType, t) orelse return;
268288
};
269289

270-
const arena = mutation_observer.arena;
271-
if (self.record == null) {
272-
self.record = .{
273-
.target = self.node,
274-
.type = event_type.recordType(),
275-
};
276-
try mutation_observer.observed.append(arena, &self.record.?);
290+
if (try self.appliesTo(node, event_type, mutation_event) == false) {
291+
return;
277292
}
278293

279-
var record = &self.record.?;
280-
const mutation_event = parser.eventToMutationEvent(event);
294+
var record = MutationRecord{
295+
.target = self.node,
296+
.type = event_type.recordType(),
297+
};
281298

299+
const arena = mutation_observer.arena;
282300
switch (event_type) {
283301
.DOMAttrModified => {
284302
record.attribute_name = parser.mutationEventAttributeName(mutation_event) catch null;
@@ -302,6 +320,13 @@ const Observer = struct {
302320
}
303321
},
304322
}
323+
324+
try mutation_observer.observed.append(arena, record);
325+
326+
if (mutation_observer.scheduled == false) {
327+
mutation_observer.scheduled = true;
328+
_ = try mutation_observer.loop.timeout(0, &mutation_observer.loop_node);
329+
}
305330
}
306331
};
307332

@@ -341,10 +366,9 @@ test "Browser.DOM.MutationObserver" {
341366
\\ document.firstElementChild.setAttribute("foo", "bar");
342367
\\ // ignored b/c it's about another target.
343368
\\ document.firstElementChild.firstChild.setAttribute("foo", "bar");
344-
\\ nb;
345-
,
346-
"1",
369+
,null
347370
},
371+
.{ "nb", "1"},
348372
.{ "mrs[0].type", "attributes" },
349373
.{ "mrs[0].target == document.firstElementChild", "true" },
350374
.{ "mrs[0].target.getAttribute('foo')", "bar" },
@@ -362,10 +386,9 @@ test "Browser.DOM.MutationObserver" {
362386
\\ nb2++;
363387
\\ }).observe(node, { characterData: true, characterDataOldValue: true });
364388
\\ node.data = "foo";
365-
\\ nb2;
366-
,
367-
"1",
389+
, null
368390
},
391+
.{ "nb2", "1"},
369392
.{ "mrs2[0].type", "characterData" },
370393
.{ "mrs2[0].target == node", "true" },
371394
.{ "mrs2[0].target.data", "foo" },
@@ -382,8 +405,23 @@ test "Browser.DOM.MutationObserver" {
382405
\\ node.innerText = 'a';
383406
\\ }).observe(document, { subtree:true,childList:true });
384407
\\ node.innerText = "2";
385-
,
386-
"2",
408+
, null
409+
},
410+
.{"node.innerText", "a"},
411+
}, .{});
412+
413+
try runner.testCases(&.{
414+
.{
415+
\\ var node = document.getElementById("para");
416+
\\ var attrWatch = 0;
417+
\\ new MutationObserver(() => {
418+
\\ attrWatch++;
419+
\\ }).observe(document, { attributeFilter: ["name"], subtree: true });
420+
\\ node.setAttribute("id", "1");
421+
, null
387422
},
423+
.{"attrWatch", "0"},
424+
.{ "node.setAttribute('name', 'other');", null},
425+
.{ "attrWatch", "1"},
388426
}, .{});
389427
}

src/browser/page.zig

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,10 @@ pub const Page = struct {
122122
// load polyfills
123123
try polyfill.load(self.arena, self.main_context);
124124

125-
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
126125
// message loop must run only non-test env
127126
if (comptime !builtin.is_test) {
128-
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.messageloop_node);
127+
_ = try session.browser.app.loop.timeout(1 * std.time.ns_per_ms, &self.microtask_node);
128+
_ = try session.browser.app.loop.timeout(100 * std.time.ns_per_ms, &self.messageloop_node);
129129
}
130130
}
131131

src/runtime/js.zig

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -595,9 +595,6 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
595595
// Some Zig types have code to execute to cleanup
596596
destructor_callbacks: std.ArrayListUnmanaged(DestructorCallback) = .empty,
597597

598-
// Some Zig types have code to execute when the call scope ends
599-
call_scope_end_callbacks: std.ArrayListUnmanaged(CallScopeEndCallback) = .empty,
600-
601598
// Our module cache: normalized module specifier => module.
602599
module_cache: std.StringHashMapUnmanaged(PersistentModule) = .empty,
603600

@@ -828,10 +825,6 @@ pub fn Env(comptime State: type, comptime WebApis: type) type {
828825
try self.destructor_callbacks.append(context_arena, DestructorCallback.init(value));
829826
}
830827

831-
if (comptime @hasDecl(ptr.child, "jsCallScopeEnd")) {
832-
try self.call_scope_end_callbacks.append(context_arena, CallScopeEndCallback.init(value));
833-
}
834-
835828
// Sometimes we're creating a new v8.Object, like when
836829
// we're returning a value from a function. In those cases
837830
// we have the FunctionTemplate, and we can get an object
@@ -2639,10 +2632,6 @@ fn Caller(comptime E: type, comptime State: type) type {
26392632
// Therefore, we keep a call_depth, and only reset the call_arena
26402633
// when a top-level (call_depth == 0) function ends.
26412634
if (call_depth == 0) {
2642-
for (js_context.call_scope_end_callbacks.items) |cb| {
2643-
cb.callScopeEnd();
2644-
}
2645-
26462635
const arena: *ArenaAllocator = @alignCast(@ptrCast(js_context.call_arena.ptr));
26472636
_ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN });
26482637
}

0 commit comments

Comments
 (0)