Trigger inline handlers

This is a follow up / fix to https://github.com/lightpanda-io/browser/pull/1487

In that PR we triggered a "load" event for special elements, and as part of that
we triggered both the "onload" attribute via dispatchWithFunction and normal
bubbling with dispatch.

This PR applies this change generically and holistically. For example, if an
"abort" event is raised, the "onabort" attribute will be generated for that
element. Importantly, this gets executed in the correct dispatch order and
respect event cancellation (stopPropagation and stopImmediatePropagation).
This commit is contained in:
Karl Seguin
2026-02-11 19:35:10 +08:00
parent b24807ea29
commit 086faf44fc
5 changed files with 196 additions and 22 deletions

View File

@@ -329,13 +329,36 @@ fn dispatchNode(self: *EventManager, target: *Node, event: *Event, was_handled:
// Phase 2: At target // Phase 2: At target
event._event_phase = .at_target; event._event_phase = .at_target;
const target_et = target.asEventTarget(); const target_et = target.asEventTarget();
if (self.lookup.get(.{
.type_string = event._type_string, blk: {
.event_target = @intFromPtr(target_et), // Get inline handler (e.g., onclick property) for this target
})) |list| { if (self.getInlineHandler(target_et, event)) |inline_handler| {
try self.dispatchPhase(list, target_et, event, was_handled, null); was_handled.* = true;
if (event._stop_propagation) { event._current_target = target_et;
return;
var ls: js.Local.Scope = undefined;
self.page.js.localScope(&ls);
defer ls.deinit();
try ls.toLocal(inline_handler).callWithThis(void, target_et, .{event});
if (event._stop_propagation) {
return;
}
if (event._stop_immediate_propagation) {
break :blk;
}
}
if (self.lookup.get(.{
.type_string = event._type_string,
.event_target = @intFromPtr(target_et),
})) |list| {
try self.dispatchPhase(list, target_et, event, was_handled, null);
if (event._stop_propagation) {
return;
}
} }
} }
@@ -460,6 +483,20 @@ fn dispatchAll(self: *EventManager, list: *std.DoublyLinkedList, current_target:
return self.dispatchPhase(list, current_target, event, was_handled, null); return self.dispatchPhase(list, current_target, event, was_handled, null);
} }
fn getInlineHandler(self: *EventManager, target: *EventTarget, event: *Event) ?js.Function.Global {
const global_event_handlers = @import("webapi/global_event_handlers.zig");
const handler_type = global_event_handlers.fromEventType(event._type_string.str()) orelse return null;
// Look up the inline handler for this target
const Element = @import("webapi/Element.zig");
const element = switch (target._type) {
.node => |n| n.is(Element) orelse return null,
else => return null,
};
return self.page.getAttrListener(element, handler_type);
}
fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void { fn removeListener(self: *EventManager, list: *std.DoublyLinkedList, listener: *Listener) void {
// If we're in a dispatch, defer removal to avoid invalidating iteration // If we're in a dispatch, defer removal to avoid invalidating iteration
if (self.dispatch_depth > 0) { if (self.dispatch_depth > 0) {

View File

@@ -710,18 +710,6 @@ fn _documentIsComplete(self: *Page) !void {
for (self._to_load.items) |element| { for (self._to_load.items) |element| {
const event = try Event.initTrusted(comptime .wrap("load"), .{}, self); const event = try Event.initTrusted(comptime .wrap("load"), .{}, self);
defer if (!event._v8_handoff) event.deinit(false); defer if (!event._v8_handoff) event.deinit(false);
// Dispatch inline event.
blk: {
const html_element = element.is(HtmlElement) orelse break :blk;
const listener = (try html_element.getOnLoad(self)) orelse break :blk;
ls.toLocal(listener).call(void, .{}) catch |err| {
log.warn(.event, "inline load event", .{ .element = element, .err = err });
};
}
// Dispatch events registered to event manager.
try self._event_manager.dispatch(element.asEventTarget(), event); try self._event_manager.dispatch(element.asEventTarget(), event);
} }

View File

@@ -635,3 +635,130 @@
// https://github.com/lightpanda-io/browser/pull/1316 // https://github.com/lightpanda-io/browser/pull/1316
testing.expectError('TypeError', () => MessageEvent('')); testing.expectError('TypeError', () => MessageEvent(''));
</script> </script>
<div id=inline_parent><div id=inline_child></div></div>
<script id=inlineHandlerReceivesEvent>
// Test that inline onclick handler receives the event object
{
const inline_child = $('#inline_child');
let receivedType = null;
let receivedTarget = null;
let receivedCurrentTarget = null;
inline_child.onclick = function(e) {
// Capture values DURING handler execution
receivedType = e.type;
receivedTarget = e.target;
receivedCurrentTarget = e.currentTarget;
};
inline_child.click();
testing.expectEqual('click', receivedType);
testing.expectEqual(inline_child, receivedTarget);
testing.expectEqual(inline_child, receivedCurrentTarget);
}
</script>
<div id=inline_order_parent><div id=inline_order_child></div></div>
<script id=inlineHandlerOrder>
// Test that inline handler executes in proper order with addEventListener
{
const inline_order_child = $('#inline_order_child');
const inline_order_parent = $('#inline_order_parent');
const order = [];
// Capture listener on parent
inline_order_parent.addEventListener('click', () => order.push('parent-capture'), true);
// Inline handler on child (should execute at target phase)
inline_order_child.onclick = () => order.push('child-onclick');
// addEventListener on child (should execute at target phase, after onclick)
inline_order_child.addEventListener('click', () => order.push('child-listener'));
// Bubble listener on parent
inline_order_parent.addEventListener('click', () => order.push('parent-bubble'));
inline_order_child.click();
// Expected order: capture, then onclick, then addEventListener, then bubble
testing.expectEqual('parent-capture', order[0]);
testing.expectEqual('child-onclick', order[1]);
testing.expectEqual('child-listener', order[2]);
testing.expectEqual('parent-bubble', order[3]);
testing.expectEqual(4, order.length);
}
</script>
<div id=inline_prevent><div id=inline_prevent_child></div></div>
<script id=inlineHandlerPreventDefault>
// Test that inline handler can preventDefault and it affects addEventListener listeners
{
const inline_prevent_child = $('#inline_prevent_child');
let preventDefaultCalled = false;
let listenerSawPrevented = false;
inline_prevent_child.onclick = function(e) {
e.preventDefault();
preventDefaultCalled = true;
};
inline_prevent_child.addEventListener('click', (e) => {
listenerSawPrevented = e.defaultPrevented;
});
const result = inline_prevent_child.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true
}));
testing.expectEqual(true, preventDefaultCalled);
testing.expectEqual(true, listenerSawPrevented);
testing.expectEqual(false, result); // dispatchEvent returns false when prevented
}
</script>
<div id=inline_stop_parent><div id=inline_stop_child></div></div>
<script id=inlineHandlerStopPropagation>
// Test that inline handler can stopPropagation
{
const inline_stop_child = $('#inline_stop_child');
const inline_stop_parent = $('#inline_stop_parent');
let childCalled = false;
let parentCalled = false;
inline_stop_child.onclick = function(e) {
childCalled = true;
e.stopPropagation();
};
inline_stop_parent.addEventListener('click', () => {
parentCalled = true;
});
inline_stop_child.click();
testing.expectEqual(true, childCalled);
testing.expectEqual(false, parentCalled); // Should not bubble to parent
}
</script>
<div id=inline_replace_test></div>
<script id=inlineHandlerReplacement>
// Test that setting onclick property replaces previous handler
{
const inline_replace_test = $('#inline_replace_test');
let calls = [];
inline_replace_test.onclick = () => calls.push('first');
inline_replace_test.click();
inline_replace_test.onclick = () => calls.push('second');
inline_replace_test.click();
testing.expectEqual('first', calls[0]);
testing.expectEqual('second', calls[1]);
testing.expectEqual(2, calls.length);
}
</script>

View File

@@ -329,14 +329,15 @@ pub fn click(self: *HtmlElement, page: *Page) !void {
else => {}, else => {},
} }
const event = try @import("../event/MouseEvent.zig").init("click", .{ const event = (try @import("../event/MouseEvent.zig").init("click", .{
.bubbles = true, .bubbles = true,
.cancelable = true, .cancelable = true,
.composed = true, .composed = true,
.clientX = 0, .clientX = 0,
.clientY = 0, .clientY = 0,
}, page); }, page)).asEvent();
try page._event_manager.dispatch(self.asEventTarget(), event.asEvent()); defer if (!event._v8_handoff) event.deinit(false);
try page._event_manager.dispatch(self.asEventTarget(), event);
} }
fn getAttributeFunction( fn getAttributeFunction(

View File

@@ -164,3 +164,24 @@ pub const Handler = enum(u7) {
onwaiting, onwaiting,
onwheel, onwheel,
}; };
const typeToHandler = std.StaticStringMap(Handler).initComptime(blk: {
const fields = std.meta.fields(Handler);
var entries: [fields.len]struct { []const u8, Handler } = undefined;
for (fields, 0..) |field, i| {
entries[i] = .{ field.name[2..], @enumFromInt(field.value) };
}
break :blk entries;
});
pub fn fromEventType(typ: []const u8) ?Handler {
return typeToHandler.get(typ);
}
const testing = @import("../../testing.zig");
test "GlobalEventHandlers: fromEventType" {
try testing.expectEqual(.onabort, fromEventType("abort"));
try testing.expectEqual(.onselect, fromEventType("select"));
try testing.expectEqual(null, fromEventType(""));
try testing.expectEqual(null, fromEventType("unknown"));
}