fix(dom): restore legacy createEvent interface semantics

This commit is contained in:
ldm0
2026-10-11 18:06:48 +08:00
parent 82bfb29590
commit 204dff40fd
9 changed files with 286 additions and 18 deletions
@@ -38,7 +38,7 @@ enum DocumentCreateEventKind {
KeyboardEvent,
MessageEvent,
StorageEvent,
SubmitEvent,
TouchEvent,
}
impl DocumentCreateEventKind {
@@ -49,17 +49,17 @@ impl DocumentCreateEventKind {
DocumentCreateEventKind::CustomEvent => "CustomEvent",
DocumentCreateEventKind::DeviceMotionEvent => "DeviceMotionEvent",
DocumentCreateEventKind::DeviceOrientationEvent => "DeviceOrientationEvent",
DocumentCreateEventKind::DragEvent => "Event",
DocumentCreateEventKind::DragEvent => "DragEvent",
DocumentCreateEventKind::UiEvent => "UIEvent",
DocumentCreateEventKind::TextEvent => "TextEvent",
DocumentCreateEventKind::CompositionEvent => "CompositionEvent",
DocumentCreateEventKind::FocusEvent => "FocusEvent",
DocumentCreateEventKind::HashChangeEvent => "Event",
DocumentCreateEventKind::HashChangeEvent => "HashChangeEvent",
DocumentCreateEventKind::MouseEvent => "MouseEvent",
DocumentCreateEventKind::KeyboardEvent => "KeyboardEvent",
DocumentCreateEventKind::MessageEvent => "MessageEvent",
DocumentCreateEventKind::StorageEvent => "StorageEvent",
DocumentCreateEventKind::SubmitEvent => "SubmitEvent",
DocumentCreateEventKind::TouchEvent => "TouchEvent",
}
}
}
@@ -176,11 +176,14 @@ mod tests {
.map(DocumentCreateEventKind::constructor_name),
Ok("MouseEvent")
);
assert_eq!(
DocumentCreateEventKind::from_str("SubmitEvent")
.map(DocumentCreateEventKind::constructor_name),
Ok("SubmitEvent")
);
for name in ["DragEvent", "HashChangeEvent", "TouchEvent"] {
assert_eq!(
DocumentCreateEventKind::from_str(name)
.map(DocumentCreateEventKind::constructor_name),
Ok(name)
);
}
assert!(DocumentCreateEventKind::from_str("SubmitEvent").is_err());
assert!(DocumentCreateEventKind::from_str("PointerEvent").is_err());
}
}
@@ -224,6 +224,15 @@ struct ToggleEventTemplateAccessorsDeclaration {
source: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::HashChangeEvent, enumerable, receiver)]
struct HashChangeEventTemplateAccessorsDeclaration {
#[webapi(accessor_property = "oldURL", getter = event_value_attribute_getter, data = crate::util::v8str(scope, "oldURL"))]
old_url: (),
#[webapi(accessor_property = "newURL", getter = event_value_attribute_getter, data = crate::util::v8str(scope, "newURL"))]
new_url: (),
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::UIEvent, enumerable, receiver)]
struct UiEventTemplateMethodsDeclaration {
@@ -673,6 +682,9 @@ fn event_template_declaration(interface: &str) -> Option<EventTemplateDeclaratio
EventTemplateDeclaration::new::<CommandEventTemplateAccessorsDeclaration>()
}
"ToggleEvent" => EventTemplateDeclaration::new::<ToggleEventTemplateAccessorsDeclaration>(),
"HashChangeEvent" => {
EventTemplateDeclaration::new::<HashChangeEventTemplateAccessorsDeclaration>()
}
"EventTarget" => EventTemplateDeclaration::new::<EventTargetTemplateMethodsDeclaration>(),
"Document" => EventTemplateDeclaration::new::<DocumentEventTemplateMethodsDeclaration>(),
_ => return None,
@@ -290,8 +290,8 @@ fn initialize_touch_ui_event<'s>(
event: v8::Local<'s, v8::Object>,
init: Option<v8::Local<'s, v8::Object>>,
) {
let view = touch_event_init_value(scope, init, "view")
.unwrap_or_else(|| scope.get_current_context().global(scope).into());
let view =
touch_event_init_value(scope, init, "view").unwrap_or_else(|| v8::null(scope).into());
let detail = init
.and_then(|object| object.get(scope, v8str(scope, "detail").into()))
.filter(|value| !value.is_null_or_undefined())
@@ -0,0 +1,141 @@
(() => {
const checks = [];
const check = (name, fn) => {
try { checks.push({name, passed: fn() === true}); }
catch (error) { checks.push({name, passed: false, error: String(error)}); }
};
const realms = [window, document.querySelector('iframe').contentWindow];
const aliases = [
['BeforeUnloadEvent', 'BeforeUnloadEvent'], ['CompositionEvent', 'CompositionEvent'],
['CustomEvent', 'CustomEvent'], ['DeviceMotionEvent', 'DeviceMotionEvent'],
['DeviceOrientationEvent', 'DeviceOrientationEvent'], ['DragEvent', 'DragEvent'],
['Event', 'Event'], ['Events', 'Event'], ['FocusEvent', 'FocusEvent'],
['HashChangeEvent', 'HashChangeEvent'], ['HTMLEvents', 'Event'],
['KeyboardEvent', 'KeyboardEvent'], ['MessageEvent', 'MessageEvent'],
['MouseEvent', 'MouseEvent'], ['MouseEvents', 'MouseEvent'],
['StorageEvent', 'StorageEvent'], ['SVGEvents', 'Event'], ['TextEvent', 'TextEvent'],
['TouchEvent', 'TouchEvent'], ['UIEvent', 'UIEvent'], ['UIEvents', 'UIEvent']
];
const rejected = (realm, operation, name) => {
try { operation(); }
catch (error) {
return Object.getPrototypeOf(error) === realm.DOMException.prototype &&
error.name === name;
}
return false;
};
const typeError = (realm, operation) => {
try { operation(); }
catch (error) { return Object.getPrototypeOf(error) === realm.TypeError.prototype; }
return false;
};
const defaults = event => event.type === '' && event.target === null &&
event.currentTarget === null && event.eventPhase === 0 && !event.bubbles &&
!event.cancelable && !event.composed && !event.defaultPrevented &&
!event.isTrusted && Number.isFinite(event.timeStamp);
for (const [ownerIndex, owner] of realms.entries()) {
const docs = [owner.document, owner.document.implementation.createHTMLDocument(''),
owner.document.implementation.createDocument('urn:legacy-event', 'root'),
new owner.DOMParser().parseFromString('<root/>', 'application/xml')];
for (const [docIndex, doc] of docs.entries()) {
for (const [calleeIndex, callee] of realms.entries()) {
const prefix = `${ownerIndex}/${docIndex}/${calleeIndex}/`;
const factory = callee.Document.prototype.createEvent;
const create = name => factory.call(doc, name);
const exposed = name => typeof owner[name] === 'function' &&
(name !== 'TouchEvent' || 'ontouchstart' in doc);
check(prefix + 'factory metadata', () => {
const d = Object.getOwnPropertyDescriptor(callee.Document.prototype, 'createEvent');
return d.enumerable && d.configurable && d.writable &&
factory.name === 'createEvent' && factory.length === 1;
});
for (const [alias, name] of aliases) {
for (const variant of [alias, alias.toLowerCase(), alias.toUpperCase()]) {
check(prefix + 'alias/defaults/' + variant, () => {
if (!exposed(name)) return rejected(callee, () => create(variant), 'NotSupportedError');
const event = create(variant);
return Object.getPrototypeOf(event) === owner[name].prototype && defaults(event);
});
}
}
for (const name of ['DragEvent', 'HashChangeEvent', 'TouchEvent']) {
const unavailable = () => rejected(callee, () => create(name), 'NotSupportedError');
check(prefix + name + '/subclass state', () => {
if (!exposed(name)) return unavailable();
const event = create(name);
if (name === 'HashChangeEvent') return event.oldURL === '' && event.newURL === '';
if (name === 'DragEvent') return event.dataTransfer === null && event.view === null &&
event.detail === 0 && event.clientX === 0 && event.clientY === 0 && !event.ctrlKey;
return event.touches.length === 0 && event.targetTouches.length === 0 &&
event.changedTouches.length === 0 && event.view === null && event.detail === 0;
});
check(prefix + name + '/native brand', () => {
if (!exposed(name)) return unavailable();
const event = create(name), key = name === 'HashChangeEvent' ? 'oldURL' :
name === 'DragEvent' ? 'dataTransfer' : 'touches';
const getter = Object.getOwnPropertyDescriptor(callee[name].prototype, key).get;
const value = getter.call(event);
return name === 'HashChangeEvent' ? value === '' :
name === 'DragEvent' ? value === null : value.length === 0;
});
check(prefix + name + '/uninitialized dispatch rejected', () => {
if (!exposed(name)) return unavailable();
return rejected(owner, () => new owner.EventTarget().dispatchEvent(create(name)), 'InvalidStateError');
});
check(prefix + name + '/initialize and dispatch', () => {
if (!exposed(name)) return unavailable();
const event = create(name), target = new owner.EventTarget();
let seen = 0, state = false;
target.addEventListener('legacy', current => {
seen++;
state = current === event && current.target === target &&
current.currentTarget === target && current.eventPhase === 2;
current.preventDefault();
});
const initialized = callee.Event.prototype.initEvent.call(event, 'legacy', false, true);
return initialized === undefined && target.dispatchEvent(event) === false &&
seen === 1 && state && event.defaultPrevented && event.currentTarget === null &&
Object.getPrototypeOf(event) === owner[name].prototype;
});
check(prefix + name + '/reinitialize retains subclass', () => {
if (!exposed(name)) return unavailable();
const event = create(name), target = new owner.EventTarget();
event.initEvent('first', true, true);
target.addEventListener('first', current => current.initEvent('during', false, false));
target.dispatchEvent(event);
if (event.type !== 'first' || !event.bubbles || !event.cancelable) return false;
event.initEvent('second', false, false);
return event.type === 'second' && event.target === null && !event.bubbles &&
!event.cancelable && !event.defaultPrevented && Object.getPrototypeOf(event) === owner[name].prototype;
});
}
for (const name of ['', 'TimeEvent', 'SubmitEvent', 'PointerEvent', 'WheelEvent',
'SVGEvent', 'DragEvents', 'HashChangeEvents', 'TouchEvents', 'UIEvent ', ' UIEvent',
'U\u0130Event', 'U\u0131Event', 'ErrorEvent', 'FormDataEvent', 'AnimationEvent']) {
check(prefix + 'unsupported/' + name, () => rejected(callee, () => create(name), 'NotSupportedError'));
}
check(prefix + 'missing argument', () => typeError(callee, () => factory.call(doc)));
check(prefix + 'conversion once', () => {
let calls = 0;
const event = create({toString() { calls++; return 'HashChangeEvent'; }});
return calls === 1 && Object.getPrototypeOf(event) === owner.HashChangeEvent.prototype;
});
let traps = 0;
const proxy = new owner.Proxy(doc, {get() { traps++; throw 42; }, getPrototypeOf() { traps++; throw 42; }});
const revoked = owner.Proxy.revocable(doc, {}); revoked.revoke();
for (const [index, receiver] of [{}, Object.create(doc), Object.create(callee.Document.prototype),
proxy, revoked.proxy, doc.createElement('div')].entries()) {
check(prefix + 'receiver/' + index, () => {
let conversions = 0;
return typeError(callee, () => factory.call(receiver, {toString() { conversions++; throw 42; }})) &&
conversions === 0 && traps === 0;
});
}
}
}
}
globalThis.__documentCreateEventResults = {complete: true, total: checks.length,
passed: checks.filter(row => row.passed).length, checks};
globalThis.__uiEventResults = globalThis.__documentCreateEventResults;
return true;
})()
@@ -0,0 +1,110 @@
use super::*;
#[test]
fn hash_change_event_payload_accessors_use_native_state_and_callee_receiver_checks() {
let mut vm = new_parsed_test_vm(
"https://hash-change-event-accessors.test/",
"<!doctype html><body><iframe></iframe></body>",
);
assert_eq!(vm.eval(r#"(() => {
const realms=[window,document.querySelector('iframe').contentWindow];
for (const owner of realms) for (const callee of realms) {
const event=new owner.HashChangeEvent('hashchange',{
oldURL:'https://example.test/#old',newURL:'https://example.test/#new'
});
event.initEvent('initialized',false,false);
for (const [key,value] of [['oldURL','https://example.test/#old'],['newURL','https://example.test/#new']]) {
const descriptor=Object.getOwnPropertyDescriptor(callee.HashChangeEvent.prototype,key);
const getter=descriptor.get;
if (!descriptor.enumerable || !descriptor.configurable || descriptor.set!==undefined ||
getter.length!==0 || getter.name!=='get '+key || Object.hasOwn(event,key) || getter.call(event)!==value) throw Error('payload descriptor');
Object.defineProperty(event,key,{value:'author value',configurable:true});
if (getter.call(event)!==value) throw Error('author field replaced native state');
let traps=0;
const author=new Proxy(event,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}});
const revoked=Proxy.revocable(event,{});revoked.revoke();
for (const receiver of [{},new owner.Event('event'),Object.create(event),author,revoked.proxy]) {
let error;
try { getter.call(receiver); } catch(e) { error=e; }
if (!error || Object.getPrototypeOf(error)!==callee.TypeError.prototype || traps) throw Error('receiver accepted');
}
}
}
return true;
})()"#).unwrap(), "true");
}
#[test]
fn document_create_event_preserves_legacy_interfaces_and_native_dispatch() {
let mut vm = new_parsed_test_vm(
"https://document-create-event.test/",
"<!doctype html><body><iframe></iframe></body>",
);
vm.eval(include_str!("document_create_event.js")).unwrap();
assert_eq!(
vm.eval("__documentCreateEventResults.complete").unwrap(),
"true"
);
assert_eq!(
vm.eval("__documentCreateEventResults.total").unwrap(),
"1648"
);
assert_eq!(
vm.eval("JSON.stringify(__documentCreateEventResults.checks.filter(row => !row.passed))")
.unwrap(),
"[]"
);
}
#[test]
fn document_create_event_uses_intrinsics_and_registered_native_receivers() {
let mut vm =
new_storage_page_task_executor_test_vm("https://document-create-event-native.test/");
vm.eval("document.body.innerHTML='<iframe></iframe>'; globalThis.child=document.querySelector('iframe').contentWindow;")
.unwrap();
let context_ptr = &vm.page_default_context as *const _;
vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
let global = scope.get_current_context().global(scope);
let key = crate::util::v8str(scope, "document");
let document =
v8::Local::<v8::Object>::try_from(global.get(scope, key.into()).unwrap()).unwrap();
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, document, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
let key = crate::util::v8str(scope, "nativeDocument");
assert_eq!(
global.create_data_property(scope, key.into(), proxy.into()),
Some(true)
);
Ok(())
})
.unwrap();
assert_eq!(vm.eval(r#"(() => {
const names=['DragEvent','HashChangeEvent','TouchEvent'];
const originals=names.map(name=>window[name]);
let constructorCalls=0;
try {
for (const name of names) window[name]=function(){constructorCalls++;throw 42;};
for (const realm of [window,child]) {
const create=realm.Document.prototype.createEvent;
for (const [index,name] of names.entries()) {
const event=create.call(nativeDocument,name);
if (Object.getPrototypeOf(event)!==originals[index].prototype || event.type!=='' || event.isTrusted) throw Error('intrinsic creation');
event.initEvent('native',false,false);
if (!new EventTarget().dispatchEvent(event)) throw Error('native dispatch');
}
let conversions=0, traps=0;
const author=new Proxy(nativeDocument,{get(){traps++;throw 42;},getPrototypeOf(){traps++;throw 42;}});
const revoked=Proxy.revocable(nativeDocument,{});revoked.revoke();
for (const receiver of [author,revoked.proxy,Object.create(nativeDocument)]) {
let error;
try { create.call(receiver,{toString(){conversions++;throw 42;}}); } catch(e) { error=e; }
if (!error || Object.getPrototypeOf(error)!==realm.TypeError.prototype || conversions || traps) throw Error('author receiver accepted');
}
}
return constructorCalls===0;
} finally {
names.forEach((name,index)=>window[name]=originals[index]);
}
})()"#).unwrap(), "true");
}
@@ -3129,7 +3129,8 @@ fn submit_event_constructor_surface() {
submitter: button
});
const desc = Object.getOwnPropertyDescriptor(SubmitEvent.prototype, 'submitter');
const created = document.createEvent('SubmitEvent');
let creationError;
try { document.createEvent('SubmitEvent'); } catch (error) { creationError = error; }
return [
typeof SubmitEvent,
SubmitEvent.length,
@@ -3146,8 +3147,8 @@ fn submit_event_constructor_surface() {
Object.prototype.toString.call(second),
typeof desc.get,
desc.set === undefined,
created instanceof SubmitEvent,
created.submitter === null
creationError instanceof DOMException,
creationError.name === 'NotSupportedError'
].join('|');
})()
"#,
@@ -2245,6 +2245,7 @@ mod device_events;
mod gamepad_interfaces;
mod document_create_event;
mod document_svg_root;
mod dom_elements;
mod dom_exception_proxy_identity;
+1 -1
View File
@@ -1582,7 +1582,7 @@ wait_until = "load"
timeout_ms = 5000
suite = "smoke"
tags = ["forms", "html", "submit", "events"]
notes = "Manual compat port for SubmitEvent surface: constructor/getter shape, document.createEvent('SubmitEvent'), requestSubmit(submitter) preserving the active submitter, and requestSubmit() exposing null submitter."
notes = "Manual compat port for SubmitEvent surface: constructor/getter shape, legacy document.createEvent('SubmitEvent') rejection, requestSubmit(submitter) preserving the active submitter, and requestSubmit() exposing null submitter."
[[test]]
id = "form-submit-button-click-basic"
@@ -16,7 +16,6 @@ test(function () {
submitter: button,
});
const descriptor = Object.getOwnPropertyDescriptor(SubmitEvent.prototype, "submitter");
const created = document.createEvent("SubmitEvent");
assert_equals(typeof SubmitEvent, "function", "SubmitEvent should be exposed");
assert_equals(SubmitEvent.length, 1, "SubmitEvent.length should match the constructor signature");
@@ -45,8 +44,9 @@ test(function () {
assert_equals(typeof descriptor.get, "function", "submitter should be a getter");
assert_equals(descriptor.set, undefined, "submitter should not expose a setter");
assert_true(created instanceof SubmitEvent, "document.createEvent('SubmitEvent') should use SubmitEvent");
assert_equals(created.submitter, null, "document.createEvent('SubmitEvent') should default submitter to null");
assert_throws_dom("NotSupportedError", function () {
document.createEvent("SubmitEvent");
}, "SubmitEvent is absent from the legacy createEvent interface table");
}, "SubmitEvent constructor and prototype getter surface");
test(function () {