diff --git a/src/browser/Page.zig b/src/browser/Page.zig index 80bae526..7a75ddc6 100644 --- a/src/browser/Page.zig +++ b/src/browser/Page.zig @@ -49,6 +49,7 @@ const Document = @import("webapi/Document.zig"); const ShadowRoot = @import("webapi/ShadowRoot.zig"); const Performance = @import("webapi/Performance.zig"); const Screen = @import("webapi/Screen.zig"); +const PerformanceObserver = @import("webapi/PerformanceObserver.zig"); const MutationObserver = @import("webapi/MutationObserver.zig"); const IntersectionObserver = @import("webapi/IntersectionObserver.zig"); const CustomElementDefinition = @import("webapi/CustomElementDefinition.zig"); @@ -111,6 +112,11 @@ _intersection_delivery_scheduled: bool = false, _slots_pending_slotchange: std.AutoHashMapUnmanaged(*Element.Html.Slot, void) = .{}, _slotchange_delivery_scheduled: bool = false, +/// List of active PerformanceObservers. +/// Contrary to MutationObserver and IntersectionObserver, these are regular tasks. +_performance_observers: std.ArrayList(*PerformanceObserver) = .{}, +_performance_delivery_scheduled: bool = false, + // Lookup for customized built-in elements. Maps element pointer to definition. _customized_builtin_definitions: std.AutoHashMapUnmanaged(*Element, *CustomElementDefinition) = .{}, _customized_builtin_connected_callback_invoked: std.AutoHashMapUnmanaged(*Element, void) = .{}, @@ -262,6 +268,7 @@ fn reset(self: *Page, comptime initializing: bool) !void { self._notified_network_idle = .init; self._notified_network_almost_idle = .init; + self._performance_observers = .{}; self._mutation_observers = .{}; self._mutation_delivery_scheduled = false; self._mutation_delivery_depth = 0; @@ -945,6 +952,16 @@ fn printWaitAnalysis(self: *Page) void { } } +pub fn tick(self: *Page) void { + if (comptime IS_DEBUG) { + log.debug(.page, "tick", .{}); + } + _ = self.scheduler.run() catch |err| { + log.err(.page, "tick", .{ .err = err }); + }; + self.js.runMicrotasks(); +} + pub fn isGoingAway(self: *const Page) bool { return self._queued_navigation != null; } @@ -1025,6 +1042,58 @@ pub fn getElementByIdFromNode(self: *Page, node: *Node, id: []const u8) ?*Elemen return null; } +pub fn registerPerformanceObserver(self: *Page, observer: *PerformanceObserver) !void { + return self._performance_observers.append(self.arena, observer); +} + +pub fn unregisterPerformanceObserver(self: *Page, observer: *PerformanceObserver) void { + for (self._performance_observers.items, 0..) |perf_observer, i| { + if (perf_observer == observer) { + _ = self._performance_observers.swapRemove(i); + return; + } + } +} + +/// Updates performance observers with the new entry. +/// This doesn't emit callbacks but rather fills the queues of observers. +pub fn notifyPerformanceObservers(self: *Page, entry: *Performance.Entry) !void { + for (self._performance_observers.items) |observer| { + if (observer.interested(entry)) { + observer._entries.append(self.arena, entry) catch |err| { + log.err(.page, "notifyPerformanceObservers", .{ .err = err }); + }; + } + } + + // Already scheduled. + if (self._performance_delivery_scheduled) { + return; + } + self._performance_delivery_scheduled = true; + + return self.scheduler.add( + self, + struct { + fn run(_page: *anyopaque) anyerror!?u32 { + const page: *Page = @ptrCast(@alignCast(_page)); + page._performance_delivery_scheduled = false; + + // Dispatch performance observer events. + for (page._performance_observers.items) |observer| { + if (observer.hasRecords()) { + try observer.dispatch(page); + } + } + + return null; + } + }.run, + 0, + .{ .low_priority = true }, + ); +} + pub fn registerMutationObserver(self: *Page, observer: *MutationObserver) !void { self._mutation_observers.append(&observer.node); } diff --git a/src/browser/tests/performance_observer/performance_observer.html b/src/browser/tests/performance_observer/performance_observer.html new file mode 100644 index 00000000..11c82f23 --- /dev/null +++ b/src/browser/tests/performance_observer/performance_observer.html @@ -0,0 +1,38 @@ + + + + diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 5253373d..9118b2f5 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -11,7 +11,7 @@ const std = @import("std"); const Performance = @This(); _time_origin: u64, -_entries: std.ArrayListUnmanaged(*Entry) = .{}, +_entries: std.ArrayList(*Entry) = .{}, /// Get high-resolution timestamp in microseconds, rounded to 5μs increments /// to match browser behavior (prevents fingerprinting) @@ -42,9 +42,16 @@ pub fn getTimeOrigin(self: *const Performance) f64 { return @as(f64, @floatFromInt(self._time_origin)) / 1000.0; } -pub fn mark(self: *Performance, name: []const u8, _options: ?Mark.Options, page: *Page) !*Mark { +pub fn mark( + self: *Performance, + name: []const u8, + _options: ?Mark.Options, + page: *Page, +) !*Mark { const m = try Mark.init(name, _options, page); try self._entries.append(page.arena, m._proto); + // Notify about the change. + try page.notifyPerformanceObservers(m._proto); return m; } @@ -95,6 +102,8 @@ pub fn measure( page, ); try self._entries.append(page.arena, m._proto); + // Notify about the change. + try page.notifyPerformanceObservers(m._proto); return m; }, .start_mark => |start_mark| { @@ -118,12 +127,16 @@ pub fn measure( page, ); try self._entries.append(page.arena, m._proto); + // Notify about the change. + try page.notifyPerformanceObservers(m._proto); return m; }, }; const m = try Measure.init(name, null, 0.0, self.now(), null, page); try self._entries.append(page.arena, m._proto); + // Notify about the change. + try page.notifyPerformanceObservers(m._proto); return m; } @@ -230,21 +243,40 @@ pub const Entry = struct { _name: []const u8, _start_time: f64 = 0.0, - const Type = union(enum) { + pub const Type = union(Enum) { element, event, first_input, - largest_contentful_paint, - layout_shift, - long_animation_frame, + @"largest-contentful-paint", + @"layout-shift", + @"long-animation-frame", longtask, measure: *Measure, navigation, paint, resource, taskattribution, - visibility_state, + @"visibility-state", mark: *Mark, + + pub const Enum = enum(u8) { + element = 1, // Changing this affect PerformanceObserver's behavior. + event = 2, + first_input = 3, + @"largest-contentful-paint" = 4, + @"layout-shift" = 5, + @"long-animation-frame" = 6, + longtask = 7, + measure = 8, + navigation = 9, + paint = 10, + resource = 11, + taskattribution = 12, + @"visibility-state" = 13, + mark = 14, + // If we ever have types more than 16, we have to update entry + // table of PerformanceObserver too. + }; }; pub fn getDuration(self: *const Entry) f64 { @@ -253,11 +285,6 @@ pub const Entry = struct { pub fn getEntryType(self: *const Entry) []const u8 { return switch (self._type) { - .first_input => "first-input", - .largest_contentful_paint => "largest-contentful-paint", - .layout_shift => "layout-shift", - .long_animation_frame => "long-animation-frame", - .visibility_state => "visibility-state", else => |t| @tagName(t), }; } diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index cd77ad18..7e4d9c5d 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -16,41 +16,140 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +const std = @import("std"); + const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const Performance = @import("Performance.zig"); -const Entry = @import("Performance.zig").Entry; - -// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver -const PerformanceObserver = @This(); - -pub fn init(callback: js.Function) PerformanceObserver { - _ = callback; - return .{}; +pub fn registerTypes() []const type { + return &.{ PerformanceObserver, EntryList }; } -const ObserverOptions = struct { - buffered: ?bool = null, - durationThreshold: ?f64 = null, +/// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserver +const PerformanceObserver = @This(); + +/// Emitted when there are events with same interests. +_callback: js.Function, +/// The threshold to deliver `PerformanceEventTiming` entries. +_duration_threshold: f64, +/// Entry types we're looking for are encoded as bit flags. +_interests: u16, +/// Entries this observer hold. +/// Don't mutate these; other observers may hold pointers to them. +_entries: std.ArrayList(*Performance.Entry), + +const DefaultDurationThreshold: f64 = 104; + +/// Creates a new PerformanceObserver object with the given observer callback. +pub fn init(callback: js.Function, page: *Page) !*PerformanceObserver { + return page._factory.create(PerformanceObserver{ + ._callback = callback, + ._duration_threshold = DefaultDurationThreshold, + ._interests = 0, + ._entries = .{}, + }); +} + +const ObserveOptions = struct { + buffered: bool = false, + durationThreshold: f64 = DefaultDurationThreshold, entryTypes: ?[]const []const u8 = null, type: ?[]const u8 = null, }; -pub fn observe(self: *const PerformanceObserver, opts_: ?ObserverOptions) void { - _ = self; - _ = opts_; - return; +/// TODO: Support `buffered` option. +pub fn observe( + self: *PerformanceObserver, + maybe_options: ?ObserveOptions, + page: *Page, +) !void { + const options: ObserveOptions = maybe_options orelse .{}; + // Update threshold. + self._duration_threshold = @max(@floor(options.durationThreshold / 8) * 8, 16); + + const entry_types: []const []const u8 = blk: { + // More likely. + if (options.type) |entry_type| { + // Can't have both. + if (options.entryTypes != null) { + return error.TypeError; + } + + break :blk &.{entry_type}; + } + + if (options.entryTypes) |entry_types| { + break :blk entry_types; + } + + return error.TypeError; + }; + + // Update entries. + var interests: u16 = 0; + for (entry_types) |entry_type| { + const fields = @typeInfo(Performance.Entry.Type.Enum).@"enum".fields; + inline for (fields) |field| { + if (std.mem.eql(u8, field.name, entry_type)) { + const flag = @as(u16, 1) << @as(u16, field.value); + interests |= flag; + } + } + } + + // Nothing has updated; no need to go further. + if (interests == 0) { + return; + } + + // If we had no interests before, it means Page is not aware of + // this observer. + if (self._interests == 0) { + try page.registerPerformanceObserver(self); + } + + // Update interests. + self._interests = interests; } -pub fn disconnect(self: *PerformanceObserver) void { - _ = self; +pub fn disconnect(self: *PerformanceObserver, page: *Page) void { + page.unregisterPerformanceObserver(self); + // Reset observer. + self._duration_threshold = DefaultDurationThreshold; + self._interests = 0; + self._entries.clearRetainingCapacity(); } -pub fn takeRecords(_: *const PerformanceObserver) []const Entry { - return &.{}; +/// Returns the current list of PerformanceEntry objects +/// stored in the performance observer, emptying it out. +pub fn takeRecords(self: *PerformanceObserver, page: *Page) ![]*Performance.Entry { + const records = try page.call_arena.dupe(*Performance.Entry, self._entries.items); + self._entries.clearRetainingCapacity(); + return records; } -pub fn getSupportedEntryTypes(_: *const PerformanceObserver) [][]const u8 { - return &.{}; +pub fn getSupportedEntryTypes(_: *const PerformanceObserver) []const []const u8 { + return &.{ "mark", "measure" }; +} + +/// Returns true if observer interested with given entry. +pub fn interested( + self: *const PerformanceObserver, + entry: *const Performance.Entry, +) bool { + const flag = @as(u16, 1) << @intCast(@intFromEnum(entry._type)); + return self._interests & flag != 0; +} + +pub inline fn hasRecords(self: *const PerformanceObserver) bool { + return self._entries.items.len > 0; +} + +/// Runs the PerformanceObserver's callback with records; emptying it out. +pub fn dispatch(self: *PerformanceObserver, page: *Page) !void { + const records = try self.takeRecords(page); + _ = try self._callback.call(void, .{ EntryList{ ._entries = records }, self }); } pub const JsApi = struct { @@ -60,13 +159,40 @@ pub const JsApi = struct { pub const name = "PerformanceObserver"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const empty_with_no_proto = true; }; - pub const constructor = bridge.constructor(PerformanceObserver.init, .{}); + pub const constructor = bridge.constructor(PerformanceObserver.init, .{ .dom_exception = true }); - pub const observe = bridge.function(PerformanceObserver.observe, .{}); + pub const observe = bridge.function(PerformanceObserver.observe, .{ .dom_exception = true }); pub const disconnect = bridge.function(PerformanceObserver.disconnect, .{}); - pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{}); + pub const takeRecords = bridge.function(PerformanceObserver.takeRecords, .{ .dom_exception = true }); pub const supportedEntryTypes = bridge.accessor(PerformanceObserver.getSupportedEntryTypes, null, .{ .static = true }); }; + +/// List of performance events that were explicitly +/// observed via the observe() method. +/// https://developer.mozilla.org/en-US/docs/Web/API/PerformanceObserverEntryList +pub const EntryList = struct { + _entries: []*Performance.Entry, + + pub fn getEntries(self: *const EntryList) []*Performance.Entry { + return self._entries; + } + + pub const JsApi = struct { + pub const bridge = js.Bridge(EntryList); + + pub const Meta = struct { + pub const name = "PerformanceObserverEntryList"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const getEntries = bridge.function(EntryList.getEntries, .{}); + }; +}; + +const testing = @import("../../testing.zig"); +test "WebApi: PerformanceObserver" { + try testing.htmlRunner("performance_observer", .{}); +}