mirror of
https://github.com/LadybirdBrowser/ladybird.git
synced 2025-04-22 12:35:14 +00:00
LibWeb/DOM: Don't assume that Animations have an associated effect
Fixes a crash on: - css/css-transitions/CSSTransition-effect.tentative.html
This commit is contained in:
parent
42a8effae5
commit
9585aeafda
Notes:
github-actions[bot]
2024-12-28 10:51:37 +00:00
Author: https://github.com/LucasChollet Commit: https://github.com/LadybirdBrowser/ladybird/commit/9585aeafdae Pull-request: https://github.com/LadybirdBrowser/ladybird/pull/3071
4 changed files with 586 additions and 0 deletions
|
@ -4741,6 +4741,10 @@ void Document::update_animations_and_send_events(Optional<double> const& timesta
|
|||
|
||||
// 6. Perform a stable sort of the animation events in events to dispatch as follows:
|
||||
auto sort_events_by_composite_order = [](auto const& a, auto const& b) {
|
||||
if (!a.animation->effect())
|
||||
return true;
|
||||
if (!b.animation->effect())
|
||||
return false;
|
||||
auto& a_effect = verify_cast<Animations::KeyframeEffect>(*a.animation->effect());
|
||||
auto& b_effect = verify_cast<Animations::KeyframeEffect>(*b.animation->effect());
|
||||
return Animations::KeyframeEffect::composite_order(a_effect, b_effect) < 0;
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
Harness status: OK
|
||||
|
||||
Found 10 tests
|
||||
|
||||
5 Pass
|
||||
5 Fail
|
||||
Pass After setting a transition's effect to null, it still reports the original transition property
|
||||
Pass After setting a transition's effect to null, it becomes finished
|
||||
Fail After setting a transition's effect to null, style is updated
|
||||
Fail After setting a transition's effect to null, a new transition can be started
|
||||
Fail After setting a transition's effect to null, it should be possible to interrupt that transition
|
||||
Pass After setting a new keyframe effect with a shorter duration, the transition becomes finished
|
||||
Pass After setting a new keyframe effect targeting different properties, the transition continues to report the original transition property
|
||||
Fail After setting a new keyframe effect on a play-pending transition, the transition remains pending
|
||||
Pass A transition with no effect still returns the original transitionProperty
|
||||
Fail A transition with a replaced effect still exhibits the regular transition reversing behavior
|
|
@ -0,0 +1,240 @@
|
|||
<!doctype html>
|
||||
<meta charset=utf-8>
|
||||
<title>CSSTransition.effect</title>
|
||||
<!-- TODO: Add a more specific link for this once it is specified. -->
|
||||
<link rel="help" href="https://drafts.csswg.org/css-transitions-2/#csstransition">
|
||||
<script src="../../resources/testharness.js"></script>
|
||||
<script src="../../resources/testharnessreport.js"></script>
|
||||
<script src='support/helper.js'></script>
|
||||
<div id="log"></div>
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
function singleFrame() {
|
||||
return new Promise((resolve, reject) => {
|
||||
requestAnimationFrame(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
test(t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
|
||||
transition.effect = null;
|
||||
|
||||
assert_equals(transition.transitionProperty, 'left');
|
||||
}, 'After setting a transition\'s effect to null, it still reports the'
|
||||
+ ' original transition property');
|
||||
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
await transition.ready;
|
||||
|
||||
transition.effect = null;
|
||||
assert_equals(transition.playState, 'finished');
|
||||
}, 'After setting a transition\'s effect to null, it becomes finished');
|
||||
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
await transition.ready;
|
||||
|
||||
transition.effect = null;
|
||||
assert_equals(getComputedStyle(div).left, '100px');
|
||||
}, 'After setting a transition\'s effect to null, style is updated');
|
||||
|
||||
// This is a regression test for https://crbug.com/964113, where Chromium would
|
||||
// crash if the running transition's effect was set to null and a new transition
|
||||
// was started before the running one could finish.
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
assert_equals(div.getAnimations().length, 1);
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
await transition.ready;
|
||||
|
||||
// Without yielding to the rendering loop, set the current transition's
|
||||
// effect to null and start a new transition. This should work correctly.
|
||||
transition.effect = null;
|
||||
|
||||
div.style.left = '150px';
|
||||
|
||||
// This will run style update.
|
||||
const animations = div.getAnimations();
|
||||
assert_equals(animations.length, 1);
|
||||
|
||||
const new_transition = animations[0];
|
||||
await new_transition.ready;
|
||||
|
||||
assert_not_equals(getComputedStyle(div).left, '150px');
|
||||
}, 'After setting a transition\'s effect to null, a new transition can be started');
|
||||
|
||||
// This is a regression test for https://crbug.com/992668, where Chromium would
|
||||
// crash if the running transition's effect was set to null and the transition
|
||||
// was interrupted before it could finish due to the null effect.
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
assert_equals(div.getAnimations().length, 1);
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
await transition.ready;
|
||||
|
||||
// The transition needs to have a non-zero currentTime for the interruption
|
||||
// reversal logic to apply.
|
||||
while (getComputedStyle(div).left == '0px') {
|
||||
await singleFrame();
|
||||
}
|
||||
assert_not_equals(transition.currentTime, 0);
|
||||
|
||||
// Without yielding to the rendering loop, set the current transition's
|
||||
// effect to null and interrupt the transition. This should work correctly.
|
||||
transition.effect = null;
|
||||
div.style.left = '0px';
|
||||
|
||||
// Yield to the rendering loop. This should not crash.
|
||||
await singleFrame();
|
||||
}, 'After setting a transition\'s effect to null, it should be possible to '
|
||||
+ 'interrupt that transition');
|
||||
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
div.style.width = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
await transition.ready;
|
||||
|
||||
transition.currentTime = 50 * MS_PER_SEC;
|
||||
transition.effect = new KeyframeEffect(div,
|
||||
{ left: [ '0px' , '100px'] },
|
||||
20 * MS_PER_SEC);
|
||||
|
||||
assert_equals(transition.playState, 'finished');
|
||||
}, 'After setting a new keyframe effect with a shorter duration,'
|
||||
+ ' the transition becomes finished');
|
||||
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
div.style.width = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
transition.effect = new KeyframeEffect(div,
|
||||
{ marginLeft: [ '0px' , '100px'] },
|
||||
100 * MS_PER_SEC);
|
||||
assert_equals(transition.transitionProperty, 'left');
|
||||
}, 'After setting a new keyframe effect targeting different properties,'
|
||||
+ ' the transition continues to report the original transition property');
|
||||
|
||||
promise_test(async t => {
|
||||
const div = addDiv(t);
|
||||
div.style.left = '0px';
|
||||
div.style.width = '0px';
|
||||
|
||||
div.style.transition = 'left 100s';
|
||||
getComputedStyle(div).left;
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
assert_true(transition.pending);
|
||||
|
||||
transition.effect = new KeyframeEffect(div,
|
||||
{ marginLeft: [ '0px' , '100px'] },
|
||||
100 * MS_PER_SEC);
|
||||
assert_true(transition.pending);
|
||||
|
||||
// As a sanity check, check that the transition actually exits the
|
||||
// pending state.
|
||||
await transition.ready;
|
||||
assert_false(transition.pending);
|
||||
}, 'After setting a new keyframe effect on a play-pending transition,'
|
||||
+ ' the transition remains pending');
|
||||
|
||||
test(t => {
|
||||
const div = addDiv(t);
|
||||
|
||||
div.style.left = '0px';
|
||||
getComputedStyle(div).transitionProperty;
|
||||
div.style.transition = 'left 100s';
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
transition.effect = null;
|
||||
|
||||
assert_equals(transition.transitionProperty, 'left');
|
||||
}, 'A transition with no effect still returns the original transitionProperty');
|
||||
|
||||
test(t => {
|
||||
const div = addDiv(t);
|
||||
|
||||
div.style.left = '0px';
|
||||
getComputedStyle(div).transitionProperty;
|
||||
div.style.transition = 'left 100s';
|
||||
div.style.left = '100px';
|
||||
|
||||
const transition = div.getAnimations()[0];
|
||||
|
||||
// Seek to the middle and get the portion.
|
||||
transition.currentTime = 50 * MS_PER_SEC;
|
||||
const portion = transition.effect.getComputedTiming().progress;
|
||||
|
||||
// Replace the effect but keep the original timing
|
||||
transition.effect = new KeyframeEffect(
|
||||
div,
|
||||
{ top: ['200px', '300px', '100px'] },
|
||||
transition.effect.getTiming()
|
||||
);
|
||||
|
||||
// Reverse the transition
|
||||
div.style.left = '0px';
|
||||
const reversedTransition = div.getAnimations()[0];
|
||||
|
||||
const expectedDuration = 100 * MS_PER_SEC * portion;
|
||||
assert_approx_equals(
|
||||
reversedTransition.effect.getComputedTiming().activeDuration,
|
||||
expectedDuration,
|
||||
1
|
||||
);
|
||||
}, 'A transition with a replaced effect still exhibits the regular transition'
|
||||
+ ' reversing behavior');
|
||||
|
||||
</script>
|
|
@ -0,0 +1,326 @@
|
|||
//
|
||||
// Simple Helper Functions For Testing CSS
|
||||
//
|
||||
|
||||
(function(root) {
|
||||
'use strict';
|
||||
|
||||
// serialize styles object and dump to dom
|
||||
// appends <style id="dynamic-style"> to <head>
|
||||
// setStyle("#some-selector", {"some-style" : "value"})
|
||||
// setStyle({"#some-selector": {"some-style" : "value"}})
|
||||
root.setStyle = function(selector, styles) {
|
||||
var target = document.getElementById('dynamic-style');
|
||||
if (!target) {
|
||||
target = document.createElement('style');
|
||||
target.id = 'dynamic-style';
|
||||
target.type = "text/css";
|
||||
document.getElementsByTagName('head')[0].appendChild(target);
|
||||
}
|
||||
|
||||
var data = [];
|
||||
// single selector/styles
|
||||
if (typeof selector === 'string' && styles !== undefined) {
|
||||
data = [selector, '{', serializeStyles(styles), '}'];
|
||||
target.textContent = data.join("\n");
|
||||
return;
|
||||
}
|
||||
// map of selector/styles
|
||||
for (var key in selector) {
|
||||
if (Object.prototype.hasOwnProperty.call(selector, key)) {
|
||||
var _data = [key, '{', serializeStyles(selector[key]), '}'];
|
||||
data.push(_data.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
target.textContent = data.join("\n");
|
||||
};
|
||||
|
||||
function serializeStyles(styles) {
|
||||
var data = [];
|
||||
for (var property in styles) {
|
||||
if (Object.prototype.hasOwnProperty.call(styles, property)) {
|
||||
var prefixedProperty = addVendorPrefix(property);
|
||||
data.push(prefixedProperty + ":" + styles[property] + ";");
|
||||
}
|
||||
}
|
||||
|
||||
return data.join('\n');
|
||||
}
|
||||
|
||||
|
||||
// shorthand for computed style
|
||||
root.computedStyle = function(element, property, pseudo) {
|
||||
var prefixedProperty = addVendorPrefix(property);
|
||||
return window
|
||||
.getComputedStyle(element, pseudo || null)
|
||||
.getPropertyValue(prefixedProperty);
|
||||
};
|
||||
|
||||
// flush rendering buffer
|
||||
root.reflow = function() {
|
||||
document.body.offsetWidth;
|
||||
};
|
||||
|
||||
// merge objects
|
||||
root.extend = function(target /*, ..rest */) {
|
||||
Array.prototype.slice.call(arguments, 1).forEach(function(obj) {
|
||||
Object.keys(obj).forEach(function(key) {
|
||||
target[key] = obj[key];
|
||||
});
|
||||
});
|
||||
|
||||
return target;
|
||||
};
|
||||
|
||||
// dom fixture helper ("resetting dom test elements")
|
||||
var _domFixture;
|
||||
var _domFixtureSelector;
|
||||
root.domFixture = function(selector) {
|
||||
var fixture = document.querySelector(selector || _domFixtureSelector);
|
||||
if (!fixture) {
|
||||
throw new Error('fixture ' + (selector || _domFixtureSelector) + ' not found!');
|
||||
}
|
||||
if (!_domFixture && selector) {
|
||||
// save a copy
|
||||
_domFixture = fixture.cloneNode(true);
|
||||
_domFixtureSelector = selector;
|
||||
} else if (_domFixture) {
|
||||
// restore the copy
|
||||
var tmp = _domFixture.cloneNode(true);
|
||||
fixture.parentNode.replaceChild(tmp, fixture);
|
||||
} else {
|
||||
throw new Error('domFixture must be initialized first!');
|
||||
}
|
||||
};
|
||||
|
||||
root.MS_PER_SEC = 1000;
|
||||
|
||||
/*
|
||||
* The recommended minimum precision to use for time values.
|
||||
*
|
||||
* Based on Web Animations:
|
||||
* https://w3c.github.io/web-animations/#precision-of-time-values
|
||||
*/
|
||||
const TIME_PRECISION = 0.0005; // ms
|
||||
|
||||
/*
|
||||
* Allow implementations to substitute an alternative method for comparing
|
||||
* times based on their precision requirements.
|
||||
*/
|
||||
root.assert_times_equal = function(actual, expected, description) {
|
||||
assert_approx_equals(actual, expected, TIME_PRECISION, description);
|
||||
};
|
||||
|
||||
/*
|
||||
* Compare a time value based on its precision requirements with a fixed value.
|
||||
*/
|
||||
root.assert_time_equals_literal = (actual, expected, description) => {
|
||||
assert_approx_equals(actual, expected, TIME_PRECISION, description);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that CSSTransition event, |evt|, has the expected property values
|
||||
* defined by |propertyName|, |elapsedTime|, and |pseudoElement|.
|
||||
*/
|
||||
root.assert_end_events_equal = function(evt, propertyName, elapsedTime,
|
||||
pseudoElement = '') {
|
||||
assert_equals(evt.propertyName, propertyName);
|
||||
assert_times_equal(evt.elapsedTime, elapsedTime);
|
||||
assert_equals(evt.pseudoElement, pseudoElement);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert that array of simultaneous CSSTransition events, |evts|, have the
|
||||
* corresponding property names listed in |propertyNames|, and the expected
|
||||
* |elapsedTimes| and |pseudoElement| members.
|
||||
*
|
||||
* |elapsedTimes| may be a single value if all events are expected to have the
|
||||
* same elapsedTime, or an array parallel to |propertyNames|.
|
||||
*/
|
||||
root.assert_end_event_batch_equal = function(evts, propertyNames, elapsedTimes,
|
||||
pseudoElement = '') {
|
||||
assert_equals(
|
||||
evts.length,
|
||||
propertyNames.length,
|
||||
'Test harness error: should have waited for the correct number of events'
|
||||
);
|
||||
assert_true(
|
||||
typeof elapsedTimes === 'number' ||
|
||||
(Array.isArray(elapsedTimes) &&
|
||||
elapsedTimes.length === propertyNames.length),
|
||||
'Test harness error: elapsedTimes must either be a number or an array of' +
|
||||
' numbers with the same length as propertyNames'
|
||||
);
|
||||
|
||||
if (typeof elapsedTimes === 'number') {
|
||||
elapsedTimes = Array(propertyNames.length).fill(elapsedTimes);
|
||||
}
|
||||
const testPairs = propertyNames.map((propertyName, index) => ({
|
||||
propertyName,
|
||||
elapsedTime: elapsedTimes[index]
|
||||
}));
|
||||
|
||||
const sortByPropertyName = (a, b) =>
|
||||
a.propertyName.localeCompare(b.propertyName);
|
||||
evts.sort(sortByPropertyName);
|
||||
testPairs.sort(sortByPropertyName);
|
||||
|
||||
for (let evt of evts) {
|
||||
const expected = testPairs.shift();
|
||||
assert_end_events_equal(
|
||||
evt,
|
||||
expected.propertyName,
|
||||
expected.elapsedTime,
|
||||
pseudoElement
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a div to the document body.
|
||||
*
|
||||
* @param t The testharness.js Test object. If provided, this will be used
|
||||
* to register a cleanup callback to remove the div when the test
|
||||
* finishes.
|
||||
*
|
||||
* @param attrs A dictionary object with attribute names and values to set on
|
||||
* the div.
|
||||
*/
|
||||
root.addDiv = function(t, attrs) {
|
||||
var div = document.createElement('div');
|
||||
if (attrs) {
|
||||
for (var attrName in attrs) {
|
||||
div.setAttribute(attrName, attrs[attrName]);
|
||||
}
|
||||
}
|
||||
document.body.appendChild(div);
|
||||
if (t && typeof t.add_cleanup === 'function') {
|
||||
t.add_cleanup(function() {
|
||||
if (div.parentNode) {
|
||||
div.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
return div;
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a style div to the document head.
|
||||
*
|
||||
* @param t The testharness.js Test object. If provided, this will be used
|
||||
* to register a cleanup callback to remove the style element
|
||||
* when the test finishes.
|
||||
*
|
||||
* @param rules A dictionary object with selector names and rules to set on
|
||||
* the style sheet.
|
||||
*/
|
||||
root.addStyle = (t, rules) => {
|
||||
const extraStyle = document.createElement('style');
|
||||
document.head.appendChild(extraStyle);
|
||||
if (rules) {
|
||||
const sheet = extraStyle.sheet;
|
||||
for (const selector in rules) {
|
||||
sheet.insertRule(selector + '{' + rules[selector] + '}',
|
||||
sheet.cssRules.length);
|
||||
}
|
||||
}
|
||||
|
||||
if (t && typeof t.add_cleanup === 'function') {
|
||||
t.add_cleanup(() => {
|
||||
extraStyle.remove();
|
||||
});
|
||||
}
|
||||
return extraStyle;
|
||||
};
|
||||
|
||||
/**
|
||||
* Promise wrapper for requestAnimationFrame.
|
||||
*/
|
||||
root.waitForFrame = () => {
|
||||
return new Promise(resolve => {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a Promise that is resolved after the given number of consecutive
|
||||
* animation frames have occured (using requestAnimationFrame callbacks).
|
||||
*
|
||||
* @param frameCount The number of animation frames.
|
||||
* @param onFrame An optional function to be processed in each animation frame.
|
||||
*/
|
||||
root.waitForAnimationFrames = (frameCount, onFrame) => {
|
||||
const timeAtStart = document.timeline.currentTime;
|
||||
return new Promise(resolve => {
|
||||
function handleFrame() {
|
||||
if (onFrame && typeof onFrame === 'function') {
|
||||
onFrame();
|
||||
}
|
||||
if (timeAtStart != document.timeline.currentTime &&
|
||||
--frameCount <= 0) {
|
||||
resolve();
|
||||
} else {
|
||||
window.requestAnimationFrame(handleFrame); // wait another frame
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(handleFrame);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper that takes a sequence of N animations and returns:
|
||||
*
|
||||
* Promise.all([animations[0].ready, animations[1].ready, ... animations[N-1].ready]);
|
||||
*/
|
||||
root.waitForAllAnimations = animations =>
|
||||
Promise.all(animations.map(animation => animation.ready));
|
||||
|
||||
/**
|
||||
* Utility that takes a Promise and a maximum number of frames to wait and
|
||||
* returns a new Promise that behaves as follows:
|
||||
*
|
||||
* - If the provided Promise resolves _before_ the specified number of frames
|
||||
* have passed, resolves with the result of the provided Promise.
|
||||
* - If the provided Promise rejects _before_ the specified number of frames
|
||||
* have passed, rejects with the error result of the provided Promise.
|
||||
* - Otherwise, rejects with a 'Timed out' error message. If |message| is
|
||||
* provided, it will be appended to the error message.
|
||||
*/
|
||||
root.frameTimeout = (promiseToWaitOn, framesToWait, message) => {
|
||||
let framesRemaining = framesToWait;
|
||||
let aborted = false;
|
||||
|
||||
const timeoutPromise = new Promise(function waitAFrame(resolve, reject) {
|
||||
if (aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (framesRemaining-- > 0) {
|
||||
requestAnimationFrame(() => {
|
||||
waitAFrame(resolve, reject);
|
||||
});
|
||||
return;
|
||||
}
|
||||
let errorMessage = 'Timed out waiting for Promise to resolve';
|
||||
if (message) {
|
||||
errorMessage += `: ${message}`;
|
||||
}
|
||||
reject(new Error(errorMessage));
|
||||
});
|
||||
|
||||
const wrappedPromiseToWaitOn = promiseToWaitOn.then(result => {
|
||||
aborted = true;
|
||||
return result;
|
||||
});
|
||||
|
||||
return Promise.race([timeoutPromise, wrappedPromiseToWaitOn]);
|
||||
};
|
||||
|
||||
root.supportsStartingStyle = () => {
|
||||
let sheet = new CSSStyleSheet();
|
||||
sheet.replaceSync("@starting-style{}");
|
||||
return sheet.cssRules.length == 1;
|
||||
};
|
||||
|
||||
})(window);
|
Loading…
Add table
Reference in a new issue