diff --git a/src/browser/tests/event/input.html b/src/browser/tests/event/input.html
new file mode 100644
index 000000000..f687f64c1
--- /dev/null
+++ b/src/browser/tests/event/input.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/browser/webapi/event/InputEvent.zig b/src/browser/webapi/event/InputEvent.zig
index 5a48b3733..3b0f6458b 100644
--- a/src/browser/webapi/event/InputEvent.zig
+++ b/src/browser/webapi/event/InputEvent.zig
@@ -80,11 +80,18 @@ fn initWithTrusted(arena: *lp.Arena, typ: String, _opts: ?Options, trusted: bool
Event.populatePrototypes(event, opts, trusted);
- // https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event
- const rootevt = event._proto._proto;
- rootevt._bubbles = true;
- rootevt._cancelable = false;
- rootevt._composed = true;
+ if (trusted) {
+ // Browser-generated input events bubble and are composed. `beforeinput`
+ // is cancelable — cancelling it vetoes the edit — while `input` is
+ // dispatched after the fact and is not:
+ // https://w3c.github.io/uievents/#event-type-beforeinput
+ // https://w3c.github.io/uievents/#event-type-input
+ // Synthetic ones follow the EventInit dictionary defaults.
+ const rootevt = event._proto._proto;
+ rootevt._bubbles = true;
+ rootevt._cancelable = typ.eqlSlice("beforeinput");
+ rootevt._composed = true;
+ }
// Hold a ref on the DataTransfer (when present) for this event's lifetime;
// released in deinit. Almost always null for input events, but keeps the
@@ -146,3 +153,8 @@ pub const JsApi = struct {
pub const inputType = bridge.accessor(InputEvent.getInputType, null, .{});
pub const isComposing = bridge.accessor(InputEvent.getIsComposing, null, .{});
};
+
+const testing = @import("../../../testing.zig");
+test "WebApi: InputEvent" {
+ try testing.htmlRunner("event/input.html", .{});
+}
diff --git a/src/server/cdp/domains/input.zig b/src/server/cdp/domains/input.zig
index 1f3c0c15e..85110812f 100644
--- a/src/server/cdp/domains/input.zig
+++ b/src/server/cdp/domains/input.zig
@@ -406,3 +406,75 @@ test "cdp.input: dispatchKeyEvent Tab runs sequential focus navigation" {
});
try testing.expect((try ls.local.compileAndRun("document.activeElement.id === 'b1'", null)).isTrue());
}
+
+test "cdp.input: dispatchKeyEvent beforeinput can veto the edit" {
+ var ctx = try testing.context();
+ defer ctx.deinit();
+
+ const bc = try ctx.loadBrowserContext(.{});
+ const page = try bc.session.createPage();
+ const frame = page.frame().?;
+ try frame.navigate("http://localhost:9582/src/browser/tests/mcp_actions.html", .{
+ .reason = .address_bar,
+ .kind = .{ .push = null },
+ });
+ try testing.waitForPage(bc);
+
+ var ls: lp.js.Local.Scope = undefined;
+ frame.js.localScope(&ls);
+ defer ls.deinit();
+
+ // A listener that rejects every keystroke, the way masked-input libraries
+ // do. Records what it saw so we can assert the event was cancelable.
+ _ = try ls.local.compileAndRun(
+ \\const inp = document.getElementById('inp');
+ \\inp.value = 'ab';
+ \\inp.focus();
+ \\window.seen = [];
+ \\inp.addEventListener('beforeinput', (e) => {
+ \\ window.seen.push(['beforeinput', e.cancelable].join(':'));
+ \\ e.preventDefault();
+ \\ window.seen.push(['defaultPrevented', e.defaultPrevented].join(':'));
+ \\});
+ \\inp.addEventListener('input', () => window.seen.push('input'));
+ , null);
+
+ try ctx.processMessage(.{
+ .id = 1,
+ .method = "Input.dispatchKeyEvent",
+ .params = .{ .type = "keyDown", .key = "c", .text = "c" },
+ });
+
+ // The veto held: no character inserted and no `input` event followed.
+ try testing.expect((try ls.local.compileAndRun(
+ \\inp.value === 'ab' &&
+ \\window.seen.join(',') === 'beforeinput:true,defaultPrevented:true'
+ , null)).isTrue());
+
+ // Backspace goes through the same pre-edit gate.
+ try ctx.processMessage(.{
+ .id = 2,
+ .method = "Input.dispatchKeyEvent",
+ .params = .{ .type = "keyDown", .key = "Backspace", .code = "Backspace" },
+ });
+ try testing.expect((try ls.local.compileAndRun("inp.value === 'ab'", null)).isTrue());
+
+ // Without the veto, the edit goes through and `input` is dispatched — and
+ // `input` itself is not cancelable.
+ _ = try ls.local.compileAndRun(
+ \\const clone = inp.cloneNode(true);
+ \\inp.replaceWith(clone);
+ \\clone.focus();
+ \\window.seen = [];
+ \\clone.addEventListener('input', (e) => window.seen.push(['input', e.cancelable].join(':')));
+ , null);
+
+ try ctx.processMessage(.{
+ .id = 3,
+ .method = "Input.dispatchKeyEvent",
+ .params = .{ .type = "keyDown", .key = "c", .text = "c" },
+ });
+ try testing.expect((try ls.local.compileAndRun(
+ \\clone.value === 'abc' && window.seen.join(',') === 'input:false'
+ , null)).isTrue());
+}