diff --git a/src/browser/EventManager.zig b/src/browser/EventManager.zig
index e5ff22d5..97c0a9cc 100644
--- a/src/browser/EventManager.zig
+++ b/src/browser/EventManager.zig
@@ -107,6 +107,9 @@ pub fn dispatchOpts(self: *EventManager, target: *EventTarget, event: *Event, co
event.acquireRef();
defer _ = event.releaseRef(self.page._session);
+ // Increment event count for Event Timing API
+ self.page.window._performance._event_counts.increment(event._type_string.str());
+
if (comptime IS_DEBUG) {
log.debug(.event, "eventManager.dispatch", .{ .type = event._type_string.str(), .bubbles = event._bubbles });
}
diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig
index 93c51f78..dc22a996 100644
--- a/src/browser/js/bridge.zig
+++ b/src/browser/js/bridge.zig
@@ -859,6 +859,7 @@ pub const JsApis = flattenTypes(&.{
@import("../webapi/URL.zig"),
@import("../webapi/Window.zig"),
@import("../webapi/Performance.zig"),
+ @import("../webapi/EventCounts.zig"),
@import("../webapi/PluginArray.zig"),
@import("../webapi/MutationObserver.zig"),
@import("../webapi/IntersectionObserver.zig"),
diff --git a/src/browser/tests/performance.html b/src/browser/tests/performance.html
index d0383d90..069d011c 100644
--- a/src/browser/tests/performance.html
+++ b/src/browser/tests/performance.html
@@ -393,3 +393,141 @@
}
}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/EventCounts.zig b/src/browser/webapi/EventCounts.zig
new file mode 100644
index 00000000..31b71628
--- /dev/null
+++ b/src/browser/webapi/EventCounts.zig
@@ -0,0 +1,172 @@
+// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
+//
+// Francis Bouvier
+// Pierre Tachoire
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, either version 3 of the
+// License, or (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// 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 log = @import("../../log.zig");
+
+pub fn registerTypes() []const type {
+ return &.{
+ EventCounts,
+ KeyIterator,
+ ValueIterator,
+ EntryIterator,
+ };
+}
+
+const EventCounts = @This();
+
+// Event types tracked per the Event Timing spec
+// https://w3c.github.io/event-timing/#sec-events-exposed
+const tracked_event_types = [_][]const u8{
+ "auxclick",
+ "click",
+ "contextmenu",
+ "dblclick",
+ "mousedown",
+ "mouseenter",
+ "mouseleave",
+ "mouseout",
+ "mouseover",
+ "mouseup",
+ "pointerover",
+ "pointerenter",
+ "pointerdown",
+ "pointerup",
+ "pointercancel",
+ "pointerout",
+ "pointerleave",
+ "gotpointercapture",
+ "lostpointercapture",
+ "touchstart",
+ "touchend",
+ "touchcancel",
+ "keydown",
+ "keypress",
+ "keyup",
+ "beforeinput",
+ "input",
+ "compositionstart",
+ "compositionupdate",
+ "compositionend",
+ "dragstart",
+ "dragend",
+ "dragenter",
+ "dragleave",
+ "dragover",
+ "drop",
+};
+
+// Counts stored in a fixed array
+_counts: [tracked_event_types.len]u32 = [_]u32{0} ** tracked_event_types.len,
+
+pub fn increment(self: *EventCounts, event_type: []const u8) void {
+ if (getIndex(event_type)) |idx| {
+ self._counts[idx] +|= 1;
+ }
+}
+
+pub fn get(self: *const EventCounts, event_type: []const u8) u32 {
+ if (getIndex(event_type)) |idx| {
+ return self._counts[idx];
+ }
+ return 0;
+}
+
+pub fn has(_: *const EventCounts, event_type: []const u8) bool {
+ return getIndex(event_type) != null;
+}
+
+pub fn getSize(_: *const EventCounts) u32 {
+ return tracked_event_types.len;
+}
+
+pub fn keys(self: *EventCounts, page: *Page) !*KeyIterator {
+ return .init(.{ .event_counts = self }, page);
+}
+
+pub fn values(self: *EventCounts, page: *Page) !*ValueIterator {
+ return .init(.{ .event_counts = self }, page);
+}
+
+pub fn entries(self: *EventCounts, page: *Page) !*EntryIterator {
+ return .init(.{ .event_counts = self }, page);
+}
+
+pub fn forEach(self: *EventCounts, cb_: js.Function, js_this_: ?js.Object) !void {
+ const cb = if (js_this_) |js_this| try cb_.withThis(js_this) else cb_;
+
+ for (tracked_event_types, self._counts) |event_type, count| {
+ var caught: js.TryCatch.Caught = undefined;
+ cb.tryCall(void, .{ count, event_type, self }, &caught) catch {
+ log.debug(.js, "forEach callback", .{ .caught = caught, .source = "EventCounts" });
+ };
+ }
+}
+
+fn getIndex(event_type: []const u8) ?usize {
+ for (tracked_event_types, 0..) |t, i| {
+ if (std.mem.eql(u8, t, event_type)) {
+ return i;
+ }
+ }
+ return null;
+}
+
+pub const JsApi = struct {
+ pub const bridge = js.Bridge(EventCounts);
+
+ pub const Meta = struct {
+ pub const name = "EventCounts";
+ pub const prototype_chain = bridge.prototypeChain();
+ pub var class_id: bridge.ClassId = undefined;
+ };
+
+ pub const get = bridge.function(EventCounts.get, .{});
+ pub const has = bridge.function(EventCounts.has, .{});
+ pub const size = bridge.accessor(EventCounts.getSize, null, .{});
+ pub const keys = bridge.function(EventCounts.keys, .{});
+ pub const values = bridge.function(EventCounts.values, .{});
+ pub const entries = bridge.function(EventCounts.entries, .{});
+ pub const forEach = bridge.function(EventCounts.forEach, .{});
+ pub const symbol_iterator = bridge.iterator(EventCounts.entries, .{});
+};
+
+// Iterator implementation
+pub const Iterator = struct {
+ index: u32 = 0,
+ event_counts: *EventCounts,
+
+ pub const Entry = struct { []const u8, u32 };
+
+ pub fn next(self: *Iterator, _: *const Page) ?Iterator.Entry {
+ const index = self.index;
+ if (index >= tracked_event_types.len) {
+ return null;
+ }
+ self.index = index + 1;
+
+ return .{ tracked_event_types[index], self.event_counts._counts[index] };
+ }
+};
+
+const GenericIterator = @import("collections/iterator.zig").Entry;
+pub const KeyIterator = GenericIterator(Iterator, "0");
+pub const ValueIterator = GenericIterator(Iterator, "1");
+pub const EntryIterator = GenericIterator(Iterator, null);
diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig
index 50c4457a..6880bed0 100644
--- a/src/browser/webapi/Performance.zig
+++ b/src/browser/webapi/Performance.zig
@@ -2,6 +2,8 @@ const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const datetime = @import("../../datetime.zig");
+const EventCounts = @import("EventCounts.zig");
+
pub fn registerTypes() []const type {
return &.{ Performance, Entry, Mark, Measure, PerformanceTiming, PerformanceNavigation };
}
@@ -14,6 +16,7 @@ _time_origin: u64,
_entries: std.ArrayList(*Entry) = .{},
_timing: PerformanceTiming = .{},
_navigation: PerformanceNavigation = .{},
+_event_counts: EventCounts = .{},
/// Get high-resolution timestamp in microseconds, rounded to 5μs increments
/// to match browser behavior (prevents fingerprinting)
@@ -54,6 +57,10 @@ pub fn getNavigation(self: *Performance) *PerformanceNavigation {
return &self._navigation;
}
+pub fn getEventCounts(self: *Performance) *EventCounts {
+ return &self._event_counts;
+}
+
pub fn mark(
self: *Performance,
name: []const u8,
@@ -277,6 +284,7 @@ pub const JsApi = struct {
pub const timeOrigin = bridge.accessor(Performance.getTimeOrigin, null, .{});
pub const timing = bridge.accessor(Performance.getTiming, null, .{});
pub const navigation = bridge.accessor(Performance.getNavigation, null, .{});
+ pub const eventCounts = bridge.accessor(Performance.getEventCounts, null, .{});
};
pub const Entry = struct {