From 5ad14ffeb96ecf5fd62327a41552d5e41b18e022 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 17 Sep 2026 17:00:26 +0800 Subject: [PATCH] fix(indexeddb): preserve native version change event attributes Store oldVersion and newVersion in private event state and expose branded readonly prototype accessors for authored and native events. Correct the constructor arity and convert the full inherited initialization dictionary in WebIDL order, honoring EventInit flags for authored events. Cover getter receivers and realms, numeric conversion and exceptions, author property shadows, subclassing, and upgrade/blocked/delete event metadata in Window, child Window, and Worker regression tests. --- .../fixtures/indexeddb-version-change.js | 171 ++++++++++++++++++ .../tests/web_apis/indexed_db_transaction.rs | 22 +++ .../assets/constructor_templates.rs | 2 +- .../src/context_bootstrap/events.rs | 1 + .../indexed_db/event_target/version_change.rs | 78 +++++++- .../indexed_db/install/constructors.rs | 5 + 6 files changed, 273 insertions(+), 6 deletions(-) create mode 100644 moli-core/tests/web_apis/fixtures/indexeddb-version-change.js diff --git a/moli-core/tests/web_apis/fixtures/indexeddb-version-change.js b/moli-core/tests/web_apis/fixtures/indexeddb-version-change.js new file mode 100644 index 0000000000..eb1e1f9280 --- /dev/null +++ b/moli-core/tests/web_apis/fixtures/indexeddb-version-change.js @@ -0,0 +1,171 @@ +globalThis.versionChangeChecks = []; +async function versionChangeProbe(prefix = 'version-change') { + const checks = versionChangeChecks; + const check = (label, pass, actual = '') => checks.push({label, pass, actual: String(actual)}); + const same = (label, actual, expected) => check(label, JSON.stringify(actual) === JSON.stringify(expected), JSON.stringify(actual)); + const throws = (label, action, Realm) => { + let caught; + try { action(); } catch (error) { caught = error; } + check(label, caught !== undefined && Object.getPrototypeOf(caught) === Realm.TypeError.prototype, caught); + }; + const exercise = async (label, Realm, GetterRealm) => { + const Ctor = Realm.IDBVersionChangeEvent; + const getters = {}; + for (const name of ['oldVersion', 'newVersion']) { + const descriptor = Object.getOwnPropertyDescriptor(GetterRealm.IDBVersionChangeEvent.prototype, name); + getters[name] = descriptor?.get; + check(label + ': ' + name + ' descriptor', typeof descriptor?.get === 'function' && + descriptor.set === undefined && descriptor.enumerable && descriptor.configurable); + check(label + ': ' + name + ' getter metadata', descriptor?.get?.name === 'get ' + name && descriptor.get.length === 0); + } + const get = (name, receiver) => typeof getters[name] === 'function' ? getters[name].call(receiver) : undefined; + check(label + ': constructor length', Ctor.length === 1, Ctor.length); + throws(label + ': new is required', () => Ctor('type'), Realm); + throws(label + ': type is required', () => new Ctor(), Realm); + throws(label + ': symbol type is rejected', () => new Ctor(Symbol()), Realm); + for (const invalid of [1, 'init', Symbol(), 1n]) { + throws(label + ': primitive dictionary is rejected', () => new Ctor('type', invalid), Realm); + } + for (const init of [undefined, null, {}]) { + const event = new Ctor('default', init); + same(label + ': default versions', [event.oldVersion, event.newVersion], [0, null]); + same(label + ': default flags', [event.bubbles, event.cancelable, event.composed, event.isTrusted], [false, false, false, false]); + } + const order = []; + const init = {}; + for (const [name, value] of [['bubbles', true], ['cancelable', true], ['composed', true], + ['newVersion', {valueOf() { order.push('convert newVersion'); return 9; }}], + ['oldVersion', {valueOf() { order.push('convert oldVersion'); return 7; }}]]) { + Object.defineProperty(init, name, {get() { order.push(name); return value; }}); + } + const event = new Ctor({toString() { order.push('type'); return 'flags'; }}, Object.create(init)); + same(label + ': conversion order', order, + ['type', 'bubbles', 'cancelable', 'composed', 'newVersion', 'convert newVersion', 'oldVersion', 'convert oldVersion']); + for (const name of ['bubbles', 'cancelable', 'composed']) { + check(label + ': EventInit ' + name, event[name] === true, event[name]); + } + const target = new EventTarget(); + target.addEventListener('flags', value => value.preventDefault()); + check(label + ': EventInit dispatch cancellation', target.dispatchEvent(event) === false); + check(label + ': EventInit defaultPrevented', event.defaultPrevented === true); + for (const [name, expected] of [['oldVersion', 7], ['newVersion', 9]]) { + check(label + ': ' + name + ' inherited', !Object.hasOwn(event, name)); + check(label + ': ' + name + ' getter value', get(name, event) === expected, get(name, event)); + check(label + ': ' + name + ' readonly', Reflect.set(event, name, 99) === false); + check(label + ': ' + name + ' unchanged', event[name] === expected, event[name]); + Object.defineProperty(event, name, {configurable: true, get() { throw new Error('author shadow'); }}); + check(label + ': ' + name + ' native value under author shadow', get(name, event) === expected, get(name, event)); + delete event[name]; + check(label + ': ' + name + ' restored getter', event[name] === expected, event[name]); + } + const revoked = Proxy.revocable(event, {}); revoked.revoke(); + let traps = 0; + const trapped = new Proxy(event, {get() { ++traps; throw new Error('trap'); }, getPrototypeOf() { ++traps; throw new Error('trap'); }}); + for (const receiver of [null, undefined, 1, {}, Ctor.prototype, Object.create(Ctor.prototype), + Object.create(event), new Realm.Event('base'), new Proxy(event, {}), revoked.proxy, trapped]) { + for (const name of ['oldVersion', 'newVersion']) { + throws(label + ': ' + name + ' invalid receiver', () => get(name, receiver), GetterRealm); + } + } + check(label + ': no Proxy traps', traps === 0, traps); + Object.setPrototypeOf(event, null); + same(label + ': native identity survives prototype removal', [get('oldVersion', event), get('newVersion', event)], [7, 9]); + class Subclass extends Ctor {} + const sub = new Subclass('sub', {oldVersion: 3, newVersion: 4}); + check(label + ': subclass prototype is preserved', Object.getPrototypeOf(sub) === Subclass.prototype); + same(label + ': subclass native identity', [get('oldVersion', sub), get('newVersion', sub)], [3, 4]); + + const members = ['bubbles', 'cancelable', 'composed', 'newVersion', 'oldVersion']; + for (let stop = 0; stop < members.length; ++stop) { + const reads = []; + const marker = new Error('dictionary getter'); + const options = new Proxy({}, {get(_target, key) { + reads.push(key); + if (key === members[stop]) throw marker; + return undefined; + }}); + let caught; + try { new Ctor('throws', options); } catch (error) { caught = error; } + check(label + ': getter exception identity ' + members[stop], caught === marker, caught); + same(label + ': stop conversion at ' + members[stop], reads, members.slice(0, stop + 1)); + } + const conversions = [[undefined, 0, null], [null, 0, null], [NaN, 0, 0], [Infinity, 0, 0], + [-Infinity, 0, 0], [1.9, 1, 1], [-1, 2 ** 64, 2 ** 64], [2 ** 64, 0, 0], ['12', 12, 12]]; + for (const [value, oldVersion, newVersion] of conversions) { + const converted = new Ctor('number', {oldVersion: value, newVersion: value}); + same(label + ': numeric conversion ' + String(value), [converted.oldVersion, converted.newVersion], [oldVersion, newVersion]); + } + for (const name of ['oldVersion', 'newVersion']) { + for (const value of [Symbol(), 1n]) { + throws(label + ': reject invalid ' + name, () => new Ctor('invalid', {[name]: value}), Realm); + } + } + const marker = new Error('type conversion'); + let dictionaryReads = 0; + let caught; + try { + new Ctor({toString() { throw marker; }}, new Proxy({}, {get() { ++dictionaryReads; }})); + } catch (error) { caught = error; } + check(label + ': type exception identity', caught === marker); + check(label + ': type exception stops dictionary conversion', dictionaryReads === 0, dictionaryReads); + + const nativeEvents = []; + const seen = []; + const inspectNative = (name, value, oldVersion, newVersion) => { + seen.push(name); + same(label + ': native ' + name + ' versions', [value.oldVersion, value.newVersion], [oldVersion, newVersion]); + same(label + ': native ' + name + ' flags', [value.bubbles, value.cancelable, value.composed, value.isTrusted], [false, false, false, true]); + check(label + ': native ' + name + ' prototype', Object.getPrototypeOf(value) === Ctor.prototype); + check(label + ': native ' + name + ' inherited attributes', !Object.hasOwn(value, 'oldVersion') && !Object.hasOwn(value, 'newVersion')); + same(label + ': native ' + name + ' borrowed getters', [get('oldVersion', value), get('newVersion', value)], [oldVersion, newVersion]); + check(label + ': native ' + name + ' readonly oldVersion', Reflect.set(value, 'oldVersion', 99) === false); + check(label + ': native ' + name + ' readonly newVersion', Reflect.set(value, 'newVersion', 99) === false); + nativeEvents.push([name, value, oldVersion, newVersion]); + }; + const requestResult = request => new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); + const factory = Realm.indexedDB; + const name = prefix + '-' + label; + const savedCtor = Realm.IDBVersionChangeEvent; + Realm.IDBVersionChangeEvent = function() { throw new Error('author constructor'); }; + try { + const first = factory.open(name, 1); + first.onupgradeneeded = value => inspectNative('create', value, 0, 1); + const oldDb = await requestResult(first); + oldDb.onversionchange = value => inspectNative('versionchange', value, 1, 2); + const second = factory.open(name, 2); + second.onblocked = value => { inspectNative('blocked', value, 1, 2); oldDb.close(); }; + second.onupgradeneeded = value => inspectNative('upgrade', value, 1, 2); + const newDb = await requestResult(second); + newDb.onversionchange = value => { inspectNative('delete notification', value, 2, null); newDb.close(); }; + const deletion = factory.deleteDatabase(name); + await new Promise((resolve, reject) => { + deletion.onsuccess = value => { inspectNative('delete success', value, 2, null); resolve(); }; + deletion.onerror = () => reject(deletion.error); + }); + const absent = factory.deleteDatabase(name); + await new Promise((resolve, reject) => { + absent.onsuccess = value => { inspectNative('absent delete', value, 0, null); resolve(); }; + absent.onerror = () => reject(absent.error); + }); + } finally { Realm.IDBVersionChangeEvent = savedCtor; } + same(label + ': native event order', seen, + ['create', 'versionchange', 'blocked', 'upgrade', 'delete notification', 'delete success', 'absent delete']); + for (const [name, value, oldVersion, newVersion] of nativeEvents) { + same(label + ': retained native ' + name, [get('oldVersion', value), get('newVersion', value)], [oldVersion, newVersion]); + } + }; + await exercise('local', globalThis, globalThis); + if (typeof document !== 'undefined') { + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.srcdoc = ''; document.documentElement.appendChild(frame); await loaded; + try { + await exercise('foreign-getter', globalThis, frame.contentWindow); + await exercise('foreign-event', frame.contentWindow, globalThis); + } finally { frame.remove(); } + } + return {state: checks.every(check => check.pass) ? 'pass' : 'fail', checks}; +} diff --git a/moli-core/tests/web_apis/indexed_db_transaction.rs b/moli-core/tests/web_apis/indexed_db_transaction.rs index e108a007e7..356722ecbe 100644 --- a/moli-core/tests/web_apis/indexed_db_transaction.rs +++ b/moli-core/tests/web_apis/indexed_db_transaction.rs @@ -1,6 +1,28 @@ use super::event_dispatch::run_probe; use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn indexed_db_version_change_events_preserve_native_attributes_and_dictionary_semantics() +-> Result<()> { + let server = FixtureServer::spawn().await?; + let browser = Browser::new(AppConfig::default())?; + let fixture = include_str!("fixtures/indexeddb-version-change.js"); + for target in ["window", "child", "worker"] { + let source = format!( + "{fixture}\nversionChangeProbe('version-change-{target}').then(finish, error => finish({{state: 'error', error: String(error), checks: versionChangeChecks}}));" + ); + let result = run_probe(&browser, &server, target, &source).await?; + assert_eq!(result["state"], "pass", "{target}: {result}"); + assert_eq!( + result["checks"].as_array().unwrap().len(), + if target == "worker" { 144 } else { 432 }, + "{target}: {result}" + ); + } + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread")] async fn indexed_db_factory_checks_native_receivers_and_rejects_promises_in_callee_realm() -> Result<()> { diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index 4edbf3e2b1..3e1d5aaea5 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -986,7 +986,7 @@ pub(in crate::context_bootstrap) fn build_constructor_template_for_profile<'s>( web_api_interfaces::IDBVersionChangeEvent, crate::context_bootstrap::indexed_db::idb_version_change_event_constructor_callback )) - .length(2) + .length(1) .build(scope) } }; diff --git a/moli-renderer-v8/src/context_bootstrap/events.rs b/moli-renderer-v8/src/context_bootstrap/events.rs index 08a1e235f0..7fee28500d 100644 --- a/moli-renderer-v8/src/context_bootstrap/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/events.rs @@ -341,6 +341,7 @@ fn storage_event_nullable_string_value_utf16<'s>( .unwrap_or_else(|| v8::null(scope).into()) } +pub(in crate::context_bootstrap) use base::define_event_property; pub(crate) use base::{ EVENT_DISPATCHING_SLOT, EVENT_PASSIVE_SLOT, EVENT_STOP_IMMEDIATE_PROPAGATION_SLOT, EVENT_STOP_PROPAGATION_SLOT, clear_event_composed_path, event_initialized, diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs index 31d4f72f91..e37b957176 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/event_target/version_change.rs @@ -1,15 +1,63 @@ use super::super::super::events; use super::*; +use crate::util::get_private_value; +use crate::web_api_interfaces; use crate::webidl; -use moli_webapi_declare::WebApiObject; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const OLD_VERSION_SLOT: &str = "__moli_idb_event_old_version"; +const NEW_VERSION_SLOT: &str = "__moli_idb_event_new_version"; #[derive(WebApiObject)] -#[webapi(plain, data_properties, enumerable)] +#[webapi(fragment)] struct IdbVersionChangeEventFieldsDeclaration<'scope> { + #[webapi(slot = OLD_VERSION_SLOT)] old_version: u64, + #[webapi(slot = NEW_VERSION_SLOT)] new_version: v8::Local<'scope, v8::Value>, } +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::IDBVersionChangeEvent, enumerable, receiver)] +struct IdbVersionChangeEventPrototypeDeclaration { + #[webapi(accessor_property, getter = old_version_getter)] + old_version: (), + #[webapi(accessor_property, getter = new_version_getter)] + new_version: (), +} + +pub(in crate::context_bootstrap::indexed_db) fn install_version_change_event_template_bindings< + 's, +>( + scope: &mut v8::PinScope<'s, '_, ()>, + prototype: v8::Local<'s, v8::ObjectTemplate>, + name: &str, +) { + if name == "IDBVersionChangeEvent" { + IdbVersionChangeEventPrototypeDeclaration::initialize_prototype_template(scope, prototype); + } +} + +fn old_version_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = get_private_value(scope, args.this(), OLD_VERSION_SLOT) { + rv.set(value); + } +} + +fn new_version_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if let Some(value) = get_private_value(scope, args.this(), NEW_VERSION_SLOT) { + rv.set(value); + } +} + #[derive(webidl::WebIdlArgs)] #[webidl(prefix = "IDBVersionChangeEvent")] struct IdbVersionChangeEventConstructorArgs { @@ -22,10 +70,18 @@ struct IdbVersionChangeEventConstructorArgs { #[derive(Default, webidl::WebIdlDictionary)] #[webidl(prefix = "IDBVersionChangeEventInit")] struct IdbVersionChangeEventInit { - #[webidl(default = 0)] - old_version: u64, + // WebIDL reads inherited EventInit members first, then this dictionary's + // members in lexicographic order. The derive preserves declaration order. + #[webidl(default = false)] + bubbles: bool, + #[webidl(default = false)] + cancelable: bool, + #[webidl(default = false)] + composed: bool, #[webidl(nullable)] new_version: Option, + #[webidl(default = 0)] + old_version: u64, } pub(in crate::context_bootstrap) fn idb_version_change_event_constructor_callback<'s>( @@ -45,7 +101,19 @@ pub(in crate::context_bootstrap) fn idb_version_change_event_constructor_callbac return; }; let event = args.this(); - events::initialize_event_object(scope, event, &parsed.event_type, false, false); + events::initialize_event_object( + scope, + event, + &parsed.event_type, + parsed.init.bubbles, + parsed.init.cancelable, + ); + events::define_event_property( + scope, + event, + "composed", + v8::Boolean::new(scope, parsed.init.composed).into(), + ); IdbVersionChangeEventFieldsDeclaration::new( parsed.init.old_version, version_change_nullable_version_value(scope, parsed.init.new_version), diff --git a/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs b/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs index d12c7fb1cd..a27f577404 100644 --- a/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs +++ b/moli-renderer-v8/src/context_bootstrap/indexed_db/install/constructors.rs @@ -12,6 +12,11 @@ pub(in crate::context_bootstrap) fn install_indexed_db_template_bindings<'s>( ) { let prototype = template.prototype_template(scope); super::super::record::install_record_template_bindings(scope, prototype, interface_name); + super::super::event_target::install_version_change_event_template_bindings( + scope, + prototype, + interface_name, + ); install_idb_event_handlers(scope, prototype, interface_name); install_dom_string_list_template_bindings(scope, prototype, interface_name); factory_request::install_factory_and_request_template_bindings(