LibWeb: Use correct scheduled time for Transition and Animation events

The spec doesn't say what time to use here but in other places where we
schedule animation events it says to use "the result of applying the
procedure to convert timeline time to origin-relative time to the
current time of the timeline with which animation is associated", so we
do that here as well.

This time is stable per animation frame per timeline so we now correctly
fall back to composite ordering of animation/transition events where we
previously wouldn't (since the scheduled time was so precise that it was
always unique), which causes the imported test to no longer be flakey.
This commit is contained in:
Callum Law 2026-05-04 22:30:23 +12:00 committed by Sam Atkins
parent b3a80f0415
commit b0a818a476
3 changed files with 628 additions and 2 deletions

View file

@ -3592,6 +3592,8 @@ void Document::dispatch_events_for_transition(GC::Ref<CSS::CSSTransition> transi
})
.value_or({});
auto timeline = transition->timeline();
append_pending_animation_event({
.event = CSS::TransitionEvent::create(
transition->owning_element()->element().realm(),
@ -3599,7 +3601,9 @@ void Document::dispatch_events_for_transition(GC::Ref<CSS::CSSTransition> transi
event_init),
.animation = transition,
.target = transition->owning_element()->element(),
.scheduled_event_time = HighResolutionTime::unsafe_shared_current_time(),
.scheduled_event_time = timeline && timeline->can_convert_a_timeline_time_to_an_origin_relative_time()
? timeline->convert_a_timeline_time_to_an_origin_relative_time(timeline->current_time())
: Optional<double> {},
});
};
@ -3696,6 +3700,8 @@ void Document::dispatch_events_for_animation_if_necessary(GC::Ref<Animations::An
})
.value_or({});
auto timeline = animation->timeline();
append_pending_animation_event({
.event = CSS::AnimationEvent::create(
owning_element->element().realm(),
@ -3703,7 +3709,9 @@ void Document::dispatch_events_for_animation_if_necessary(GC::Ref<Animations::An
event_init),
.animation = css_animation,
.target = owning_element->element(),
.scheduled_event_time = HighResolutionTime::unsafe_shared_current_time(),
.scheduled_event_time = timeline && timeline->can_convert_a_timeline_time_to_an_origin_relative_time()
? timeline->convert_a_timeline_time_to_an_origin_relative_time(timeline->current_time())
: Optional<double> {},
});
};

View file

@ -0,0 +1,38 @@
Harness status: OK
Found 32 tests
24 Pass
8 Fail
Pass Idle -> Active
Fail Idle -> After
Pass Before -> Active
Pass Before -> After
Pass Active -> Idle, display: none
Pass After -> Idle, display: none
Fail Active -> Idle, setting Animation.timeline = null
Fail Active -> Idle, calling Animation.cancel()
Pass Active -> Before
Pass Active -> After
Pass After -> Before
Pass After -> Active
Pass Active -> Active (forwards)
Pass Active -> Active (backwards)
Pass Active -> Idle -> Active: animationstart is fired by restarting animation
Pass Negative playbackRate sanity test(Before -> Active -> Before)
Pass Redundant change, before -> active, then back
Pass Redundant change, before -> after, then back
Pass Redundant change, active -> before, then back
Pass Redundant change, active -> after, then back
Pass Redundant change, after -> before, then back
Pass Redundant change, after -> active, then back
Pass Call Animation.cancel after canceling animation.
Fail Restart animation after canceling animation immediately.
Pass Call Animation.cancel after restarting animation immediately.
Pass Set timeline and play transition after clearing the timeline.
Pass Set null target effect after canceling the animation.
Fail Cancel the animation after clearing the target effect.
Pass Replacing a running animation should get animationcancel earlier than animationstart
Fail The cancel event should be fired before the new start event if both have the same position in the animation list
Fail The cancel event with an earlier position in animation list should be fired earlier
Fail The order of the cancel events follows the relative positions in the animation list at the point when they were cancelled

View file

