<!doctype html> <head> <meta charset=utf-8> <title>Tests restyles caused by animations</title> <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script> <script src="chrome://mochikit/content/tests/SimpleTest/paint_listener.js"></script> <script src="../testcommon.js"></script> <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"> <style> @keyframes opacity { from { opacity: 1; } to { opacity: 0; } } @keyframes background-color { from { background-color: red; } to { background-color: blue; } } @keyframes rotate { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } div { /* Element needs geometry to be eligible for layerization */ width: 100px; height: 100px; background-color: white; } </style> </head> <body> <script> 'use strict'; function observeStyling(frameCount, onFrame) { var docShell = window.QueryInterface(SpecialPowers.Ci.nsIInterfaceRequestor) .getInterface(SpecialPowers.Ci.nsIWebNavigation) .QueryInterface(SpecialPowers.Ci.nsIDocShell); docShell.recordProfileTimelineMarkers = true; docShell.popProfileTimelineMarkers(); return new Promise(function(resolve) { return waitForAnimationFrames(frameCount, onFrame).then(function() { var markers = docShell.popProfileTimelineMarkers(); docShell.recordProfileTimelineMarkers = false; var stylingMarkers = markers.filter(function(marker, index) { return marker.name == 'Styles' && (marker.restyleHint == 'eRestyle_CSSAnimations' || marker.restyleHint == 'eRestyle_CSSTransitions'); }); resolve(stylingMarkers); }); }); } function ensureElementRemoval(aElement) { return new Promise(function(resolve) { aElement.remove(); waitForAllPaintsFlushed(resolve); }); } SimpleTest.waitForExplicitFinish(); var omtaEnabled = isOMTAEnabled(); var isAndroid = !!navigator.userAgent.includes("Android"); function add_task_if_omta_enabled(test) { if (!omtaEnabled) { info(test.name + " is skipped because OMTA is disabled"); return; } add_task(test); } // We need to wait for all paints before running tests to avoid contaminations // from styling of this document itself. waitForAllPaints(function() { add_task(function* restyling_for_main_thread_animations() { var div = addDiv(null, { style: 'animation: background-color 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(!animation.isRunningOnCompositor); var markers = yield observeStyling(5); is(markers.length, 5, 'CSS animations running on the main-thread should update style ' + 'on the main thread'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_for_compositor_animations() { var div = addDiv(null, { style: 'animation: opacity 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(animation.isRunningOnCompositor); var markers = yield observeStyling(5); is(markers.length, 0, 'CSS animations running on the compositor should not update style ' + 'on the main thread'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_for_compositor_transitions() { var div = addDiv(null, { style: 'transition: opacity 100s; opacity: 0' }); getComputedStyle(div).opacity; div.style.opacity = 1; var animation = div.getAnimations()[0]; yield animation.ready; ok(animation.isRunningOnCompositor); var markers = yield observeStyling(5); is(markers.length, 0, 'CSS transitions running on the compositor should not update style ' + 'on the main thread'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_when_animation_duration_is_changed() { var div = addDiv(null, { style: 'animation: opacity 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(animation.isRunningOnCompositor); div.animationDuration = '200s'; var markers = yield observeStyling(5); is(markers.length, 0, 'Animations running on the compositor should not update style ' + 'on the main thread'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* only_one_restyling_after_finish_is_called() { var div = addDiv(null, { style: 'animation: opacity 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(animation.isRunningOnCompositor); animation.finish(); var markers = yield observeStyling(5); is(markers.length, 1, 'Animations running on the compositor should only update style ' + 'once after finish() is called'); yield ensureElementRemoval(div); }); add_task(function* no_restyling_mouse_movement_on_finished_transition() { var div = addDiv(null, { style: 'transition: opacity 1ms; opacity: 0' }); getComputedStyle(div).opacity; div.style.opacity = 1; var animation = div.getAnimations()[0]; var initialRect = div.getBoundingClientRect(); yield animation.finished; var mouseX = initialRect.left + initialRect.width / 2; var mouseY = initialRect.top + initialRect.height / 2; var markers = yield observeStyling(5, function() { // We can't use synthesizeMouse here since synthesizeMouse causes // layout flush. synthesizeMouseAtPoint(mouseX++, mouseY++, { type: 'mousemove' }, window); }); is(markers.length, 0, 'Bug 1219236: Finished transitions should never cause restyles ' + 'when mouse is moved on the animations'); yield ensureElementRemoval(div); }); add_task(function* no_restyling_mouse_movement_on_finished_animation() { var div = addDiv(null, { style: 'animation: opacity 1ms' }); var animation = div.getAnimations()[0]; var initialRect = div.getBoundingClientRect(); yield animation.finished; var mouseX = initialRect.left + initialRect.width / 2; var mouseY = initialRect.top + initialRect.height / 2; var markers = yield observeStyling(5, function() { // We can't use synthesizeMouse here since synthesizeMouse causes // layout flush. synthesizeMouseAtPoint(mouseX++, mouseY++, { type: 'mousemove' }, window); }); is(markers.length, 0, 'Bug 1219236: Finished animations should never cause restyles ' + 'when mouse is moved on the animations'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_compositor_animations_out_of_view_element() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } var div = addDiv(null, { style: 'animation: opacity 100s; transform: translateY(-400px);' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(!animation.isRunningOnCompositor); var markers = yield observeStyling(5); is(markers.length, 0, 'Animations running on the compositor in an out-of-view element ' + 'should never cause restyles'); yield ensureElementRemoval(div); }); add_task(function* no_restyling_main_thread_animations_out_of_view_element() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } var div = addDiv(null, { style: 'animation: background-color 100s; transform: translateY(-400px);' }); var animation = div.getAnimations()[0]; yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Animations running on the main-thread in an out-of-view element ' + 'should never cause restyles'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_compositor_animations_in_scrolled_out_element() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } /* On Android the opacity animation runs on the compositor even if it is scrolled out of view. We will fix this in bug 1247800. */ if (isAndroid) { return; } var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 20px;' }); var div = addDiv(null, { style: 'animation: opacity 100s; position: relative; top: 100px;' }); parentElement.appendChild(div); var animation = div.getAnimations()[0]; yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Animations running on the compositor for elements ' + 'which are scrolled out should never cause restyles'); yield ensureElementRemoval(parentElement); }); add_task(function* no_restyling_main_thread_animations_in_scrolled_out_element() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } /* On Android throttled animations are left behind on the main thread in some frames, We will fix this in bug 1247800. */ if (isAndroid) { return; } var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 20px;' }); var div = addDiv(null, { style: 'animation: background-color 100s; position: relative; top: 100px;' }); parentElement.appendChild(div); var animation = div.getAnimations()[0]; yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Animations running on the main-thread for elements ' + 'which are scrolled out should never cause restyles'); yield ensureElementRemoval(parentElement); }); add_task(function* no_restyling_main_thread_animations_in_nested_scrolled_out_element() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } /* On Android throttled animations are left behind on the main thread in some frames, We will fix this in bug 1247800. */ if (isAndroid) { return; } var grandParent = addDiv(null, { style: 'overflow-y: scroll; height: 20px;' }); var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 100px;' }); var div = addDiv(null, { style: 'animation: background-color 100s; position: relative; top: 100px;' }); grandParent.appendChild(parentElement); parentElement.appendChild(div); var animation = div.getAnimations()[0]; yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Animations running on the main-thread which are in nested elements ' + 'which are scrolled out should never cause restyles'); yield ensureElementRemoval(grandParent); }); add_task_if_omta_enabled(function* no_restyling_compositor_animations_in_visiblily_hidden_element() { var div = addDiv(null, { style: 'animation: opacity 100s; visibility: hidden' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(!animation.isRunningOnCompositor); var markers = yield observeStyling(5); todo_is(markers.length, 0, 'Bug 1237454: Animations running on the compositor in ' + 'visibility hidden element should never cause restyles'); yield ensureElementRemoval(div); }); add_task(function* restyling_main_thread_animations_moved_in_view_by_scrolling() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } /* On Android throttled animations are left behind on the main thread in some frames, We will fix this in bug 1247800. */ if (isAndroid) { return; } var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 20px;' }); var div = addDiv(null, { style: 'animation: background-color 100s; position: relative; top: 100px;' }); parentElement.appendChild(div); var animation = div.getAnimations()[0]; var parentRect = parentElement.getBoundingClientRect(); var centerX = parentRect.left + parentRect.width / 2; var centerY = parentRect.top + parentRect.height / 2; yield animation.ready; var markers = yield observeStyling(1, function() { // We can't use synthesizeWheel here since synthesizeWheel causes // layout flush. synthesizeWheelAtPoint(centerX, centerY, { deltaMode: WheelEvent.DOM_DELTA_PIXEL, deltaY: 100 }); }); is(markers.length, 1, 'Animations running on the main-thread which were in scrolled out ' + 'elements should update restyling soon after the element moved in ' + 'view by scrolling'); yield ensureElementRemoval(parentElement); }); add_task(function* restyling_main_thread_animations_moved_in_view_by_scrolling() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } var grandParent = addDiv(null, { style: 'overflow-y: scroll; height: 20px;' }); var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 200px;' }); var div = addDiv(null, { style: 'animation: background-color 100s; position: relative; top: 100px;' }); grandParent.appendChild(parentElement); parentElement.appendChild(div); var animation = div.getAnimations()[0]; var parentRect = grandParent.getBoundingClientRect(); var centerX = parentRect.left + parentRect.width / 2; var centerY = parentRect.top + parentRect.height / 2; yield animation.ready; var markers = yield observeStyling(1, function() { // We can't use synthesizeWheel here since synthesizeWheel causes // layout flush. synthesizeWheelAtPoint(centerX, centerY, { deltaMode: WheelEvent.DOM_DELTA_PIXEL, deltaY: 100 }); }); // FIXME: We should reduce a redundant restyle here. ok(markers.length >= 1, 'Animations running on the main-thread which were in nested scrolled ' + 'out elements should update restyle soon after the element moved ' + 'in view by scrolling'); yield ensureElementRemoval(grandParent); }); add_task(function* restyling_main_thread_animations_move_out_of_view_by_scrolling() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } /* On Android throttled animations are left behind on the main thread in some frames, We will fix this in bug 1247800. */ if (isAndroid) { return; } var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 200px;' }); var div = addDiv(null, { style: 'animation: background-color 100s;' }); var pad = addDiv(null, { style: 'height: 400px;' }); parentElement.appendChild(div); parentElement.appendChild(pad); var animation = div.getAnimations()[0]; var parentRect = parentElement.getBoundingClientRect(); var centerX = parentRect.left + parentRect.width / 2; var centerY = parentRect.top + parentRect.height / 2; yield animation.ready; // We can't use synthesizeWheel here since synthesizeWheel causes // layout flush. synthesizeWheelAtPoint(centerX, centerY, { deltaMode: WheelEvent.DOM_DELTA_PIXEL, deltaY: 200 }); var markers = yield observeStyling(5); // FIXME: We should reduce a redundant restyle here. ok(markers.length >= 0, 'Animations running on the main-thread which are in scrolled out ' + 'elements should throttle restyling'); yield ensureElementRemoval(parentElement); }); add_task(function* restyling_main_thread_animations_moved_in_view_by_resizing() { if (!SpecialPowers.getBoolPref('dom.animations.offscreen-throttling')) { return; } var parentElement = addDiv(null, { style: 'overflow-y: scroll; height: 20px;' }); var div = addDiv(null, { style: 'animation: background-color 100s; position: relative; top: 100px;' }); parentElement.appendChild(div); var animation = div.getAnimations()[0]; yield animation.ready; var markers = yield observeStyling(1, function() { parentElement.style.height = '100px'; }); is(markers.length, 1, 'Animations running on the main-thread which was in scrolled out ' + 'elements should update restyling soon after the element moved in ' + 'view by resizing'); yield ensureElementRemoval(parentElement); }); add_task(function* no_restyling_main_thread_animations_in_visiblily_hidden_element() { var div = addDiv(null, { style: 'animation: background-color 100s; visibility: hidden' }); var animation = div.getAnimations()[0]; yield animation.ready; var markers = yield observeStyling(5); todo_is(markers.length, 0, 'Bug 1237454: Animations running on the main-thread in ' + 'visibility hidden element should never cause restyles'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_compositor_animations_after_pause_is_called() { var div = addDiv(null, { style: 'animation: opacity 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(animation.isRunningOnCompositor); animation.pause(); yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Bug 1232563: Paused animations running on the compositor should ' + 'never cause restyles once after pause() is called'); yield ensureElementRemoval(div); }); add_task(function* no_restyling_main_thread_animations_after_pause_is_called() { var div = addDiv(null, { style: 'animation: background-color 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; animation.pause(); yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Bug 1232563: Paused animations running on the main-thread should ' + 'never cause restyles after pause() is called'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* only_one_restyling_when_current_time_is_set_to_middle_of_duration() { var div = addDiv(null, { style: 'animation: opacity 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; animation.currentTime = 50 * MS_PER_SEC; var markers = yield observeStyling(5); is(markers.length, 1, 'Bug 1235478: Animations running on the compositor should only once ' + 'update style when currentTime is set to middle of duration time'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* change_duration_and_currenttime() { var div = addDiv(null); var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); yield animation.ready; ok(animation.isRunningOnCompositor); // Set currentTime to a time longer than duration. animation.currentTime = 500 * MS_PER_SEC; // Now the animation immediately get back from compositor. ok(!animation.isRunningOnCompositor); // Extend the duration. animation.effect.timing.duration = 800 * MS_PER_SEC; var markers = yield observeStyling(5); is(markers.length, 1, 'Animations running on the compositor should update style ' + 'when timing.duration is made longer than the current time'); yield ensureElementRemoval(div); }); add_task(function* script_animation_on_display_none_element() { var div = addDiv(null); var animation = div.animate({ backgroundColor: [ 'red', 'blue' ] }, 100 * MS_PER_SEC); yield animation.ready; div.style.display = 'none'; // We need to wait a frame to apply display:none style. yield waitForFrame(); is(animation.playState, 'running', 'Script animations keep running even when the target element has ' + '"display: none" style'); ok(!animation.isRunningOnCompositor, 'Script animations on "display:none" element should not run on the ' + 'compositor'); var markers = yield observeStyling(5); is(markers.length, 0, 'Script animations on "display: none" element should not update styles'); div.style.display = ''; // We need to wait a frame to unapply display:none style. yield waitForFrame(); var markers = yield observeStyling(5); is(markers.length, 5, 'Script animations restored from "display: none" state should update ' + 'styles'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* compositable_script_animation_on_display_none_element() { var div = addDiv(null); var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); yield animation.ready; div.style.display = 'none'; // We need to wait a frame to apply display:none style. yield waitForFrame(); is(animation.playState, 'running', 'Opacity script animations keep running even when the target element ' + 'has "display: none" style'); ok(!animation.isRunningOnCompositor, 'Opacity script animations on "display:none" element should not ' + 'run on the compositor'); var markers = yield observeStyling(5); is(markers.length, 0, 'Opacity script animations on "display: none" element should not ' + 'update styles'); div.style.display = ''; // We need to wait a frame to unapply display:none style. yield waitForFrame(); ok(animation.isRunningOnCompositor, 'Opacity script animations restored from "display: none" should be ' + 'run on the compositor'); yield ensureElementRemoval(div); }); add_task(function* restyling_for_empty_keyframes() { var div = addDiv(null); var animation = div.animate({ }, 100 * MS_PER_SEC); yield animation.ready; var markers = yield observeStyling(5); is(markers.length, 0, 'Animations with no keyframes should not cause restyles'); animation.effect.setKeyframes({ backgroundColor: ['red', 'blue'] }); markers = yield observeStyling(5); is(markers.length, 5, 'Setting valid keyframes should cause regular animation restyles to ' + 'occur'); animation.effect.setKeyframes({ }); markers = yield observeStyling(5); is(markers.length, 1, 'Setting an empty set of keyframes should trigger a single restyle ' + 'to remove the previous animated style'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled(function* no_restyling_when_animation_style_when_re_setting_same_animation_property() { var div = addDiv(null, { style: 'animation: opacity 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; ok(animation.isRunningOnCompositor); // Apply the same animation style div.style.animation = 'opacity 100s'; var markers = yield observeStyling(5); is(markers.length, 0, 'Applying same animation style ' + 'should never cause restyles'); yield ensureElementRemoval(div); }); add_task(function* necessary_update_should_be_invoked() { var div = addDiv(null, { style: 'animation: background-color 100s' }); var animation = div.getAnimations()[0]; yield animation.ready; yield waitForAnimationFrames(5); // Apply another animation style div.style.animation = 'background-color 110s'; var animation = div.getAnimations()[0]; var markers = yield observeStyling(5); is(markers.length, 5, 'Applying animation style with different duration ' + 'should cause restyles on every frame.'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled( function* changing_cascading_result_for_main_thread_animation() { var div = addDiv(null, { style: 'background-color: blue' }); var animation = div.animate({ opacity: [0, 1], backgroundColor: ['green', 'red'] }, 100 * MS_PER_SEC); yield animation.ready; ok(animation.isRunningOnCompositor, 'The opacity animation is running on the compositor'); // Make the background-color style as !important to cause an update // to the cascade. // Bug 1300982: The background-color animation should be no longer // running on the main thread. div.style.setProperty('background-color', '1', 'important'); var markers = yield observeStyling(5); todo_is(markers.length, 0, 'Changing cascading result for the property running on the main ' + 'thread does not cause synchronization layer of opacity animation ' + 'running on the compositor'); yield ensureElementRemoval(div); } ); add_task(function* restyling_for_animation_on_orphaned_element() { var div = addDiv(null); var animation = div.animate({ marginLeft: [ '0px', '100px' ] }, 100 * MS_PER_SEC); yield animation.ready; div.remove(); var markers = yield observeStyling(5); is(markers.length, 0, 'Animation on orphaned element should not cause restyles'); document.body.appendChild(div); markers = yield observeStyling(1); // We are observing restyles in rAF callback which is processed before // restyling process in each frame, so in the first frame there should be // no observed restyle since we don't process restyle while the element // is not attached to the document. is(markers.length, 0, 'We observe no restyle in the first frame right after re-atatching ' + 'to the document'); markers = yield observeStyling(5); is(markers.length, 5, 'Animation on re-attached to the document begins to update style'); yield ensureElementRemoval(div); }); add_task_if_omta_enabled( // Tests that if we remove an element from the document whose animation // cascade needs recalculating, that it is correctly updated when it is // re-attached to the document. function* restyling_for_opacity_animation_on_re_attached_element() { var div = addDiv(null, { style: 'opacity: 1 ! important' }); var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); yield animation.ready; ok(!animation.isRunningOnCompositor, 'The opacity animation overridden by an !important rule is NOT ' + 'running on the compositor'); // Drop the !important rule to update the cascade. div.style.setProperty('opacity', '1', ''); div.remove(); var markers = yield observeStyling(5); is(markers.length, 0, 'Opacity animation on orphaned element should not cause restyles'); document.body.appendChild(div); // Need a frame to give the animation a chance to be sent to the // compositor. yield waitForFrame(); ok(animation.isRunningOnCompositor, 'The opacity animation which is no longer overridden by the ' + '!important rule begins running on the compositor even if the ' + '!important rule had been dropped before the target element was ' + 'removed'); yield ensureElementRemoval(div); } ); }); </script> </body>