From 776c915c8cf08a8b1201d2afa8b9f6e21913b192 Mon Sep 17 00:00:00 2001 From: "c.kai" Date: Mon, 27 Jul 2026 14:47:48 +0800 Subject: [PATCH 1/2] fix(js): add global PromiseRejectionEvent and StorageEvent constructors Obscura lacked these two standard event constructors on globalThis. The missing PromiseRejectionEvent caused core-js to misdetect the environment and override native Promise with a broken polyfill, breaking Vue application rendering. StorageEvent completes the localStorage/sessionStorage API surface. Both are also registered in document.createEvent() for legacy callers. Fixes #521, fixes #522. --- crates/obscura-js/js/bootstrap.js | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index 3ef371a..c11b770 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2627,6 +2627,8 @@ class Document extends Node { 'popstateevent': PopStateEvent, 'animationevent': AnimationEvent, 'transitionevent': TransitionEvent, + 'promiserejectionevent': PromiseRejectionEvent, + 'storageevent': StorageEvent, }; const Cls = map[String(type || '').toLowerCase()] || Event; return new Cls(''); @@ -5007,6 +5009,35 @@ globalThis.ToggleEvent = class ToggleEvent extends Event { }; _markNative(globalThis.ToggleEvent); +globalThis.PromiseRejectionEvent = class PromiseRejectionEvent extends Event { + constructor(type, init = {}) { + super(type, init); + this.promise = init.promise; + this.reason = init.reason; + } +}; +_markNative(globalThis.PromiseRejectionEvent); + +globalThis.StorageEvent = class StorageEvent extends Event { + constructor(type, init = {}) { + super(type, init); + this.key = init.key !== undefined ? init.key : null; + this.oldValue = init.oldValue !== undefined ? init.oldValue : null; + this.newValue = init.newValue !== undefined ? init.newValue : null; + this.url = init.url || ""; + this.storageArea = init.storageArea || null; + } + initStorageEvent(type, bubbles, cancelable, key, oldValue, newValue, url, storageArea) { + this.initEvent(type, bubbles, cancelable); + this.key = key !== undefined ? key : null; + this.oldValue = oldValue !== undefined ? oldValue : null; + this.newValue = newValue !== undefined ? newValue : null; + this.url = url || ""; + this.storageArea = storageArea || null; + } +}; +_markNative(globalThis.StorageEvent); + // AbortController / AbortSignal. AbortSignal is a real constructor with a // prototype, so feature-detection and `AbortSignal.prototype` access work. It // carries aborted/reason, supports throwIfAborted(), and fires "abort" to From 0ff1ba0ea9c1bfb74a071bc040c7ad5c54331d12 Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:43:33 +0000 Subject: [PATCH 2/2] fix(js): match event constructor semantics --- crates/obscura-js/js/bootstrap.js | 17 +++++- crates/obscura-js/src/runtime.rs | 95 +++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 3 deletions(-) diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index c11b770..3cd84c3 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -2613,6 +2613,13 @@ class Document extends Node { // returned a generic Event for every type, which broke libraries that call // createEvent('CustomEvent').initCustomEvent(...) — see issue #41. createEvent(type) { + const normalized = String(type || '').toLowerCase(); + if (normalized === 'promiserejectionevent') { + throw new DOMException( + "The provided event type ('PromiseRejectionEvent') is invalid", + 'NotSupportedError' + ); + } const map = { 'customevent': CustomEvent, 'customevents': CustomEvent, 'mouseevent': MouseEvent, 'mouseevents': MouseEvent, @@ -2627,10 +2634,9 @@ class Document extends Node { 'popstateevent': PopStateEvent, 'animationevent': AnimationEvent, 'transitionevent': TransitionEvent, - 'promiserejectionevent': PromiseRejectionEvent, 'storageevent': StorageEvent, }; - const Cls = map[String(type || '').toLowerCase()] || Event; + const Cls = map[normalized] || Event; return new Cls(''); } createRange() { return new Range(); } @@ -5010,7 +5016,12 @@ globalThis.ToggleEvent = class ToggleEvent extends Event { _markNative(globalThis.ToggleEvent); globalThis.PromiseRejectionEvent = class PromiseRejectionEvent extends Event { - constructor(type, init = {}) { + constructor(type, init) { + if (arguments.length < 2 || init == null || !('promise' in Object(init))) { + throw new TypeError( + "Failed to construct 'PromiseRejectionEvent': required member promise is undefined." + ); + } super(type, init); this.promise = init.promise; this.reason = init.reason; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index fdfeb4d..23e1677 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -3085,6 +3085,101 @@ mod tests { assert_eq!(kind, serde_json::json!(true)); } + #[test] + fn test_promise_rejection_event_requires_promise() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#"(() => { + const promise = Promise.resolve(1); + const event = new PromiseRejectionEvent('unhandledrejection', { + promise, + reason: 'failed' + }); + let missingPromiseThrows = false; + try { + new PromiseRejectionEvent('unhandledrejection'); + } catch (error) { + missingPromiseThrows = error instanceof TypeError; + } + return [ + event instanceof Event, + event.promise === promise, + event.reason === 'failed', + missingPromiseThrows + ]; + })()"#, + ) + .unwrap(); + assert_eq!(result, serde_json::json!([true, true, true, true])); + } + + #[test] + fn test_create_event_rejects_promise_rejection_event() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#"(() => { + try { + document.createEvent('PromiseRejectionEvent'); + return null; + } catch (error) { + return [error.name, error instanceof DOMException]; + } + })()"#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!(["NotSupportedError", true]) + ); + } + + #[test] + fn test_storage_event_constructor_and_legacy_factory() { + let mut rt = setup_runtime(""); + let result = rt + .evaluate( + r#"(() => { + const event = new StorageEvent('storage', { + key: 'theme', + oldValue: 'light', + newValue: 'dark', + url: 'https://example.test/' + }); + const legacy = document.createEvent('StorageEvent'); + legacy.initStorageEvent( + 'storage', false, false, 'count', '1', '2', + 'https://example.test/', null + ); + return [ + event instanceof Event, + event.key, + event.oldValue, + event.newValue, + event.url, + legacy instanceof StorageEvent, + legacy.key, + legacy.newValue + ]; + })()"#, + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([ + true, + "theme", + "light", + "dark", + "https://example.test/", + true, + "count", + "2" + ]) + ); + } + #[test] fn test_html_to_markdown_headings() { let mut rt = setup_runtime("

Title

Sub

Body

");