fix(webstorage): preserve StorageEvent WebIDL initialization

This commit is contained in:
ldm0
2026-10-06 03:11:26 +08:00
committed by Donough Liu
parent 706f22f3ec
commit b9fc881f87
8 changed files with 297 additions and 69 deletions
@@ -49,16 +49,16 @@ struct InitStorageEventArgs<'s> {
bubbles: bool,
#[webidl(default = false)]
cancelable: bool,
#[webidl(index = 3, nullable)]
key: Option<String>,
#[webidl(name = "oldValue", index = 4, nullable)]
old_value: Option<String>,
#[webidl(name = "newValue", index = 5, nullable)]
new_value: Option<String>,
#[webidl(index = 3, nullable, converter = "raw")]
key: Option<webidl::DomString16>,
#[webidl(name = "oldValue", index = 4, nullable, converter = "raw")]
old_value: Option<webidl::DomString16>,
#[webidl(name = "newValue", index = 5, nullable, converter = "raw")]
new_value: Option<webidl::DomString16>,
#[webidl(default = "", index = 6, converter = "usv_string")]
url: String,
#[webidl(index = 7, converter = "raw", nullable)]
storage_area: Option<v8::Local<'s, v8::Value>>,
storage_area: Option<super::web_storage::StorageReference<'s>>,
}
#[derive(webidl::WebIdlArgs)]
@@ -508,14 +508,14 @@ pub(super) fn storage_event_init_callback<'s>(
) {
return;
}
super::events::define_storage_event_properties(
super::events::define_storage_event_properties_utf16(
scope,
event,
parsed.key.as_deref(),
parsed.old_value.as_deref(),
parsed.new_value.as_deref(),
parsed.key.as_ref().map(|value| value.0.as_slice()),
parsed.old_value.as_ref().map(|value| value.0.as_slice()),
parsed.new_value.as_ref().map(|value| value.0.as_slice()),
&parsed.url,
parsed.storage_area,
parsed.storage_area.map(|value| value.0.into()),
);
}
@@ -1,5 +1,5 @@
use super::*;
use crate::util::{get_private_value, utf16_units, v8_string, v8_string_from_utf16_units, v8str};
use crate::util::{get_private_value, v8_string, v8_string_from_utf16_units, v8str};
use moli_webapi_declare::{ObjectLiteralDeclaration, WebApiObject};
mod base;
@@ -181,29 +181,6 @@ pub(crate) fn construct_original_storage_event_utf16<'s>(
Some(event)
}
pub(in crate::context_bootstrap) fn define_storage_event_properties<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
key: Option<&str>,
old_value: Option<&str>,
new_value: Option<&str>,
url: &str,
storage_area: Option<v8::Local<'s, v8::Value>>,
) {
let key = key.map(utf16_units);
let old_value = old_value.map(utf16_units);
let new_value = new_value.map(utf16_units);
define_storage_event_properties_utf16(
scope,
event,
key.as_deref(),
old_value.as_deref(),
new_value.as_deref(),
url,
storage_area,
);
}
pub(in crate::context_bootstrap) fn define_storage_event_properties_utf16<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
@@ -42,7 +42,18 @@ fn event_subclass_constructor_callback<'s>(
init_arg.to_object(scope)
}
};
let (bubbles, cancelable, composed) = read_event_init(scope, &args);
let storage_event_init = if kind == EventSubclassKind::StorageEvent {
let Some(init) = data::parse_storage_event_init(scope, &args) else {
return;
};
Some(init)
} else {
None
};
let (bubbles, cancelable, composed) = storage_event_init
.as_ref()
.map(data::StorageEventInitMembers::event_flags)
.unwrap_or_else(|| read_event_init(scope, &args));
initialize_event_object_with_type(scope, event, event_type, bubbles, cancelable);
define_event_property(
@@ -113,9 +124,11 @@ fn event_subclass_constructor_callback<'s>(
}
EventSubclassKind::MessageEvent => data::initialize_message_event(scope, event, init),
EventSubclassKind::StorageEvent => {
if !data::initialize_storage_event(scope, event, init) {
return;
}
data::initialize_storage_event(
scope,
event,
storage_event_init.expect("StorageEvent init should be parsed"),
);
}
EventSubclassKind::ErrorEvent => data::initialize_error_event(scope, event, init),
EventSubclassKind::PromiseRejectionEvent => {
@@ -10,6 +10,7 @@ use crate::context_bootstrap::navigation_handler_callbacks::{
use crate::context_bootstrap::navigation_window::{
child_browsing_context_handle_for_runtime_owner, runtime_window_is_global, runtime_window_owner,
};
use crate::context_bootstrap::web_storage::StorageReference;
use crate::native_bridge::element::scroll_to_url_fragment_or_top;
use crate::native_bridge::throw_dom_exception;
use crate::util::context_host_ptr_from_global_bridge;
@@ -314,17 +315,47 @@ struct ToggleEventInitMembers<'s> {
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "StorageEventInit")]
struct StorageEventInitMembers<'s> {
#[webidl(nullable)]
key: Option<String>,
#[webidl(name = "oldValue", nullable)]
old_value: Option<String>,
#[webidl(name = "newValue", nullable)]
new_value: Option<String>,
pub(super) struct StorageEventInitMembers<'s> {
#[webidl(default = false)]
bubbles: bool,
#[webidl(default = false)]
cancelable: bool,
#[webidl(default = false)]
composed: bool,
#[webidl(nullable, converter = "raw")]
key: Option<webidl::DomString16>,
#[webidl(name = "newValue", nullable, converter = "raw")]
new_value: Option<webidl::DomString16>,
#[webidl(name = "oldValue", nullable, converter = "raw")]
old_value: Option<webidl::DomString16>,
#[webidl(name = "storageArea", converter = "raw", nullable)]
storage_area: Option<StorageReference<'s>>,
#[webidl(default = "", converter = "usv_string")]
url: String,
#[webidl(name = "storageArea", converter = "raw", nullable)]
storage_area: Option<v8::Local<'s, v8::Value>>,
}
impl StorageEventInitMembers<'_> {
pub(super) fn event_flags(&self) -> (bool, bool, bool) {
(self.bubbles, self.cancelable, self.composed)
}
}
pub(super) fn parse_storage_event_init<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: &v8::FunctionCallbackArguments<'s>,
) -> Option<StorageEventInitMembers<'s>> {
let parsed = webidl::dictionary_arg(args, 1, webidl::Context::argument("StorageEvent", 2))
.and_then(|object| match object {
Some(object) => webidl::parse_dictionary_object(scope, object),
None => Ok(StorageEventInitMembers::default()),
});
match parsed {
Ok(init) => Some(init),
Err(error) => {
webidl::throw_error(scope, &error);
None
}
}
}
fn toggle_event_source_member<'s>(
@@ -482,30 +513,17 @@ pub(in crate::context_bootstrap::events::subclasses) fn initialize_message_event
pub(in crate::context_bootstrap::events::subclasses) fn initialize_storage_event<'s>(
scope: &mut v8::PinScope<'s, '_>,
event: v8::Local<'s, v8::Object>,
init: Option<v8::Local<'s, v8::Object>>,
) -> bool {
let parsed = match init {
Some(init) => {
match webidl::parse_dictionary_object::<StorageEventInitMembers>(scope, init) {
Ok(parsed) => parsed,
Err(error) => {
webidl::throw_error(scope, &error);
return false;
}
}
}
None => StorageEventInitMembers::default(),
};
crate::context_bootstrap::events::define_storage_event_properties(
parsed: StorageEventInitMembers<'s>,
) {
crate::context_bootstrap::events::define_storage_event_properties_utf16(
scope,
event,
parsed.key.as_deref(),
parsed.old_value.as_deref(),
parsed.new_value.as_deref(),
parsed.key.as_ref().map(|value| value.0.as_slice()),
parsed.old_value.as_ref().map(|value| value.0.as_slice()),
parsed.new_value.as_ref().map(|value| value.0.as_slice()),
&parsed.url,
parsed.storage_area,
parsed.storage_area.map(|value| value.0.into()),
);
true
}
pub(in crate::context_bootstrap::events::subclasses) fn initialize_input_event<'s>(
@@ -1,4 +1,25 @@
use super::*;
use crate::webidl;
pub(crate) struct StorageReference<'scope>(pub(crate) v8::Local<'scope, v8::Object>);
impl<'scope> webidl::WebIdlConverter<'scope> for StorageReference<'scope> {
type Options = ();
fn convert(
scope: &mut v8::PinScope<'scope, '_>,
value: v8::Local<'scope, v8::Value>,
context: webidl::Context,
_options: &Self::Options,
) -> Result<Self, webidl::WebIdlError> {
if let Ok(object) = v8::Local::<v8::Object>::try_from(value)
&& crate::web_api_interfaces::Storage::is_instance(scope, object)
{
return Ok(Self(object));
}
Err(webidl::WebIdlError::cannot_convert(context, "Storage"))
}
}
mod accessors;
mod callbacks;
@@ -49,6 +49,7 @@ mod session_description;
mod simple_handler_object;
mod speech_synthesis;
mod storage_access;
mod storage_event_init;
mod structured_clone;
mod transferable_streams;
mod traversal;
@@ -0,0 +1,186 @@
(() => {
const rows = [];
const check = (name, run) => {
try {
if (run() !== true) throw new Error('assertion failed');
rows.push({name, passed: true});
} catch (error) {
rows.push({name, passed: false, error: String(error), stack: error.stack});
}
};
const throws = (run, expected) => {
try { run(); } catch (error) { return error === expected; }
return false;
};
const typeError = (run, realm = globalThis) => {
try { run(); } catch (error) { return error instanceof realm.TypeError; }
return false;
};
const legacy = (key, oldValue, newValue, url, storageArea) => {
const event = new StorageEvent('before');
event.initStorageEvent('after', true, true, key, oldValue, newValue, url, storageArea);
return event;
};
const strings = ['', '\ud800', '\udc00', '\ud83d\ude00', 'a\ud800b\udc00\u0000\ud83d\ude00'];
for (const field of ['key', 'oldValue', 'newValue']) {
for (let i = 0; i < strings.length; i++) {
const text = strings[i];
check(`constructor ${field} UTF-16 ${i}`, () => new StorageEvent('x', {[field]: text})[field] === text);
check(`legacy ${field} UTF-16 ${i}`, () => legacy(text, text, text, '')[field] === text);
}
for (const value of [undefined, null]) {
check(`constructor ${field} nullable ${String(value)}`, () => new StorageEvent('x', {[field]: value})[field] === null);
check(`legacy ${field} nullable ${String(value)}`, () => legacy(value, value, value, '')[field] === null);
}
check(`constructor ${field} string conversion`, () => {
let conversions = 0;
const value = {[Symbol.toPrimitive](hint) { if (hint !== 'string') throw new Error(hint); conversions++; return strings[4]; }};
const event = new StorageEvent('x', {[field]: value});
return conversions === 1 && event[field] === strings[4];
});
check(`legacy ${field} string conversion`, () => {
let conversions = 0;
const value = {toString() { conversions++; return strings[4]; }};
const values = {key: null, oldValue: null, newValue: null, [field]: value};
const event = legacy(values.key, values.oldValue, values.newValue, '');
return conversions === 1 && event[field] === strings[4];
});
check(`constructor ${field} Symbol rejects`, () => typeError(() => new StorageEvent('x', {[field]: Symbol()})));
check(`legacy ${field} Symbol rejects`, () => {
const values = {key: null, oldValue: null, newValue: null, [field]: Symbol()};
return typeError(() => legacy(values.key, values.oldValue, values.newValue, ''));
});
}
check('constructor defaults', () => {
const event = new StorageEvent('x');
return event.key === null && event.oldValue === null && event.newValue === null && event.url === '' && event.storageArea === null && !event.bubbles && !event.cancelable && !event.composed;
});
check('legacy defaults', () => {
const event = new StorageEvent('before', {key: 'k', oldValue: 'o', newValue: 'n', composed: true});
const result = event.initStorageEvent('after');
return result === undefined && event.type === 'after' && event.key === null && event.oldValue === null && event.newValue === null && event.url === '' && event.storageArea === null && event.composed;
});
for (const [value, expected] of [[undefined, ''], [null, 'null'], [strings[4], 'a\ufffdb\ufffd\u0000\ud83d\ude00']]) {
check(`constructor url USVString ${String(value)}`, () => new StorageEvent('x', {url: value}).url === expected);
check(`legacy url USVString ${String(value)}`, () => legacy(null, null, null, value).url === expected);
}
for (const value of [undefined, null]) {
check(`nullable dictionary ${String(value)}`, () => new StorageEvent('x', value).key === null);
}
for (const value of [false, true, 0, 1, '', 'init', Symbol('init'), 1n]) {
check(`primitive dictionary ${String(value)}`, () => typeError(() => new StorageEvent('x', value)));
}
check('function dictionary', () => {
const init = () => {};
init.key = strings[4];
return new StorageEvent('x', init).key === strings[4];
});
const order = ['bubbles', 'cancelable', 'composed', 'key', 'newValue', 'oldValue', 'storageArea', 'url'];
check('inherited then own dictionary order', () => {
const seen = [];
new StorageEvent('x', new Proxy({}, {get(_target, name) { seen.push(name); }}));
return seen.join() === order.join();
});
check('dictionary reads and conversions are interleaved once', () => {
const seen = [];
const init = new Proxy({}, {get(_target, name) {
seen.push(`get:${name}`);
if (['key', 'newValue', 'oldValue', 'url'].includes(name)) {
return {[Symbol.toPrimitive](hint) { seen.push(`convert:${name}:${hint}`); return strings[4]; }};
}
}});
const event = new StorageEvent('x', init);
const expected = order.flatMap(name => ['key', 'newValue', 'oldValue', 'url'].includes(name) ? [`get:${name}`, `convert:${name}:string`] : [`get:${name}`]);
return seen.join() === expected.join() && event.key === strings[4] && event.url === 'a\ufffdb\ufffd\u0000\ud83d\ude00';
});
for (const stop of order) {
check(`dictionary getter exception stops at ${stop}`, () => {
const sentinel = {};
const seen = [];
const init = new Proxy({}, {get(_target, name) { seen.push(name); if (name === stop) throw sentinel; }});
return throws(() => new StorageEvent('x', init), sentinel) && seen.join() === order.slice(0, order.indexOf(stop) + 1).join();
});
}
for (const stop of ['key', 'newValue', 'oldValue', 'url']) {
check(`dictionary conversion exception stops at ${stop}`, () => {
const sentinel = {};
const seen = [];
const init = new Proxy({}, {get(_target, name) { seen.push(name); if (name === stop) return {toString() { throw sentinel; }}; }});
return throws(() => new StorageEvent('x', init), sentinel) && seen.join() === order.slice(0, order.indexOf(stop) + 1).join();
});
}
check('inherited dictionary values retain UTF-16', () => new StorageEvent('x', Object.create({key: strings[4], newValue: strings[1]})).key === strings[4]);
check('event type converts before dictionary', () => {
const seen = [];
const type = {toString() { seen.push('type'); return strings[4]; }};
const init = new Proxy({}, {get(_target, name) { seen.push(name); }});
const event = new StorageEvent(type, init);
return event.type === strings[4] && seen.join() === ['type', ...order].join();
});
check('legacy positional conversion order', () => {
const seen = [];
const value = name => ({toString() { seen.push(name); return name; }});
const event = new StorageEvent('before');
event.initStorageEvent(value('type'), true, true, value('key'), value('oldValue'), value('newValue'), value('url'), null);
return seen.join() === 'type,key,oldValue,newValue,url' && event.key === 'key' && event.oldValue === 'oldValue' && event.newValue === 'newValue';
});
for (const field of ['key', 'oldValue', 'newValue', 'url']) {
check(`legacy failed ${field} conversion is atomic`, () => {
const event = new StorageEvent('before', {bubbles: true, composed: true, key: 'k', oldValue: 'o', newValue: 'n', url: 'u'});
const sentinel = {};
const seen = [];
const names = ['key', 'oldValue', 'newValue', 'url'];
const values = names.map(name => ({toString() { seen.push(name); if (name === field) throw sentinel; return name; }}));
return throws(() => event.initStorageEvent('after', false, true, ...values, null), sentinel) && seen.join() === names.slice(0, names.indexOf(field) + 1).join() && event.type === 'before' && event.bubbles && !event.cancelable && event.composed && event.key === 'k' && event.oldValue === 'o' && event.newValue === 'n' && event.url === 'u';
});
}
const invalidAreas = [false, 1, 'storage', {}, [], Object.create(Storage.prototype)];
for (let i = 0; i < invalidAreas.length; i++) {
const storageArea = invalidAreas[i];
check(`constructor rejects unbranded storageArea ${i}`, () => typeError(() => new StorageEvent('x', {storageArea})));
check(`legacy rejects unbranded storageArea ${i} before mutation`, () => {
const event = new StorageEvent('before', {key: 'keep'});
return typeError(() => event.initStorageEvent('after', false, false, null, null, null, '', storageArea)) && event.type === 'before' && event.key === 'keep';
});
}
check('invalid storageArea stops before url getter', () => {
let reads = 0;
return typeError(() => new StorageEvent('x', {storageArea: {}, get url() { reads++; return ''; }})) && reads === 0;
});
const other = document.getElementById('child').contentWindow;
check('constructor conversion TypeError uses callee realm', () => typeError(() => new other.StorageEvent('x', {key: Symbol()}), other));
check('legacy conversion TypeError uses callee realm', () => typeError(() => other.StorageEvent.prototype.initStorageEvent.call(new StorageEvent('x'), 'x', false, false, Symbol()), other));
check('constructor storageArea TypeError uses callee realm', () => typeError(() => new other.StorageEvent('x', {storageArea: {}}), other));
check('legacy rejects forged receiver before converting arguments', () => {
let conversions = 0;
const type = {toString() { conversions++; return 'x'; }};
return typeError(() => StorageEvent.prototype.initStorageEvent.call(Object.create(StorageEvent.prototype), type)) && conversions === 0;
});
check('legacy dispatch guard follows conversion and preserves state', () => {
const target = new EventTarget();
const event = new StorageEvent('storage', {key: 'keep'});
let conversions = 0;
target.addEventListener('storage', current => {
current.initStorageEvent('after', true, true, {toString() { conversions++; return strings[4]; }});
});
target.dispatchEvent(event);
return conversions === 1 && event.type === 'storage' && event.key === 'keep' && !event.bubbles && !event.cancelable;
});
if (location.protocol !== 'data:') {
for (const [name, storageArea] of [['local', localStorage], ['session', sessionStorage], ['child', other.localStorage]]) {
check(`constructor accepts branded ${name} Storage`, () => new StorageEvent('x', {storageArea}).storageArea === storageArea);
check(`legacy accepts branded ${name} Storage`, () => legacy(null, null, null, '', storageArea).storageArea === storageArea);
}
let traps = 0;
const author = new Proxy(localStorage, {get() { traps++; throw new Error('trap'); }, getPrototypeOf() { traps++; throw new Error('trap'); }});
const revoked = Proxy.revocable(localStorage, {}); revoked.revoke();
for (const [name, storageArea] of [['author proxy', author], ['revoked proxy', revoked.proxy], ['inherited instance', Object.create(localStorage)]]) {
check(`constructor rejects ${name} Storage without traps`, () => typeError(() => new StorageEvent('x', {storageArea})) && traps === 0);
check(`legacy rejects ${name} Storage without traps`, () => typeError(() => legacy(null, null, null, '', storageArea)) && traps === 0);
}
check('cross realm constructor accepts genuine parent Storage', () => new other.StorageEvent('x', {storageArea: localStorage, key: strings[4]}).storageArea === localStorage);
}
globalThis.__storageEventResults = {total: rows.length, passed: rows.filter(row => row.passed).length, rows};
const failed = rows.filter(row => !row.passed);
return failed.length ? JSON.stringify(failed) : true;
})()
@@ -0,0 +1,12 @@
use super::*;
#[test]
fn storage_event_initializers_preserve_dom_strings_and_validate_storage_interfaces() {
let mut vm = new_storage_html_test_vm("https://storage-event-init-webidl.test/");
vm.eval("document.body.innerHTML = '<iframe id=child></iframe>';")
.unwrap();
assert_eq!(
vm.eval(include_str!("storage_event_init.js")).unwrap(),
"true"
);
}