fix(cssom): preserve keyframe mutation error semantics

This commit is contained in:
ldm0
2026-10-11 18:06:49 +08:00
parent 31af12ca14
commit 8b8655fd27
12 changed files with 300 additions and 33 deletions
@@ -0,0 +1,114 @@
<!doctype html>
<html>
<body
data-ok=""
data-insert-0=""
data-insert-1=""
data-length-after-insert=""
data-first-rule=""
data-second-rule=""
data-length-after-delete=""
data-remaining-rule=""
data-group-insert-0=""
data-group-insert-1=""
data-group-length-after-insert=""
data-group-first-parent=""
data-group-first-sheet=""
data-group-css-text-after-insert=""
data-group-length-after-delete=""
data-group-css-text-after-delete=""
data-group-css-text-after-style=""
data-keyframes-rule-brand=""
data-keyframes-length=""
data-keyframe-rule-brand=""
data-keyframe-find=""
data-keyframe-parent=""
data-keyframe-sheet=""
data-keyframes-after-append=""
data-keyframes-after-keytext=""
data-keyframes-after-style=""
data-keyframes-after-csstext=""
data-keyframe-type-after-csstext=""
data-keyframes-after-delete=""
data-keyframes-missing=""
data-keyframe-invalid-error=""
data-keyframe-invalid-preserved=""
data-fontface-css-text-after-style=""
>
<script>
const body = document.body;
let ok = true;
try {
const sheet = new CSSStyleSheet();
body.setAttribute('data-insert-0', String(sheet.insertRule('div { color: red; }', 0)));
body.setAttribute('data-insert-1', String(sheet.insertRule('span { color: blue; }', 1)));
body.setAttribute('data-length-after-insert', String(sheet.cssRules.length));
body.setAttribute('data-first-rule', sheet.cssRules[0]?.cssText ?? '');
body.setAttribute('data-second-rule', sheet.cssRules[1]?.cssText ?? '');
sheet.deleteRule(1);
body.setAttribute('data-length-after-delete', String(sheet.cssRules.length));
body.setAttribute('data-remaining-rule', sheet.cssRules[0]?.cssText ?? '');
const groupSheet = new CSSStyleSheet();
groupSheet.replaceSync('@media screen { div { color: red; } }');
const media = groupSheet.cssRules[0];
body.setAttribute('data-group-insert-0', String(media.insertRule('span { color: blue; }', 1)));
body.setAttribute('data-group-insert-1', String(media.insertRule('main { display: block; }', 0)));
body.setAttribute('data-group-length-after-insert', String(media.cssRules.length));
body.setAttribute('data-group-first-parent', String(media.cssRules[0].parentRule === media));
body.setAttribute('data-group-first-sheet', String(media.cssRules[0].parentStyleSheet === groupSheet));
body.setAttribute('data-group-css-text-after-insert', media.cssText);
media.deleteRule(2);
body.setAttribute('data-group-length-after-delete', String(media.cssRules.length));
body.setAttribute('data-group-css-text-after-delete', media.cssText);
media.cssRules[0].style.color = 'green';
body.setAttribute('data-group-css-text-after-style', media.cssText);
const keyframesSheet = new CSSStyleSheet();
keyframesSheet.replaceSync('@keyframes fade { from { opacity: 0; } to { opacity: 1; } }');
const keyframes = keyframesSheet.cssRules[0];
body.setAttribute('data-keyframes-rule-brand', String(keyframes instanceof CSSKeyframesRule));
body.setAttribute('data-keyframes-length', String(keyframes.cssRules.length));
const fromRule = keyframes.findRule('0%');
body.setAttribute('data-keyframe-rule-brand', String(fromRule instanceof CSSKeyframeRule));
body.setAttribute('data-keyframe-find', fromRule?.keyText ?? '');
body.setAttribute('data-keyframe-parent', String(fromRule.parentRule === keyframes));
body.setAttribute('data-keyframe-sheet', String(fromRule.parentStyleSheet === keyframesSheet));
keyframes.appendRule('50% { opacity: 0.5; transform: scale(1); }');
body.setAttribute('data-keyframes-after-append', keyframes.cssText);
const midRule = keyframes.findRule('50%');
midRule.keyText = '75%';
body.setAttribute('data-keyframes-after-keytext', keyframes.cssText);
midRule.style.opacity = '0.75';
body.setAttribute('data-keyframes-after-style', keyframes.cssText);
midRule.cssText = '80% { opacity: 0.8; transform: scale(1); }';
body.setAttribute('data-keyframes-after-csstext', keyframes.cssText);
body.setAttribute('data-keyframe-type-after-csstext', String(midRule.type));
let invalidKeyTextError;
try {
midRule.keyText = 'body';
} catch (error) {
invalidKeyTextError = error;
}
if (!(invalidKeyTextError instanceof DOMException) ||
invalidKeyTextError.name !== 'SyntaxError' || invalidKeyTextError.code !== 12) {
throw new Error('Invalid keyText must throw DOM SyntaxError');
}
body.setAttribute('data-keyframe-invalid-error', invalidKeyTextError.name);
body.setAttribute('data-keyframe-invalid-preserved', midRule.keyText);
keyframes.deleteRule('80%');
body.setAttribute('data-keyframes-after-delete', keyframes.cssText);
body.setAttribute('data-keyframes-missing', String(keyframes.findRule('80%') === null));
const fontSheet = new CSSStyleSheet();
fontSheet.replaceSync('@font-face { font-family: Test; src: url(test.woff2); }');
const fontRule = fontSheet.cssRules[0];
fontRule.style.fontStyle = 'italic';
body.setAttribute('data-fontface-css-text-after-style', fontRule.cssText);
} catch (error) {
ok = false;
}
body.setAttribute('data-ok', String(ok));
</script>
</body>
</html>
+7 -1
View File
@@ -4156,7 +4156,7 @@ async fn chrome_cssstylesheet_rule_mutation_keeps_cssrules_list_operable_subset(
let browser = Browser::new(AppConfig::default())?;
let page = browser
.fetch(&server.url("/compat/chrome-cssstylesheet-rule-mutation"))
.fetch(&server.url("/compat/chrome-cssstylesheet-rule-mutation-v2"))
.await?;
assert!(
@@ -4276,6 +4276,12 @@ async fn chrome_cssstylesheet_rule_mutation_keeps_cssrules_list_operable_subset(
.unwrap()
.contains("data-keyframe-type-after-csstext=\"8\"")
);
assert!(
page.serialize_html_async()
.await
.unwrap()
.contains("data-keyframe-invalid-error=\"SyntaxError\"")
);
assert!(
page.serialize_html_async()
.await
@@ -482,35 +482,34 @@ pub(crate) fn css_keyframes_rule_append_rule_callback<'s>(
let attached_rule = parent_style_sheet.and_then(|sheet| {
css_rule_attached_native_path(scope, this, sheet).map(|path| (sheet, path))
});
if let Some((sheet, path)) = attached_rule {
if let Err(error) = apply_live_stylesheet_keyframe_rule_insert_mutation(
let result = if let Some((sheet, path)) = attached_rule {
apply_live_stylesheet_keyframe_rule_insert_mutation(
scope,
rules,
&parsed.rule,
index,
sheet,
&path,
) {
throw_insert_rule_error(scope, error);
return;
)
} else {
let result = apply_detached_keyframe_rule_insert_mutation(
scope,
this,
rules,
&parsed.rule,
index,
parent_style_sheet,
);
if result.is_ok() {
sync_css_keyframes_rule_css_text_from_rules(scope, this, rules);
}
rv.set_undefined();
return;
result
};
match result {
// Unlike CSSStyleSheet.insertRule, appendRule ignores invalid CSS.
Ok(()) | Err(CssRuleInsertError::Syntax) => rv.set_undefined(),
Err(error) => throw_insert_rule_error(scope, error),
}
if let Err(error) = apply_detached_keyframe_rule_insert_mutation(
scope,
this,
rules,
&parsed.rule,
index,
parent_style_sheet,
) {
throw_insert_rule_error(scope, error);
return;
}
sync_css_keyframes_rule_css_text_from_rules(scope, this, rules);
rv.set_undefined();
}
pub(crate) fn css_keyframes_rule_delete_rule_callback<'s>(
@@ -2195,11 +2195,13 @@ pub(crate) fn css_keyframe_rule_key_text_setter_callback<'s>(
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
let Some(parsed) = webidl::parse_args::<CssKeyframeRuleKeyTextArgs>(scope, &args) else {
let Some(key_text) =
cssom_dom_string_property_value(scope, args.get(0), "CSSKeyframeRule", "keyText")
else {
return;
};
let Some(key_text) = normalize_keyframe_selector_text_with_stylo(&parsed.key_text) else {
rv.set_undefined();
let Some(key_text) = normalize_keyframe_selector_text_with_stylo(&key_text) else {
throw_dom_exception_value(scope, "Invalid keyframe selector.", "SyntaxError");
return;
};
let this = css_rule_receiver(scope, &args);
@@ -876,13 +876,6 @@ pub struct CssKeyframesRuleFindRuleArgs {
pub key: String,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "CSSKeyframeRule.keyText")]
pub struct CssKeyframeRuleKeyTextArgs {
#[webidl(required)]
pub key_text: String,
}
#[derive(webidl::WebIdlArgs)]
#[webidl(prefix = "CSSStyleSheet.replace")]
pub struct CssStyleSheetReplaceArgs {
@@ -0,0 +1,44 @@
((bindings, values, label) => {
const checks=[];
const check=(name,fn)=>{try{const result=fn();checks.push({name:label+': '+name,passed:result===true,detail:result===true?null:String(result)});}catch(error){checks.push({name:label+': '+name,passed:false,detail:String(error)});}};
const setter=Object.getOwnPropertyDescriptor(bindings.CSSKeyframeRule.prototype,'keyText').set;
const append=bindings.CSSKeyframesRule.prototype.appendRule;
const find=bindings.CSSKeyframesRule.prototype.findRule;
const remove=bindings.CSSKeyframesRule.prototype.deleteRule;
const syntaxError=error=>error instanceof bindings.DOMException && error.name==='SyntaxError' && error.code===12 && !(error instanceof bindings.SyntaxError);
for(const mode of ['attached','constructed','detached']) {
let owner;
const sheet=mode==='attached' ? (()=>{owner=values.document.createElement('style');owner.textContent='@keyframes original {from {left:0px} to {left:100px}}';values.document.head.appendChild(owner);return owner.sheet;})() : new values.CSSStyleSheet();
if(mode!=='attached')sheet.replaceSync('@keyframes original {from {left:0px} to {left:100px}}');
const frames=sheet.cssRules[0],list=frames.cssRules,frame=list[0],declaration=frame.style;
if(mode==='detached')sheet.deleteRule(0);
const snapshot=()=>JSON.stringify([frame.keyText,frame.cssText,declaration.cssText,frames.cssText,frames.length,sheet.cssRules.length]);
const initial=snapshot();
const unchanged=()=>snapshot()===initial && frames.cssRules===list && list[0]===frame && frame.style===declaration;
try {
for(const [index,value] of ['', ' ', 'body', '-1%', '101%', '0', '0px', 'from,', 'from to', '0%, broken', '50% {}', '@keyframes k{}', '50%;', 'calc(50px)', undefined, null, false, 0, 1n, {}].entries()) {
check(mode+' invalid keyText '+index+' throws DOM SyntaxError',()=>{let error;try{setter.call(frame,value);}catch(caught){error=caught;}return syntaxError(error);});
check(mode+' invalid keyText '+index+' preserves state and identities',unchanged);
}
check(mode+' omitted setter value converts undefined',()=>{let error;try{setter.call(frame);}catch(caught){error=caught;}return syntaxError(error) && unchanged();});
check(mode+' symbol keyText throws TypeError',()=>{let error;try{setter.call(frame,Symbol('selector'));}catch(caught){error=caught;}return error instanceof bindings.TypeError && unchanged();});
check(mode+' keyText conversion exception identity',()=>{const sentinel=new values.Error('keyText');let calls=0,error;try{setter.call(frame,{[Symbol.toPrimitive](hint){calls++;if(hint!=='string')throw Error(hint);throw sentinel;}});}catch(caught){error=caught;}return error===sentinel && calls===1 && unchanged();});
for(const [index,value] of ['', ' ', 'body {}', '-1% {}', '101% {}', '50px {}', '50% trailing {}', 'from, {}', '@media all {}', '50%;', 'from {} to {}', '0%, bad {}', undefined, null, false, 0, 1n, {}].entries()) {
check(mode+' invalid appendRule '+index+' returns undefined',()=>append.call(frames,value)===undefined);
check(mode+' invalid appendRule '+index+' preserves state and identities',unchanged);
}
check(mode+' appendRule missing argument throws TypeError',()=>{let error;try{append.call(frames);}catch(caught){error=caught;}return error instanceof bindings.TypeError && unchanged();});
check(mode+' appendRule symbol throws TypeError',()=>{let error;try{append.call(frames,Symbol('rule'));}catch(caught){error=caught;}return error instanceof bindings.TypeError && unchanged();});
check(mode+' appendRule conversion exception identity',()=>{const sentinel=new values.Error('appendRule');let calls=0,error;try{append.call(frames,{toString(){calls++;throw sentinel;}});}catch(caught){error=caught;}return error===sentinel && calls===1 && unchanged();});
check(mode+' receiver validation precedes mutation conversion',()=>{let calls=0,error;try{setter.call(frames,{toString(){calls++;return '50%';}});}catch(caught){error=caught;}return error instanceof bindings.TypeError && calls===0 && unchanged();});
check(mode+' valid keyText converts once and normalizes',()=>{let calls=0;const result=setter.call(frame,{[Symbol.toPrimitive](hint){calls++;if(hint!=='string')throw Error(hint);return ' 25% , to ';}});return result===undefined && calls===1 && frame.keyText==='25%, 100%' && frame.style===declaration && list[0]===frame && find.call(frames,'25%, to')===frame;});
check(mode+' valid appendRule converts once and appends',()=>{let calls=0;const result=append.call(frames,{toString(){calls++;return '75% {left:75px}';}});return result===undefined && calls===1 && frames.length===3 && list[2]===find.call(frames,'75%') && list[2].style.left==='75px';});
const last=list[2];
check(mode+' duplicate selector returns last rule',()=>{append.call(frames,'75% {left:76px}');return frames.length===4 && find.call(frames,'75%')===list[3] && list[2]===last;});
check(mode+' deleteRule deletes last duplicate only',()=>{remove.call(frames,'75%');return frames.length===3 && find.call(frames,'75%')===last;});
check(mode+' selector list ordering stays significant',()=>find.call(frames,'100%, 25%')===null && find.call(frames,'25%, 100%')===frame);
check(mode+' invalid findRule and deleteRule do not mutate',()=>{const before=snapshot();const result=find.call(frames,'body');remove.call(frames,'body');return result===null && snapshot()===before && frames.cssRules===list;});
} finally {owner?.remove();}
}
return checks;
})
@@ -0,0 +1,93 @@
use super::*;
#[test]
fn css_keyframes_mutations_use_cssom_error_semantics_across_realms_and_rule_lifetimes() {
let mut vm = new_storage_page_task_executor_test_vm("https://css-keyframes.test/");
vm.eval("document.body.innerHTML='<iframe id=child></iframe>'")
.unwrap();
let source = format!(
"(() => {{const probe={};const realms=[globalThis,child.contentWindow];globalThis.mutationChecks=[];for(let a=0;a<2;a++)for(let b=0;b<2;b++)mutationChecks.push(...probe(realms[a],realms[b],a+'-'+b));return JSON.stringify(mutationChecks.filter(row=>!row.passed));}})()",
include_str!("css_keyframes_mutations.js")
);
assert_eq!(vm.eval(&source).unwrap(), "[]");
assert_eq!(vm.eval("mutationChecks.length").unwrap(), "1068");
}
#[test]
fn css_keyframes_mutations_accept_native_proxies_and_preserve_intrinsic_exception_realms() {
let mut vm = new_storage_page_task_executor_test_vm("https://css-keyframes.test/");
vm.eval(r#"
document.body.innerHTML='<iframe id=child></iframe>';
globalThis.other=child.contentWindow;
const sheet=new other.CSSStyleSheet();sheet.replaceSync('@keyframes native {from {left:0px}}');
globalThis.keyframes=sheet.cssRules[0];globalThis.frame=keyframes[0];
"#).unwrap();
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);
for (name, proxy_name) in [("keyframes", "keyframesProxy"), ("frame", "frameProxy")] {
let key = crate::util::v8str(scope, name);
let target =
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, target, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
let key = crate::util::v8str(scope, proxy_name);
global.create_data_property(scope, key.into(), proxy.into());
}
Ok(())
})
.unwrap();
assert_eq!(vm.eval(r#"
(() => {
const assert=(value,message)=>{if(!value)throw Error(message);};
const realms=[globalThis,other],exceptions=realms.map(realm=>realm.DOMException);
const descriptors=realms.map(realm=>Object.getOwnPropertyDescriptor(realm,'DOMException'));
let constructorReads=0;
try {
for(const realm of realms)Object.defineProperty(realm,'DOMException',{configurable:true,get(){constructorReads++;throw Error('public constructor');}});
for(let index=0;index<realms.length;index++) {
const realm=realms[index],setter=Object.getOwnPropertyDescriptor(realm.CSSKeyframeRule.prototype,'keyText').set;
for(const supplied of [true,false]) {
let error;try{if(supplied)setter.call(frameProxy,'body');else setter.call(frameProxy);}catch(caught){error=caught;}
assert(error instanceof exceptions[index] && !(error instanceof exceptions[1-index]) && error.name==='SyntaxError' && error.code===12,'native SyntaxError uses callee intrinsic');
}
assert(frame.keyText==='0%' && keyframes[0]===frame,'invalid setter preserves backing rule');
assert(realm.CSSKeyframesRule.prototype.appendRule.call(keyframesProxy,'body {}')===undefined && keyframes.length===1,'invalid native append ignored');
setter.call(frameProxy,'50%');assert(frame.keyText==='50%','valid native selector update');
realm.CSSKeyframesRule.prototype.appendRule.call(keyframesProxy,'to {left:100px}');
assert(keyframes.length===2 && keyframes[1].keyText==='100%','valid native append');
realm.CSSKeyframesRule.prototype.deleteRule.call(keyframesProxy,'to');setter.call(frameProxy,'from');
}
return constructorReads===0;
} finally {for(let index=0;index<realms.length;index++)Object.defineProperty(realms[index],'DOMException',descriptors[index]);}
})()
"#).unwrap(), "true");
}
#[test]
fn css_keyframes_mutations_invalid_input_preserves_native_cascade_and_animation_identity() {
let mut vm = new_storage_page_task_executor_test_vm("https://css-keyframes.test/");
vm.eval(r#"
const style=document.createElement('style');style.textContent='@keyframes stable {from {left:0px} to {left:100px}}';document.head.appendChild(style);
globalThis.keyframes=style.sheet.cssRules[0];globalThis.frame=keyframes[0];
globalThis.item=document.createElement('div');document.body.appendChild(item);item.style.animation='stable 100s';
globalThis.animation=item.getAnimations()[0];
"#).unwrap();
crate::live_stylesheet::reset_live_stylesheet_mutation_metrics_for_test();
assert_eq!(vm.eval(r#"
(() => {
for(let index=0;index<20;index++) {
let error;try{frame.keyText='body';}catch(caught){error=caught;}
if(!(error instanceof DOMException) || error.name!=='SyntaxError')throw Error('invalid selector');
if(keyframes.appendRule('body {}')!==undefined)throw Error('invalid append');
}
return frame.keyText==='0%' && keyframes.length===2 && keyframes[0]===frame && item.getAnimations()[0]===animation;
})()
"#).unwrap(), "true");
let metrics = crate::live_stylesheet::live_stylesheet_mutation_metrics_for_test();
assert_eq!(metrics.native_keyframe_mutations, 0);
assert_eq!(metrics.native_rule_value_mutations, 0);
assert_eq!(metrics.native_top_level_mutations, 0);
assert_eq!(metrics.native_nested_mutations, 0);
}
@@ -460,7 +460,11 @@ fn css_keyframe_key_text_uses_stylo_selector_helpers() {
keyframes.cssText,
].join('/');
frame.keyText = 'body';
let invalidError;
try { frame.keyText = 'body'; } catch (error) { invalidError = error; }
if (!(invalidError instanceof DOMException) || invalidError.name !== 'SyntaxError') {
throw Error('Invalid keyText must throw DOM SyntaxError');
}
const afterInvalid = [
frame.keyText,
keyframes.findRule('body') === null,
@@ -2244,6 +2244,7 @@ mod cross_origin_symbol_fallback;
mod cross_origin_window_indexes;
mod cross_origin_window_names;
mod css_animation_interfaces;
mod css_keyframes_mutations;
mod css_keyframes_receivers;
mod device_events;
mod payment_events;
+3
View File
@@ -431,6 +431,9 @@ const CHROME_OVERFLOW_PROPERTY_HTML: &str =
include_str!("../../moli-core/tests/fixtures/runtime/chrome_overflow_property.html");
const CHROME_CSSSTYLESHEET_RULE_MUTATION_HTML: &str =
include_str!("../../moli-core/tests/fixtures/runtime/chrome_cssstylesheet_rule_mutation.html");
const CHROME_CSSSTYLESHEET_RULE_MUTATION_V2_HTML: &str = include_str!(
"../../moli-core/tests/fixtures/runtime/chrome_cssstylesheet_rule_mutation_v2.html"
);
const CHROME_DELETE_RULE_NO_CRASH_HTML: &str =
include_str!("../../moli-core/tests/fixtures/runtime/chrome_delete_rule_no_crash.html");
const CHROME_IMPORTANT_JS_OVERRIDE_HTML: &str =
+4
View File
@@ -1496,6 +1496,10 @@ pub(super) async fn chrome_cssstylesheet_rule_mutation_page() -> Html<&'static s
Html(CHROME_CSSSTYLESHEET_RULE_MUTATION_HTML)
}
pub(super) async fn chrome_cssstylesheet_rule_mutation_v2_page() -> Html<&'static str> {
Html(CHROME_CSSSTYLESHEET_RULE_MUTATION_V2_HTML)
}
pub(super) async fn chrome_delete_rule_no_crash_page() -> Html<&'static str> {
Html(CHROME_DELETE_RULE_NO_CRASH_HTML)
}
+4
View File
@@ -683,6 +683,10 @@ pub(super) fn build_router() -> Router {
"/compat/chrome-cssstylesheet-rule-mutation",
get(chrome_cssstylesheet_rule_mutation_page),
)
.route(
"/compat/chrome-cssstylesheet-rule-mutation-v2",
get(chrome_cssstylesheet_rule_mutation_v2_page),
)
.route(
"/compat/chrome-delete-rule-no-crash",
get(chrome_delete_rule_no_crash_page),