From af1e15f6c8d80037c65d2ef00d9cac91ffcfb3ef Mon Sep 17 00:00:00 2001 From: Andrey Mnatsakanov Date: Tue, 4 Aug 2026 11:00:01 +0200 Subject: [PATCH] fix(js): make Event/CustomEvent constructors match WebIDL The Event and CustomEvent constructors diverged from Chrome in three observable ways: - new Event() / new CustomEvent() with no argument did not throw, though the type argument is required (Chrome: "1 argument required, but only 0 present."). initEvent/initMouseEvent already had this check; the constructors did not. - the type argument was not coerced to a string, so new Event(123).type was the number 123 instead of "123". - CustomEvent.detail defaulted to undefined; WebIDL CustomEventInit specifies `any detail = null`. Add the required-argument guard to both constructors, coerce type via String(t) (inherited by every subclass through super), and default detail to null. createEvent() builds events with an explicit '' type, so the arity guard leaves the type-"" uninitialized-event path intact. Fixes #552 --- crates/obscura-js/js/bootstrap.js | 4 ++-- crates/obscura-js/src/runtime.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 4ffed6c..ddfc21f 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -5086,7 +5086,7 @@ globalThis.__obscura_setInputFiles = function(el, specs) { try { el.dispatchEvent(globalThis.__obscura_markTrusted(new Event("change", { bubbles: true }))); } catch (_e) {} }; globalThis.Event = class Event { - constructor(t,o={}) { this.type=t;this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now();this._propagationStopped=false;this._immediatePropagationStopped=false; } + constructor(t,o={}) { if (arguments.length < 1) throw new TypeError("Failed to construct 'Event': 1 argument required, but only 0 present."); this.type=String(t);this.bubbles=!!o.bubbles;this.cancelable=!!o.cancelable;this.composed=!!o.composed;this.defaultPrevented=false;this.target=null;this.currentTarget=null;this.eventPhase=0;this.timeStamp=Date.now();this._propagationStopped=false;this._immediatePropagationStopped=false; } get isTrusted() { return _trustedEvents.has(this); } preventDefault() { if (this.cancelable) this.defaultPrevented=true; } stopPropagation(){ this._propagationStopped=true; } stopImmediatePropagation(){ this._propagationStopped=true; this._immediatePropagationStopped=true; } initEvent(type,bubbles,cancelable) { if (arguments.length < 1) throw new TypeError("Failed to execute 'initEvent' on 'Event': 1 argument required, but only 0 present."); this.type=String(type);this.bubbles=!!bubbles;this.cancelable=!!cancelable;this.defaultPrevented=false;this._propagationStopped=false;this._immediatePropagationStopped=false; } @@ -5101,7 +5101,7 @@ globalThis.Event = class Event { }; _markNative(Event); globalThis.CustomEvent = class extends Event { - constructor(t,o={}) { super(t,o);this.detail=o.detail; } + constructor(t,o={}) { if (arguments.length < 1) throw new TypeError("Failed to construct 'CustomEvent': 1 argument required, but only 0 present."); super(t,o);this.detail=o.detail!==undefined?o.detail:null; } // Legacy DOM Level 2 init; some libraries (Starbucks China bundle, older // analytics shims) still call createEvent('CustomEvent') + initCustomEvent // instead of new CustomEvent(...). See issue #41. diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index 03a12fc..c72f95c 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -3493,6 +3493,34 @@ mod tests { assert_eq!(kind, serde_json::json!(true)); } + #[test] + fn event_constructor_matches_webidl_conformance() { + // new Event()/new CustomEvent() must throw (type is a required arg), + // the type argument must be coerced to a string, CustomEvent.detail must + // default to null (not undefined), createEvent must still build a + // type-"" event, and an explicit detail must be preserved. + let mut rt = setup_runtime(""); + let v = rt + .evaluate( + "(function(){\ + var out=[];\ + try{new Event();out.push('no-throw')}catch(e){out.push(e.name)}\ + try{new CustomEvent();out.push('no-throw')}catch(e){out.push(e.name)}\ + out.push(new Event(123).type+':'+typeof new Event(123).type);\ + out.push(String(new CustomEvent('x').detail));\ + out.push(String(new CustomEvent('x',{detail:7}).detail));\ + out.push(new Event('click').type);\ + out.push(JSON.stringify(document.createEvent('Event').type));\ + return out.join('|');\ + })()", + ) + .unwrap(); + assert_eq!( + v, + serde_json::json!("TypeError|TypeError|123:string|null|7|click|\"\"") + ); + } + #[test] fn test_promise_rejection_event_requires_promise() { let mut rt = setup_runtime("");