From 670ca1641e6c9e2fe4df57f9c418216e6140a2fa Mon Sep 17 00:00:00 2001 From: Navid EMAD Date: Sun, 6 Sep 2026 04:15:41 +0200 Subject: [PATCH] webapi: make trusted beforeinput cancelable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InputEvent.initWithTrusted overwrote _bubbles/_cancelable/_composed for every InputEvent right after Event.populatePrototypes had applied the caller's options, forcing _cancelable = false. user_input.zig#allowEdit asks for a cancelable beforeinput so a listener can veto the edit, but preventDefault() is a no-op on a non-cancelable event, so the character was inserted and `input` fired regardless. Guard the flag block with `if (trusted)` — the shape KeyboardEvent already uses — and derive _cancelable from the event type: `beforeinput` is cancelable, `input` is not. Constructed events and document.createEvent('InputEvent') now follow the EventInit dictionary defaults instead of being forced to bubbling and composed. Closes #3413 --- src/browser/tests/event/input.html | 66 +++++++++++++++++++++++ src/browser/webapi/event/InputEvent.zig | 22 ++++++-- src/server/cdp/domains/input.zig | 72 +++++++++++++++++++++++++ 3 files changed, 155 insertions(+), 5 deletions(-) create mode 100644 src/browser/tests/event/input.html 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()); +}