@ -0,0 +1,580 @@
<!doctype html>
<meta charset=utf-8>
<title>Tests for CSS animation event dispatch</title>
<meta name="timeout" content="long">
<link rel="help" href="https://drafts.csswg.org/css-animations-2/#event-dispatch"/>
<script src="../../resources/testharness.js"></script>
<script src="../../resources/testharnessreport.js"></script>
<script src="support/testcommon.js"></script>
<style>
@keyframes anim {
from { margin-left: 0px; }
to { margin-left: 100px; }
}
@keyframes anim2 {
from { margin-top: 100px; }
to { margin-top: 200px; }
}
@keyframes anim3 {
from { padding-left: 100px; }
to { padding-left: 200px; }
}
</style>
<div id="log"></div>
<script>
'use strict';
const setupAnimation = (t, animationStyle) => {
const div = addDiv(t, { style: 'animation: ' + animationStyle });
const animation = div.getAnimations()[0];
const timeoutPromise = armTimeoutWhenReady(animation, fastEventsTimeout);
const watcher = new EventWatcher(t, div, [ 'animationstart',
'animationiteration',
'animationend',
'animationcancel' ],
timeoutPromise);
return { animation, watcher, div };
};
promise_test(async t => {
// Add 1ms delay to ensure that the delay is not included in the elapsedTime.
const { animation, watcher } = setupAnimation(t, 'anim 100s 1ms');
const evt = await watcher.wait_for('animationstart');
assert_equals(evt.elapsedTime, 0.0);
}, 'Idle -> Active');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
// Seek to After phase.
animation.finish();
const events = await watcher.wait_for(['animationstart', 'animationend'], {
record: 'all',
});
assert_equals(events[0].elapsedTime, 0.0);
assert_equals(events[1].elapsedTime, 100);
}, 'Idle -> After');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s paused');
await animation.ready;
// Seek to Active phase.
animation.currentTime = 100 * MS_PER_SEC;
const evt = await watcher.wait_for('animationstart');
assert_equals(evt.elapsedTime, 0.0);
}, 'Before -> Active');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s paused');
const allEvents = watcher.wait_for(['animationstart', 'animationend'], {
record: 'all',
});
await animation.ready;
// Seek to After phase.
animation.finish();
const events = await allEvents;
assert_equals(events[0].elapsedTime, 0.0);
assert_equals(events[1].elapsedTime, 100.0);
}, 'Before -> After');
promise_test(async t => {
const { animation, watcher, div } = setupAnimation(t, 'anim 100s paused');
await watcher.wait_for('animationstart');
// Make idle
div.style.display = 'none';
const evt = await watcher.wait_for('animationcancel');
assert_equals(evt.elapsedTime, 0.0);
}, 'Active -> Idle, display: none');
promise_test(async t => {
const { animation, watcher, div } = setupAnimation(t, 'anim 100s paused');
// Seek to After phase.
animation.finish();
await watcher.wait_for(['animationstart', 'animationend']);
div.style.display = 'none';
// Wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'After -> Idle, display: none');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
animation.currentTime = 100.0;
// Make idle
animation.timeline = null;
const evt = await watcher.wait_for('animationcancel');
assert_time_equals_literal(evt.elapsedTime, 0.1);
}, 'Active -> Idle, setting Animation.timeline = null');
promise_test(async t => {
// We should NOT pause animation since calling cancel synchronously.
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
animation.currentTime = 50.0;
animation.cancel();
const evt = await watcher.wait_for('animationcancel');
assert_time_equals_literal(evt.elapsedTime, 0.05);
}, 'Active -> Idle, calling Animation.cancel()');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s paused');
// Seek to Active phase.
animation.currentTime = 100 * MS_PER_SEC;
await watcher.wait_for('animationstart');
// Seek to Before phase.
animation.currentTime = 0;
const evt = await watcher.wait_for('animationend');
assert_equals(evt.elapsedTime, 0.0);
}, 'Active -> Before');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s paused');
await watcher.wait_for('animationstart');
// Seek to After phase.
animation.finish();
const evt = await watcher.wait_for('animationend');
assert_equals(evt.elapsedTime, 100.0);
}, 'Active -> After');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s paused');
// Seek to After phase.
animation.finish();
await watcher.wait_for([ 'animationstart', 'animationend' ]);
// Seek to Before phase.
animation.currentTime = 0;
const events = await watcher.wait_for(['animationstart', 'animationend'], {
record: 'all',
});
assert_equals(events[0].elapsedTime, 100.0);
assert_equals(events[1].elapsedTime, 0.0);
}, 'After -> Before');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s paused');
// Seek to After phase.
animation.finish();
await watcher.wait_for([ 'animationstart', 'animationend' ]);
// Seek to Active phase.
animation.currentTime = 100 * MS_PER_SEC;
const evt = await watcher.wait_for('animationstart');
assert_equals(evt.elapsedTime, 100.0);
}, 'After -> Active');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s 3 paused');
await animation.ready;
// Seek to iteration 0 (no animationiteration event should be dispatched)
animation.currentTime = 100 * MS_PER_SEC;
await watcher.wait_for('animationstart');
// Seek to iteration 2
animation.currentTime = 300 * MS_PER_SEC;
let evt = await watcher.wait_for('animationiteration');
assert_equals(evt.elapsedTime, 200);
// Seek to After phase (no animationiteration event should be dispatched)
animation.currentTime = 400 * MS_PER_SEC;
evt = await watcher.wait_for('animationend');
assert_equals(evt.elapsedTime, 300);
}, 'Active -> Active (forwards)');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s 3');
// Seek to After phase.
animation.finish();
await watcher.wait_for([ 'animationstart', 'animationend' ]);
// Seek to iteration 2 (no animationiteration event should be dispatched)
animation.pause();
animation.currentTime = 300 * MS_PER_SEC;
await watcher.wait_for('animationstart');
// Seek to mid of iteration 0 phase.
animation.currentTime = 200 * MS_PER_SEC;
const evt = await watcher.wait_for('animationiteration');
assert_equals(evt.elapsedTime, 200.0);
// Seek to before phase (no animationiteration event should be dispatched)
animation.currentTime = 0;
await watcher.wait_for('animationend');
}, 'Active -> Active (backwards)');
promise_test(async t => {
const { animation, watcher, div } = setupAnimation(t, 'anim 100s paused');
await watcher.wait_for('animationstart');
// Seek to Idle phase.
div.style.display = 'none';
flushComputedStyle(div);
await watcher.wait_for('animationcancel');
// Restart this animation.
div.style.display = '';
await watcher.wait_for('animationstart');
}, 'Active -> Idle -> Active: animationstart is fired by restarting animation');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s 2 paused');
// Make After.
animation.finish();
await watcher.wait_for([ 'animationstart', 'animationend' ]);
animation.playbackRate = -1;
let evt = await watcher.wait_for('animationstart');
assert_equals(evt.elapsedTime, 200);
// Seek to 1st iteration
animation.currentTime = 200 * MS_PER_SEC - 1;
evt = await watcher.wait_for('animationiteration');
assert_equals(evt.elapsedTime, 100);
// Seek to before
animation.currentTime = 100 * MS_PER_SEC - 1;
evt = await watcher.wait_for('animationend');
assert_equals(evt.elapsedTime, 0);
assert_equals(animation.playState, 'running'); // delay
}, 'Negative playbackRate sanity test(Before -> Active -> Before)');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s');
animation.currentTime = 150 * MS_PER_SEC;
animation.currentTime = 50 * MS_PER_SEC;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Redundant change, before -> active, then back');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s');
animation.currentTime = 250 * MS_PER_SEC;
animation.currentTime = 50 * MS_PER_SEC;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Redundant change, before -> after, then back');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s');
// Get us into the initial state:
animation.currentTime = 150 * MS_PER_SEC;
await watcher.wait_for('animationstart');
animation.currentTime = 50 * MS_PER_SEC;
animation.currentTime = 150 * MS_PER_SEC;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Redundant change, active -> before, then back');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s');
// Get us into the initial state:
animation.currentTime = 150 * MS_PER_SEC;
await watcher.wait_for('animationstart');
animation.currentTime = 250 * MS_PER_SEC;
animation.currentTime = 150 * MS_PER_SEC;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Redundant change, active -> after, then back');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s');
// Get us into the initial state:
animation.currentTime = 250 * MS_PER_SEC;
await watcher.wait_for(['animationstart', 'animationend']);
animation.currentTime = 50 * MS_PER_SEC;
animation.currentTime = 250 * MS_PER_SEC;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Redundant change, after -> before, then back');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s 100s');
// Get us into the initial state:
animation.currentTime = 250 * MS_PER_SEC;
await watcher.wait_for(['animationstart', 'animationend']);
animation.currentTime = 150 * MS_PER_SEC;
animation.currentTime = 250 * MS_PER_SEC;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Redundant change, after -> active, then back');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
// Make idle
animation.cancel();
await watcher.wait_for('animationcancel');
animation.cancel();
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Call Animation.cancel after canceling animation.');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
// Make idle
animation.cancel();
animation.play();
await watcher.wait_for([ 'animationcancel', 'animationstart' ]);
}, 'Restart animation after canceling animation immediately.');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
// Make idle
animation.cancel();
animation.play();
animation.cancel();
await watcher.wait_for('animationcancel');
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Call Animation.cancel after restarting animation immediately.');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
// Make idle
animation.timeline = null;
await watcher.wait_for('animationcancel');
animation.timeline = document.timeline;
animation.play();
await watcher.wait_for('animationstart');
}, 'Set timeline and play transition after clearing the timeline.');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
// Make idle
animation.cancel();
await watcher.wait_for('animationcancel');
animation.effect = null;
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Set null target effect after canceling the animation.');
promise_test(async t => {
const { animation, watcher } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
animation.effect = null;
await watcher.wait_for('animationend');
animation.cancel();
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Cancel the animation after clearing the target effect.');
promise_test(async t => {
const { animation, watcher, div } = setupAnimation(t, 'anim 100s');
await watcher.wait_for('animationstart');
// Replace the running animation.
div.style.animation = 'anim2 100s';
// animationcancel event should be fired before animationstart because we
// expect to cancel the running animation first.
const events = await watcher.wait_for(
['animationcancel', 'animationstart'],
{ record: 'all' }
);
assert_equals(events[0].animationName, 'anim');
assert_equals(events[1].animationName, 'anim2');
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'Replacing a running animation should get animationcancel earlier than ' +
'animationstart');
promise_test(async t => {
const div = addDiv(t, { style: 'animation: anim 100s, anim2 100s' });
const animations = div.getAnimations();
const watcher = new EventWatcher(t, div, [ 'animationstart',
'animationcancel' ],
() => {
return Promise.all([animations[0].ready, animations[1].ready]).then(() => {
return fastEventsTimeout();
});
}
);
await watcher.wait_for(['animationstart', 'animationstart']);
// Replace the first animation
div.style.animation = 'anim3 100s, anim2 100s';
// animationcancel event should be fired before animationstart because we
// expect to cancel the running animation first.
const events = await watcher.wait_for(
['animationcancel', 'animationstart'],
{ record: 'all' }
);
assert_equals(events[0].animationName, 'anim');
assert_equals(events[1].animationName, 'anim3');
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'The cancel event should be fired before the new start event if both have ' +
'the same position in the animation list');
promise_test(async t => {
const div =
addDiv(t, { style: 'animation: anim 100s, anim2 100s, anim3 100s' });
const animations = div.getAnimations();
const watcher = new EventWatcher(t, div, [ 'animationstart',
'animationcancel' ],
() => {
return Promise.all([
animations[0].ready,
animations[1].ready,
animations[2].ready
]).then(() => {
return fastEventsTimeout();
});
}
);
await watcher.wait_for(
['animationstart', 'animationstart', 'animationstart']
);
// Cancel the first and the second animations.
div.style.animation = 'anim3 100s';
const events = await watcher.wait_for(
['animationcancel', 'animationcancel'],
{ record: 'all' }
);
// Per https://drafts.csswg.org/css-animations-2/#animation-composite-order,
// the cancel event with the earlier position should be fired earlier.
assert_equals(events[0].animationName, 'anim');
assert_equals(events[1].animationName, 'anim2');
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'The cancel event with an earlier position in animation list should be ' +
'fired earlier');
promise_test(async t => {
const div =
addDiv(t, { style: 'animation: anim 100s, anim2 100s, anim3 100s' });
const animations = div.getAnimations();
const watcher = new EventWatcher(t, div, [ 'animationstart',
'animationcancel' ],
() => {
return Promise.all([
animations[0].ready,
animations[1].ready,
animations[2].ready
]).then(() => {
return fastEventsTimeout();
});
}
);
await watcher.wait_for(
['animationstart', 'animationstart', 'animationstart']
);
// Change the order, i.e. swap the order of |anim| and |anim2|.
div.style.animation = 'anim2 100s, anim 100s, anim3 100s';
getComputedStyle(div).animation;
// Then we cancel the first and the second animations.
div.style.animation = 'anim3 100s';
const events = await watcher.wait_for(
['animationcancel', 'animationcancel'],
{ record: 'all' }
);
// Per https://drafts.csswg.org/css-animations-2/#animation-composite-order,
// the cancel event to |anim2| should be dispatched before the cancel event to
// |anim| since |anim2| appeared before |anim| in the animation-name at the
// point when they were cancelled.
assert_equals(events[0].animationName, 'anim2');
assert_equals(events[1].animationName, 'anim');
// Then wait a couple of frames and check that no event was dispatched.
await waitForAnimationFrames(2);
}, 'The order of the cancel events follows the relative positions in the ' +
'animation list at the point when they were cancelled');
</script>