fix(dom): convert MutationObserver options through WebIDL

This commit is contained in:
ldm0
2026-10-22 14:42:05 +08:00
committed by Donough Liu
parent a29b5fbe13
commit 882cc62396
5 changed files with 395 additions and 113 deletions
@@ -18,7 +18,7 @@ use moli_webapi_declare::WebApiFunctionTemplate;
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::MutationObserver, enumerable, receiver)]
struct MutationObserverTemplateMethodsDeclaration {
#[webapi(method, length = 2, callback = observer_runtime::mutation_observer_observe_callback)]
#[webapi(method, length = 1, callback = observer_runtime::mutation_observer_observe_callback)]
observe: (),
#[webapi(
+90 -111
View File
@@ -312,6 +312,54 @@ pub(super) struct MutationObserverOptions {
attribute_filter: Option<HashSet<String>>,
}
impl TryFrom<MutationObserverInit> for MutationObserverOptions {
type Error = &'static str;
fn try_from(init: MutationObserverInit) -> Result<Self, Self::Error> {
let attributes = init
.attributes
.unwrap_or(init.attribute_old_value.is_some() || init.attribute_filter.is_some());
let character_data = init
.character_data
.unwrap_or(init.character_data_old_value.is_some());
let attribute_old_value = init.attribute_old_value.unwrap_or(false);
let character_data_old_value = init.character_data_old_value.unwrap_or(false);
if !init.child_list && !attributes && !character_data {
return Err(
"Failed to execute 'observe' on 'MutationObserver': at least one of childList, attributes, or characterData must be true.",
);
}
if !attributes && (attribute_old_value || init.attribute_filter.is_some()) {
return Err(
"Failed to execute 'observe' on 'MutationObserver': attributeOldValue and attributeFilter require attributes to be true.",
);
}
if !character_data && character_data_old_value {
return Err(
"Failed to execute 'observe' on 'MutationObserver': characterDataOldValue requires characterData to be true.",
);
}
Ok(Self {
child_list: init.child_list,
attributes,
character_data,
subtree: init.subtree,
attribute_old_value,
character_data_old_value,
// Native mutation local names are UTF-8. Ill-formed UTF-16 entries
// cannot match them; retain the filter even if no entries can match.
attribute_filter: init.attribute_filter.map(|names| {
names
.into_iter()
.filter_map(|name| String::from_utf16(&name.0).ok())
.collect()
}),
})
}
}
#[derive(Clone)]
struct QueuedMutationRecord {
target: NativeNodeId,
@@ -400,6 +448,30 @@ struct MutationObserverConstructorArgs {
callback: webidl::WebIdlCallbackFunction,
}
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "MutationObserverInit")]
struct MutationObserverInit {
#[webidl(sequence, converter = "raw")]
attribute_filter: Option<Vec<webidl::DomString16>>,
attribute_old_value: Option<bool>,
attributes: Option<bool>,
character_data: Option<bool>,
character_data_old_value: Option<bool>,
#[webidl(default = false)]
child_list: bool,
#[webidl(default = false)]
subtree: bool,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "MutationObserver.observe")]
struct MutationObserverObserveArgs<'s> {
#[webidl(required, interface = web_api_interfaces::Node)]
target: v8::Local<'s, v8::Object>,
#[webidl(dictionary, default = MutationObserverInit::default())]
options: MutationObserverInit,
}
#[derive(webidl::WebIdlDictionary)]
#[webidl(prefix = "IntersectionObserverInit")]
struct IntersectionObserverInitMembers {
@@ -987,8 +1059,7 @@ impl MutationObserverRegistration {
return false;
}
self.options.attribute_filter.as_ref().is_none_or(|filter| {
filter.contains(mutation.local_name())
|| filter.contains(&mutation.local_name().to_ascii_lowercase())
mutation.namespace().is_none() && filter.contains(mutation.local_name())
})
}
DomMutationRecordKind::CharacterData { .. } => self.options.character_data,
@@ -1058,21 +1129,25 @@ pub(super) fn mutation_observer_observe_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let raw_target = args.get(0);
let Some(parsed) = webidl::parse_args::<MutationObserverObserveArgs>(scope, &args) else {
return;
};
let options = match MutationObserverOptions::try_from(parsed.options) {
Ok(options) => options,
Err(message) => {
throw_type_error(scope, message);
return;
}
};
let raw_target = moli_webapi_declare::web_api_object_target(scope, parsed.target)
.unwrap_or(parsed.target)
.into();
let Some(target) = callback_value_dom_handle(scope, raw_target)
.or_else(|| detached_native_target_handle(scope, raw_target))
else {
if is_detached_node_value(scope, raw_target) {
match parse_mutation_observer_options(scope, args.get(1)) {
Ok(_) => {
rv.set_undefined();
return;
}
Err(message) => {
throw_type_error(scope, message);
return;
}
}
rv.set_undefined();
return;
}
throw_type_error(
scope,
@@ -1080,13 +1155,6 @@ pub(super) fn mutation_observer_observe_callback<'s>(
);
return;
};
let options = match parse_mutation_observer_options(scope, args.get(1)) {
Ok(options) => options,
Err(message) => {
throw_type_error(scope, message);
return;
}
};
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
rv.set_undefined();
return;
@@ -1498,67 +1566,6 @@ fn wrap_node_handle<'s>(
.and_then(|value| v8::Local::<v8::Object>::try_from(value).ok())
}
fn parse_mutation_observer_options(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Result<MutationObserverOptions, &'static str> {
let object = v8::Local::<v8::Object>::try_from(value).map_err(
|_| "Failed to execute 'observe' on 'MutationObserver': parameter 2 is not an object.",
)?;
let attributes_specified = has_property(scope, object, "attributes");
let attributes_enabled = bool_option(scope, object, "attributes");
let character_data_specified = has_property(scope, object, "characterData");
let character_data_enabled = bool_option(scope, object, "characterData");
let mut options = MutationObserverOptions {
child_list: bool_option(scope, object, "childList"),
attributes: attributes_enabled,
character_data: character_data_enabled,
subtree: bool_option(scope, object, "subtree"),
attribute_old_value: bool_option(scope, object, "attributeOldValue"),
character_data_old_value: bool_option(scope, object, "characterDataOldValue"),
attribute_filter: string_set_option(scope, object, "attributeFilter"),
};
if attributes_specified
&& !attributes_enabled
&& (options.attribute_old_value || options.attribute_filter.is_some())
{
return Err(
"Failed to execute 'observe' on 'MutationObserver': attributeOldValue and attributeFilter require attributes to be true.",
);
}
if character_data_specified && !character_data_enabled && options.character_data_old_value {
return Err(
"Failed to execute 'observe' on 'MutationObserver': characterDataOldValue requires characterData to be true.",
);
}
if !attributes_specified && (options.attribute_old_value || options.attribute_filter.is_some())
{
options.attributes = true;
}
if !character_data_specified && options.character_data_old_value {
options.character_data = true;
}
if !options.child_list && !options.attributes && !options.character_data {
return Err(
"Failed to execute 'observe' on 'MutationObserver': at least one of childList, attributes, or characterData must be true.",
);
}
Ok(options)
}
fn has_property(
scope: &mut v8::PinScope<'_, '_>,
object: v8::Local<'_, v8::Object>,
key: &str,
) -> bool {
let Some(key) = v8_string(scope, key) else {
return false;
};
object.has(scope, key.into()).unwrap_or(false)
}
enum IntersectionObserverOptionsError {
Syntax(&'static str),
WebIdl(webidl::WebIdlError),
@@ -1642,19 +1649,6 @@ fn parse_intersection_observer_options<'s>(
Ok(options)
}
fn bool_option(
scope: &mut v8::PinScope<'_, '_>,
object: v8::Local<'_, v8::Object>,
key: &str,
) -> bool {
let Some(key) = v8_string(scope, key) else {
return false;
};
object
.get(scope, key.into())
.is_some_and(|value| value.boolean_value(scope))
}
fn intersection_observer_threshold_member<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
@@ -1698,23 +1692,6 @@ fn normalize_intersection_thresholds(
Ok(thresholds)
}
fn string_set_option(
scope: &mut v8::PinScope<'_, '_>,
object: v8::Local<'_, v8::Object>,
key: &str,
) -> Option<HashSet<String>> {
let key = v8_string(scope, key)?;
let value = object.get(scope, key.into())?;
let array = v8::Local::<v8::Array>::try_from(value).ok()?;
let mut result = HashSet::new();
for index in 0..array.length() {
let entry = array.get_index(scope, index)?;
let text = entry.to_string(scope)?.to_rust_string_lossy(scope);
result.insert(text);
}
Some(result)
}
fn define_intersection_observer_slots(
scope: &mut v8::PinScope<'_, '_>,
host_ptr: *mut JsContextHost,
@@ -2420,6 +2397,8 @@ fn mutation_observer_id_from_object(
scope: &mut v8::PinScope<'_, '_>,
object: v8::Local<'_, v8::Object>,
) -> Option<u32> {
let object = v8::Local::new(scope, object);
let object = moli_webapi_declare::web_api_object_target(scope, object)?;
observer_id_from_private_slot(scope, object, MUTATION_OBSERVER_ID_SLOT)
}
@@ -1,5 +1,105 @@
use super::*;
fn run_mutation_observer_init_checks(group: &str, total: usize) {
let mut vm = new_storage_html_test_vm("https://mutation-observer-init.test/");
vm.eval(&format!(
"document.body.innerHTML='<iframe id=child></iframe>'; \
globalThis.__mutationObserverGroup={group:?};"
))
.unwrap();
vm.eval(include_str!(
"../../../tests/fixtures/mutation-observer-init.js"
))
.unwrap();
assert_eq!(vm.eval("__uiEventResults.complete").unwrap(), "true");
assert_eq!(
vm.eval("JSON.stringify(__uiEventResults.checks.filter(row=>!row.passed))")
.unwrap(),
"[]"
);
assert_eq!(
vm.eval("__uiEventResults.total").unwrap(),
total.to_string()
);
let names: Vec<String> = serde_json::from_str(
&vm.eval("JSON.stringify(__uiEventResults.checks.map(row=>row.name))")
.unwrap(),
)
.unwrap();
assert_eq!(names.len(), total);
assert_eq!(
names
.into_iter()
.collect::<std::collections::BTreeSet<_>>()
.len(),
total
);
}
#[test]
fn mutation_observer_init_converts_iterables_and_preserves_dictionary_order_and_registration() {
run_mutation_observer_init_checks("conversion", 128);
}
#[test]
fn mutation_observer_filters_match_exact_names_namespaces_and_utf16_identity() {
run_mutation_observer_init_checks("filters", 52);
}
#[test]
fn mutation_observer_observe_accepts_registered_native_proxy_targets_and_receivers() {
fn register_proxy<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s>,
) {
let target = v8::Local::<v8::Object>::try_from(args.get(0)).unwrap();
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, target, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
rv.set(proxy.into());
}
let mut vm = new_storage_html_test_vm("https://mutation-observer-native-proxy.test/");
let context_ptr = &vm.page_default_runtime.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 function = v8::Function::new(scope, register_proxy).unwrap();
let key = crate::util::v8str(scope, "registerNativeProxy");
assert_eq!(
global.create_data_property(scope, key.into(), function.into()),
Some(true)
);
Ok(())
})
.unwrap();
assert_eq!(vm.eval(r#"(() => {
const observer = new MutationObserver(() => {});
const target = document.createElement('div');
const observerProxy = registerNativeProxy(observer);
const targetProxy = registerNativeProxy(target);
MutationObserver.prototype.observe.call(observerProxy, targetProxy, {
attributeFilter: new Set(['keep'])
});
target.setAttribute('drop', 'x'); target.setAttribute('keep', 'y');
const records = MutationObserver.prototype.takeRecords.call(observerProxy);
if (records.length !== 1 || records[0].target !== target || records[0].attributeName !== 'keep')
throw Error('registered native identity');
let gets = 0, traps = 0;
for (const [receiver, node] of [
[new Proxy(observerProxy, {get(){traps++;}}), targetProxy],
[observerProxy, new Proxy(targetProxy, {get(){traps++;}})]
]) {
let error;
try {MutationObserver.prototype.observe.call(receiver, node, {
get childList(){gets++;return true;}
});} catch(caught) {error = caught;}
if (!(error instanceof TypeError)) throw Error('author Proxy was accepted');
}
MutationObserver.prototype.disconnect.call(observerProxy);
return gets === 0 && traps === 0;
})()"#).unwrap(), "true");
}
fn observer_callback_test_vm(url: &str) -> StandaloneScriptVmHarness {
let mut vm = new_storage_test_vm(url);
vm.eval(
@@ -0,0 +1,203 @@
(() => {
const realms = [globalThis, document.getElementById('child').contentWindow];
const checks = [];
const group = globalThis.__mutationObserverGroup || 'all';
const facts = globalThis.__uiEventResults = {complete: false, group, checks};
const assert = (ok, message) => {if (!ok) throw Error(message);};
const equal = (a, b, message) => assert(JSON.stringify(a) === JSON.stringify(b),
message + ': ' + JSON.stringify(a) + ' != ' + JSON.stringify(b));
function test(name, body) {
globalThis.__mutationObserverCurrentCase = name;
try {body(); checks.push({name, passed: true});}
catch (error) {checks.push({name, passed: false, detail: String(error)});}
}
for (const [ownerIndex, owner] of realms.entries()) {
for (const [calleeIndex, callee] of realms.entries()) {
const prefix = ownerIndex + ':' + calleeIndex + ':';
const method = callee.MutationObserver.prototype.observe;
function withObserver(body, target) {
const observer = new owner.MutationObserver(() => {});
target = target || owner.document.createElement('div');
const observe = options => Reflect.apply(method, observer, [target, options]);
try {body(observer, target, observe);} finally {observer.disconnect();}
}
const names = observer => observer.takeRecords().map(record => record.attributeName);
const typeError = body => {
let error;
try {body();} catch (caught) {error = caught;}
assert(error instanceof callee.TypeError, 'callee TypeError');
};
if (group !== 'filters') {
test(prefix + 'observe.length', () => assert(method.length === 1, 'one required argument'));
for (const [label, make] of [
['Set', () => new Set(['keep'])],
['generator', () => (function*() {yield 'keep';})()],
['array custom iterator', () => Object.assign(['drop'], {[Symbol.iterator]: function*() {yield 'keep';}})],
]) {
test(prefix + 'iterable ' + label, () => withObserver((observer, target, observe) => {
observe({attributes: true, attributeFilter: make()});
target.setAttribute('drop', 'x'); target.setAttribute('keep', 'y');
equal(names(observer), ['keep'], 'converted filter');
}));
}
test(prefix + 'empty iterable remains an empty filter', () => withObserver((observer, target, observe) => {
observe({attributeFilter: new Set()}); target.setAttribute('drop', 'x');
equal(names(observer), [], 'nothing matches an empty filter');
}));
for (const [label, value] of [['null', null], ['string', 'keep'], ['number', 1], ['boolean', false],
['plain object', {}], ['array-like', {0: 'keep', length: 1}], ['non-callable iterator', {[Symbol.iterator]: 1}]]) {
test(prefix + 'reject non-sequence ' + label, () => withObserver((observer, target, observe) => {
typeError(() => observe({attributes: true, attributeFilter: value}));
target.setAttribute('keep', 'x'); equal(names(observer), [], 'failure does not register');
}));
}
for (const oldValue of [false, null]) {
test(prefix + 'attributeOldValue present ' + oldValue, () => withObserver((observer, target, observe) => {
observe({attributeOldValue: oldValue}); target.setAttribute('keep', 'x');
const records = observer.takeRecords();
assert(records.length === 1 && records[0].oldValue === null, 'presence enables attributes');
}));
test(prefix + 'characterDataOldValue present ' + oldValue, () => {
const target = owner.document.createTextNode('before');
withObserver((observer, node, observe) => {
observe({characterDataOldValue: oldValue}); node.data = 'after';
const records = observer.takeRecords();
assert(records.length === 1 && records[0].type === 'characterData' && records[0].oldValue === null,
'presence enables characterData');
}, target);
});
}
test(prefix + 'undefined flags are absent', () => withObserver((observer, target, observe) => {
target.setAttribute('keep', 'before');
observe({attributes: undefined, attributeOldValue: true});
target.setAttribute('keep', 'after');
const records = observer.takeRecords();
assert(records.length === 1 && records[0].oldValue === 'before', 'undefined allows implicit attributes');
}));
const keys = ['attributeFilter', 'attributeOldValue', 'attributes', 'characterData',
'characterDataOldValue', 'childList', 'subtree'];
test(prefix + 'dictionary Get order without HasProperty', () => withObserver((observer, target, observe) => {
const log = [], options = new Proxy({attributeFilter: ['keep'], attributes: true}, {
get(object, key, receiver) {log.push(key); return Reflect.get(object, key, receiver);},
has() {throw Error('unexpected HasProperty');},
});
observe(options); equal(log, keys, 'lexical dictionary order');
}));
test(prefix + 'inherited members are read once in lexical order', () => withObserver((observer, target, observe) => {
const log = [], prototype = {};
for (const key of keys) Object.defineProperty(prototype, key, {get() {
log.push(key); return key === 'attributes' ? true : undefined;
}});
observe(Object.create(prototype)); equal(log, keys, 'inherited getter order');
}));
for (const failKey of keys) {
test(prefix + 'getter abrupt completion at ' + failKey, () => withObserver((observer, target, observe) => {
const sentinel = {}, log = [], options = {};
for (const key of keys) Object.defineProperty(options, key, {get() {
log.push(key); if (key === failKey) throw sentinel;
return key === 'attributeFilter' ? ['keep'] : key === 'attributes' ? true : undefined;
}});
let error; try {observe(options);} catch (caught) {error = caught;}
assert(error === sentinel, 'original thrown value');
equal(log, keys.slice(0, keys.indexOf(failKey) + 1), 'stop at failed member');
target.setAttribute('keep', 'after'); equal(names(observer), [], 'no partial registration');
}));
}
for (const failure of ['iterator', 'item']) {
test(prefix + 'failed reconfiguration preserves registration: ' + failure, () => withObserver((observer, target, observe) => {
target.setAttribute('keep', 'before');
observe({attributes: true, attributeOldValue: true, attributeFilter: ['keep']});
const sentinel = {}, filter = failure === 'iterator' ? {
*[Symbol.iterator]() {yield 'changed'; throw sentinel;}
} : [{toString() {throw sentinel;}}];
let error, laterGets = 0;
try {observe({attributeFilter: filter, get attributes() {laterGets++; return true;}});}
catch (caught) {error = caught;}
assert(error === sentinel && laterGets === 0, 'conversion fails before later members');
target.setAttribute('changed', 'x'); target.setAttribute('keep', 'after');
const records = observer.takeRecords();
assert(records.length === 1 && records[0].attributeName === 'keep' && records[0].oldValue === 'before',
'old registration is intact');
}));
}
test(prefix + 'iteration and DOMString conversion happen at member position', () => withObserver((observer, target, observe) => {
const log = [], iterable = {
get [Symbol.iterator]() {log.push('iterator'); return new Proxy(function*() {
log.push('iterate'); yield {toString() {log.push('string'); return 'keep';}};
}, {});}
};
observe({get attributeFilter() {log.push('filter'); return iterable;},
get attributeOldValue() {log.push('oldValue'); return false;},
get attributes() {log.push('attributes'); return true;}});
equal(log, ['filter', 'iterator', 'iterate', 'string', 'oldValue', 'attributes'], 'conversion position');
target.setAttribute('drop', 'x'); target.setAttribute('keep', 'after');
equal(names(observer), ['keep'], 'resulting filter');
}));
test(prefix + 'invalid target precedes options conversion', () => withObserver((observer, target) => {
const revoked = Proxy.revocable(target, {}); revoked.revoke();
for (const invalid of [{}, Object.create(target), new Proxy(target, {}), revoked.proxy, null]) {
let gets = 0;
typeError(() => Reflect.apply(method, observer, [invalid, {get childList() {gets++; return true;}}]));
assert(gets === 0, 'invalid target stops dictionary getters');
}
}));
test(prefix + 'invalid receiver precedes argument conversion', () => withObserver((observer, target) => {
for (const invalid of [{}, Object.create(observer), new Proxy(observer, {})]) {
let gets = 0;
typeError(() => Reflect.apply(method, invalid, [target, {get childList() {gets++; return true;}}]));
assert(gets === 0, 'receiver validation comes first');
}
}));
test(prefix + 'explicit false stays false', () => withObserver((observer, target, observe) => {
observe({childList: true, attributes: false, attributeOldValue: false});
target.setAttribute('keep', 'x'); target.appendChild(owner.document.createTextNode('text'));
const records = observer.takeRecords();
assert(records.length === 1 && records[0].type === 'childList', 'no implicit attributes');
typeError(() => observe({childList: true, attributes: false, attributeFilter: []}));
typeError(() => observe({childList: true, characterData: false, characterDataOldValue: true}));
}));
}
if (group !== 'conversion') {
for (const documentKind of ['live', 'html', 'xml']) {
const doc = documentKind === 'live' ? owner.document : documentKind === 'html' ?
owner.document.implementation.createHTMLDocument('') :
owner.document.implementation.createDocument('urn:fixture', 'root');
test(prefix + documentKind + ':filter excludes non-null namespaces', () => withObserver((observer, target, observe) => {
observe({attributes: true, attributeFilter: ['keep']});
target.setAttributeNS('urn:fixture', 'p:keep', 'namespaced');
target.setAttribute('keep', 'plain');
const records = observer.takeRecords();
assert(records.length === 1 && records[0].attributeName === 'keep' && records[0].attributeNamespace === null,
'only unnamespaced local name matches');
}, doc.createElement('item')));
test(prefix + documentKind + ':no filter retains namespaced mutations', () => withObserver((observer, target, observe) => {
observe({attributes: true}); target.setAttributeNS('urn:fixture', 'p:keep', 'value');
const records = observer.takeRecords();
assert(records.length === 1 && records[0].attributeNamespace === 'urn:fixture', 'unfiltered namespace');
}, doc.createElement('item')));
test(prefix + documentKind + ':filter preserves lone-surrogate identity', () => withObserver((observer, target, observe) => {
observe({attributes: true, attributeFilter: [String.fromCharCode(0xd800)]});
target.setAttribute('\ufffd', 'value'); equal(names(observer), [], 'no replacement-character alias');
}, doc.createElement('item')));
test(prefix + documentKind + ':filter preserves well-formed Unicode', () => withObserver((observer, target, observe) => {
const name = String.fromCodePoint(0x10000);
observe({attributeFilter: [name]}); target.setAttribute(name, 'value');
equal(names(observer), [name], 'non-BMP local name');
}, doc.createElement('item')));
}
test(prefix + 'XML attribute filters match case exactly', () => {
const doc = owner.document.implementation.createDocument('urn:fixture', 'root');
withObserver((observer, target, observe) => {
observe({attributeFilter: ['lower']});
target.setAttribute('LOWER', 'upper'); target.setAttribute('lower', 'lower');
equal(names(observer), ['lower'], 'case-sensitive local names');
}, doc.documentElement);
});
}
}
}
facts.total = checks.length;
facts.passed = checks.filter(check => check.passed).length;
facts.complete = true;
return facts.passed === facts.total;
})()
@@ -12,7 +12,7 @@ callback MutationCallback = undefined (sequence<MutationRecord> mutations, Mutat
interface MutationObserver {
constructor(MutationCallback callback);
undefined observe(Node target, MutationObserverInit options);
undefined observe(Node target, optional MutationObserverInit options = {});
undefined disconnect();
sequence<MutationRecord> takeRecords();
};