diff options
Diffstat (limited to '')
178 files changed, 30517 insertions, 0 deletions
diff --git a/dom/animation/Animation.cpp b/dom/animation/Animation.cpp new file mode 100644 index 0000000000..7e20dfd9e6 --- /dev/null +++ b/dom/animation/Animation.cpp @@ -0,0 +1,2210 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "Animation.h" + +#include "AnimationUtils.h" +#include "mozAutoDocUpdate.h" +#include "mozilla/dom/AnimationBinding.h" +#include "mozilla/dom/AnimationPlaybackEvent.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentInlines.h" +#include "mozilla/dom/DocumentTimeline.h" +#include "mozilla/dom/MutationObservers.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/AnimationEventDispatcher.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/AutoRestore.h" +#include "mozilla/CycleCollectedJSContext.h" +#include "mozilla/DeclarationBlock.h" +#include "mozilla/Maybe.h" // For Maybe +#include "mozilla/StaticPrefs_dom.h" +#include "nsAnimationManager.h" // For CSSAnimation +#include "nsComputedDOMStyle.h" +#include "nsDOMMutationObserver.h" // For nsAutoAnimationMutationBatch +#include "nsDOMCSSAttrDeclaration.h" // For nsDOMCSSAttributeDeclaration +#include "nsThreadUtils.h" // For nsRunnableMethod and nsRevocableEventPtr +#include "nsTransitionManager.h" // For CSSTransition +#include "PendingAnimationTracker.h" // For PendingAnimationTracker +#include "ScrollTimelineAnimationTracker.h" + +namespace mozilla::dom { + +// Static members +uint64_t Animation::sNextAnimationIndex = 0; + +NS_IMPL_CYCLE_COLLECTION_INHERITED(Animation, DOMEventTargetHelper, mTimeline, + mEffect, mReady, mFinished) + +NS_IMPL_ADDREF_INHERITED(Animation, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(Animation, DOMEventTargetHelper) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(Animation) +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +JSObject* Animation::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::Animation_Binding::Wrap(aCx, this, aGivenProto); +} + +// --------------------------------------------------------------------------- +// +// Utility methods +// +// --------------------------------------------------------------------------- + +namespace { +// A wrapper around nsAutoAnimationMutationBatch that looks up the +// appropriate document from the supplied animation. +class MOZ_RAII AutoMutationBatchForAnimation { + public: + explicit AutoMutationBatchForAnimation(const Animation& aAnimation) { + NonOwningAnimationTarget target = aAnimation.GetTargetForAnimation(); + if (!target) { + return; + } + + // For mutation observers, we use the OwnerDoc. + mAutoBatch.emplace(target.mElement->OwnerDoc()); + } + + private: + Maybe<nsAutoAnimationMutationBatch> mAutoBatch; +}; +} // namespace + +// --------------------------------------------------------------------------- +// +// Animation interface: +// +// --------------------------------------------------------------------------- + +Animation::Animation(nsIGlobalObject* aGlobal) + : DOMEventTargetHelper(aGlobal), + mAnimationIndex(sNextAnimationIndex++), + mRTPCallerType(aGlobal->GetRTPCallerType()) {} + +Animation::~Animation() = default; + +/* static */ +already_AddRefed<Animation> Animation::ClonePausedAnimation( + nsIGlobalObject* aGlobal, const Animation& aOther, AnimationEffect& aEffect, + AnimationTimeline& aTimeline) { + // FIXME: Bug 1805950: Support printing for scroll-timeline once we resolve + // the spec issue. + if (aOther.UsingScrollTimeline()) { + return nullptr; + } + + RefPtr<Animation> animation = new Animation(aGlobal); + + // Setup the timeline. We always use document-timeline of the new document, + // even if the timeline of |aOther| is null. + animation->mTimeline = &aTimeline; + + // Setup the playback rate. + animation->mPlaybackRate = aOther.mPlaybackRate; + + // Setup the timing. + const Nullable<TimeDuration> currentTime = aOther.GetCurrentTimeAsDuration(); + if (!aOther.GetTimeline()) { + // This simulates what we do in SetTimelineNoUpdate(). It's possible to + // preserve the progress if the previous timeline is a scroll-timeline. + // So for null timeline, it may have a progress and the non-null current + // time. + if (!currentTime.IsNull()) { + animation->SilentlySetCurrentTime(currentTime.Value()); + } + animation->mPreviousCurrentTime = animation->GetCurrentTimeAsDuration(); + } else { + animation->mHoldTime = currentTime; + if (!currentTime.IsNull()) { + // FIXME: Should we use |timelineTime| as previous current time here? It + // seems we should use animation->GetCurrentTimeAsDuration(), per + // UpdateFinishedState(). + const Nullable<TimeDuration> timelineTime = + aTimeline.GetCurrentTimeAsDuration(); + MOZ_ASSERT(!timelineTime.IsNull(), "Timeline not yet set"); + animation->mPreviousCurrentTime = timelineTime; + } + } + + // Setup the effect's link to this. + animation->mEffect = &aEffect; + animation->mEffect->SetAnimation(animation); + + animation->mPendingState = PendingState::PausePending; + + Document* doc = animation->GetRenderedDocument(); + MOZ_ASSERT(doc, + "Cloning animation should already have the rendered document"); + PendingAnimationTracker* tracker = doc->GetOrCreatePendingAnimationTracker(); + tracker->AddPausePending(*animation); + + // We expect our relevance to be the same as the orginal. + animation->mIsRelevant = aOther.mIsRelevant; + + animation->PostUpdate(); + animation->mTimeline->NotifyAnimationUpdated(*animation); + return animation.forget(); +} + +NonOwningAnimationTarget Animation::GetTargetForAnimation() const { + AnimationEffect* effect = GetEffect(); + NonOwningAnimationTarget target; + if (!effect || !effect->AsKeyframeEffect()) { + return target; + } + return effect->AsKeyframeEffect()->GetAnimationTarget(); +} + +/* static */ +already_AddRefed<Animation> Animation::Constructor( + const GlobalObject& aGlobal, AnimationEffect* aEffect, + const Optional<AnimationTimeline*>& aTimeline, ErrorResult& aRv) { + nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports()); + + AnimationTimeline* timeline; + Document* document = + AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + + if (aTimeline.WasPassed()) { + timeline = aTimeline.Value(); + } else { + if (!document) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + timeline = document->Timeline(); + } + + RefPtr<Animation> animation = new Animation(global); + animation->SetTimelineNoUpdate(timeline); + animation->SetEffectNoUpdate(aEffect); + + return animation.forget(); +} + +void Animation::SetId(const nsAString& aId) { + if (mId == aId) { + return; + } + mId = aId; + MutationObservers::NotifyAnimationChanged(this); +} + +void Animation::SetEffect(AnimationEffect* aEffect) { + SetEffectNoUpdate(aEffect); + PostUpdate(); +} + +// https://drafts.csswg.org/web-animations/#setting-the-target-effect +void Animation::SetEffectNoUpdate(AnimationEffect* aEffect) { + RefPtr<Animation> kungFuDeathGrip(this); + + if (mEffect == aEffect) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + bool wasRelevant = mIsRelevant; + + if (mEffect) { + // We need to notify observers now because once we set mEffect to null + // we won't be able to find the target element to notify. + if (mIsRelevant) { + MutationObservers::NotifyAnimationRemoved(this); + } + + // Break links with the old effect and then drop it. + RefPtr<AnimationEffect> oldEffect = mEffect; + mEffect = nullptr; + if (IsPartialPrerendered()) { + if (KeyframeEffect* oldKeyframeEffect = oldEffect->AsKeyframeEffect()) { + oldKeyframeEffect->ResetPartialPrerendered(); + } + } + oldEffect->SetAnimation(nullptr); + + // The following will not do any notification because mEffect is null. + UpdateRelevance(); + } + + if (aEffect) { + // Break links from the new effect to its previous animation, if any. + RefPtr<AnimationEffect> newEffect = aEffect; + Animation* prevAnim = aEffect->GetAnimation(); + if (prevAnim) { + prevAnim->SetEffect(nullptr); + } + + // Create links with the new effect. SetAnimation(this) will also update + // mIsRelevant of this animation, and then notify mutation observer if + // needed by calling Animation::UpdateRelevance(), so we don't need to + // call it again. + mEffect = newEffect; + mEffect->SetAnimation(this); + + // Notify possible add or change. + // If the target is different, the change notification will be ignored by + // AutoMutationBatchForAnimation. + if (wasRelevant && mIsRelevant) { + MutationObservers::NotifyAnimationChanged(this); + } + + ReschedulePendingTasks(); + } + + MaybeScheduleReplacementCheck(); + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); +} + +void Animation::SetTimeline(AnimationTimeline* aTimeline) { + SetTimelineNoUpdate(aTimeline); + PostUpdate(); +} + +// https://drafts.csswg.org/web-animations/#setting-the-timeline +void Animation::SetTimelineNoUpdate(AnimationTimeline* aTimeline) { + if (mTimeline == aTimeline) { + return; + } + + StickyTimeDuration activeTime = + mEffect ? mEffect->GetComputedTiming().mActiveTime : StickyTimeDuration(); + + const AnimationPlayState previousPlayState = PlayState(); + const Nullable<TimeDuration> previousCurrentTime = GetCurrentTimeAsDuration(); + // FIXME: The definition of end time in web-animation-1 is different from that + // in web-animation-2, which includes the start time. We are still using the + // definition in web-animation-1 here for now. + const TimeDuration endTime = TimeDuration(EffectEnd()); + double previousProgress = 0.0; + if (!previousCurrentTime.IsNull() && !endTime.IsZero()) { + previousProgress = + previousCurrentTime.Value().ToSeconds() / endTime.ToSeconds(); + } + + RefPtr<AnimationTimeline> oldTimeline = mTimeline; + if (oldTimeline) { + oldTimeline->RemoveAnimation(this); + } + + mTimeline = aTimeline; + + mResetCurrentTimeOnResume = false; + + if (mEffect) { + mEffect->UpdateNormalizedTiming(); + } + + if (mTimeline && !mTimeline->IsMonotonicallyIncreasing()) { + // If "to finite timeline" is true. + + ApplyPendingPlaybackRate(); + Nullable<TimeDuration> seekTime; + if (mPlaybackRate >= 0.0) { + seekTime.SetValue(TimeDuration()); + } else { + seekTime.SetValue(TimeDuration(EffectEnd())); + } + + switch (previousPlayState) { + case AnimationPlayState::Running: + case AnimationPlayState::Finished: + mStartTime = seekTime; + break; + case AnimationPlayState::Paused: + if (!previousCurrentTime.IsNull()) { + mResetCurrentTimeOnResume = true; + mStartTime.SetNull(); + mHoldTime.SetValue( + TimeDuration(EffectEnd().MultDouble(previousProgress))); + } else { + mStartTime = seekTime; + } + break; + case AnimationPlayState::Idle: + default: + break; + } + } else if (oldTimeline && !oldTimeline->IsMonotonicallyIncreasing() && + !previousCurrentTime.IsNull()) { + // If "from finite timeline" and previous progress is resolved. + SetCurrentTimeNoUpdate( + TimeDuration(EffectEnd().MultDouble(previousProgress))); + } + + if (!mStartTime.IsNull()) { + mHoldTime.SetNull(); + } + + if (!aTimeline) { + MaybeQueueCancelEvent(activeTime); + } + + UpdatePendingAnimationTracker(oldTimeline, aTimeline); + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + + // FIXME: Bug 1799071: Check if we need to add + // MutationObservers::NotifyAnimationChanged(this) here. +} + +// https://drafts.csswg.org/web-animations/#set-the-animation-start-time +void Animation::SetStartTime(const Nullable<TimeDuration>& aNewStartTime) { + // Return early if the start time will not change. However, if we + // are pending, then setting the start time to any value + // including the current value has the effect of aborting + // pending tasks so we should not return early in that case. + if (!Pending() && aNewStartTime == mStartTime) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + Nullable<TimeDuration> timelineTime; + if (mTimeline) { + // The spec says to check if the timeline is active (has a resolved time) + // before using it here, but we don't need to since it's harmless to set + // the already null time to null. + timelineTime = mTimeline->GetCurrentTimeAsDuration(); + } + if (timelineTime.IsNull() && !aNewStartTime.IsNull()) { + mHoldTime.SetNull(); + } + + Nullable<TimeDuration> previousCurrentTime = GetCurrentTimeAsDuration(); + + ApplyPendingPlaybackRate(); + mStartTime = aNewStartTime; + + mResetCurrentTimeOnResume = false; + + if (!aNewStartTime.IsNull()) { + if (mPlaybackRate != 0.0) { + mHoldTime.SetNull(); + } + } else { + mHoldTime = previousCurrentTime; + } + + CancelPendingTasks(); + if (mReady) { + // We may have already resolved mReady, but in that case calling + // MaybeResolve is a no-op, so that's okay. + mReady->MaybeResolve(this); + } + + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + PostUpdate(); +} + +// https://drafts.csswg.org/web-animations/#current-time +Nullable<TimeDuration> Animation::GetCurrentTimeForHoldTime( + const Nullable<TimeDuration>& aHoldTime) const { + Nullable<TimeDuration> result; + if (!aHoldTime.IsNull()) { + result = aHoldTime; + return result; + } + + if (mTimeline && !mStartTime.IsNull()) { + Nullable<TimeDuration> timelineTime = mTimeline->GetCurrentTimeAsDuration(); + if (!timelineTime.IsNull()) { + result = CurrentTimeFromTimelineTime(timelineTime.Value(), + mStartTime.Value(), mPlaybackRate); + } + } + return result; +} + +// https://drafts.csswg.org/web-animations/#set-the-current-time +void Animation::SetCurrentTime(const TimeDuration& aSeekTime) { + // Return early if the current time has not changed. However, if we + // are pause-pending, then setting the current time to any value + // including the current value has the effect of aborting the + // pause so we should not return early in that case. + if (mPendingState != PendingState::PausePending && + Nullable<TimeDuration>(aSeekTime) == GetCurrentTimeAsDuration()) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + SetCurrentTimeNoUpdate(aSeekTime); + + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + PostUpdate(); +} + +void Animation::SetCurrentTimeNoUpdate(const TimeDuration& aSeekTime) { + SilentlySetCurrentTime(aSeekTime); + + if (mPendingState == PendingState::PausePending) { + // Finish the pause operation + mHoldTime.SetValue(aSeekTime); + + ApplyPendingPlaybackRate(); + mStartTime.SetNull(); + + if (mReady) { + mReady->MaybeResolve(this); + } + CancelPendingTasks(); + } + + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Async); +} + +// https://drafts.csswg.org/web-animations/#set-the-playback-rate +void Animation::SetPlaybackRate(double aPlaybackRate) { + mPendingPlaybackRate.reset(); + + if (aPlaybackRate == mPlaybackRate) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + Nullable<TimeDuration> previousTime = GetCurrentTimeAsDuration(); + mPlaybackRate = aPlaybackRate; + if (!previousTime.IsNull()) { + SetCurrentTime(previousTime.Value()); + } + + // In the case where GetCurrentTimeAsDuration() returns the same result before + // and after updating mPlaybackRate, SetCurrentTime will return early since, + // as far as it can tell, nothing has changed. + // As a result, we need to perform the following updates here: + // - update timing (since, if the sign of the playback rate has changed, our + // finished state may have changed), + // - dispatch a change notification for the changed playback rate, and + // - update the playback rate on animations on layers. + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + PostUpdate(); +} + +// https://drafts.csswg.org/web-animations/#seamlessly-update-the-playback-rate +void Animation::UpdatePlaybackRate(double aPlaybackRate) { + if (mPendingPlaybackRate && mPendingPlaybackRate.value() == aPlaybackRate) { + return; + } + + // Calculate the play state using the existing playback rate since below we + // want to know if the animation is _currently_ finished or not, not whether + // it _will_ be finished. + AnimationPlayState playState = PlayState(); + + mPendingPlaybackRate = Some(aPlaybackRate); + + if (Pending()) { + // If we already have a pending task, there is nothing more to do since the + // playback rate will be applied then. + // + // However, as with the idle/paused case below, we still need to update the + // relevance (and effect set to make sure it only contains relevant + // animations) since the relevance is based on the Animation play state + // which incorporates the _pending_ playback rate. + UpdateEffect(PostRestyleMode::Never); + return; + } + + AutoMutationBatchForAnimation mb(*this); + + if (playState == AnimationPlayState::Idle || + playState == AnimationPlayState::Paused || + GetCurrentTimeAsDuration().IsNull()) { + // If |previous play state| is idle or paused, or the current time is + // unresolved, we apply any pending playback rate on animation immediately. + ApplyPendingPlaybackRate(); + + // We don't need to update timing or post an update here because: + // + // * the current time hasn't changed -- it's either unresolved or fixed + // with a hold time -- so the output won't have changed + // * the finished state won't have changed even if the sign of the + // playback rate changed since we're not finished (we're paused or idle) + // * the playback rate on layers doesn't need to be updated since we're not + // moving. Once we get a start time etc. we'll update the playback rate + // then. + // + // However we still need to update the relevance and effect set as well as + // notifying observers. + UpdateEffect(PostRestyleMode::Never); + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + } else if (playState == AnimationPlayState::Finished) { + MOZ_ASSERT(mTimeline && !mTimeline->GetCurrentTimeAsDuration().IsNull(), + "If we have no active timeline, we should be idle or paused"); + if (aPlaybackRate != 0) { + // The unconstrained current time can only be unresolved if either we + // don't have an active timeline (and we already asserted that is not + // true) or we have an unresolved start time (in which case we should be + // paused). + MOZ_ASSERT(!GetUnconstrainedCurrentTime().IsNull(), + "Unconstrained current time should be resolved"); + TimeDuration unconstrainedCurrentTime = + GetUnconstrainedCurrentTime().Value(); + TimeDuration timelineTime = mTimeline->GetCurrentTimeAsDuration().Value(); + mStartTime = StartTimeFromTimelineTime( + timelineTime, unconstrainedCurrentTime, aPlaybackRate); + } else { + mStartTime = mTimeline->GetCurrentTimeAsDuration(); + } + + ApplyPendingPlaybackRate(); + + // Even though we preserve the current time, we might now leave the finished + // state (e.g. if the playback rate changes sign) so we need to update + // timing. + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + PostUpdate(); + } else { + ErrorResult rv; + Play(rv, LimitBehavior::Continue); + MOZ_ASSERT(!rv.Failed(), + "We should only fail to play when using auto-rewind behavior"); + } +} + +// https://drafts.csswg.org/web-animations/#play-state +AnimationPlayState Animation::PlayState() const { + Nullable<TimeDuration> currentTime = GetCurrentTimeAsDuration(); + if (currentTime.IsNull() && mStartTime.IsNull() && !Pending()) { + return AnimationPlayState::Idle; + } + + if (mPendingState == PendingState::PausePending || + (mStartTime.IsNull() && !Pending())) { + return AnimationPlayState::Paused; + } + + double playbackRate = CurrentOrPendingPlaybackRate(); + if (!currentTime.IsNull() && + ((playbackRate > 0.0 && currentTime.Value() >= EffectEnd()) || + (playbackRate < 0.0 && currentTime.Value() <= TimeDuration()))) { + return AnimationPlayState::Finished; + } + + return AnimationPlayState::Running; +} + +Promise* Animation::GetReady(ErrorResult& aRv) { + nsCOMPtr<nsIGlobalObject> global = GetOwnerGlobal(); + if (!mReady && global) { + mReady = Promise::Create(global, aRv); // Lazily create on demand + } + if (!mReady) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + if (!Pending()) { + mReady->MaybeResolve(this); + } + return mReady; +} + +Promise* Animation::GetFinished(ErrorResult& aRv) { + nsCOMPtr<nsIGlobalObject> global = GetOwnerGlobal(); + if (!mFinished && global) { + mFinished = Promise::Create(global, aRv); // Lazily create on demand + } + if (!mFinished) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + if (mFinishedIsResolved) { + MaybeResolveFinishedPromise(); + } + return mFinished; +} + +// https://drafts.csswg.org/web-animations/#cancel-an-animation +void Animation::Cancel(PostRestyleMode aPostRestyle) { + bool newlyIdle = false; + + if (PlayState() != AnimationPlayState::Idle) { + newlyIdle = true; + + ResetPendingTasks(); + + if (mFinished) { + mFinished->MaybeReject(NS_ERROR_DOM_ABORT_ERR); + // mFinished can already be resolved. + MOZ_ALWAYS_TRUE(mFinished->SetAnyPromiseIsHandled()); + } + ResetFinishedPromise(); + + QueuePlaybackEvent(u"cancel"_ns, GetTimelineCurrentTimeAsTimeStamp()); + } + + StickyTimeDuration activeTime = + mEffect ? mEffect->GetComputedTiming().mActiveTime : StickyTimeDuration(); + + mHoldTime.SetNull(); + mStartTime.SetNull(); + + // Allow our effect to remove itself from the its target element's EffectSet. + UpdateEffect(aPostRestyle); + + if (mTimeline) { + mTimeline->RemoveAnimation(this); + } + MaybeQueueCancelEvent(activeTime); + + if (newlyIdle && aPostRestyle == PostRestyleMode::IfNeeded) { + PostUpdate(); + } +} + +// https://drafts.csswg.org/web-animations/#finish-an-animation +void Animation::Finish(ErrorResult& aRv) { + double effectivePlaybackRate = CurrentOrPendingPlaybackRate(); + + if (effectivePlaybackRate == 0) { + return aRv.ThrowInvalidStateError( + "Can't finish animation with zero playback rate"); + } + if (effectivePlaybackRate > 0 && EffectEnd() == TimeDuration::Forever()) { + return aRv.ThrowInvalidStateError("Can't finish infinite animation"); + } + + AutoMutationBatchForAnimation mb(*this); + + ApplyPendingPlaybackRate(); + + // Seek to the end + TimeDuration limit = + mPlaybackRate > 0 ? TimeDuration(EffectEnd()) : TimeDuration(0); + bool didChange = GetCurrentTimeAsDuration() != Nullable<TimeDuration>(limit); + SilentlySetCurrentTime(limit); + + // If we are paused or play-pending we need to fill in the start time in + // order to transition to the finished state. + // + // We only do this, however, if we have an active timeline. If we have an + // inactive timeline we can't transition into the finished state just like + // we can't transition to the running state (this finished state is really + // a substate of the running state). + if (mStartTime.IsNull() && mTimeline && + !mTimeline->GetCurrentTimeAsDuration().IsNull()) { + mStartTime = StartTimeFromTimelineTime( + mTimeline->GetCurrentTimeAsDuration().Value(), limit, mPlaybackRate); + didChange = true; + } + + // If we just resolved the start time for a pause or play-pending + // animation, we need to clear the task. We don't do this as a branch of + // the above however since we can have a play-pending animation with a + // resolved start time if we aborted a pause operation. + if (!mStartTime.IsNull() && (mPendingState == PendingState::PlayPending || + mPendingState == PendingState::PausePending)) { + if (mPendingState == PendingState::PausePending) { + mHoldTime.SetNull(); + } + CancelPendingTasks(); + didChange = true; + if (mReady) { + mReady->MaybeResolve(this); + } + } + UpdateTiming(SeekFlag::DidSeek, SyncNotifyFlag::Sync); + if (didChange && IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + PostUpdate(); +} + +void Animation::Play(ErrorResult& aRv, LimitBehavior aLimitBehavior) { + PlayNoUpdate(aRv, aLimitBehavior); + PostUpdate(); +} + +// https://drafts.csswg.org/web-animations/#reverse-an-animation +void Animation::Reverse(ErrorResult& aRv) { + if (!mTimeline) { + return aRv.ThrowInvalidStateError( + "Can't reverse an animation with no associated timeline"); + } + if (mTimeline->GetCurrentTimeAsDuration().IsNull()) { + return aRv.ThrowInvalidStateError( + "Can't reverse an animation associated with an inactive timeline"); + } + + double effectivePlaybackRate = CurrentOrPendingPlaybackRate(); + + if (effectivePlaybackRate == 0.0) { + return; + } + + Maybe<double> originalPendingPlaybackRate = mPendingPlaybackRate; + + mPendingPlaybackRate = Some(-effectivePlaybackRate); + + Play(aRv, LimitBehavior::AutoRewind); + + // If Play() threw, restore state and don't report anything to mutation + // observers. + if (aRv.Failed()) { + mPendingPlaybackRate = originalPendingPlaybackRate; + } + + // Play(), above, unconditionally calls PostUpdate so we don't need to do + // it here. +} + +void Animation::Persist() { + if (mReplaceState == AnimationReplaceState::Persisted) { + return; + } + + bool wasRemoved = mReplaceState == AnimationReplaceState::Removed; + + mReplaceState = AnimationReplaceState::Persisted; + + // If the animation is not (yet) removed, there should be no side effects of + // persisting it. + if (wasRemoved) { + UpdateEffect(PostRestyleMode::IfNeeded); + PostUpdate(); + } +} + +DocGroup* Animation::GetDocGroup() { + Document* doc = GetRenderedDocument(); + return doc ? doc->GetDocGroup() : nullptr; +} + +// https://drafts.csswg.org/web-animations/#dom-animation-commitstyles +void Animation::CommitStyles(ErrorResult& aRv) { + if (!mEffect) { + return; + } + + // Take an owning reference to the keyframe effect. This will ensure that + // this Animation and the target element remain alive after flushing style. + RefPtr<KeyframeEffect> keyframeEffect = mEffect->AsKeyframeEffect(); + if (!keyframeEffect) { + return; + } + + NonOwningAnimationTarget target = keyframeEffect->GetAnimationTarget(); + if (!target) { + return; + } + + if (target.mPseudoType != PseudoStyleType::NotPseudo) { + return aRv.ThrowNoModificationAllowedError( + "Can't commit styles of a pseudo-element"); + } + + // Check it is an element with a style attribute + RefPtr<nsStyledElement> styledElement = + nsStyledElement::FromNodeOrNull(target.mElement); + if (!styledElement) { + return aRv.ThrowNoModificationAllowedError( + "Target is not capable of having a style attribute"); + } + + // Hold onto a strong reference to the doc in case the flush destroys it. + RefPtr<Document> doc = target.mElement->GetComposedDoc(); + + // Flush frames before checking if the target element is rendered since the + // result could depend on pending style changes, and IsRendered() looks at the + // primary frame. + if (doc) { + doc->FlushPendingNotifications(FlushType::Frames); + } + if (!target.mElement->IsRendered()) { + return aRv.ThrowInvalidStateError("Target is not rendered"); + } + nsPresContext* presContext = + nsContentUtils::GetContextForContent(target.mElement); + if (!presContext) { + return aRv.ThrowInvalidStateError("Target is not rendered"); + } + + // Get the computed animation values + UniquePtr<StyleAnimationValueMap> animationValues( + Servo_AnimationValueMap_Create()); + if (!presContext->EffectCompositor()->ComposeServoAnimationRuleForEffect( + *keyframeEffect, CascadeLevel(), animationValues.get())) { + NS_WARNING("Failed to compose animation style to commit"); + return; + } + + // Calling SetCSSDeclaration will trigger attribute setting code. + // Start the update now so that the old rule doesn't get used + // between when we mutate the declaration and when we set the new + // rule. + mozAutoDocUpdate autoUpdate(target.mElement->OwnerDoc(), true); + + // Get the inline style to append to + RefPtr<DeclarationBlock> declarationBlock; + if (auto* existing = target.mElement->GetInlineStyleDeclaration()) { + declarationBlock = existing->EnsureMutable(); + } else { + declarationBlock = new DeclarationBlock(); + declarationBlock->SetDirty(); + } + + // Prepare the callback + MutationClosureData closureData; + closureData.mShouldBeCalled = true; + closureData.mElement = target.mElement; + DeclarationBlockMutationClosure beforeChangeClosure = { + nsDOMCSSAttributeDeclaration::MutationClosureFunction, + &closureData, + }; + + // Set the animated styles + bool changed = false; + nsCSSPropertyIDSet properties = keyframeEffect->GetPropertySet(); + for (nsCSSPropertyID property : properties) { + RefPtr<StyleAnimationValue> computedValue = + Servo_AnimationValueMap_GetValue(animationValues.get(), property) + .Consume(); + if (computedValue) { + changed |= Servo_DeclarationBlock_SetPropertyToAnimationValue( + declarationBlock->Raw(), computedValue, beforeChangeClosure); + } + } + + if (!changed) { + MOZ_ASSERT(!closureData.mWasCalled); + return; + } + + MOZ_ASSERT(closureData.mWasCalled); + // Update inline style declaration + target.mElement->SetInlineStyleDeclaration(*declarationBlock, closureData); +} + +// --------------------------------------------------------------------------- +// +// JS wrappers for Animation interface: +// +// --------------------------------------------------------------------------- + +Nullable<double> Animation::GetStartTimeAsDouble() const { + return AnimationUtils::TimeDurationToDouble(mStartTime, mRTPCallerType); +} + +void Animation::SetStartTimeAsDouble(const Nullable<double>& aStartTime) { + return SetStartTime(AnimationUtils::DoubleToTimeDuration(aStartTime)); +} + +Nullable<double> Animation::GetCurrentTimeAsDouble() const { + return AnimationUtils::TimeDurationToDouble(GetCurrentTimeAsDuration(), + mRTPCallerType); +} + +void Animation::SetCurrentTimeAsDouble(const Nullable<double>& aCurrentTime, + ErrorResult& aRv) { + if (aCurrentTime.IsNull()) { + if (!GetCurrentTimeAsDuration().IsNull()) { + aRv.ThrowTypeError( + "Current time is resolved but trying to set it to an unresolved " + "time"); + } + return; + } + + return SetCurrentTime(TimeDuration::FromMilliseconds(aCurrentTime.Value())); +} + +// --------------------------------------------------------------------------- + +void Animation::Tick() { + // Finish pending if we have a pending ready time, but only if we also + // have an active timeline. + if (mPendingState != PendingState::NotPending && + !mPendingReadyTime.IsNull() && mTimeline && + !mTimeline->GetCurrentTimeAsDuration().IsNull()) { + // Even though mPendingReadyTime is initialized using TimeStamp::Now() + // during the *previous* tick of the refresh driver, it can still be + // ahead of the *current* timeline time when we are using the + // vsync timer so we need to clamp it to the timeline time. + TimeDuration currentTime = mTimeline->GetCurrentTimeAsDuration().Value(); + if (currentTime < mPendingReadyTime.Value()) { + mPendingReadyTime.SetValue(currentTime); + } + FinishPendingAt(mPendingReadyTime.Value()); + mPendingReadyTime.SetNull(); + } + + if (IsPossiblyOrphanedPendingAnimation()) { + MOZ_ASSERT(mTimeline && !mTimeline->GetCurrentTimeAsDuration().IsNull(), + "Orphaned pending animations should have an active timeline"); + FinishPendingAt(mTimeline->GetCurrentTimeAsDuration().Value()); + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Sync); + + // Check for changes to whether or not this animation is replaceable. + bool isReplaceable = IsReplaceable(); + if (isReplaceable && !mWasReplaceableAtLastTick) { + ScheduleReplacementCheck(); + } + mWasReplaceableAtLastTick = isReplaceable; + + if (!mEffect) { + return; + } + + // Update layers if we are newly finished. + KeyframeEffect* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect && !keyframeEffect->Properties().IsEmpty() && + !mFinishedAtLastComposeStyle && + PlayState() == AnimationPlayState::Finished) { + PostUpdate(); + } +} + +void Animation::TriggerOnNextTick(const Nullable<TimeDuration>& aReadyTime) { + // Normally we expect the play state to be pending but it's possible that, + // due to the handling of possibly orphaned animations in Tick(), this + // animation got started whilst still being in another document's pending + // animation map. + if (!Pending()) { + return; + } + + // If aReadyTime.IsNull() we'll detect this in Tick() where we check for + // orphaned animations and trigger this animation anyway + mPendingReadyTime = aReadyTime; +} + +void Animation::TriggerNow() { + // Normally we expect the play state to be pending but when an animation + // is cancelled and its rendered document can't be reached, we can end up + // with the animation still in a pending player tracker even after it is + // no longer pending. + if (!Pending()) { + return; + } + + // If we don't have an active timeline we can't trigger the animation. + // However, this is a test-only method that we don't expect to be used in + // conjunction with animations without an active timeline so generate + // a warning if we do find ourselves in that situation. + if (!mTimeline || mTimeline->GetCurrentTimeAsDuration().IsNull()) { + NS_WARNING("Failed to trigger an animation with an active timeline"); + return; + } + + FinishPendingAt(mTimeline->GetCurrentTimeAsDuration().Value()); +} + +bool Animation::TryTriggerNowForFiniteTimeline() { + // Normally we expect the play state to be pending but when an animation + // is cancelled and its rendered document can't be reached, we can end up + // with the animation still in a pending player tracker even after it is + // no longer pending. + if (!Pending()) { + return true; + } + + MOZ_ASSERT(mTimeline && !mTimeline->IsMonotonicallyIncreasing()); + + // It's possible that the primary frame or the scrollable frame is not ready + // when setting up this animation. So we don't finish pending right now. In + // this case, the timeline is inactive so it is still pending. The caller + // should handle this case by trying this later once the scrollable frame is + // ready. + const auto currentTime = mTimeline->GetCurrentTimeAsDuration(); + if (currentTime.IsNull()) { + return false; + } + + FinishPendingAt(currentTime.Value()); + return true; +} + +Nullable<TimeDuration> Animation::GetCurrentOrPendingStartTime() const { + Nullable<TimeDuration> result; + + // If we have a pending playback rate, work out what start time we will use + // when we come to updating that playback rate. + // + // This logic roughly shadows that in ResumeAt but is just different enough + // that it is difficult to extract out the common functionality (and + // extracting that functionality out would make it harder to match ResumeAt up + // against the spec). + if (mPendingPlaybackRate && !mPendingReadyTime.IsNull() && + !mStartTime.IsNull()) { + // If we have a hold time, use it as the current time to match. + TimeDuration currentTimeToMatch = + !mHoldTime.IsNull() + ? mHoldTime.Value() + : CurrentTimeFromTimelineTime(mPendingReadyTime.Value(), + mStartTime.Value(), mPlaybackRate); + + result = StartTimeFromTimelineTime( + mPendingReadyTime.Value(), currentTimeToMatch, *mPendingPlaybackRate); + return result; + } + + if (!mStartTime.IsNull()) { + result = mStartTime; + return result; + } + + if (mPendingReadyTime.IsNull() || mHoldTime.IsNull()) { + return result; + } + + // Calculate the equivalent start time from the pending ready time. + result = StartTimeFromTimelineTime(mPendingReadyTime.Value(), + mHoldTime.Value(), mPlaybackRate); + + return result; +} + +TimeStamp Animation::AnimationTimeToTimeStamp( + const StickyTimeDuration& aTime) const { + // Initializes to null. Return the same object every time to benefit from + // return-value-optimization. + TimeStamp result; + + // We *don't* check for mTimeline->TracksWallclockTime() here because that + // method only tells us if the timeline times can be converted to + // TimeStamps that can be compared to TimeStamp::Now() or not, *not* + // whether the timelines can be converted to TimeStamp values at all. + // + // Furthermore, we want to be able to use this method when the refresh driver + // is under test control (in which case TracksWallclockTime() will return + // false). + // + // Once we introduce timelines that are not time-based we will need to + // differentiate between them here and determine how to sort their events. + if (!mTimeline) { + return result; + } + + // Check the time is convertible to a timestamp + if (aTime == TimeDuration::Forever() || mPlaybackRate == 0.0 || + mStartTime.IsNull()) { + return result; + } + + // Invert the standard relation: + // current time = (timeline time - start time) * playback rate + TimeDuration timelineTime = + TimeDuration(aTime).MultDouble(1.0 / mPlaybackRate) + mStartTime.Value(); + + result = mTimeline->ToTimeStamp(timelineTime); + return result; +} + +TimeStamp Animation::ElapsedTimeToTimeStamp( + const StickyTimeDuration& aElapsedTime) const { + TimeDuration delay = + mEffect ? mEffect->NormalizedTiming().Delay() : TimeDuration(); + return AnimationTimeToTimeStamp(aElapsedTime + delay); +} + +// https://drafts.csswg.org/web-animations/#silently-set-the-current-time +void Animation::SilentlySetCurrentTime(const TimeDuration& aSeekTime) { + // TODO: Bug 1762238: Introduce "valid seek time" after introducing + // CSSNumberish time values. + // https://drafts.csswg.org/web-animations-2/#silently-set-the-current-time + + if (!mHoldTime.IsNull() || mStartTime.IsNull() || !mTimeline || + mTimeline->GetCurrentTimeAsDuration().IsNull() || mPlaybackRate == 0.0) { + mHoldTime.SetValue(aSeekTime); + } else { + mStartTime = + StartTimeFromTimelineTime(mTimeline->GetCurrentTimeAsDuration().Value(), + aSeekTime, mPlaybackRate); + } + + if (!mTimeline || mTimeline->GetCurrentTimeAsDuration().IsNull()) { + mStartTime.SetNull(); + } + + mPreviousCurrentTime.SetNull(); + mResetCurrentTimeOnResume = false; +} + +bool Animation::ShouldBeSynchronizedWithMainThread( + const nsCSSPropertyIDSet& aPropertySet, const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning) const { + // Only synchronize playing animations + if (!IsPlaying()) { + return false; + } + + // Currently only transform animations need to be synchronized + if (!aPropertySet.Intersects(nsCSSPropertyIDSet::TransformLikeProperties())) { + return false; + } + + KeyframeEffect* keyframeEffect = + mEffect ? mEffect->AsKeyframeEffect() : nullptr; + if (!keyframeEffect) { + return false; + } + + // Are we starting at the same time as other geometric animations? + // We check this before calling ShouldBlockAsyncTransformAnimations, partly + // because it's cheaper, but also because it's often the most useful thing + // to know when you're debugging performance. + // Note: |mSyncWithGeometricAnimations| wouldn't be set if the geometric + // animations use scroll-timeline. + if (StaticPrefs:: + dom_animations_mainthread_synchronization_with_geometric_animations() && + mSyncWithGeometricAnimations && + keyframeEffect->HasAnimationOfPropertySet( + nsCSSPropertyIDSet::TransformLikeProperties())) { + aPerformanceWarning = + AnimationPerformanceWarning::Type::TransformWithSyncGeometricAnimations; + return true; + } + + return keyframeEffect->ShouldBlockAsyncTransformAnimations( + aFrame, aPropertySet, aPerformanceWarning); +} + +void Animation::UpdateRelevance() { + bool wasRelevant = mIsRelevant; + mIsRelevant = mReplaceState != AnimationReplaceState::Removed && + (HasCurrentEffect() || IsInEffect()); + + // Notify animation observers. + if (wasRelevant && !mIsRelevant) { + MutationObservers::NotifyAnimationRemoved(this); + } else if (!wasRelevant && mIsRelevant) { + MutationObservers::NotifyAnimationAdded(this); + } +} + +template <class T> +bool IsMarkupAnimation(T* aAnimation) { + return aAnimation && aAnimation->IsTiedToMarkup(); +} + +// https://drafts.csswg.org/web-animations/#replaceable-animation +bool Animation::IsReplaceable() const { + // We never replace CSS animations or CSS transitions since they are managed + // by CSS. + if (IsMarkupAnimation(AsCSSAnimation()) || + IsMarkupAnimation(AsCSSTransition())) { + return false; + } + + // Only finished animations can be replaced. + if (PlayState() != AnimationPlayState::Finished) { + return false; + } + + // Already removed animations cannot be replaced. + if (ReplaceState() == AnimationReplaceState::Removed) { + return false; + } + + // We can only replace an animation if we know that, uninterfered, it would + // never start playing again. That excludes any animations on timelines that + // aren't monotonically increasing. + // + // If we don't have any timeline at all, then we can't be in the finished + // state (since we need both a resolved start time and current time for that) + // and will have already returned false above. + // + // (However, if it ever does become possible to be finished without a timeline + // then we will want to return false here since it probably suggests an + // animation being driven directly by script, in which case we can't assume + // anything about how they will behave.) + if (!GetTimeline() || !GetTimeline()->TracksWallclockTime()) { + return false; + } + + // If the animation doesn't have an effect then we can't determine if it is + // filling or not so just leave it alone. + if (!GetEffect()) { + return false; + } + + // At the time of writing we only know about KeyframeEffects. If we introduce + // other types of effects we will need to decide if they are replaceable or + // not. + MOZ_ASSERT(GetEffect()->AsKeyframeEffect(), + "Effect should be a keyframe effect"); + + // We only replace animations that are filling. + if (GetEffect()->GetComputedTiming().mProgress.IsNull()) { + return false; + } + + // We should only replace animations with a target element (since otherwise + // what other effects would we consider when determining if they are covered + // or not?). + if (!GetEffect()->AsKeyframeEffect()->GetAnimationTarget()) { + return false; + } + + return true; +} + +bool Animation::IsRemovable() const { + return ReplaceState() == AnimationReplaceState::Active && IsReplaceable(); +} + +void Animation::ScheduleReplacementCheck() { + MOZ_ASSERT( + IsReplaceable(), + "Should only schedule a replacement check for a replaceable animation"); + + // If IsReplaceable() is true, the following should also hold + MOZ_ASSERT(GetEffect()); + MOZ_ASSERT(GetEffect()->AsKeyframeEffect()); + + NonOwningAnimationTarget target = + GetEffect()->AsKeyframeEffect()->GetAnimationTarget(); + + MOZ_ASSERT(target); + + nsPresContext* presContext = + nsContentUtils::GetContextForContent(target.mElement); + if (presContext) { + presContext->EffectCompositor()->NoteElementForReducing(target); + } +} + +void Animation::MaybeScheduleReplacementCheck() { + if (!IsReplaceable()) { + return; + } + + ScheduleReplacementCheck(); +} + +void Animation::Remove() { + MOZ_ASSERT(IsRemovable(), + "Should not be trying to remove an effect that is not removable"); + + mReplaceState = AnimationReplaceState::Removed; + + UpdateEffect(PostRestyleMode::IfNeeded); + PostUpdate(); + + QueuePlaybackEvent(u"remove"_ns, GetTimelineCurrentTimeAsTimeStamp()); +} + +bool Animation::HasLowerCompositeOrderThan(const Animation& aOther) const { + // 0. Object-equality case + if (&aOther == this) { + return false; + } + + // 1. CSS Transitions sort lowest + { + auto asCSSTransitionForSorting = + [](const Animation& anim) -> const CSSTransition* { + const CSSTransition* transition = anim.AsCSSTransition(); + return transition && transition->IsTiedToMarkup() ? transition : nullptr; + }; + auto thisTransition = asCSSTransitionForSorting(*this); + auto otherTransition = asCSSTransitionForSorting(aOther); + if (thisTransition && otherTransition) { + return thisTransition->HasLowerCompositeOrderThan(*otherTransition); + } + if (thisTransition || otherTransition) { + // Cancelled transitions no longer have an owning element. To be strictly + // correct we should store a strong reference to the owning element + // so that if we arrive here while sorting cancel events, we can sort + // them in the correct order. + // + // However, given that cancel events are almost always queued + // synchronously in some deterministic manner, we can be fairly sure + // that cancel events will be dispatched in a deterministic order + // (which is our only hard requirement until specs say otherwise). + // Furthermore, we only reach here when we have events with equal + // timestamps so this is an edge case we can probably ignore for now. + return thisTransition; + } + } + + // 2. CSS Animations sort next + { + auto asCSSAnimationForSorting = + [](const Animation& anim) -> const CSSAnimation* { + const CSSAnimation* animation = anim.AsCSSAnimation(); + return animation && animation->IsTiedToMarkup() ? animation : nullptr; + }; + auto thisAnimation = asCSSAnimationForSorting(*this); + auto otherAnimation = asCSSAnimationForSorting(aOther); + if (thisAnimation && otherAnimation) { + return thisAnimation->HasLowerCompositeOrderThan(*otherAnimation); + } + if (thisAnimation || otherAnimation) { + return thisAnimation; + } + } + + // Subclasses of Animation repurpose mAnimationIndex to implement their + // own brand of composite ordering. However, by this point we should have + // handled any such custom composite ordering so we should now have unique + // animation indices. + MOZ_ASSERT(mAnimationIndex != aOther.mAnimationIndex, + "Animation indices should be unique"); + + // 3. Finally, generic animations sort by their position in the global + // animation array. + return mAnimationIndex < aOther.mAnimationIndex; +} + +void Animation::WillComposeStyle() { + mFinishedAtLastComposeStyle = (PlayState() == AnimationPlayState::Finished); + + MOZ_ASSERT(mEffect); + + KeyframeEffect* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect) { + keyframeEffect->WillComposeStyle(); + } +} + +void Animation::ComposeStyle(StyleAnimationValueMap& aComposeResult, + const nsCSSPropertyIDSet& aPropertiesToSkip) { + if (!mEffect) { + return; + } + + // In order to prevent flicker, there are a few cases where we want to use + // a different time for rendering that would otherwise be returned by + // GetCurrentTimeAsDuration. These are: + // + // (a) For animations that are pausing but which are still running on the + // compositor. In this case we send a layer transaction that removes the + // animation but which also contains the animation values calculated on + // the main thread. To prevent flicker when this occurs we want to ensure + // the timeline time used to calculate the main thread animation values + // does not lag far behind the time used on the compositor. Ideally we + // would like to use the "animation ready time" calculated at the end of + // the layer transaction as the timeline time but it will be too late to + // update the style rule at that point so instead we just use the current + // wallclock time. + // + // (b) For animations that are pausing that we have already taken off the + // compositor. In this case we record a pending ready time but we don't + // apply it until the next tick. However, while waiting for the next tick, + // we should still use the pending ready time as the timeline time. If we + // use the regular timeline time the animation may appear jump backwards + // if the main thread's timeline time lags behind the compositor. + // + // (c) For animations that are play-pending due to an aborted pause operation + // (i.e. a pause operation that was interrupted before we entered the + // paused state). When we cancel a pending pause we might momentarily take + // the animation off the compositor, only to re-add it moments later. In + // that case the compositor might have been ahead of the main thread so we + // should use the current wallclock time to ensure the animation doesn't + // temporarily jump backwards. + // + // To address each of these cases we temporarily tweak the hold time + // immediately before updating the style rule and then restore it immediately + // afterwards. This is purely to prevent visual flicker. Other behavior + // such as dispatching events continues to rely on the regular timeline time. + bool pending = Pending(); + { + AutoRestore<Nullable<TimeDuration>> restoreHoldTime(mHoldTime); + + if (pending && mHoldTime.IsNull() && !mStartTime.IsNull()) { + Nullable<TimeDuration> timeToUse = mPendingReadyTime; + if (timeToUse.IsNull() && mTimeline && mTimeline->TracksWallclockTime()) { + timeToUse = mTimeline->ToTimelineTime(TimeStamp::Now()); + } + if (!timeToUse.IsNull()) { + mHoldTime = CurrentTimeFromTimelineTime( + timeToUse.Value(), mStartTime.Value(), mPlaybackRate); + } + } + + KeyframeEffect* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect) { + keyframeEffect->ComposeStyle(aComposeResult, aPropertiesToSkip); + } + } + + MOZ_ASSERT( + pending == Pending(), + "Pending state should not change during the course of compositing"); +} + +void Animation::NotifyEffectTimingUpdated() { + MOZ_ASSERT(mEffect, + "We should only update effect timing when we have a target " + "effect"); + UpdateTiming(Animation::SeekFlag::NoSeek, Animation::SyncNotifyFlag::Async); +} + +void Animation::NotifyEffectPropertiesUpdated() { + MOZ_ASSERT(mEffect, + "We should only update effect properties when we have a target " + "effect"); + + MaybeScheduleReplacementCheck(); +} + +void Animation::NotifyEffectTargetUpdated() { + MOZ_ASSERT(mEffect, + "We should only update the effect target when we have a target " + "effect"); + + MaybeScheduleReplacementCheck(); +} + +void Animation::NotifyGeometricAnimationsStartingThisFrame() { + if (!IsNewlyStarted() || !mEffect) { + return; + } + + mSyncWithGeometricAnimations = true; +} + +// https://drafts.csswg.org/web-animations/#play-an-animation +void Animation::PlayNoUpdate(ErrorResult& aRv, LimitBehavior aLimitBehavior) { + AutoMutationBatchForAnimation mb(*this); + + const bool isAutoRewind = aLimitBehavior == LimitBehavior::AutoRewind; + const bool abortedPause = mPendingState == PendingState::PausePending; + double effectivePlaybackRate = CurrentOrPendingPlaybackRate(); + + Nullable<TimeDuration> currentTime = GetCurrentTimeAsDuration(); + if (mResetCurrentTimeOnResume) { + currentTime.SetNull(); + mResetCurrentTimeOnResume = false; + } + + Nullable<TimeDuration> seekTime; + if (isAutoRewind) { + if (effectivePlaybackRate >= 0.0 && + (currentTime.IsNull() || currentTime.Value() < TimeDuration() || + currentTime.Value() >= EffectEnd())) { + seekTime.SetValue(TimeDuration()); + } else if (effectivePlaybackRate < 0.0 && + (currentTime.IsNull() || currentTime.Value() <= TimeDuration() || + currentTime.Value() > EffectEnd())) { + if (EffectEnd() == TimeDuration::Forever()) { + return aRv.ThrowInvalidStateError( + "Can't rewind animation with infinite effect end"); + } + seekTime.SetValue(TimeDuration(EffectEnd())); + } + } + + if (seekTime.IsNull() && mStartTime.IsNull() && currentTime.IsNull()) { + seekTime.SetValue(TimeDuration()); + } + + if (!seekTime.IsNull()) { + if (HasFiniteTimeline()) { + mStartTime = seekTime; + mHoldTime.SetNull(); + ApplyPendingPlaybackRate(); + } else { + mHoldTime = seekTime; + } + } + + bool reuseReadyPromise = false; + if (mPendingState != PendingState::NotPending) { + CancelPendingTasks(); + reuseReadyPromise = true; + } + + // If the hold time is null then we're already playing normally and, + // typically, we can bail out here. + // + // However, there are two cases where we can't do that: + // + // (a) If we just aborted a pause. In this case, for consistency, we need to + // go through the motions of doing an asynchronous start. + // + // (b) If we have timing changes (specifically a change to the playbackRate) + // that should be applied asynchronously. + // + if (mHoldTime.IsNull() && seekTime.IsNull() && !abortedPause && + !mPendingPlaybackRate) { + return; + } + + // Clear the start time until we resolve a new one. We do this except + // for the case where we are aborting a pause and don't have a hold time. + // + // If we're aborting a pause and *do* have a hold time (e.g. because + // the animation is finished or we just applied the auto-rewind behavior + // above) we should respect it by clearing the start time. If we *don't* + // have a hold time we should keep the current start time so that the + // the animation continues moving uninterrupted by the aborted pause. + // + // (If we're not aborting a pause, mHoldTime must be resolved by now + // or else we would have returned above.) + if (!mHoldTime.IsNull()) { + mStartTime.SetNull(); + } + + if (!reuseReadyPromise) { + // Clear ready promise. We'll create a new one lazily. + mReady = nullptr; + } + + mPendingState = PendingState::PlayPending; + + // Clear flag that causes us to sync transform animations with the main + // thread for now. We'll set this when we go to set up compositor + // animations if it applies. + mSyncWithGeometricAnimations = false; + + if (HasFiniteTimeline()) { + // Always schedule a task even if we would like to let this animation + // immedidately ready, per spec. + // https://drafts.csswg.org/web-animations/#playing-an-animation-section + if (Document* doc = GetRenderedDocument()) { + doc->GetOrCreateScrollTimelineAnimationTracker()->AddPending(*this); + } // else: we fail to track this animation, so let the scroll frame to + // trigger it when ticking. + } else { + if (Document* doc = GetRenderedDocument()) { + PendingAnimationTracker* tracker = + doc->GetOrCreatePendingAnimationTracker(); + tracker->AddPlayPending(*this); + } else { + TriggerOnNextTick(Nullable<TimeDuration>()); + } + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } +} + +// https://drafts.csswg.org/web-animations/#pause-an-animation +void Animation::Pause(ErrorResult& aRv) { + if (IsPausedOrPausing()) { + return; + } + + AutoMutationBatchForAnimation mb(*this); + + Nullable<TimeDuration> seekTime; + // If we are transitioning from idle, fill in the current time + if (GetCurrentTimeAsDuration().IsNull()) { + if (mPlaybackRate >= 0.0) { + seekTime.SetValue(TimeDuration(0)); + } else { + if (EffectEnd() == TimeDuration::Forever()) { + return aRv.ThrowInvalidStateError("Can't seek to infinite effect end"); + } + seekTime.SetValue(TimeDuration(EffectEnd())); + } + } + + if (!seekTime.IsNull()) { + if (HasFiniteTimeline()) { + mStartTime = seekTime; + } else { + mHoldTime = seekTime; + } + } + + bool reuseReadyPromise = false; + if (mPendingState == PendingState::PlayPending) { + CancelPendingTasks(); + reuseReadyPromise = true; + } + + if (!reuseReadyPromise) { + // Clear ready promise. We'll create a new one lazily. + mReady = nullptr; + } + + mPendingState = PendingState::PausePending; + + if (HasFiniteTimeline()) { + // Always schedule a task even if we would like to let this animation + // immedidately ready, per spec. + // https://drafts.csswg.org/web-animations/#playing-an-animation-section + if (Document* doc = GetRenderedDocument()) { + doc->GetOrCreateScrollTimelineAnimationTracker()->AddPending(*this); + } // else: we fail to track this animation, so let the scroll frame to + // trigger it when ticking. + } else { + if (Document* doc = GetRenderedDocument()) { + PendingAnimationTracker* tracker = + doc->GetOrCreatePendingAnimationTracker(); + tracker->AddPausePending(*this); + } else { + TriggerOnNextTick(Nullable<TimeDuration>()); + } + } + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + if (IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + + PostUpdate(); +} + +// https://drafts.csswg.org/web-animations/#play-an-animation +void Animation::ResumeAt(const TimeDuration& aReadyTime) { + // This method is only expected to be called for an animation that is + // waiting to play. We can easily adapt it to handle other states + // but it's currently not necessary. + MOZ_ASSERT(mPendingState == PendingState::PlayPending, + "Expected to resume a play-pending animation"); + MOZ_ASSERT(!mHoldTime.IsNull() || !mStartTime.IsNull(), + "An animation in the play-pending state should have either a" + " resolved hold time or resolved start time"); + + AutoMutationBatchForAnimation mb(*this); + bool hadPendingPlaybackRate = mPendingPlaybackRate.isSome(); + + if (!mHoldTime.IsNull()) { + // The hold time is set, so we don't need any special handling to preserve + // the current time. + ApplyPendingPlaybackRate(); + mStartTime = + StartTimeFromTimelineTime(aReadyTime, mHoldTime.Value(), mPlaybackRate); + if (mPlaybackRate != 0) { + mHoldTime.SetNull(); + } + } else if (!mStartTime.IsNull() && mPendingPlaybackRate) { + // Apply any pending playback rate, preserving the current time. + TimeDuration currentTimeToMatch = CurrentTimeFromTimelineTime( + aReadyTime, mStartTime.Value(), mPlaybackRate); + ApplyPendingPlaybackRate(); + mStartTime = StartTimeFromTimelineTime(aReadyTime, currentTimeToMatch, + mPlaybackRate); + if (mPlaybackRate == 0) { + mHoldTime.SetValue(currentTimeToMatch); + } + } + + mPendingState = PendingState::NotPending; + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Sync); + + // If we had a pending playback rate, we will have now applied it so we need + // to notify observers. + if (hadPendingPlaybackRate && IsRelevant()) { + MutationObservers::NotifyAnimationChanged(this); + } + + if (mReady) { + mReady->MaybeResolve(this); + } +} + +void Animation::PauseAt(const TimeDuration& aReadyTime) { + MOZ_ASSERT(mPendingState == PendingState::PausePending, + "Expected to pause a pause-pending animation"); + + if (!mStartTime.IsNull() && mHoldTime.IsNull()) { + mHoldTime = CurrentTimeFromTimelineTime(aReadyTime, mStartTime.Value(), + mPlaybackRate); + } + ApplyPendingPlaybackRate(); + mStartTime.SetNull(); + mPendingState = PendingState::NotPending; + + UpdateTiming(SeekFlag::NoSeek, SyncNotifyFlag::Async); + + if (mReady) { + mReady->MaybeResolve(this); + } +} + +void Animation::UpdateTiming(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) { + // We call UpdateFinishedState before UpdateEffect because the former + // can change the current time, which is used by the latter. + UpdateFinishedState(aSeekFlag, aSyncNotifyFlag); + UpdateEffect(PostRestyleMode::IfNeeded); + + if (mTimeline) { + mTimeline->NotifyAnimationUpdated(*this); + } +} + +// https://drafts.csswg.org/web-animations/#update-an-animations-finished-state +void Animation::UpdateFinishedState(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) { + Nullable<TimeDuration> unconstrainedCurrentTime = + aSeekFlag == SeekFlag::NoSeek ? GetUnconstrainedCurrentTime() + : GetCurrentTimeAsDuration(); + TimeDuration effectEnd = TimeDuration(EffectEnd()); + + if (!unconstrainedCurrentTime.IsNull() && !mStartTime.IsNull() && + mPendingState == PendingState::NotPending) { + if (mPlaybackRate > 0.0 && unconstrainedCurrentTime.Value() >= effectEnd) { + if (aSeekFlag == SeekFlag::DidSeek) { + mHoldTime = unconstrainedCurrentTime; + } else if (!mPreviousCurrentTime.IsNull()) { + mHoldTime.SetValue(std::max(mPreviousCurrentTime.Value(), effectEnd)); + } else { + mHoldTime.SetValue(effectEnd); + } + } else if (mPlaybackRate < 0.0 && + unconstrainedCurrentTime.Value() <= TimeDuration()) { + if (aSeekFlag == SeekFlag::DidSeek) { + mHoldTime = unconstrainedCurrentTime; + } else if (!mPreviousCurrentTime.IsNull()) { + mHoldTime.SetValue( + std::min(mPreviousCurrentTime.Value(), TimeDuration(0))); + } else { + mHoldTime.SetValue(0); + } + } else if (mPlaybackRate != 0.0 && mTimeline && + !mTimeline->GetCurrentTimeAsDuration().IsNull()) { + if (aSeekFlag == SeekFlag::DidSeek && !mHoldTime.IsNull()) { + mStartTime = StartTimeFromTimelineTime( + mTimeline->GetCurrentTimeAsDuration().Value(), mHoldTime.Value(), + mPlaybackRate); + } + mHoldTime.SetNull(); + } + } + + // We must recalculate the current time to take account of any mHoldTime + // changes the code above made. + mPreviousCurrentTime = GetCurrentTimeAsDuration(); + + bool currentFinishedState = PlayState() == AnimationPlayState::Finished; + if (currentFinishedState && !mFinishedIsResolved) { + DoFinishNotification(aSyncNotifyFlag); + } else if (!currentFinishedState && mFinishedIsResolved) { + ResetFinishedPromise(); + } +} + +void Animation::UpdateEffect(PostRestyleMode aPostRestyle) { + if (mEffect) { + UpdateRelevance(); + + KeyframeEffect* keyframeEffect = mEffect->AsKeyframeEffect(); + if (keyframeEffect) { + keyframeEffect->NotifyAnimationTimingUpdated(aPostRestyle); + } + } +} + +void Animation::FlushUnanimatedStyle() const { + if (Document* doc = GetRenderedDocument()) { + doc->FlushPendingNotifications( + ChangesToFlush(FlushType::Style, false /* flush animations */)); + } +} + +void Animation::PostUpdate() { + if (!mEffect) { + return; + } + + KeyframeEffect* keyframeEffect = mEffect->AsKeyframeEffect(); + if (!keyframeEffect) { + return; + } + keyframeEffect->RequestRestyle(EffectCompositor::RestyleType::Layer); +} + +void Animation::CancelPendingTasks() { + if (mPendingState == PendingState::NotPending) { + return; + } + + if (Document* doc = GetRenderedDocument()) { + PendingAnimationTracker* tracker = doc->GetPendingAnimationTracker(); + if (tracker) { + if (mPendingState == PendingState::PlayPending) { + tracker->RemovePlayPending(*this); + } else { + tracker->RemovePausePending(*this); + } + } + } + + mPendingState = PendingState::NotPending; + mPendingReadyTime.SetNull(); +} + +// https://drafts.csswg.org/web-animations/#reset-an-animations-pending-tasks +void Animation::ResetPendingTasks() { + if (mPendingState == PendingState::NotPending) { + return; + } + + CancelPendingTasks(); + ApplyPendingPlaybackRate(); + + if (mReady) { + mReady->MaybeReject(NS_ERROR_DOM_ABORT_ERR); + MOZ_ALWAYS_TRUE(mReady->SetAnyPromiseIsHandled()); + mReady = nullptr; + } +} + +void Animation::ReschedulePendingTasks() { + if (mPendingState == PendingState::NotPending) { + return; + } + + mPendingReadyTime.SetNull(); + + if (Document* doc = GetRenderedDocument()) { + PendingAnimationTracker* tracker = + doc->GetOrCreatePendingAnimationTracker(); + if (mPendingState == PendingState::PlayPending && + !tracker->IsWaitingToPlay(*this)) { + tracker->AddPlayPending(*this); + } else if (mPendingState == PendingState::PausePending && + !tracker->IsWaitingToPause(*this)) { + tracker->AddPausePending(*this); + } + } +} + +// https://drafts.csswg.org/web-animations-2/#at-progress-timeline-boundary +/* static*/ Animation::ProgressTimelinePosition +Animation::AtProgressTimelineBoundary( + const Nullable<TimeDuration>& aTimelineDuration, + const Nullable<TimeDuration>& aCurrentTime, + const TimeDuration& aEffectStartTime, const double aPlaybackRate) { + // Based on changed defined in: https://github.com/w3c/csswg-drafts/pull/6702 + // 1. If any of the following conditions are true: + // * the associated animation's timeline is not a progress-based timeline, + // or + // * the associated animation's timeline duration is unresolved or zero, + // or + // * the animation's playback rate is zero + // return false + // Note: We can detect a progress-based timeline by relying on the fact that + // monotonic timelines (i.e. non-progress-based timelines) have an unresolved + // timeline duration. + if (aTimelineDuration.IsNull() || aTimelineDuration.Value().IsZero() || + aPlaybackRate == 0.0) { + return ProgressTimelinePosition::NotBoundary; + } + + // 2. Let effective start time be the animation's start time if resolved, or + // zero otherwise. + const TimeDuration& effectiveStartTime = aEffectStartTime; + + // 3. Let effective timeline time be (animation's current time / animation's + // playback rate) + effective start time. + // Note: we use zero if the current time is unresolved. See the spec issue: + // https://github.com/w3c/csswg-drafts/issues/7458 + const TimeDuration effectiveTimelineTime = + (aCurrentTime.IsNull() + ? TimeDuration() + : aCurrentTime.Value().MultDouble(1.0 / aPlaybackRate)) + + effectiveStartTime; + + // 4. Let effective timeline progress be (effective timeline time / timeline + // duration) + // 5. If effective timeline progress is 0 or 1, return true, + // We avoid the division here but it is effectively the same as 4 & 5 above. + return effectiveTimelineTime.IsZero() || + (AnimationUtils::IsWithinAnimationTimeTolerance( + effectiveTimelineTime, aTimelineDuration.Value())) + ? ProgressTimelinePosition::Boundary + : ProgressTimelinePosition::NotBoundary; +} + +bool Animation::IsPossiblyOrphanedPendingAnimation() const { + // Check if we are pending but might never start because we are not being + // tracked. + // + // This covers the following cases: + // + // * We started playing but our effect's target element was orphaned + // or bound to a different document. + // (note that for the case of our effect changing we should handle + // that in SetEffect) + // * We started playing but our timeline became inactive. + // In this case the pending animation tracker will drop us from its hashmap + // when we have been painted. + // * When we started playing we couldn't find a + // PendingAnimationTracker/ScrollTimelineAnimationTracker to register with + // (perhaps the effect had no document) so we may + // 1. simply set mPendingState in PlayNoUpdate and relied on this method to + // catch us on the next tick, or + // 2. rely on the scroll frame to tick this animation and catch us in this + // method. + + // If we're not pending we're ok. + if (mPendingState == PendingState::NotPending) { + return false; + } + + // If we have a pending ready time then we will be started on the next + // tick. + if (!mPendingReadyTime.IsNull()) { + return false; + } + + // If we don't have an active timeline then we shouldn't start until + // we do. + if (!mTimeline || mTimeline->GetCurrentTimeAsDuration().IsNull()) { + return false; + } + + // If we have no rendered document, or we're not in our rendered document's + // PendingAnimationTracker then there's a good chance no one is tracking us. + // + // If we're wrong and another document is tracking us then, at worst, we'll + // simply start/pause the animation one tick too soon. That's better than + // never starting/pausing the animation and is unlikely. + Document* doc = GetRenderedDocument(); + if (!doc) { + return true; + } + + PendingAnimationTracker* tracker = doc->GetPendingAnimationTracker(); + return !tracker || (!tracker->IsWaitingToPlay(*this) && + !tracker->IsWaitingToPause(*this)); +} + +StickyTimeDuration Animation::EffectEnd() const { + if (!mEffect) { + return StickyTimeDuration(0); + } + + return mEffect->NormalizedTiming().EndTime(); +} + +Document* Animation::GetRenderedDocument() const { + if (!mEffect || !mEffect->AsKeyframeEffect()) { + return nullptr; + } + + return mEffect->AsKeyframeEffect()->GetRenderedDocument(); +} + +Document* Animation::GetTimelineDocument() const { + return mTimeline ? mTimeline->GetDocument() : nullptr; +} + +void Animation::UpdatePendingAnimationTracker(AnimationTimeline* aOldTimeline, + AnimationTimeline* aNewTimeline) { + // If we are still in pending, we may have to move this animation into the + // correct animation tracker. + Document* doc = GetRenderedDocument(); + if (!doc || !Pending()) { + return; + } + + const bool fromFiniteTimeline = + aOldTimeline && !aOldTimeline->IsMonotonicallyIncreasing(); + const bool toFiniteTimeline = + aNewTimeline && !aNewTimeline->IsMonotonicallyIncreasing(); + if (fromFiniteTimeline == toFiniteTimeline) { + return; + } + + const bool isPlayPending = mPendingState == PendingState::PlayPending; + if (toFiniteTimeline) { + // From null/document-timeline to scroll-timeline + if (auto* tracker = doc->GetPendingAnimationTracker()) { + if (isPlayPending) { + tracker->RemovePlayPending(*this); + } else { + tracker->RemovePausePending(*this); + } + } + + doc->GetOrCreateScrollTimelineAnimationTracker()->AddPending(*this); + } else { + // From scroll-timeline to null/document-timeline + if (auto* tracker = doc->GetScrollTimelineAnimationTracker()) { + tracker->RemovePending(*this); + } + + auto* tracker = doc->GetOrCreatePendingAnimationTracker(); + if (isPlayPending) { + tracker->AddPlayPending(*this); + } else { + tracker->AddPausePending(*this); + } + } +} + +class AsyncFinishNotification : public MicroTaskRunnable { + public: + explicit AsyncFinishNotification(Animation* aAnimation) + : MicroTaskRunnable(), mAnimation(aAnimation) {} + + virtual void Run(AutoSlowOperation& aAso) override { + mAnimation->DoFinishNotificationImmediately(this); + mAnimation = nullptr; + } + + virtual bool Suppressed() override { + nsIGlobalObject* global = mAnimation->GetOwnerGlobal(); + return global && global->IsInSyncOperation(); + } + + private: + RefPtr<Animation> mAnimation; +}; + +void Animation::DoFinishNotification(SyncNotifyFlag aSyncNotifyFlag) { + CycleCollectedJSContext* context = CycleCollectedJSContext::Get(); + + if (aSyncNotifyFlag == SyncNotifyFlag::Sync) { + DoFinishNotificationImmediately(); + } else if (!mFinishNotificationTask) { + RefPtr<MicroTaskRunnable> runnable = new AsyncFinishNotification(this); + context->DispatchToMicroTask(do_AddRef(runnable)); + mFinishNotificationTask = std::move(runnable); + } +} + +void Animation::ResetFinishedPromise() { + mFinishedIsResolved = false; + mFinished = nullptr; +} + +void Animation::MaybeResolveFinishedPromise() { + if (mFinished) { + mFinished->MaybeResolve(this); + } + mFinishedIsResolved = true; +} + +void Animation::DoFinishNotificationImmediately(MicroTaskRunnable* aAsync) { + if (aAsync && aAsync != mFinishNotificationTask) { + return; + } + + mFinishNotificationTask = nullptr; + + if (PlayState() != AnimationPlayState::Finished) { + return; + } + + MaybeResolveFinishedPromise(); + + QueuePlaybackEvent(u"finish"_ns, AnimationTimeToTimeStamp(EffectEnd())); +} + +void Animation::QueuePlaybackEvent(const nsAString& aName, + TimeStamp&& aScheduledEventTime) { + // Use document for timing. + // https://drafts.csswg.org/web-animations-1/#document-for-timing + Document* doc = GetTimelineDocument(); + if (!doc) { + return; + } + + nsPresContext* presContext = doc->GetPresContext(); + if (!presContext) { + return; + } + + AnimationPlaybackEventInit init; + + if (aName.EqualsLiteral("finish") || aName.EqualsLiteral("remove")) { + init.mCurrentTime = GetCurrentTimeAsDouble(); + } + if (mTimeline) { + init.mTimelineTime = mTimeline->GetCurrentTimeAsDouble(); + } + + RefPtr<AnimationPlaybackEvent> event = + AnimationPlaybackEvent::Constructor(this, aName, init); + event->SetTrusted(true); + + presContext->AnimationEventDispatcher()->QueueEvent(AnimationEventInfo( + aName, std::move(event), std::move(aScheduledEventTime), this)); +} + +bool Animation::IsRunningOnCompositor() const { + return mEffect && mEffect->AsKeyframeEffect() && + mEffect->AsKeyframeEffect()->IsRunningOnCompositor(); +} + +bool Animation::HasCurrentEffect() const { + return GetEffect() && GetEffect()->IsCurrent(); +} + +bool Animation::IsInEffect() const { + return GetEffect() && GetEffect()->IsInEffect(); +} + +void Animation::SetHiddenByContentVisibility(bool hidden) { + if (mHiddenByContentVisibility == hidden) { + return; + } + + mHiddenByContentVisibility = hidden; + + if (!GetTimeline()) { + return; + } + + GetTimeline()->NotifyAnimationContentVisibilityChanged(this, !hidden); +} + +StickyTimeDuration Animation::IntervalStartTime( + const StickyTimeDuration& aActiveDuration) const { + MOZ_ASSERT(AsCSSTransition() || AsCSSAnimation(), + "Should be called for CSS animations or transitions"); + static constexpr StickyTimeDuration zeroDuration = StickyTimeDuration(); + return std::max( + std::min(StickyTimeDuration(-mEffect->NormalizedTiming().Delay()), + aActiveDuration), + zeroDuration); +} + +// Later side of the elapsed time range reported in CSS Animations and CSS +// Transitions events. +// +// https://drafts.csswg.org/css-animations-2/#interval-end +// https://drafts.csswg.org/css-transitions-2/#interval-end +StickyTimeDuration Animation::IntervalEndTime( + const StickyTimeDuration& aActiveDuration) const { + MOZ_ASSERT(AsCSSTransition() || AsCSSAnimation(), + "Should be called for CSS animations or transitions"); + + static constexpr StickyTimeDuration zeroDuration = StickyTimeDuration(); + const StickyTimeDuration& effectEnd = EffectEnd(); + + // If both "associated effect end" and "start delay" are Infinity, we skip it + // because we will get NaN when computing "Infinity - Infinity", and + // using NaN in std::min or std::max is undefined. + if (MOZ_UNLIKELY(effectEnd == TimeDuration::Forever() && + effectEnd == mEffect->NormalizedTiming().Delay())) { + // Note: If we use TimeDuration::Forever(), within our animation event + // handling, we'd end up turning that into a null TimeStamp which can causes + // errors if we try to do any arithmetic with it. Given that we should never + // end up _using_ the interval end time. So returning zeroDuration here is + // probably fine. + return zeroDuration; + } + + return std::max(std::min(effectEnd - mEffect->NormalizedTiming().Delay(), + aActiveDuration), + zeroDuration); +} + +} // namespace mozilla::dom diff --git a/dom/animation/Animation.h b/dom/animation/Animation.h new file mode 100644 index 0000000000..bbe2ab7a3e --- /dev/null +++ b/dom/animation/Animation.h @@ -0,0 +1,709 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_Animation_h +#define mozilla_dom_Animation_h + +#include "X11UndefineNone.h" +#include "nsCycleCollectionParticipant.h" +#include "mozilla/AnimationPerformanceWarning.h" +#include "mozilla/Attributes.h" +#include "mozilla/BasePrincipal.h" +#include "mozilla/DOMEventTargetHelper.h" +#include "mozilla/EffectCompositor.h" // For EffectCompositor::CascadeLevel +#include "mozilla/LinkedList.h" +#include "mozilla/Maybe.h" +#include "mozilla/PostRestyleMode.h" +#include "mozilla/StickyTimeDuration.h" +#include "mozilla/TimeStamp.h" // for TimeStamp, TimeDuration +#include "mozilla/dom/AnimationBinding.h" // for AnimationPlayState +#include "mozilla/dom/AnimationTimeline.h" + +struct JSContext; +class nsCSSPropertyIDSet; +class nsIFrame; +class nsIGlobalObject; + +namespace mozilla { + +struct AnimationRule; +class MicroTaskRunnable; + +namespace dom { + +class AnimationEffect; +class AsyncFinishNotification; +class CSSAnimation; +class CSSTransition; +class Document; +class Promise; + +class Animation : public DOMEventTargetHelper, + public LinkedListElement<Animation> { + protected: + virtual ~Animation(); + + public: + explicit Animation(nsIGlobalObject* aGlobal); + + // Constructs a copy of |aOther| with a new effect and timeline. + // This is only intended to be used while making a static clone of a document + // during printing, and does not assume that |aOther| is in the same document + // as any of the other arguments. + static already_AddRefed<Animation> ClonePausedAnimation( + nsIGlobalObject* aGlobal, const Animation& aOther, + AnimationEffect& aEffect, AnimationTimeline& aTimeline); + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(Animation, DOMEventTargetHelper) + + nsIGlobalObject* GetParentObject() const { return GetOwnerGlobal(); } + + /** + * Utility function to get the target (pseudo-)element associated with an + * animation. + */ + NonOwningAnimationTarget GetTargetForAnimation() const; + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + virtual CSSAnimation* AsCSSAnimation() { return nullptr; } + virtual const CSSAnimation* AsCSSAnimation() const { return nullptr; } + virtual CSSTransition* AsCSSTransition() { return nullptr; } + virtual const CSSTransition* AsCSSTransition() const { return nullptr; } + + /** + * Flag to pass to Play to indicate whether or not it should automatically + * rewind the current time to the start point if the animation is finished. + * For regular calls to play() from script we should do this, but when a CSS + * animation's animation-play-state changes we shouldn't rewind the animation. + */ + enum class LimitBehavior { AutoRewind, Continue }; + + // Animation interface methods + static already_AddRefed<Animation> Constructor( + const GlobalObject& aGlobal, AnimationEffect* aEffect, + const Optional<AnimationTimeline*>& aTimeline, ErrorResult& aRv); + + void GetId(nsAString& aResult) const { aResult = mId; } + void SetId(const nsAString& aId); + + AnimationEffect* GetEffect() const { return mEffect; } + virtual void SetEffect(AnimationEffect* aEffect); + void SetEffectNoUpdate(AnimationEffect* aEffect); + + // FIXME: Bug 1676794. This is a tentative solution before we implement + // ScrollTimeline interface. If the timeline is scroll/view timeline, we + // return null. Once we implement ScrollTimeline interface, we can drop this. + already_AddRefed<AnimationTimeline> GetTimelineFromJS() const { + return mTimeline && mTimeline->IsScrollTimeline() ? nullptr + : do_AddRef(mTimeline); + } + void SetTimelineFromJS(AnimationTimeline* aTimeline) { + SetTimeline(aTimeline); + } + + AnimationTimeline* GetTimeline() const { return mTimeline; } + void SetTimeline(AnimationTimeline* aTimeline); + void SetTimelineNoUpdate(AnimationTimeline* aTimeline); + + Nullable<TimeDuration> GetStartTime() const { return mStartTime; } + Nullable<double> GetStartTimeAsDouble() const; + void SetStartTime(const Nullable<TimeDuration>& aNewStartTime); + virtual void SetStartTimeAsDouble(const Nullable<double>& aStartTime); + + // This is deliberately _not_ called GetCurrentTime since that would clash + // with a macro defined in winbase.h + Nullable<TimeDuration> GetCurrentTimeAsDuration() const { + return GetCurrentTimeForHoldTime(mHoldTime); + } + Nullable<double> GetCurrentTimeAsDouble() const; + void SetCurrentTime(const TimeDuration& aSeekTime); + void SetCurrentTimeNoUpdate(const TimeDuration& aSeekTime); + void SetCurrentTimeAsDouble(const Nullable<double>& aCurrentTime, + ErrorResult& aRv); + + double PlaybackRate() const { return mPlaybackRate; } + void SetPlaybackRate(double aPlaybackRate); + + AnimationPlayState PlayState() const; + virtual AnimationPlayState PlayStateFromJS() const { return PlayState(); } + + bool Pending() const { return mPendingState != PendingState::NotPending; } + virtual bool PendingFromJS() const { return Pending(); } + AnimationReplaceState ReplaceState() const { return mReplaceState; } + + virtual Promise* GetReady(ErrorResult& aRv); + Promise* GetFinished(ErrorResult& aRv); + + IMPL_EVENT_HANDLER(finish); + IMPL_EVENT_HANDLER(cancel); + IMPL_EVENT_HANDLER(remove); + + void Cancel(PostRestyleMode aPostRestyle = PostRestyleMode::IfNeeded); + + void Finish(ErrorResult& aRv); + + void Play(ErrorResult& aRv, LimitBehavior aLimitBehavior); + virtual void PlayFromJS(ErrorResult& aRv) { + Play(aRv, LimitBehavior::AutoRewind); + } + + void Pause(ErrorResult& aRv); + virtual void PauseFromJS(ErrorResult& aRv) { Pause(aRv); } + + void UpdatePlaybackRate(double aPlaybackRate); + virtual void Reverse(ErrorResult& aRv); + + void Persist(); + MOZ_CAN_RUN_SCRIPT void CommitStyles(ErrorResult& aRv); + + bool IsRunningOnCompositor() const; + + virtual void Tick(); + bool NeedsTicks() const { + return Pending() || + (PlayState() == AnimationPlayState::Running && + // An animation with a zero playback rate doesn't need ticks even if + // it is running since it effectively behaves as if it is paused. + // + // It's important we return false in this case since a zero playback + // rate animation in the before or after phase that doesn't fill + // won't be relevant and hence won't be returned by GetAnimations(). + // We don't want its timeline to keep it alive (which would happen + // if we return true) since otherwise it will effectively be leaked. + PlaybackRate() != 0.0) || + // Always return true for not idle animations attached to not + // monotonically increasing timelines even if the animation is + // finished. This is required to accommodate cases where timeline + // ticks back in time. + (mTimeline && !mTimeline->IsMonotonicallyIncreasing() && + PlayState() != AnimationPlayState::Idle); + } + + /** + * Set the time to use for starting or pausing a pending animation. + * + * Typically, when an animation is played, it does not start immediately but + * is added to a table of pending animations on the document of its effect. + * In the meantime it sets its hold time to the time from which playback + * should begin. + * + * When the document finishes painting, any pending animations in its table + * are marked as being ready to start by calling TriggerOnNextTick. + * The moment when the paint completed is also recorded, converted to a + * timeline time, and passed to StartOnTick. This is so that when these + * animations do start, they can be timed from the point when painting + * completed. + * + * After calling TriggerOnNextTick, animations remain in the pending state + * until the next refresh driver tick. At that time they transition out of + * the pending state using the time passed to TriggerOnNextTick as the + * effective time at which they resumed. + * + * This approach means that any setup time required for performing the + * initial paint of an animation such as layerization is not deducted from + * the running time of the animation. Without this we can easily drop the + * first few frames of an animation, or, on slower devices, the whole + * animation. + * + * Furthermore: + * + * - Starting the animation immediately when painting finishes is problematic + * because the start time of the animation will be ahead of its timeline + * (since the timeline time is based on the refresh driver time). + * That's a problem because the animation is playing but its timing + * suggests it starts in the future. We could update the timeline to match + * the start time of the animation but then we'd also have to update the + * timing and style of all animations connected to that timeline or else be + * stuck in an inconsistent state until the next refresh driver tick. + * + * - If we simply use the refresh driver time on its next tick, the lag + * between triggering an animation and its effective start is unacceptably + * long. + * + * For pausing, we apply the same asynchronous approach. This is so that we + * synchronize with animations that are running on the compositor. Otherwise + * if the main thread lags behind the compositor there will be a noticeable + * jump backwards when the main thread takes over. Even though main thread + * animations could be paused immediately, we do it asynchronously for + * consistency and so that animations paused together end up in step. + * + * Note that the caller of this method is responsible for removing the + * animation from any PendingAnimationTracker it may have been added to. + */ + void TriggerOnNextTick(const Nullable<TimeDuration>& aReadyTime); + /** + * For the monotonically increasing timeline, we use this only for testing: + * Start or pause a pending animation using the current timeline time. This + * is used to support existing tests that expect animations to begin + * immediately. Ideally we would rewrite the those tests and get rid of this + * method, but there are a lot of them. + * + * As with TriggerOnNextTick, the caller of this method is responsible for + * removing the animation from any PendingAnimationTracker it may have been + * added to. + */ + void TriggerNow(); + /** + * For the non-monotonically increasing timeline (e.g. ScrollTimeline), we try + * to trigger it in ScrollTimelineAnimationTracker by this method. This uses + * the current scroll position as the ready time. Return true if we don't need + * to trigger it or we trigger it successfully. + */ + bool TryTriggerNowForFiniteTimeline(); + /** + * When TriggerOnNextTick is called, we store the ready time but we don't + * apply it until the next tick. In the meantime, GetStartTime() will return + * null. + * + * However, if we build layer animations again before the next tick, we + * should initialize them with the start time that GetStartTime() will return + * on the next tick. + * + * If we were to simply set the start time of layer animations to null, their + * start time would be updated to the current wallclock time when rendering + * finishes, thus making them out of sync with the start time stored here. + * This, in turn, will make the animation jump backwards when we build + * animations on the next tick and apply the start time stored here. + * + * This method returns the start time, if resolved. Otherwise, if we have + * a pending ready time, it returns the corresponding start time. If neither + * of those are available, it returns null. + */ + Nullable<TimeDuration> GetCurrentOrPendingStartTime() const; + + /** + * As with the start time, we should use the pending playback rate when + * producing layer animations. + */ + double CurrentOrPendingPlaybackRate() const { + return mPendingPlaybackRate.valueOr(mPlaybackRate); + } + bool HasPendingPlaybackRate() const { return mPendingPlaybackRate.isSome(); } + + /** + * The following relationship from the definition of the 'current time' is + * re-used in many algorithms so we extract it here into a static method that + * can be re-used: + * + * current time = (timeline time - start time) * playback rate + * + * As per https://drafts.csswg.org/web-animations-1/#current-time + */ + static TimeDuration CurrentTimeFromTimelineTime( + const TimeDuration& aTimelineTime, const TimeDuration& aStartTime, + float aPlaybackRate) { + return (aTimelineTime - aStartTime).MultDouble(aPlaybackRate); + } + + /** + * As with calculating the current time, we often need to calculate a start + * time from a current time. The following method simply inverts the current + * time relationship. + * + * In each case where this is used, the desired behavior for playbackRate == + * 0 is to return the specified timeline time (often referred to as the ready + * time). + */ + static TimeDuration StartTimeFromTimelineTime( + const TimeDuration& aTimelineTime, const TimeDuration& aCurrentTime, + float aPlaybackRate) { + TimeDuration result = aTimelineTime; + if (aPlaybackRate == 0) { + return result; + } + + result -= aCurrentTime.MultDouble(1.0 / aPlaybackRate); + return result; + } + + /** + * Converts a time in the timescale of this Animation's currentTime, to a + * TimeStamp. Returns a null TimeStamp if the conversion cannot be performed + * because of the current state of this Animation (e.g. it has no timeline, a + * zero playbackRate, an unresolved start time etc.) or the value of the time + * passed-in (e.g. an infinite time). + */ + TimeStamp AnimationTimeToTimeStamp(const StickyTimeDuration& aTime) const; + + // Converts an AnimationEvent's elapsedTime value to an equivalent TimeStamp + // that can be used to sort events by when they occurred. + TimeStamp ElapsedTimeToTimeStamp( + const StickyTimeDuration& aElapsedTime) const; + + bool IsPausedOrPausing() const { + return PlayState() == AnimationPlayState::Paused; + } + + bool HasCurrentEffect() const; + bool IsInEffect() const; + + bool IsPlaying() const { + return mPlaybackRate != 0.0 && mTimeline && + !mTimeline->GetCurrentTimeAsDuration().IsNull() && + PlayState() == AnimationPlayState::Running; + } + + bool ShouldBeSynchronizedWithMainThread( + const nsCSSPropertyIDSet& aPropertySet, const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */) const; + + bool IsRelevant() const { return mIsRelevant; } + void UpdateRelevance(); + + // https://drafts.csswg.org/web-animations-1/#replaceable-animation + bool IsReplaceable() const; + + /** + * Returns true if this Animation satisfies the requirements for being + * removed when it is replaced. + * + * Returning true does not imply this animation _should_ be removed. + * Determining that depends on the other effects in the same EffectSet to + * which this animation's effect, if any, contributes. + */ + bool IsRemovable() const; + + /** + * Make this animation's target effect no-longer part of the effect stack + * while preserving its timing information. + */ + void Remove(); + + /** + * Returns true if this Animation has a lower composite order than aOther. + */ + bool HasLowerCompositeOrderThan(const Animation& aOther) const; + + /** + * Returns the level at which the effect(s) associated with this Animation + * are applied to the CSS cascade. + */ + virtual EffectCompositor::CascadeLevel CascadeLevel() const { + return EffectCompositor::CascadeLevel::Animations; + } + + /** + * Returns true if this animation does not currently need to update + * style on the main thread (e.g. because it is empty, or is + * running on the compositor). + */ + bool CanThrottle() const; + + /** + * Updates various bits of state that we need to update as the result of + * running ComposeStyle(). + * See the comment of KeyframeEffect::WillComposeStyle for more detail. + */ + void WillComposeStyle(); + + /** + * Updates |aComposeResult| with the animation values of this animation's + * effect, if any. + * Any properties contained in |aPropertiesToSkip| will not be added or + * updated in |aComposeResult|. + */ + void ComposeStyle(StyleAnimationValueMap& aComposeResult, + const nsCSSPropertyIDSet& aPropertiesToSkip); + + void NotifyEffectTimingUpdated(); + void NotifyEffectPropertiesUpdated(); + void NotifyEffectTargetUpdated(); + void NotifyGeometricAnimationsStartingThisFrame(); + + /** + * Reschedule pending pause or pending play tasks when updating the target + * effect. + * + * If we are pending, we will either be registered in the pending animation + * tracker and have a null pending ready time, or, after our effect has been + * painted, we will be removed from the tracker and assigned a pending ready + * time. + * + * When the target effect is updated, we'll typically need to repaint so for + * the latter case where we already have a pending ready time, clear it and + * put ourselves back in the pending animation tracker. + */ + void ReschedulePendingTasks(); + + /** + * Used by subclasses to synchronously queue a cancel event in situations + * where the Animation may have been cancelled. + * + * We need to do this synchronously because after a CSS animation/transition + * is canceled, it will be released by its owning element and may not still + * exist when we would normally go to queue events on the next tick. + */ + virtual void MaybeQueueCancelEvent(const StickyTimeDuration& aActiveTime){}; + + Maybe<uint32_t>& CachedChildIndexRef() { return mCachedChildIndex; } + + void SetPartialPrerendered(uint64_t aIdOnCompositor) { + mIdOnCompositor = aIdOnCompositor; + mIsPartialPrerendered = true; + } + bool IsPartialPrerendered() const { return mIsPartialPrerendered; } + uint64_t IdOnCompositor() const { return mIdOnCompositor; } + /** + * Needs to be called when the pre-rendered animation is going to no longer + * run on the compositor. + */ + void ResetPartialPrerendered() { + MOZ_ASSERT(mIsPartialPrerendered); + mIsPartialPrerendered = false; + mIdOnCompositor = 0; + } + /** + * Called via NotifyJankedAnimations IPC call from the compositor to update + * pre-rendered area on the main-thread. + */ + void UpdatePartialPrerendered() { + ResetPartialPrerendered(); + PostUpdate(); + } + + bool UsingScrollTimeline() const { + return mTimeline && mTimeline->IsScrollTimeline(); + } + + /** + * Returns true if this is at the progress timeline boundary. + * https://drafts.csswg.org/web-animations-2/#at-progress-timeline-boundary + */ + enum class ProgressTimelinePosition : uint8_t { Boundary, NotBoundary }; + static ProgressTimelinePosition AtProgressTimelineBoundary( + const Nullable<TimeDuration>& aTimelineDuration, + const Nullable<TimeDuration>& aCurrentTime, + const TimeDuration& aEffectStartTime, const double aPlaybackRate); + ProgressTimelinePosition AtProgressTimelineBoundary() const { + Nullable<TimeDuration> currentTime = GetUnconstrainedCurrentTime(); + return AtProgressTimelineBoundary( + mTimeline ? mTimeline->TimelineDuration() : nullptr, + // Set unlimited current time based on the first matching condition: + // 1. start time is resolved: + // (timeline time - start time) × playback rate + // 2. Otherwise: + // animation’s current time + !currentTime.IsNull() ? currentTime : GetCurrentTimeAsDuration(), + mStartTime.IsNull() ? TimeDuration() : mStartTime.Value(), + mPlaybackRate); + } + + void SetHiddenByContentVisibility(bool hidden); + bool IsHiddenByContentVisibility() const { + return mHiddenByContentVisibility; + } + + DocGroup* GetDocGroup(); + + protected: + void SilentlySetCurrentTime(const TimeDuration& aNewCurrentTime); + void CancelNoUpdate(); + void PlayNoUpdate(ErrorResult& aRv, LimitBehavior aLimitBehavior); + void ResumeAt(const TimeDuration& aReadyTime); + void PauseAt(const TimeDuration& aReadyTime); + void FinishPendingAt(const TimeDuration& aReadyTime) { + if (mPendingState == PendingState::PlayPending) { + ResumeAt(aReadyTime); + } else if (mPendingState == PendingState::PausePending) { + PauseAt(aReadyTime); + } else { + MOZ_ASSERT_UNREACHABLE( + "Can't finish pending if we're not in a pending state"); + } + } + void ApplyPendingPlaybackRate() { + if (mPendingPlaybackRate) { + mPlaybackRate = *mPendingPlaybackRate; + mPendingPlaybackRate.reset(); + } + } + + /** + * Finishing behavior depends on if changes to timing occurred due + * to a seek or regular playback. + */ + enum class SeekFlag { NoSeek, DidSeek }; + + enum class SyncNotifyFlag { Sync, Async }; + + virtual void UpdateTiming(SeekFlag aSeekFlag, SyncNotifyFlag aSyncNotifyFlag); + void UpdateFinishedState(SeekFlag aSeekFlag, SyncNotifyFlag aSyncNotifyFlag); + void UpdateEffect(PostRestyleMode aPostRestyle); + /** + * Flush all pending styles other than throttled animation styles (e.g. + * animations running on the compositor). + */ + void FlushUnanimatedStyle() const; + void PostUpdate(); + void ResetFinishedPromise(); + void MaybeResolveFinishedPromise(); + void DoFinishNotification(SyncNotifyFlag aSyncNotifyFlag); + friend class AsyncFinishNotification; + void DoFinishNotificationImmediately(MicroTaskRunnable* aAsync = nullptr); + void QueuePlaybackEvent(const nsAString& aName, + TimeStamp&& aScheduledEventTime); + + /** + * Remove this animation from the pending animation tracker and reset + * mPendingState as necessary. The caller is responsible for resolving or + * aborting the mReady promise as necessary. + */ + void CancelPendingTasks(); + + /** + * Performs the same steps as CancelPendingTasks and also rejects and + * recreates the ready promise if the animation was pending. + */ + void ResetPendingTasks(); + + /** + * Returns true if this animation is not only play-pending, but has + * yet to be given a pending ready time. This roughly corresponds to + * animations that are waiting to be painted (since we set the pending + * ready time at the end of painting). Identifying such animations is + * useful because in some cases animations that are painted together + * may need to be synchronized. + * + * We don't, however, want to include animations with a fixed start time such + * as animations that are simply having their playbackRate updated or which + * are resuming from an aborted pause. + */ + bool IsNewlyStarted() const { + return mPendingState == PendingState::PlayPending && + mPendingReadyTime.IsNull() && mStartTime.IsNull(); + } + bool IsPossiblyOrphanedPendingAnimation() const; + StickyTimeDuration EffectEnd() const; + + Nullable<TimeDuration> GetCurrentTimeForHoldTime( + const Nullable<TimeDuration>& aHoldTime) const; + Nullable<TimeDuration> GetUnconstrainedCurrentTime() const { + return GetCurrentTimeForHoldTime(Nullable<TimeDuration>()); + } + + void ScheduleReplacementCheck(); + void MaybeScheduleReplacementCheck(); + + // Earlier side of the elapsed time range reported in CSS Animations and CSS + // Transitions events. + // + // https://drafts.csswg.org/css-animations-2/#interval-start + // https://drafts.csswg.org/css-transitions-2/#interval-start + StickyTimeDuration IntervalStartTime( + const StickyTimeDuration& aActiveDuration) const; + + // Later side of the elapsed time range reported in CSS Animations and CSS + // Transitions events. + // + // https://drafts.csswg.org/css-animations-2/#interval-end + // https://drafts.csswg.org/css-transitions-2/#interval-end + StickyTimeDuration IntervalEndTime( + const StickyTimeDuration& aActiveDuration) const; + + TimeStamp GetTimelineCurrentTimeAsTimeStamp() const { + return mTimeline ? mTimeline->GetCurrentTimeAsTimeStamp() : TimeStamp(); + } + + Document* GetRenderedDocument() const; + Document* GetTimelineDocument() const; + + bool HasFiniteTimeline() const { + return mTimeline && !mTimeline->IsMonotonicallyIncreasing(); + } + + void UpdatePendingAnimationTracker(AnimationTimeline* aOldTimeline, + AnimationTimeline* aNewTimeline); + + RefPtr<AnimationTimeline> mTimeline; + RefPtr<AnimationEffect> mEffect; + // The beginning of the delay period. + Nullable<TimeDuration> mStartTime; // Timeline timescale + Nullable<TimeDuration> mHoldTime; // Animation timescale + Nullable<TimeDuration> mPendingReadyTime; // Timeline timescale + Nullable<TimeDuration> mPreviousCurrentTime; // Animation timescale + double mPlaybackRate = 1.0; + Maybe<double> mPendingPlaybackRate; + + // A Promise that is replaced on each call to Play() + // and fulfilled when Play() is successfully completed. + // This object is lazily created by GetReady. + // See http://drafts.csswg.org/web-animations/#current-ready-promise + RefPtr<Promise> mReady; + + // A Promise that is resolved when we reach the end of the effect, or + // 0 when playing backwards. The Promise is replaced if the animation is + // finished but then a state change makes it not finished. + // This object is lazily created by GetFinished. + // See http://drafts.csswg.org/web-animations/#current-finished-promise + RefPtr<Promise> mFinished; + + static uint64_t sNextAnimationIndex; + + // The relative position of this animation within the global animation list. + // + // Note that subclasses such as CSSTransition and CSSAnimation may repurpose + // this member to implement their own brand of sorting. As a result, it is + // possible for two different objects to have the same index. + uint64_t mAnimationIndex; + + // While ordering Animation objects for event dispatch, the index of the + // target node in its parent may be cached in mCachedChildIndex. + Maybe<uint32_t> mCachedChildIndex; + + // Indicates if the animation is in the pending state (and what state it is + // waiting to enter when it finished pending). We use this rather than + // checking if this animation is tracked by a PendingAnimationTracker because + // the animation will continue to be pending even after it has been removed + // from the PendingAnimationTracker while it is waiting for the next tick + // (see TriggerOnNextTick for details). + enum class PendingState : uint8_t { NotPending, PlayPending, PausePending }; + PendingState mPendingState = PendingState::NotPending; + + // Handling of this animation's target effect when filling while finished. + AnimationReplaceState mReplaceState = AnimationReplaceState::Active; + + bool mFinishedAtLastComposeStyle = false; + bool mWasReplaceableAtLastTick = false; + + bool mHiddenByContentVisibility = false; + + // Indicates that the animation should be exposed in an element's + // getAnimations() list. + bool mIsRelevant = false; + + // True if mFinished is resolved or would be resolved if mFinished has + // yet to be created. This is not set when mFinished is rejected since + // in that case mFinished is immediately reset to represent a new current + // finished promise. + bool mFinishedIsResolved = false; + + // True if this animation was triggered at the same time as one or more + // geometric animations and hence we should run any transform animations on + // the main thread. + bool mSyncWithGeometricAnimations = false; + + RefPtr<MicroTaskRunnable> mFinishNotificationTask; + + nsString mId; + + bool mResetCurrentTimeOnResume = false; + + // Whether the Animation is System, ResistFingerprinting, or neither + RTPCallerType mRTPCallerType; + + private: + // The id for this animaiton on the compositor. + uint64_t mIdOnCompositor = 0; + bool mIsPartialPrerendered = false; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_Animation_h diff --git a/dom/animation/AnimationComparator.h b/dom/animation/AnimationComparator.h new file mode 100644 index 0000000000..080eb42475 --- /dev/null +++ b/dom/animation/AnimationComparator.h @@ -0,0 +1,32 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_AnimationComparator_h +#define mozilla_AnimationComparator_h + +#include "mozilla/dom/Animation.h" + +namespace mozilla { + +// Although this file is called AnimationComparator, we don't actually +// implement AnimationComparator (to compare const Animation& parameters) +// since it's not actually needed (yet). + +template <typename AnimationPtrType> +class AnimationPtrComparator { + public: + bool Equals(const AnimationPtrType& a, const AnimationPtrType& b) const { + return a == b; + } + + bool LessThan(const AnimationPtrType& a, const AnimationPtrType& b) const { + return a->HasLowerCompositeOrderThan(*b); + } +}; + +} // namespace mozilla + +#endif // mozilla_AnimationComparator_h diff --git a/dom/animation/AnimationEffect.cpp b/dom/animation/AnimationEffect.cpp new file mode 100644 index 0000000000..6ef8c30d49 --- /dev/null +++ b/dom/animation/AnimationEffect.cpp @@ -0,0 +1,370 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/AnimationEffect.h" +#include "mozilla/dom/AnimationEffectBinding.h" + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/dom/MutationObservers.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/FloatingPoint.h" +#include "nsDOMMutationObserver.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(AnimationEffect) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AnimationEffect) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument, mAnimation) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(AnimationEffect) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument, mAnimation) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(AnimationEffect) +NS_IMPL_CYCLE_COLLECTING_RELEASE(AnimationEffect) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(AnimationEffect) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +AnimationEffect::AnimationEffect(Document* aDocument, TimingParams&& aTiming) + : mDocument(aDocument), mTiming(std::move(aTiming)) { + mRTPCallerType = mDocument->GetScopeObject()->GetRTPCallerType(); +} + +AnimationEffect::~AnimationEffect() = default; + +nsISupports* AnimationEffect::GetParentObject() const { + return ToSupports(mDocument); +} + +// https://drafts.csswg.org/web-animations/#current +bool AnimationEffect::IsCurrent() const { + if (!mAnimation || mAnimation->PlayState() == AnimationPlayState::Finished) { + return false; + } + + ComputedTiming computedTiming = GetComputedTiming(); + if (computedTiming.mPhase == ComputedTiming::AnimationPhase::Active) { + return true; + } + + return (mAnimation->PlaybackRate() > 0 && + computedTiming.mPhase == ComputedTiming::AnimationPhase::Before) || + (mAnimation->PlaybackRate() < 0 && + computedTiming.mPhase == ComputedTiming::AnimationPhase::After); +} + +// https://drafts.csswg.org/web-animations/#in-effect +bool AnimationEffect::IsInEffect() const { + ComputedTiming computedTiming = GetComputedTiming(); + return !computedTiming.mProgress.IsNull(); +} + +void AnimationEffect::SetSpecifiedTiming(TimingParams&& aTiming) { + if (mTiming == aTiming) { + return; + } + + mTiming = aTiming; + + UpdateNormalizedTiming(); + + if (mAnimation) { + Maybe<nsAutoAnimationMutationBatch> mb; + if (AsKeyframeEffect() && AsKeyframeEffect()->GetAnimationTarget()) { + mb.emplace(AsKeyframeEffect()->GetAnimationTarget().mElement->OwnerDoc()); + } + + mAnimation->NotifyEffectTimingUpdated(); + + if (mAnimation->IsRelevant()) { + MutationObservers::NotifyAnimationChanged(mAnimation); + } + + if (AsKeyframeEffect()) { + AsKeyframeEffect()->RequestRestyle(EffectCompositor::RestyleType::Layer); + } + } + + // For keyframe effects, NotifyEffectTimingUpdated above will eventually + // cause KeyframeEffect::NotifyAnimationTimingUpdated to be called so it can + // update its registration with the target element as necessary. +} + +ComputedTiming AnimationEffect::GetComputedTimingAt( + const Nullable<TimeDuration>& aLocalTime, const TimingParams& aTiming, + double aPlaybackRate, + Animation::ProgressTimelinePosition aProgressTimelinePosition) { + static const StickyTimeDuration zeroDuration; + + // Always return the same object to benefit from return-value optimization. + ComputedTiming result; + + if (aTiming.Duration()) { + MOZ_ASSERT(aTiming.Duration().ref() >= zeroDuration, + "Iteration duration should be positive"); + result.mDuration = aTiming.Duration().ref(); + } + + MOZ_ASSERT(aTiming.Iterations() >= 0.0 && !std::isnan(aTiming.Iterations()), + "mIterations should be nonnegative & finite, as ensured by " + "ValidateIterations or CSSParser"); + result.mIterations = aTiming.Iterations(); + + MOZ_ASSERT(aTiming.IterationStart() >= 0.0, + "mIterationStart should be nonnegative, as ensured by " + "ValidateIterationStart"); + result.mIterationStart = aTiming.IterationStart(); + + result.mActiveDuration = aTiming.ActiveDuration(); + result.mEndTime = aTiming.EndTime(); + result.mFill = aTiming.Fill() == dom::FillMode::Auto ? dom::FillMode::None + : aTiming.Fill(); + + // The default constructor for ComputedTiming sets all other members to + // values consistent with an animation that has not been sampled. + if (aLocalTime.IsNull()) { + return result; + } + const TimeDuration& localTime = aLocalTime.Value(); + const bool atProgressTimelineBoundary = + aProgressTimelinePosition == + Animation::ProgressTimelinePosition::Boundary; + + StickyTimeDuration beforeActiveBoundary = aTiming.CalcBeforeActiveBoundary(); + StickyTimeDuration activeAfterBoundary = aTiming.CalcActiveAfterBoundary(); + + if (localTime > activeAfterBoundary || + (aPlaybackRate >= 0 && localTime == activeAfterBoundary && + !atProgressTimelineBoundary)) { + result.mPhase = ComputedTiming::AnimationPhase::After; + if (!result.FillsForwards()) { + // The animation isn't active or filling at this time. + return result; + } + result.mActiveTime = + std::max(std::min(StickyTimeDuration(localTime - aTiming.Delay()), + result.mActiveDuration), + zeroDuration); + } else if (localTime < beforeActiveBoundary || + (aPlaybackRate < 0 && localTime == beforeActiveBoundary && + !atProgressTimelineBoundary)) { + result.mPhase = ComputedTiming::AnimationPhase::Before; + if (!result.FillsBackwards()) { + // The animation isn't active or filling at this time. + return result; + } + result.mActiveTime = + std::max(StickyTimeDuration(localTime - aTiming.Delay()), zeroDuration); + } else { + // Note: For progress-based timeline, it's possible to have a zero active + // duration with active phase. + result.mPhase = ComputedTiming::AnimationPhase::Active; + result.mActiveTime = localTime - aTiming.Delay(); + } + + // Convert active time to a multiple of iterations. + // https://drafts.csswg.org/web-animations/#overall-progress + double overallProgress; + if (!result.mDuration) { + overallProgress = result.mPhase == ComputedTiming::AnimationPhase::Before + ? 0.0 + : result.mIterations; + } else { + overallProgress = result.mActiveTime / result.mDuration; + } + + // Factor in iteration start offset. + if (std::isfinite(overallProgress)) { + overallProgress += result.mIterationStart; + } + + // Determine the 0-based index of the current iteration. + // https://drafts.csswg.org/web-animations/#current-iteration + result.mCurrentIteration = + (result.mIterations >= double(UINT64_MAX) && + result.mPhase == ComputedTiming::AnimationPhase::After) || + overallProgress >= double(UINT64_MAX) + ? UINT64_MAX // In GetComputedTimingDictionary(), + // we will convert this into Infinity + : static_cast<uint64_t>(std::max(overallProgress, 0.0)); + + // Convert the overall progress to a fraction of a single iteration--the + // simply iteration progress. + // https://drafts.csswg.org/web-animations/#simple-iteration-progress + double progress = std::isfinite(overallProgress) + ? fmod(overallProgress, 1.0) + : fmod(result.mIterationStart, 1.0); + + // When we are at the end of the active interval and the end of an iteration + // we need to report the end of the final iteration and not the start of the + // next iteration. We *don't* want to do this, however, when we have + // a zero-iteration animation. + if (progress == 0.0 && + (result.mPhase == ComputedTiming::AnimationPhase::After || + result.mPhase == ComputedTiming::AnimationPhase::Active) && + result.mActiveTime == result.mActiveDuration && + result.mIterations != 0.0) { + // The only way we can reach the end of the active interval and have + // a progress of zero and a current iteration of zero, is if we have a + // zero iteration count -- something we should have detected above. + MOZ_ASSERT(result.mCurrentIteration != 0, + "Should not have zero current iteration"); + progress = 1.0; + if (result.mCurrentIteration != UINT64_MAX) { + result.mCurrentIteration--; + } + } + + // Factor in the direction. + bool thisIterationReverse = false; + switch (aTiming.Direction()) { + case PlaybackDirection::Normal: + thisIterationReverse = false; + break; + case PlaybackDirection::Reverse: + thisIterationReverse = true; + break; + case PlaybackDirection::Alternate: + thisIterationReverse = (result.mCurrentIteration & 1) == 1; + break; + case PlaybackDirection::Alternate_reverse: + thisIterationReverse = (result.mCurrentIteration & 1) == 0; + break; + default: + MOZ_ASSERT_UNREACHABLE("Unknown PlaybackDirection type"); + } + if (thisIterationReverse) { + progress = 1.0 - progress; + } + + // Calculate the 'before flag' which we use when applying step timing + // functions. + if ((result.mPhase == ComputedTiming::AnimationPhase::After && + thisIterationReverse) || + (result.mPhase == ComputedTiming::AnimationPhase::Before && + !thisIterationReverse)) { + result.mBeforeFlag = true; + } + + // Apply the easing. + if (const auto& fn = aTiming.TimingFunction()) { + progress = fn->At(progress, result.mBeforeFlag); + } + + MOZ_ASSERT(std::isfinite(progress), "Progress value should be finite"); + result.mProgress.SetValue(progress); + return result; +} + +ComputedTiming AnimationEffect::GetComputedTiming( + const TimingParams* aTiming) const { + const double playbackRate = mAnimation ? mAnimation->PlaybackRate() : 1; + const auto progressTimelinePosition = + mAnimation ? mAnimation->AtProgressTimelineBoundary() + : Animation::ProgressTimelinePosition::NotBoundary; + return GetComputedTimingAt(GetLocalTime(), + aTiming ? *aTiming : NormalizedTiming(), + playbackRate, progressTimelinePosition); +} + +// Helper function for generating an (Computed)EffectTiming dictionary +static void GetEffectTimingDictionary(const TimingParams& aTiming, + EffectTiming& aRetVal) { + aRetVal.mDelay = aTiming.Delay().ToMilliseconds(); + aRetVal.mEndDelay = aTiming.EndDelay().ToMilliseconds(); + aRetVal.mFill = aTiming.Fill(); + aRetVal.mIterationStart = aTiming.IterationStart(); + aRetVal.mIterations = aTiming.Iterations(); + if (aTiming.Duration()) { + aRetVal.mDuration.SetAsUnrestrictedDouble() = + aTiming.Duration()->ToMilliseconds(); + } + aRetVal.mDirection = aTiming.Direction(); + if (aTiming.TimingFunction()) { + aRetVal.mEasing.Truncate(); + aTiming.TimingFunction()->AppendToString(aRetVal.mEasing); + } +} + +void AnimationEffect::GetTiming(EffectTiming& aRetVal) const { + GetEffectTimingDictionary(SpecifiedTiming(), aRetVal); +} + +void AnimationEffect::GetComputedTimingAsDict( + ComputedEffectTiming& aRetVal) const { + // Specified timing + GetEffectTimingDictionary(SpecifiedTiming(), aRetVal); + + // Computed timing + double playbackRate = mAnimation ? mAnimation->PlaybackRate() : 1; + const Nullable<TimeDuration> currentTime = GetLocalTime(); + const auto progressTimelinePosition = + mAnimation ? mAnimation->AtProgressTimelineBoundary() + : Animation::ProgressTimelinePosition::NotBoundary; + ComputedTiming computedTiming = GetComputedTimingAt( + currentTime, SpecifiedTiming(), playbackRate, progressTimelinePosition); + + aRetVal.mDuration.SetAsUnrestrictedDouble() = + computedTiming.mDuration.ToMilliseconds(); + aRetVal.mFill = computedTiming.mFill; + aRetVal.mActiveDuration = computedTiming.mActiveDuration.ToMilliseconds(); + aRetVal.mEndTime = computedTiming.mEndTime.ToMilliseconds(); + aRetVal.mLocalTime = + AnimationUtils::TimeDurationToDouble(currentTime, mRTPCallerType); + aRetVal.mProgress = computedTiming.mProgress; + + if (!aRetVal.mProgress.IsNull()) { + // Convert the returned currentIteration into Infinity if we set + // (uint64_t) computedTiming.mCurrentIteration to UINT64_MAX + double iteration = + computedTiming.mCurrentIteration == UINT64_MAX + ? PositiveInfinity<double>() + : static_cast<double>(computedTiming.mCurrentIteration); + aRetVal.mCurrentIteration.SetValue(iteration); + } +} + +void AnimationEffect::UpdateTiming(const OptionalEffectTiming& aTiming, + ErrorResult& aRv) { + TimingParams timing = + TimingParams::MergeOptionalEffectTiming(mTiming, aTiming, aRv); + if (aRv.Failed()) { + return; + } + + SetSpecifiedTiming(std::move(timing)); +} + +void AnimationEffect::UpdateNormalizedTiming() { + mNormalizedTiming.reset(); + + if (!mAnimation || !mAnimation->UsingScrollTimeline()) { + return; + } + + // Since `mAnimation` has a scroll timeline, we can be sure `GetTimeline()` + // and `TimelineDuration()` will not return null. + mNormalizedTiming.emplace( + mTiming.Normalize(mAnimation->GetTimeline()->TimelineDuration().Value())); +} + +Nullable<TimeDuration> AnimationEffect::GetLocalTime() const { + // Since the *animation* start time is currently always zero, the local + // time is equal to the parent time. + Nullable<TimeDuration> result; + if (mAnimation) { + result = mAnimation->GetCurrentTimeAsDuration(); + } + return result; +} + +} // namespace mozilla::dom diff --git a/dom/animation/AnimationEffect.h b/dom/animation/AnimationEffect.h new file mode 100644 index 0000000000..7dbd51c2bf --- /dev/null +++ b/dom/animation/AnimationEffect.h @@ -0,0 +1,117 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_AnimationEffect_h +#define mozilla_dom_AnimationEffect_h + +#include "mozilla/ComputedTiming.h" +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/dom/ScrollTimeline.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/TimingParams.h" +#include "nsCycleCollectionParticipant.h" +#include "nsWrapperCache.h" + +namespace mozilla { +class ErrorResult; + +namespace dom { + +class KeyframeEffect; +struct ComputedEffectTiming; +struct EffectTiming; +struct OptionalEffectTiming; +class Document; + +class AnimationEffect : public nsISupports, public nsWrapperCache { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(AnimationEffect) + + AnimationEffect(Document* aDocument, TimingParams&& aTiming); + + virtual KeyframeEffect* AsKeyframeEffect() { return nullptr; } + + nsISupports* GetParentObject() const; + + bool IsCurrent() const; + bool IsInEffect() const; + bool HasFiniteActiveDuration() const { + return NormalizedTiming().ActiveDuration() != TimeDuration::Forever(); + } + + // AnimationEffect interface + virtual void GetTiming(EffectTiming& aRetVal) const; + virtual void GetComputedTimingAsDict(ComputedEffectTiming& aRetVal) const; + virtual void UpdateTiming(const OptionalEffectTiming& aTiming, + ErrorResult& aRv); + + const TimingParams& SpecifiedTiming() const { return mTiming; } + void SetSpecifiedTiming(TimingParams&& aTiming); + + const TimingParams& NormalizedTiming() const { + MOZ_ASSERT((mAnimation && mAnimation->UsingScrollTimeline() && + mNormalizedTiming) || + !mNormalizedTiming, + "We do normalization only for progress-based timeline"); + return mNormalizedTiming ? *mNormalizedTiming : mTiming; + } + + // There are 3 conditions where we have to update the normalized timing: + // 1. mAnimation is changed, or + // 2. the timeline of mAnimation is changed, or + // 3. mTiming is changed. + void UpdateNormalizedTiming(); + + // This function takes as input the timing parameters of an animation and + // returns the computed timing at the specified local time. + // + // The local time may be null in which case only static parameters such as the + // active duration are calculated. All other members of the returned object + // are given a null/initial value. + // + // This function returns a null mProgress member of the return value + // if the animation should not be run + // (because it is not currently active and is not filling at this time). + static ComputedTiming GetComputedTimingAt( + const Nullable<TimeDuration>& aLocalTime, const TimingParams& aTiming, + double aPlaybackRate, + Animation::ProgressTimelinePosition aProgressTimelinePosition); + // Shortcut that gets the computed timing using the current local time as + // calculated from the timeline time. + ComputedTiming GetComputedTiming(const TimingParams* aTiming = nullptr) const; + + virtual void SetAnimation(Animation* aAnimation) = 0; + Animation* GetAnimation() const { return mAnimation; }; + + /** + * Returns true if this effect animates one of the properties we consider + * geometric properties, e.g. properties such as 'width' or 'margin-left' + * that we try to synchronize with transform animations, on a valid target + * element. + */ + virtual bool AffectsGeometry() const = 0; + + protected: + virtual ~AnimationEffect(); + + Nullable<TimeDuration> GetLocalTime() const; + + protected: + RefPtr<Document> mDocument; + RefPtr<Animation> mAnimation; + TimingParams mTiming; + Maybe<TimingParams> mNormalizedTiming; + + // Whether the Animation is System, ResistFingerprinting, or neither + enum RTPCallerType mRTPCallerType; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_AnimationEffect_h diff --git a/dom/animation/AnimationEventDispatcher.cpp b/dom/animation/AnimationEventDispatcher.cpp new file mode 100644 index 0000000000..b4c55cddce --- /dev/null +++ b/dom/animation/AnimationEventDispatcher.cpp @@ -0,0 +1,62 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/AnimationEventDispatcher.h" + +#include "mozilla/EventDispatcher.h" +#include "nsPresContext.h" +#include "nsRefreshDriver.h" + +namespace mozilla { + +NS_IMPL_CYCLE_COLLECTION_CLASS(AnimationEventDispatcher) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AnimationEventDispatcher) + tmp->ClearEventQueue(); +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(AnimationEventDispatcher) + for (auto& info : tmp->mPendingEvents) { + ImplCycleCollectionTraverse( + cb, info.mTarget, + "mozilla::AnimationEventDispatcher.mPendingEvents.mTarget"); + ImplCycleCollectionTraverse( + cb, info.mAnimation, + "mozilla::AnimationEventDispatcher.mPendingEvents.mAnimation"); + } +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +void AnimationEventDispatcher::Disconnect() { + if (mIsObserving) { + MOZ_ASSERT(mPresContext && mPresContext->RefreshDriver(), + "The pres context and the refresh driver should be still " + "alive if we haven't disassociated from the refresh driver"); + mPresContext->RefreshDriver()->CancelPendingAnimationEvents(this); + mIsObserving = false; + } + mPresContext = nullptr; +} + +void AnimationEventDispatcher::QueueEvent(AnimationEventInfo&& aEvent) { + mPendingEvents.AppendElement(std::move(aEvent)); + mIsSorted = false; + ScheduleDispatch(); +} + +void AnimationEventDispatcher::QueueEvents( + nsTArray<AnimationEventInfo>&& aEvents) { + mPendingEvents.AppendElements(std::move(aEvents)); + mIsSorted = false; + ScheduleDispatch(); +} + +void AnimationEventDispatcher::ScheduleDispatch() { + MOZ_ASSERT(mPresContext, "The pres context should be valid"); + if (!mIsObserving) { + mPresContext->RefreshDriver()->ScheduleAnimationEventDispatch(this); + mIsObserving = true; + } +} + +} // namespace mozilla diff --git a/dom/animation/AnimationEventDispatcher.h b/dom/animation/AnimationEventDispatcher.h new file mode 100644 index 0000000000..3fe0663829 --- /dev/null +++ b/dom/animation/AnimationEventDispatcher.h @@ -0,0 +1,407 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_AnimationEventDispatcher_h +#define mozilla_AnimationEventDispatcher_h + +#include <algorithm> // For <std::stable_sort> +#include "mozilla/AnimationComparator.h" +#include "mozilla/Assertions.h" +#include "mozilla/Attributes.h" +#include "mozilla/ContentEvents.h" +#include "mozilla/EventDispatcher.h" +#include "mozilla/Variant.h" +#include "mozilla/dom/AnimationEffect.h" +#include "mozilla/dom/AnimationPlaybackEvent.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/ProfilerMarkers.h" +#include "nsCSSProps.h" +#include "nsCycleCollectionParticipant.h" +#include "nsPresContext.h" + +class nsRefreshDriver; + +namespace geckoprofiler::markers { + +using namespace mozilla; + +struct CSSAnimationMarker { + static constexpr Span<const char> MarkerTypeName() { + return MakeStringSpan("CSSAnimation"); + } + static void StreamJSONMarkerData(baseprofiler::SpliceableJSONWriter& aWriter, + const nsCString& aName, + const nsCString& aTarget, + nsCSSPropertyIDSet aPropertySet) { + aWriter.StringProperty("Name", aName); + aWriter.StringProperty("Target", aTarget); + nsAutoCString properties; + nsAutoCString oncompositor; + for (nsCSSPropertyID property : aPropertySet) { + if (!properties.IsEmpty()) { + properties.AppendLiteral(", "); + oncompositor.AppendLiteral(", "); + } + properties.Append(nsCSSProps::GetStringValue(property)); + oncompositor.Append(nsCSSProps::PropHasFlags( + property, CSSPropFlags::CanAnimateOnCompositor) + ? "true" + : "false"); + } + + aWriter.StringProperty("properties", properties); + aWriter.StringProperty("oncompositor", oncompositor); + } + static MarkerSchema MarkerTypeDisplay() { + using MS = MarkerSchema; + MS schema{MS::Location::MarkerChart, MS::Location::MarkerTable}; + schema.AddKeyFormatSearchable("Name", MS::Format::String, + MS::Searchable::Searchable); + schema.AddKeyLabelFormat("properties", "Animated Properties", + MS::Format::String); + schema.AddKeyLabelFormat("oncompositor", "Can Run on Compositor", + MS::Format::String); + schema.AddKeyFormat("Target", MS::Format::String); + schema.SetChartLabel("{marker.data.Name}"); + schema.SetTableLabel( + "{marker.name} - {marker.data.Name}: {marker.data.properties}"); + return schema; + } +}; + +struct CSSTransitionMarker { + static constexpr Span<const char> MarkerTypeName() { + return MakeStringSpan("CSSTransition"); + } + static void StreamJSONMarkerData(baseprofiler::SpliceableJSONWriter& aWriter, + const nsCString& aTarget, + nsCSSPropertyID aProperty, bool aCanceled) { + aWriter.StringProperty("Target", aTarget); + aWriter.StringProperty("property", nsCSSProps::GetStringValue(aProperty)); + aWriter.BoolProperty("oncompositor", + nsCSSProps::PropHasFlags( + aProperty, CSSPropFlags::CanAnimateOnCompositor)); + if (aCanceled) { + aWriter.BoolProperty("Canceled", aCanceled); + } + } + static MarkerSchema MarkerTypeDisplay() { + using MS = MarkerSchema; + MS schema{MS::Location::MarkerChart, MS::Location::MarkerTable}; + schema.AddKeyLabelFormat("property", "Animated Property", + MS::Format::String); + schema.AddKeyLabelFormat("oncompositor", "Can Run on Compositor", + MS::Format::String); + schema.AddKeyFormat("Canceled", MS::Format::String); + schema.AddKeyFormat("Target", MS::Format::String); + schema.SetChartLabel("{marker.data.property}"); + schema.SetTableLabel("{marker.name} - {marker.data.property}"); + return schema; + } +}; + +} // namespace geckoprofiler::markers + +namespace mozilla { + +struct AnimationEventInfo { + RefPtr<dom::EventTarget> mTarget; + RefPtr<dom::Animation> mAnimation; + TimeStamp mScheduledEventTimeStamp; + + typedef Variant<InternalTransitionEvent, InternalAnimationEvent, + RefPtr<dom::AnimationPlaybackEvent>> + EventVariant; + EventVariant mEvent; + + // For CSS animation events + AnimationEventInfo(nsAtom* aAnimationName, + const NonOwningAnimationTarget& aTarget, + EventMessage aMessage, double aElapsedTime, + const TimeStamp& aScheduledEventTimeStamp, + dom::Animation* aAnimation) + : mTarget(aTarget.mElement), + mAnimation(aAnimation), + mScheduledEventTimeStamp(aScheduledEventTimeStamp), + mEvent(EventVariant(InternalAnimationEvent(true, aMessage))) { + InternalAnimationEvent& event = mEvent.as<InternalAnimationEvent>(); + + aAnimationName->ToString(event.mAnimationName); + // XXX Looks like nobody initialize WidgetEvent::time + event.mElapsedTime = aElapsedTime; + event.mPseudoElement = + nsCSSPseudoElements::PseudoTypeAsString(aTarget.mPseudoType); + + if ((aMessage == eAnimationCancel || aMessage == eAnimationEnd || + aMessage == eAnimationIteration) && + profiler_thread_is_being_profiled_for_markers()) { + nsAutoCString name; + aAnimationName->ToUTF8String(name); + + const TimeStamp startTime = [&] { + if (aMessage == eAnimationIteration) { + if (auto* effect = aAnimation->GetEffect()) { + return aScheduledEventTimeStamp - + TimeDuration(effect->GetComputedTiming().mDuration); + } + } + return aScheduledEventTimeStamp - + TimeDuration::FromSeconds(aElapsedTime); + }(); + + nsCSSPropertyIDSet propertySet; + nsAutoString target; + if (dom::AnimationEffect* effect = aAnimation->GetEffect()) { + if (dom::KeyframeEffect* keyFrameEffect = effect->AsKeyframeEffect()) { + keyFrameEffect->GetTarget()->Describe(target, true); + for (const AnimationProperty& property : + keyFrameEffect->Properties()) { + propertySet.AddProperty(property.mProperty); + } + } + } + + PROFILER_MARKER( + aMessage == eAnimationIteration + ? ProfilerString8View("CSS animation iteration") + : ProfilerString8View("CSS animation"), + DOM, + MarkerOptions( + MarkerTiming::Interval(startTime, aScheduledEventTimeStamp), + aAnimation->GetOwner() + ? MarkerInnerWindowId(aAnimation->GetOwner()->WindowID()) + : MarkerInnerWindowId::NoId()), + CSSAnimationMarker, name, NS_ConvertUTF16toUTF8(target), propertySet); + } + } + + // For CSS transition events + AnimationEventInfo(nsCSSPropertyID aProperty, + const NonOwningAnimationTarget& aTarget, + EventMessage aMessage, double aElapsedTime, + const TimeStamp& aScheduledEventTimeStamp, + dom::Animation* aAnimation) + : mTarget(aTarget.mElement), + mAnimation(aAnimation), + mScheduledEventTimeStamp(aScheduledEventTimeStamp), + mEvent(EventVariant(InternalTransitionEvent(true, aMessage))) { + InternalTransitionEvent& event = mEvent.as<InternalTransitionEvent>(); + + event.mPropertyName = + NS_ConvertUTF8toUTF16(nsCSSProps::GetStringValue(aProperty)); + // XXX Looks like nobody initialize WidgetEvent::time + event.mElapsedTime = aElapsedTime; + event.mPseudoElement = + nsCSSPseudoElements::PseudoTypeAsString(aTarget.mPseudoType); + + if ((aMessage == eTransitionEnd || aMessage == eTransitionCancel) && + profiler_thread_is_being_profiled_for_markers()) { + nsAutoString target; + if (dom::AnimationEffect* effect = aAnimation->GetEffect()) { + if (dom::KeyframeEffect* keyFrameEffect = effect->AsKeyframeEffect()) { + keyFrameEffect->GetTarget()->Describe(target, true); + } + } + PROFILER_MARKER( + "CSS transition", DOM, + MarkerOptions( + MarkerTiming::Interval( + aScheduledEventTimeStamp - + TimeDuration::FromSeconds(aElapsedTime), + aScheduledEventTimeStamp), + aAnimation->GetOwner() + ? MarkerInnerWindowId(aAnimation->GetOwner()->WindowID()) + : MarkerInnerWindowId::NoId()), + CSSTransitionMarker, NS_ConvertUTF16toUTF8(target), aProperty, + aMessage == eTransitionCancel); + } + } + + // For web animation events + AnimationEventInfo(const nsAString& aName, + RefPtr<dom::AnimationPlaybackEvent>&& aEvent, + TimeStamp&& aScheduledEventTimeStamp, + dom::Animation* aAnimation) + : mTarget(aAnimation), + mAnimation(aAnimation), + mScheduledEventTimeStamp(std::move(aScheduledEventTimeStamp)), + mEvent(std::move(aEvent)) {} + + AnimationEventInfo(const AnimationEventInfo& aOther) = delete; + AnimationEventInfo& operator=(const AnimationEventInfo& aOther) = delete; + AnimationEventInfo(AnimationEventInfo&& aOther) = default; + AnimationEventInfo& operator=(AnimationEventInfo&& aOther) = default; + + bool IsWebAnimationEvent() const { + return mEvent.is<RefPtr<dom::AnimationPlaybackEvent>>(); + } + +#ifdef DEBUG + bool IsStale() const { + const WidgetEvent* widgetEvent = AsWidgetEvent(); + return widgetEvent->mFlags.mIsBeingDispatched || + widgetEvent->mFlags.mDispatchedAtLeastOnce; + } + + const WidgetEvent* AsWidgetEvent() const { + return const_cast<AnimationEventInfo*>(this)->AsWidgetEvent(); + } +#endif + + WidgetEvent* AsWidgetEvent() { + if (mEvent.is<InternalTransitionEvent>()) { + return &mEvent.as<InternalTransitionEvent>(); + } + if (mEvent.is<InternalAnimationEvent>()) { + return &mEvent.as<InternalAnimationEvent>(); + } + if (mEvent.is<RefPtr<dom::AnimationPlaybackEvent>>()) { + return mEvent.as<RefPtr<dom::AnimationPlaybackEvent>>()->WidgetEventPtr(); + } + + MOZ_MAKE_COMPILER_ASSUME_IS_UNREACHABLE("Unexpected event type"); + return nullptr; + } + + // TODO: Convert this to MOZ_CAN_RUN_SCRIPT (bug 1415230) + MOZ_CAN_RUN_SCRIPT_BOUNDARY void Dispatch(nsPresContext* aPresContext) { + RefPtr<dom::EventTarget> target = mTarget; + if (mEvent.is<RefPtr<dom::AnimationPlaybackEvent>>()) { + auto playbackEvent = mEvent.as<RefPtr<dom::AnimationPlaybackEvent>>(); + EventDispatcher::DispatchDOMEvent(target, nullptr /* WidgetEvent */, + playbackEvent, aPresContext, + nullptr /* nsEventStatus */); + return; + } + + MOZ_ASSERT(mEvent.is<InternalTransitionEvent>() || + mEvent.is<InternalAnimationEvent>()); + + if (mEvent.is<InternalTransitionEvent>() && target->IsNode()) { + nsPIDOMWindowInner* inner = + target->AsNode()->OwnerDoc()->GetInnerWindow(); + if (inner && !inner->HasTransitionEventListeners()) { + MOZ_ASSERT(AsWidgetEvent()->mMessage == eTransitionStart || + AsWidgetEvent()->mMessage == eTransitionRun || + AsWidgetEvent()->mMessage == eTransitionEnd || + AsWidgetEvent()->mMessage == eTransitionCancel); + return; + } + } + + EventDispatcher::Dispatch(target, aPresContext, AsWidgetEvent()); + } +}; + +class AnimationEventDispatcher final { + public: + explicit AnimationEventDispatcher(nsPresContext* aPresContext) + : mPresContext(aPresContext), mIsSorted(true), mIsObserving(false) {} + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(AnimationEventDispatcher) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(AnimationEventDispatcher) + + void Disconnect(); + + void QueueEvent(AnimationEventInfo&& aEvent); + void QueueEvents(nsTArray<AnimationEventInfo>&& aEvents); + + // This will call SortEvents automatically if it has not already been + // called. + void DispatchEvents() { + mIsObserving = false; + if (!mPresContext || mPendingEvents.IsEmpty()) { + return; + } + + SortEvents(); + + EventArray events = std::move(mPendingEvents); + // mIsSorted will be set to true by SortEvents above, and we leave it + // that way since mPendingEvents is now empty + for (AnimationEventInfo& info : events) { + MOZ_ASSERT(!info.IsStale(), "The event shouldn't be stale"); + info.Dispatch(mPresContext); + + // Bail out if our mPresContext was nullified due to destroying the pres + // context. + if (!mPresContext) { + break; + } + } + } + + void ClearEventQueue() { + mPendingEvents.Clear(); + mIsSorted = true; + } + bool HasQueuedEvents() const { return !mPendingEvents.IsEmpty(); } + + private: +#ifndef DEBUG + ~AnimationEventDispatcher() = default; +#else + ~AnimationEventDispatcher() { + MOZ_ASSERT(!mIsObserving, + "AnimationEventDispatcher should have disassociated from " + "nsRefreshDriver"); + } +#endif + + class AnimationEventInfoLessThan { + public: + bool operator()(const AnimationEventInfo& a, + const AnimationEventInfo& b) const { + if (a.mScheduledEventTimeStamp != b.mScheduledEventTimeStamp) { + // Null timestamps sort first + if (a.mScheduledEventTimeStamp.IsNull() || + b.mScheduledEventTimeStamp.IsNull()) { + return a.mScheduledEventTimeStamp.IsNull(); + } else { + return a.mScheduledEventTimeStamp < b.mScheduledEventTimeStamp; + } + } + + // Events in the Web Animations spec are prior to CSS events. + if (a.IsWebAnimationEvent() != b.IsWebAnimationEvent()) { + return a.IsWebAnimationEvent(); + } + + AnimationPtrComparator<RefPtr<dom::Animation>> comparator; + return comparator.LessThan(a.mAnimation, b.mAnimation); + } + }; + + // Sort all pending CSS animation/transition events by scheduled event time + // and composite order. + // https://drafts.csswg.org/web-animations/#update-animations-and-send-events + void SortEvents() { + if (mIsSorted) { + return; + } + + for (auto& pending : mPendingEvents) { + pending.mAnimation->CachedChildIndexRef().reset(); + } + + // FIXME: Replace with mPendingEvents.StableSort when bug 1147091 is + // fixed. + std::stable_sort(mPendingEvents.begin(), mPendingEvents.end(), + AnimationEventInfoLessThan()); + mIsSorted = true; + } + void ScheduleDispatch(); + + nsPresContext* mPresContext; + typedef nsTArray<AnimationEventInfo> EventArray; + EventArray mPendingEvents; + bool mIsSorted; + bool mIsObserving; +}; + +} // namespace mozilla + +#endif // mozilla_AnimationEventDispatcher_h diff --git a/dom/animation/AnimationPerformanceWarning.cpp b/dom/animation/AnimationPerformanceWarning.cpp new file mode 100644 index 0000000000..e57beff51f --- /dev/null +++ b/dom/animation/AnimationPerformanceWarning.cpp @@ -0,0 +1,81 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "AnimationPerformanceWarning.h" + +#include "nsContentUtils.h" + +namespace mozilla { + +template <uint32_t N> +nsresult AnimationPerformanceWarning::ToLocalizedStringWithIntParams( + const char* aKey, nsAString& aLocalizedString) const { + AutoTArray<nsString, N> strings; + + MOZ_DIAGNOSTIC_ASSERT(mParams->Length() == N); + for (size_t i = 0, n = mParams->Length(); i < n; i++) { + strings.AppendElement()->AppendInt((*mParams)[i]); + } + + return nsContentUtils::FormatLocalizedString( + nsContentUtils::eLAYOUT_PROPERTIES, aKey, strings, aLocalizedString); +} + +bool AnimationPerformanceWarning::ToLocalizedString( + nsAString& aLocalizedString) const { + const char* key = nullptr; + + switch (mType) { + case Type::ContentTooLarge: + MOZ_ASSERT(mParams && mParams->Length() == 6, + "Parameter's length should be 6 for ContentTooLarge2"); + + return NS_SUCCEEDED(ToLocalizedStringWithIntParams<6>( + "CompositorAnimationWarningContentTooLarge2", aLocalizedString)); + case Type::ContentTooLargeArea: + MOZ_ASSERT(mParams && mParams->Length() == 2, + "Parameter's length should be 2 for ContentTooLargeArea"); + + return NS_SUCCEEDED(ToLocalizedStringWithIntParams<2>( + "CompositorAnimationWarningContentTooLargeArea", aLocalizedString)); + case Type::TransformBackfaceVisibilityHidden: + key = "CompositorAnimationWarningTransformBackfaceVisibilityHidden"; + break; + case Type::TransformSVG: + key = "CompositorAnimationWarningTransformSVG"; + break; + case Type::TransformWithGeometricProperties: + key = "CompositorAnimationWarningTransformWithGeometricProperties"; + break; + case Type::TransformWithSyncGeometricAnimations: + key = "CompositorAnimationWarningTransformWithSyncGeometricAnimations"; + break; + case Type::TransformFrameInactive: + key = "CompositorAnimationWarningTransformFrameInactive"; + break; + case Type::TransformIsBlockedByImportantRules: + key = "CompositorAnimationWarningTransformIsBlockedByImportantRules"; + break; + case Type::OpacityFrameInactive: + key = "CompositorAnimationWarningOpacityFrameInactive"; + break; + case Type::HasRenderingObserver: + key = "CompositorAnimationWarningHasRenderingObserver"; + break; + case Type::HasCurrentColor: + key = "CompositorAnimationWarningHasCurrentColor"; + break; + case Type::None: + MOZ_ASSERT_UNREACHABLE("Uninitialized type shouldn't be used"); + return false; + } + + nsresult rv = nsContentUtils::GetLocalizedString( + nsContentUtils::eLAYOUT_PROPERTIES, key, aLocalizedString); + return NS_SUCCEEDED(rv); +} + +} // namespace mozilla diff --git a/dom/animation/AnimationPerformanceWarning.h b/dom/animation/AnimationPerformanceWarning.h new file mode 100644 index 0000000000..e59eade9e6 --- /dev/null +++ b/dom/animation/AnimationPerformanceWarning.h @@ -0,0 +1,81 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_AnimationPerformanceWarning_h +#define mozilla_dom_AnimationPerformanceWarning_h + +#include <initializer_list> + +#include "mozilla/Maybe.h" +#include "nsStringFwd.h" +#include "nsTArray.h" + +namespace mozilla { + +// Represents the reason why we can't run the CSS property on the compositor. +struct AnimationPerformanceWarning { + enum class Type : uint8_t { + None, + ContentTooLarge, + ContentTooLargeArea, + TransformBackfaceVisibilityHidden, + TransformSVG, + TransformWithGeometricProperties, + TransformWithSyncGeometricAnimations, + TransformFrameInactive, + TransformIsBlockedByImportantRules, + OpacityFrameInactive, + HasRenderingObserver, + HasCurrentColor, + }; + + explicit AnimationPerformanceWarning(Type aType) : mType(aType) { + MOZ_ASSERT(mType != Type::None); + } + + AnimationPerformanceWarning(Type aType, + std::initializer_list<int32_t> aParams) + : mType(aType) { + MOZ_ASSERT(mType != Type::None); + // FIXME: Once std::initializer_list::size() become a constexpr function, + // we should use static_assert here. + MOZ_ASSERT(aParams.size() <= kMaxParamsForLocalization, + "The length of parameters should be less than " + "kMaxParamsForLocalization"); + mParams.emplace(aParams); + } + + // Maximum number of parameters passed to + // nsContentUtils::FormatLocalizedString to localize warning messages. + // + // NOTE: This constexpr can't be forward declared, so if you want to use + // this variable, please include this header file directly. + // This value is the same as the limit of nsStringBundle::FormatString. + // See the implementation of nsStringBundle::FormatString. + static constexpr uint8_t kMaxParamsForLocalization = 10; + + // Indicates why this property could not be animated on the compositor. + Type mType; + + // Optional parameters that may be used for localization. + Maybe<CopyableTArray<int32_t>> mParams; + + bool ToLocalizedString(nsAString& aLocalizedString) const; + template <uint32_t N> + nsresult ToLocalizedStringWithIntParams(const char* aKey, + nsAString& aLocalizedString) const; + + bool operator==(const AnimationPerformanceWarning& aOther) const { + return mType == aOther.mType && mParams == aOther.mParams; + } + bool operator!=(const AnimationPerformanceWarning& aOther) const { + return !(*this == aOther); + } +}; + +} // namespace mozilla + +#endif // mozilla_dom_AnimationPerformanceWarning_h diff --git a/dom/animation/AnimationPropertySegment.h b/dom/animation/AnimationPropertySegment.h new file mode 100644 index 0000000000..fb4ea7ee26 --- /dev/null +++ b/dom/animation/AnimationPropertySegment.h @@ -0,0 +1,55 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_AnimationPropertySegment_h +#define mozilla_dom_AnimationPropertySegment_h + +#include "mozilla/ServoStyleConsts.h" +#include "mozilla/Maybe.h" +#include "mozilla/StyleAnimationValue.h" // For AnimationValue +#include "mozilla/dom/BaseKeyframeTypesBinding.h" // For CompositeOperation + +namespace mozilla { + +struct AnimationPropertySegment { + float mFromKey, mToKey; + // NOTE: In the case that no keyframe for 0 or 1 offset is specified + // the unit of mFromValue or mToValue is eUnit_Null. + AnimationValue mFromValue, mToValue; + + Maybe<StyleComputedTimingFunction> mTimingFunction; + dom::CompositeOperation mFromComposite = dom::CompositeOperation::Replace; + dom::CompositeOperation mToComposite = dom::CompositeOperation::Replace; + + bool HasReplaceableValues() const { + return HasReplaceableFromValue() && HasReplaceableToValue(); + } + + bool HasReplaceableFromValue() const { + return !mFromValue.IsNull() && + mFromComposite == dom::CompositeOperation::Replace; + } + + bool HasReplaceableToValue() const { + return !mToValue.IsNull() && + mToComposite == dom::CompositeOperation::Replace; + } + + bool operator==(const AnimationPropertySegment& aOther) const { + return mFromKey == aOther.mFromKey && mToKey == aOther.mToKey && + mFromValue == aOther.mFromValue && mToValue == aOther.mToValue && + mTimingFunction == aOther.mTimingFunction && + mFromComposite == aOther.mFromComposite && + mToComposite == aOther.mToComposite; + } + bool operator!=(const AnimationPropertySegment& aOther) const { + return !(*this == aOther); + } +}; + +} // namespace mozilla + +#endif // mozilla_dom_AnimationPropertySegment_h diff --git a/dom/animation/AnimationTarget.h b/dom/animation/AnimationTarget.h new file mode 100644 index 0000000000..3a0dacb9aa --- /dev/null +++ b/dom/animation/AnimationTarget.h @@ -0,0 +1,108 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_AnimationTarget_h +#define mozilla_AnimationTarget_h + +#include "mozilla/Attributes.h" // For MOZ_NON_OWNING_REF +#include "mozilla/HashFunctions.h" // For HashNumber, AddToHash +#include "mozilla/HashTable.h" // For DefaultHasher, PointerHasher +#include "mozilla/Maybe.h" +#include "mozilla/RefPtr.h" +#include "nsCSSPseudoElements.h" + +namespace mozilla { + +namespace dom { +class Element; +} // namespace dom + +struct OwningAnimationTarget { + OwningAnimationTarget() = default; + + OwningAnimationTarget(dom::Element* aElement, PseudoStyleType aType) + : mElement(aElement), mPseudoType(aType) {} + + explicit OwningAnimationTarget(dom::Element* aElement) : mElement(aElement) {} + + bool operator==(const OwningAnimationTarget& aOther) const { + return mElement == aOther.mElement && mPseudoType == aOther.mPseudoType; + } + + explicit operator bool() const { return !!mElement; } + + // mElement represents the parent element of a pseudo-element, not the + // generated content element. + RefPtr<dom::Element> mElement; + PseudoStyleType mPseudoType = PseudoStyleType::NotPseudo; +}; + +struct NonOwningAnimationTarget { + NonOwningAnimationTarget() = default; + + NonOwningAnimationTarget(dom::Element* aElement, PseudoStyleType aType) + : mElement(aElement), mPseudoType(aType) {} + + explicit NonOwningAnimationTarget(const OwningAnimationTarget& aOther) + : mElement(aOther.mElement), mPseudoType(aOther.mPseudoType) {} + + bool operator==(const NonOwningAnimationTarget& aOther) const { + return mElement == aOther.mElement && mPseudoType == aOther.mPseudoType; + } + + NonOwningAnimationTarget& operator=(const OwningAnimationTarget& aOther) { + mElement = aOther.mElement; + mPseudoType = aOther.mPseudoType; + return *this; + } + + explicit operator bool() const { return !!mElement; } + + // mElement represents the parent element of a pseudo-element, not the + // generated content element. + dom::Element* MOZ_NON_OWNING_REF mElement = nullptr; + PseudoStyleType mPseudoType = PseudoStyleType::NotPseudo; +}; + +// Helper functions for cycle-collecting Maybe<OwningAnimationTarget> +inline void ImplCycleCollectionTraverse( + nsCycleCollectionTraversalCallback& aCallback, + Maybe<OwningAnimationTarget>& aTarget, const char* aName, + uint32_t aFlags = 0) { + if (aTarget) { + ImplCycleCollectionTraverse(aCallback, aTarget->mElement, aName, aFlags); + } +} + +inline void ImplCycleCollectionUnlink(Maybe<OwningAnimationTarget>& aTarget) { + if (aTarget) { + ImplCycleCollectionUnlink(aTarget->mElement); + } +} + +// A DefaultHasher specialization for OwningAnimationTarget. +template <> +struct DefaultHasher<OwningAnimationTarget> { + using Key = OwningAnimationTarget; + using Lookup = OwningAnimationTarget; + using PtrHasher = PointerHasher<dom::Element*>; + + static HashNumber hash(const Lookup& aLookup) { + return AddToHash(PtrHasher::hash(aLookup.mElement.get()), + static_cast<uint8_t>(aLookup.mPseudoType)); + } + + static bool match(const Key& aKey, const Lookup& aLookup) { + return PtrHasher::match(aKey.mElement.get(), aLookup.mElement.get()) && + aKey.mPseudoType == aLookup.mPseudoType; + } + + static void rekey(Key& aKey, Key&& aNewKey) { aKey = std::move(aNewKey); } +}; + +} // namespace mozilla + +#endif // mozilla_AnimationTarget_h diff --git a/dom/animation/AnimationTimeline.cpp b/dom/animation/AnimationTimeline.cpp new file mode 100644 index 0000000000..8be0554683 --- /dev/null +++ b/dom/animation/AnimationTimeline.cpp @@ -0,0 +1,116 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "AnimationTimeline.h" +#include "mozilla/AnimationComparator.h" +#include "mozilla/dom/Animation.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(AnimationTimeline) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(AnimationTimeline) + tmp->mAnimationOrder.clear(); + NS_IMPL_CYCLE_COLLECTION_UNLINK(mWindow, mAnimations) + NS_IMPL_CYCLE_COLLECTION_UNLINK_PRESERVED_WRAPPER +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(AnimationTimeline) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mWindow, mAnimations) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTING_ADDREF(AnimationTimeline) +NS_IMPL_CYCLE_COLLECTING_RELEASE(AnimationTimeline) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(AnimationTimeline) + NS_WRAPPERCACHE_INTERFACE_MAP_ENTRY + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +AnimationTimeline::AnimationTimeline(nsIGlobalObject* aWindow, + RTPCallerType aRTPCallerType) + : mWindow(aWindow), mRTPCallerType(aRTPCallerType) { + MOZ_ASSERT(mWindow); +} + +AnimationTimeline::~AnimationTimeline() { mAnimationOrder.clear(); } + +bool AnimationTimeline::Tick() { + bool needsTicks = false; + + nsTArray<Animation*> animationsToRemove; + + for (Animation* animation = mAnimationOrder.getFirst(); animation; + animation = + static_cast<LinkedListElement<Animation>*>(animation)->getNext()) { + MOZ_ASSERT(mAnimations.Contains(animation), + "The sampling order list should be a subset of the hashset"); + MOZ_ASSERT(!animation->IsHiddenByContentVisibility(), + "The sampling order list should not contain any animations " + "that are hidden by content-visibility"); + + // Skip any animations that are longer need associated with this timeline. + if (animation->GetTimeline() != this) { + // If animation has some other timeline, it better not be also in the + // animation list of this timeline object! + MOZ_ASSERT(!animation->GetTimeline()); + animationsToRemove.AppendElement(animation); + continue; + } + + needsTicks |= animation->NeedsTicks(); + // Even if |animation| doesn't need future ticks, we should still + // Tick it this time around since it might just need a one-off tick in + // order to dispatch events. + animation->Tick(); + + if (!animation->NeedsTicks()) { + animationsToRemove.AppendElement(animation); + } + } + + for (Animation* animation : animationsToRemove) { + RemoveAnimation(animation); + } + + return needsTicks; +} + +void AnimationTimeline::NotifyAnimationUpdated(Animation& aAnimation) { + if (mAnimations.EnsureInserted(&aAnimation)) { + if (aAnimation.GetTimeline() && aAnimation.GetTimeline() != this) { + aAnimation.GetTimeline()->RemoveAnimation(&aAnimation); + } + if (!aAnimation.IsHiddenByContentVisibility()) { + mAnimationOrder.insertBack(&aAnimation); + } + } +} + +void AnimationTimeline::RemoveAnimation(Animation* aAnimation) { + MOZ_ASSERT(!aAnimation->GetTimeline() || aAnimation->GetTimeline() == this); + if (static_cast<LinkedListElement<Animation>*>(aAnimation)->isInList()) { + MOZ_ASSERT(mAnimations.Contains(aAnimation), + "The sampling order list should be a subset of the hashset"); + static_cast<LinkedListElement<Animation>*>(aAnimation)->remove(); + } + mAnimations.Remove(aAnimation); +} + +void AnimationTimeline::NotifyAnimationContentVisibilityChanged( + Animation* aAnimation, bool aIsVisible) { + bool inList = + static_cast<LinkedListElement<Animation>*>(aAnimation)->isInList(); + MOZ_ASSERT(!inList || mAnimations.Contains(aAnimation), + "The sampling order list should be a subset of the hashset"); + if (aIsVisible && !inList && mAnimations.Contains(aAnimation)) { + mAnimationOrder.insertBack(aAnimation); + } else if (!aIsVisible && inList) { + static_cast<LinkedListElement<Animation>*>(aAnimation)->remove(); + } +} + +} // namespace mozilla::dom diff --git a/dom/animation/AnimationTimeline.h b/dom/animation/AnimationTimeline.h new file mode 100644 index 0000000000..715cb28969 --- /dev/null +++ b/dom/animation/AnimationTimeline.h @@ -0,0 +1,143 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_AnimationTimeline_h +#define mozilla_dom_AnimationTimeline_h + +#include "nsISupports.h" +#include "nsWrapperCache.h" +#include "nsCycleCollectionParticipant.h" +#include "js/TypeDecls.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/Attributes.h" +#include "nsHashKeys.h" +#include "nsIGlobalObject.h" +#include "nsTHashSet.h" + +namespace mozilla::dom { + +class Animation; +class Document; +class ScrollTimeline; + +class AnimationTimeline : public nsISupports, public nsWrapperCache { + public: + explicit AnimationTimeline(nsIGlobalObject* aWindow, + RTPCallerType aRTPCallerType); + + protected: + virtual ~AnimationTimeline(); + + // Tick animations and may remove them from the list if we don't need to + // tick it. Return true if any animations need to be ticked. + bool Tick(); + + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_WRAPPERCACHE_CLASS(AnimationTimeline) + + nsIGlobalObject* GetParentObject() const { return mWindow; } + + // AnimationTimeline methods + virtual Nullable<TimeDuration> GetCurrentTimeAsDuration() const = 0; + + // Wrapper functions for AnimationTimeline DOM methods when called from + // script. + Nullable<double> GetCurrentTimeAsDouble() const { + return AnimationUtils::TimeDurationToDouble(GetCurrentTimeAsDuration(), + mRTPCallerType); + } + + TimeStamp GetCurrentTimeAsTimeStamp() const { + Nullable<TimeDuration> currentTime = GetCurrentTimeAsDuration(); + return !currentTime.IsNull() ? ToTimeStamp(currentTime.Value()) + : TimeStamp(); + } + + /** + * Returns true if the times returned by GetCurrentTimeAsDuration() are + * convertible to and from wallclock-based TimeStamp (e.g. from + * TimeStamp::Now()) values using ToTimelineTime() and ToTimeStamp(). + * + * Typically this is true, but it will be false in the case when this + * timeline has no refresh driver or is tied to a refresh driver under test + * control. + */ + virtual bool TracksWallclockTime() const = 0; + + /** + * Converts a TimeStamp to the equivalent value in timeline time. + * Note that when TracksWallclockTime() is false, there is no correspondence + * between timeline time and wallclock time. In such a case, passing a + * timestamp from TimeStamp::Now() to this method will not return a + * meaningful result. + */ + virtual Nullable<TimeDuration> ToTimelineTime( + const TimeStamp& aTimeStamp) const = 0; + + virtual TimeStamp ToTimeStamp(const TimeDuration& aTimelineTime) const = 0; + + /** + * Inform this timeline that |aAnimation| which is or was observing the + * timeline, has been updated. This serves as both the means to associate + * AND disassociate animations with a timeline. The timeline itself will + * determine if it needs to begin, continue or stop tracking this animation. + */ + virtual void NotifyAnimationUpdated(Animation& aAnimation); + + /** + * Returns true if any CSS animations, CSS transitions or Web animations are + * currently associated with this timeline. As soon as an animation is + * applied to an element it is associated with the timeline even if it has a + * delayed start, so this includes animations that may not be active for some + * time. + */ + bool HasAnimations() const { return !mAnimations.IsEmpty(); } + + virtual void RemoveAnimation(Animation* aAnimation); + virtual void NotifyAnimationContentVisibilityChanged(Animation* aAnimation, + bool aIsVisible); + + virtual Document* GetDocument() const = 0; + + virtual bool IsMonotonicallyIncreasing() const = 0; + + RTPCallerType GetRTPCallerType() const { return mRTPCallerType; } + + virtual bool IsScrollTimeline() const { return false; } + virtual const ScrollTimeline* AsScrollTimeline() const { return nullptr; } + virtual bool IsViewTimeline() const { return false; } + + // For a monotonic timeline, there is no upper bound on current time, and + // timeline duration is unresolved. For a non-monotonic (e.g. scroll) + // timeline, the duration has a fixed upper bound. + // + // https://drafts.csswg.org/web-animations-2/#timeline-duration + virtual Nullable<TimeDuration> TimelineDuration() const { return nullptr; } + + protected: + nsCOMPtr<nsIGlobalObject> mWindow; + + // Animations observing this timeline + // + // We store them in (a) a hashset for quick lookup, and (b) a LinkedList + // to maintain a fixed sampling order. Animations that are hidden by + // `content-visibility` are not sampled and will only be in the hashset. + // The LinkedList should always be a subset of the hashset. + // + // The hashset keeps a strong reference to each animation since + // dealing with addref/release with LinkedList is difficult. + typedef nsTHashSet<nsRefPtrHashKey<dom::Animation>> AnimationSet; + AnimationSet mAnimations; + LinkedList<dom::Animation> mAnimationOrder; + + // Whether the Timeline is System, ResistFingerprinting, or neither + enum RTPCallerType mRTPCallerType; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_AnimationTimeline_h diff --git a/dom/animation/AnimationUtils.cpp b/dom/animation/AnimationUtils.cpp new file mode 100644 index 0000000000..8d00d9ebcf --- /dev/null +++ b/dom/animation/AnimationUtils.cpp @@ -0,0 +1,145 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "AnimationUtils.h" + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/EffectSet.h" +#include "nsDebug.h" +#include "nsAtom.h" +#include "nsIContent.h" +#include "nsLayoutUtils.h" +#include "nsGlobalWindow.h" +#include "nsString.h" +#include "xpcpublic.h" // For xpc::NativeGlobal + +using namespace mozilla::dom; + +namespace mozilla { + +/* static */ +void AnimationUtils::LogAsyncAnimationFailure(nsCString& aMessage, + const nsIContent* aContent) { + if (aContent) { + aMessage.AppendLiteral(" ["); + aMessage.Append(nsAtomCString(aContent->NodeInfo()->NameAtom())); + + nsAtom* id = aContent->GetID(); + if (id) { + aMessage.AppendLiteral(" with id '"); + aMessage.Append(nsAtomCString(aContent->GetID())); + aMessage.Append('\''); + } + aMessage.Append(']'); + } + aMessage.Append('\n'); + printf_stderr("%s", aMessage.get()); +} + +/* static */ +Document* AnimationUtils::GetCurrentRealmDocument(JSContext* aCx) { + nsGlobalWindowInner* win = xpc::CurrentWindowOrNull(aCx); + if (!win) { + return nullptr; + } + return win->GetDoc(); +} + +/* static */ +Document* AnimationUtils::GetDocumentFromGlobal(JSObject* aGlobalObject) { + nsGlobalWindowInner* win = xpc::WindowOrNull(aGlobalObject); + if (!win) { + return nullptr; + } + return win->GetDoc(); +} + +/* static */ +bool AnimationUtils::FrameHasAnimatedScale(const nsIFrame* aFrame) { + EffectSet* effectSet = EffectSet::GetForFrame( + aFrame, nsCSSPropertyIDSet::TransformLikeProperties()); + if (!effectSet) { + return false; + } + + for (const dom::KeyframeEffect* effect : *effectSet) { + if (effect->ContainsAnimatedScale(aFrame)) { + return true; + } + } + + return false; +} + +/* static */ +bool AnimationUtils::HasCurrentTransitions(const Element* aElement, + PseudoStyleType aPseudoType) { + MOZ_ASSERT(aElement); + + EffectSet* effectSet = EffectSet::Get(aElement, aPseudoType); + if (!effectSet) { + return false; + } + + for (const dom::KeyframeEffect* effect : *effectSet) { + // If |effect| is current, it must have an associated Animation + // so we don't need to null-check the result of GetAnimation(). + if (effect->IsCurrent() && effect->GetAnimation()->AsCSSTransition()) { + return true; + } + } + + return false; +} + +/*static*/ Element* AnimationUtils::GetElementForRestyle( + Element* aElement, PseudoStyleType aPseudoType) { + if (aPseudoType == PseudoStyleType::NotPseudo) { + return aElement; + } + + if (aPseudoType == PseudoStyleType::before) { + return nsLayoutUtils::GetBeforePseudo(aElement); + } + + if (aPseudoType == PseudoStyleType::after) { + return nsLayoutUtils::GetAfterPseudo(aElement); + } + + if (aPseudoType == PseudoStyleType::marker) { + return nsLayoutUtils::GetMarkerPseudo(aElement); + } + + MOZ_ASSERT_UNREACHABLE( + "Should not try to get the element to restyle for a pseudo other that " + ":before, :after or ::marker"); + return nullptr; +} + +/*static*/ std::pair<const Element*, PseudoStyleType> +AnimationUtils::GetElementPseudoPair(const Element* aElementOrPseudo) { + MOZ_ASSERT(aElementOrPseudo); + + if (aElementOrPseudo->IsGeneratedContentContainerForBefore()) { + return {aElementOrPseudo->GetParent()->AsElement(), + PseudoStyleType::before}; + } + + if (aElementOrPseudo->IsGeneratedContentContainerForAfter()) { + return {aElementOrPseudo->GetParent()->AsElement(), PseudoStyleType::after}; + } + + if (aElementOrPseudo->IsGeneratedContentContainerForMarker()) { + return {aElementOrPseudo->GetParent()->AsElement(), + PseudoStyleType::marker}; + } + + return {aElementOrPseudo, PseudoStyleType::NotPseudo}; +} + +} // namespace mozilla diff --git a/dom/animation/AnimationUtils.h b/dom/animation/AnimationUtils.h new file mode 100644 index 0000000000..0d8c53afa0 --- /dev/null +++ b/dom/animation/AnimationUtils.h @@ -0,0 +1,134 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_AnimationUtils_h +#define mozilla_dom_AnimationUtils_h + +#include "mozilla/PseudoStyleType.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/Nullable.h" +#include "nsRFPService.h" +#include "nsStringFwd.h" + +class nsIContent; +class nsIFrame; +struct JSContext; + +namespace mozilla { + +class EffectSet; + +namespace dom { +class Document; +class Element; +} // namespace dom + +class AnimationUtils { + public: + using Document = dom::Document; + + static dom::Nullable<double> TimeDurationToDouble( + const dom::Nullable<TimeDuration>& aTime, RTPCallerType aRTPCallerType) { + dom::Nullable<double> result; + + if (!aTime.IsNull()) { + // 0 is an inappropriate mixin for this this area; however CSS Animations + // needs to have it's Time Reduction Logic refactored, so it's currently + // only clamping for RFP mode. RFP mode gives a much lower time precision, + // so we accept the security leak here for now + result.SetValue(nsRFPService::ReduceTimePrecisionAsMSecsRFPOnly( + aTime.Value().ToMilliseconds(), 0, aRTPCallerType)); + } + + return result; + } + + static dom::Nullable<TimeDuration> DoubleToTimeDuration( + const dom::Nullable<double>& aTime) { + dom::Nullable<TimeDuration> result; + + if (!aTime.IsNull()) { + result.SetValue(TimeDuration::FromMilliseconds(aTime.Value())); + } + + return result; + } + + static void LogAsyncAnimationFailure(nsCString& aMessage, + const nsIContent* aContent = nullptr); + + /** + * Get the document from the JS context to use when parsing CSS properties. + */ + static Document* GetCurrentRealmDocument(JSContext* aCx); + + /** + * Get the document from the global object, or nullptr if the document has + * no window, to use when constructing DOM object without entering the + * target window's compartment (see KeyframeEffect constructor). + */ + static Document* GetDocumentFromGlobal(JSObject* aGlobalObject); + + /** + * Returns true if the given frame has an animated scale. + */ + static bool FrameHasAnimatedScale(const nsIFrame* aFrame); + + /** + * Returns true if the given (pseudo-)element has any transitions that are + * current (playing or waiting to play) or in effect (e.g. filling forwards). + */ + static bool HasCurrentTransitions(const dom::Element* aElement, + PseudoStyleType aPseudoType); + + /** + * Returns true if this pseudo style type is supported by animations. + * Note: This doesn't include PseudoStyleType::NotPseudo. + */ + static bool IsSupportedPseudoForAnimations(PseudoStyleType aType) { + // FIXME: Bug 1615469: Support first-line and first-letter for Animation. + return aType == PseudoStyleType::before || + aType == PseudoStyleType::after || aType == PseudoStyleType::marker; + } + + /** + * Returns true if the difference between |aFirst| and |aSecond| is within + * the animation time tolerance (i.e. 1 microsecond). + */ + static bool IsWithinAnimationTimeTolerance(const TimeDuration& aFirst, + const TimeDuration& aSecond) { + if (aFirst == TimeDuration::Forever() || + aSecond == TimeDuration::Forever()) { + return aFirst == aSecond; + } + + TimeDuration diff = aFirst >= aSecond ? aFirst - aSecond : aSecond - aFirst; + return diff <= TimeDuration::FromMicroseconds(1); + } + + // Returns the target element for restyling. + // + // If |aPseudoType| is ::after, ::before or ::marker, returns the generated + // content element of which |aElement| is the parent. If |aPseudoType| is any + // other pseudo type (other than PseudoStyleType::NotPseudo) returns nullptr. + // Otherwise, returns |aElement|. + static dom::Element* GetElementForRestyle(dom::Element* aElement, + PseudoStyleType aPseudoType); + + // Returns the pair of |Element, PseudoStyleType| from an element which could + // be an element or a pseudo element (i.e. an element used for restyling and + // DOM tree). + // + // Animation module usually uses a pair of (Element*, PseudoStyleType) to + // represent the animation target, and the |Element| in the pair is the + // generated content container if it's a pseudo element. + static std::pair<const dom::Element*, PseudoStyleType> GetElementPseudoPair( + const dom::Element* aElementOrPseudo); +}; + +} // namespace mozilla + +#endif diff --git a/dom/animation/CSSAnimation.cpp b/dom/animation/CSSAnimation.cpp new file mode 100644 index 0000000000..ed48d24806 --- /dev/null +++ b/dom/animation/CSSAnimation.cpp @@ -0,0 +1,376 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "CSSAnimation.h" + +#include "mozilla/AnimationEventDispatcher.h" +#include "mozilla/dom/CSSAnimationBinding.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/TimeStamp.h" +#include "nsPresContext.h" + +namespace mozilla::dom { + +using AnimationPhase = ComputedTiming::AnimationPhase; + +JSObject* CSSAnimation::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::CSSAnimation_Binding::Wrap(aCx, this, aGivenProto); +} + +void CSSAnimation::SetEffect(AnimationEffect* aEffect) { + Animation::SetEffect(aEffect); + + AddOverriddenProperties(CSSAnimationProperties::Effect); +} + +void CSSAnimation::SetStartTimeAsDouble(const Nullable<double>& aStartTime) { + // Note that we always compare with the paused state since for the purposes + // of determining if play control is being overridden or not, we want to + // treat the finished state as running. + bool wasPaused = PlayState() == AnimationPlayState::Paused; + + Animation::SetStartTimeAsDouble(aStartTime); + + bool isPaused = PlayState() == AnimationPlayState::Paused; + + if (wasPaused != isPaused) { + AddOverriddenProperties(CSSAnimationProperties::PlayState); + } +} + +mozilla::dom::Promise* CSSAnimation::GetReady(ErrorResult& aRv) { + FlushUnanimatedStyle(); + return Animation::GetReady(aRv); +} + +void CSSAnimation::Reverse(ErrorResult& aRv) { + // As with CSSAnimation::SetStartTimeAsDouble, we're really only interested in + // the paused state. + bool wasPaused = PlayState() == AnimationPlayState::Paused; + + Animation::Reverse(aRv); + if (aRv.Failed()) { + return; + } + + bool isPaused = PlayState() == AnimationPlayState::Paused; + + if (wasPaused != isPaused) { + AddOverriddenProperties(CSSAnimationProperties::PlayState); + } +} + +AnimationPlayState CSSAnimation::PlayStateFromJS() const { + // Flush style to ensure that any properties controlling animation state + // (e.g. animation-play-state) are fully updated. + FlushUnanimatedStyle(); + return Animation::PlayStateFromJS(); +} + +bool CSSAnimation::PendingFromJS() const { + // Flush style since, for example, if the animation-play-state was just + // changed its possible we should now be pending. + FlushUnanimatedStyle(); + return Animation::PendingFromJS(); +} + +void CSSAnimation::PlayFromJS(ErrorResult& aRv) { + // Note that flushing style below might trigger calls to + // PlayFromStyle()/PauseFromStyle() on this object. + FlushUnanimatedStyle(); + Animation::PlayFromJS(aRv); + if (aRv.Failed()) { + return; + } + + AddOverriddenProperties(CSSAnimationProperties::PlayState); +} + +void CSSAnimation::PauseFromJS(ErrorResult& aRv) { + Animation::PauseFromJS(aRv); + if (aRv.Failed()) { + return; + } + + AddOverriddenProperties(CSSAnimationProperties::PlayState); +} + +void CSSAnimation::PlayFromStyle() { + ErrorResult rv; + Animation::Play(rv, Animation::LimitBehavior::Continue); + // play() should not throw when LimitBehavior is Continue + MOZ_ASSERT(!rv.Failed(), "Unexpected exception playing animation"); +} + +void CSSAnimation::PauseFromStyle() { + ErrorResult rv; + Animation::Pause(rv); + // pause() should only throw when *all* of the following conditions are true: + // - we are in the idle state, and + // - we have a negative playback rate, and + // - we have an infinitely repeating animation + // The first two conditions will never happen under regular style processing + // but could happen if an author made modifications to the Animation object + // and then updated animation-play-state. It's an unusual case and there's + // no obvious way to pass on the exception information so we just silently + // fail for now. + if (rv.Failed()) { + NS_WARNING("Unexpected exception pausing animation - silently failing"); + } +} + +void CSSAnimation::Tick() { + Animation::Tick(); + QueueEvents(); +} + +bool CSSAnimation::HasLowerCompositeOrderThan( + const CSSAnimation& aOther) const { + MOZ_ASSERT(IsTiedToMarkup() && aOther.IsTiedToMarkup(), + "Should only be called for CSS animations that are sorted " + "as CSS animations (i.e. tied to CSS markup)"); + + // 0. Object-equality case + if (&aOther == this) { + return false; + } + + // 1. Sort by document order + if (!mOwningElement.Equals(aOther.mOwningElement)) { + return mOwningElement.LessThan( + const_cast<CSSAnimation*>(this)->CachedChildIndexRef(), + aOther.mOwningElement, + const_cast<CSSAnimation*>(&aOther)->CachedChildIndexRef()); + } + + // 2. (Same element and pseudo): Sort by position in animation-name + return mAnimationIndex < aOther.mAnimationIndex; +} + +void CSSAnimation::QueueEvents(const StickyTimeDuration& aActiveTime) { + // If the animation is pending, we ignore animation events until we finish + // pending. + if (mPendingState != PendingState::NotPending) { + return; + } + + // CSS animations dispatch events at their owning element. This allows + // script to repurpose a CSS animation to target a different element, + // to use a group effect (which has no obvious "target element"), or + // to remove the animation effect altogether whilst still getting + // animation events. + // + // It does mean, however, that for a CSS animation that has no owning + // element (e.g. it was created using the CSSAnimation constructor or + // disassociated from CSS) no events are fired. If it becomes desirable + // for these animations to still fire events we should spec the concept + // of the "original owning element" or "event target" and allow script + // to set it when creating a CSSAnimation object. + if (!mOwningElement.IsSet()) { + return; + } + + nsPresContext* presContext = mOwningElement.GetPresContext(); + if (!presContext) { + return; + } + + uint64_t currentIteration = 0; + ComputedTiming::AnimationPhase currentPhase; + StickyTimeDuration intervalStartTime; + StickyTimeDuration intervalEndTime; + StickyTimeDuration iterationStartTime; + + if (!mEffect) { + currentPhase = + GetAnimationPhaseWithoutEffect<ComputedTiming::AnimationPhase>(*this); + if (currentPhase == mPreviousPhase) { + return; + } + } else { + ComputedTiming computedTiming = mEffect->GetComputedTiming(); + currentPhase = computedTiming.mPhase; + currentIteration = computedTiming.mCurrentIteration; + if (currentPhase == mPreviousPhase && + currentIteration == mPreviousIteration) { + return; + } + intervalStartTime = IntervalStartTime(computedTiming.mActiveDuration); + intervalEndTime = IntervalEndTime(computedTiming.mActiveDuration); + + uint64_t iterationBoundary = mPreviousIteration > currentIteration + ? currentIteration + 1 + : currentIteration; + double multiplier = iterationBoundary - computedTiming.mIterationStart; + if (multiplier != 0.0) { + iterationStartTime = computedTiming.mDuration.MultDouble(multiplier); + } + } + + TimeStamp startTimeStamp = ElapsedTimeToTimeStamp(intervalStartTime); + TimeStamp endTimeStamp = ElapsedTimeToTimeStamp(intervalEndTime); + TimeStamp iterationTimeStamp = ElapsedTimeToTimeStamp(iterationStartTime); + + AutoTArray<AnimationEventInfo, 2> events; + + auto appendAnimationEvent = [&](EventMessage aMessage, + const StickyTimeDuration& aElapsedTime, + const TimeStamp& aScheduledEventTimeStamp) { + double elapsedTime = aElapsedTime.ToSeconds(); + if (aMessage == eAnimationCancel) { + // 0 is an inappropriate value for this callsite. What we need to do is + // use a single random value for all increasing times reportable. + // That is to say, whenever elapsedTime goes negative (because an + // animation restarts, something rewinds the animation, or otherwise) + // a new random value for the mix-in must be generated. + elapsedTime = nsRFPService::ReduceTimePrecisionAsSecsRFPOnly( + elapsedTime, 0, mRTPCallerType); + } + events.AppendElement( + AnimationEventInfo(mAnimationName, mOwningElement.Target(), aMessage, + elapsedTime, aScheduledEventTimeStamp, this)); + }; + + // Handle cancel event first + if ((mPreviousPhase != AnimationPhase::Idle && + mPreviousPhase != AnimationPhase::After) && + currentPhase == AnimationPhase::Idle) { + appendAnimationEvent(eAnimationCancel, aActiveTime, + GetTimelineCurrentTimeAsTimeStamp()); + } + + switch (mPreviousPhase) { + case AnimationPhase::Idle: + case AnimationPhase::Before: + if (currentPhase == AnimationPhase::Active) { + appendAnimationEvent(eAnimationStart, intervalStartTime, + startTimeStamp); + } else if (currentPhase == AnimationPhase::After) { + appendAnimationEvent(eAnimationStart, intervalStartTime, + startTimeStamp); + appendAnimationEvent(eAnimationEnd, intervalEndTime, endTimeStamp); + } + break; + case AnimationPhase::Active: + if (currentPhase == AnimationPhase::Before) { + appendAnimationEvent(eAnimationEnd, intervalStartTime, startTimeStamp); + } else if (currentPhase == AnimationPhase::Active) { + // The currentIteration must have changed or element we would have + // returned early above. + MOZ_ASSERT(currentIteration != mPreviousIteration); + appendAnimationEvent(eAnimationIteration, iterationStartTime, + iterationTimeStamp); + } else if (currentPhase == AnimationPhase::After) { + appendAnimationEvent(eAnimationEnd, intervalEndTime, endTimeStamp); + } + break; + case AnimationPhase::After: + if (currentPhase == AnimationPhase::Before) { + appendAnimationEvent(eAnimationStart, intervalEndTime, startTimeStamp); + appendAnimationEvent(eAnimationEnd, intervalStartTime, endTimeStamp); + } else if (currentPhase == AnimationPhase::Active) { + appendAnimationEvent(eAnimationStart, intervalEndTime, endTimeStamp); + } + break; + } + mPreviousPhase = currentPhase; + mPreviousIteration = currentIteration; + + if (!events.IsEmpty()) { + presContext->AnimationEventDispatcher()->QueueEvents(std::move(events)); + } +} + +void CSSAnimation::UpdateTiming(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) { + if (mNeedsNewAnimationIndexWhenRun && + PlayState() != AnimationPlayState::Idle) { + mAnimationIndex = sNextAnimationIndex++; + mNeedsNewAnimationIndexWhenRun = false; + } + + Animation::UpdateTiming(aSeekFlag, aSyncNotifyFlag); +} + +/////////////////////// CSSAnimationKeyframeEffect //////////////////////// + +void CSSAnimationKeyframeEffect::GetTiming(EffectTiming& aRetVal) const { + MaybeFlushUnanimatedStyle(); + KeyframeEffect::GetTiming(aRetVal); +} + +void CSSAnimationKeyframeEffect::GetComputedTimingAsDict( + ComputedEffectTiming& aRetVal) const { + MaybeFlushUnanimatedStyle(); + KeyframeEffect::GetComputedTimingAsDict(aRetVal); +} + +void CSSAnimationKeyframeEffect::UpdateTiming( + const OptionalEffectTiming& aTiming, ErrorResult& aRv) { + KeyframeEffect::UpdateTiming(aTiming, aRv); + + if (aRv.Failed()) { + return; + } + + if (CSSAnimation* cssAnimation = GetOwningCSSAnimation()) { + CSSAnimationProperties updatedProperties = CSSAnimationProperties::None; + if (aTiming.mDuration.WasPassed()) { + updatedProperties |= CSSAnimationProperties::Duration; + } + if (aTiming.mIterations.WasPassed()) { + updatedProperties |= CSSAnimationProperties::IterationCount; + } + if (aTiming.mDirection.WasPassed()) { + updatedProperties |= CSSAnimationProperties::Direction; + } + if (aTiming.mDelay.WasPassed()) { + updatedProperties |= CSSAnimationProperties::Delay; + } + if (aTiming.mFill.WasPassed()) { + updatedProperties |= CSSAnimationProperties::FillMode; + } + + cssAnimation->AddOverriddenProperties(updatedProperties); + } +} + +void CSSAnimationKeyframeEffect::SetKeyframes(JSContext* aContext, + JS::Handle<JSObject*> aKeyframes, + ErrorResult& aRv) { + KeyframeEffect::SetKeyframes(aContext, aKeyframes, aRv); + + if (aRv.Failed()) { + return; + } + + if (CSSAnimation* cssAnimation = GetOwningCSSAnimation()) { + cssAnimation->AddOverriddenProperties(CSSAnimationProperties::Keyframes); + } +} + +void CSSAnimationKeyframeEffect::SetComposite( + const CompositeOperation& aComposite) { + KeyframeEffect::SetComposite(aComposite); + + if (CSSAnimation* cssAnimation = GetOwningCSSAnimation()) { + cssAnimation->AddOverriddenProperties(CSSAnimationProperties::Composition); + } +} + +void CSSAnimationKeyframeEffect::MaybeFlushUnanimatedStyle() const { + if (!GetOwningCSSAnimation()) { + return; + } + + if (dom::Document* doc = GetRenderedDocument()) { + doc->FlushPendingNotifications( + ChangesToFlush(FlushType::Style, false /* flush animations */)); + } +} + +} // namespace mozilla::dom diff --git a/dom/animation/CSSAnimation.h b/dom/animation/CSSAnimation.h new file mode 100644 index 0000000000..e1c45c49b9 --- /dev/null +++ b/dom/animation/CSSAnimation.h @@ -0,0 +1,238 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_CSSAnimation_h +#define mozilla_dom_CSSAnimation_h + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/dom/MutationObservers.h" +#include "mozilla/StyleAnimationValue.h" +#include "AnimationCommon.h" + +namespace mozilla { +// Properties of CSS Animations that can be overridden by the Web Animations API +// in a manner that means we should ignore subsequent changes to markup for that +// property. +enum class CSSAnimationProperties { + None = 0, + Keyframes = 1 << 0, + Duration = 1 << 1, + IterationCount = 1 << 2, + Direction = 1 << 3, + Delay = 1 << 4, + FillMode = 1 << 5, + Composition = 1 << 6, + Effect = Keyframes | Duration | IterationCount | Direction | Delay | + FillMode | Composition, + PlayState = 1 << 7, +}; +MOZ_MAKE_ENUM_CLASS_BITWISE_OPERATORS(CSSAnimationProperties) + +namespace dom { + +class CSSAnimation final : public Animation { + public: + explicit CSSAnimation(nsIGlobalObject* aGlobal, nsAtom* aAnimationName) + : dom::Animation(aGlobal), + mAnimationName(aAnimationName), + mNeedsNewAnimationIndexWhenRun(false), + mPreviousPhase(ComputedTiming::AnimationPhase::Idle), + mPreviousIteration(0) { + // We might need to drop this assertion once we add a script-accessible + // constructor but for animations generated from CSS markup the + // animation-name should never be empty. + MOZ_ASSERT(mAnimationName != nsGkAtoms::_empty, + "animation-name should not be 'none'"); + } + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + CSSAnimation* AsCSSAnimation() override { return this; } + const CSSAnimation* AsCSSAnimation() const override { return this; } + + // CSSAnimation interface + void GetAnimationName(nsString& aRetVal) const { + mAnimationName->ToString(aRetVal); + } + + nsAtom* AnimationName() const { return mAnimationName; } + + // Animation interface overrides + void SetEffect(AnimationEffect* aEffect) override; + void SetStartTimeAsDouble(const Nullable<double>& aStartTime) override; + Promise* GetReady(ErrorResult& aRv) override; + void Reverse(ErrorResult& aRv) override; + + // NOTE: tabbrowser.xml currently relies on the fact that reading the + // currentTime of a CSSAnimation does *not* flush style (whereas reading the + // playState does). If CSS Animations 2 specifies that reading currentTime + // also flushes style we will need to find another way to detect canceled + // animations in tabbrowser.xml. On the other hand, if CSS Animations 2 + // specifies that reading playState does *not* flush style (and we drop the + // following override), then we should update tabbrowser.xml to check + // the playState instead. + AnimationPlayState PlayStateFromJS() const override; + bool PendingFromJS() const override; + void PlayFromJS(ErrorResult& aRv) override; + void PauseFromJS(ErrorResult& aRv) override; + + void PlayFromStyle(); + void PauseFromStyle(); + void CancelFromStyle(PostRestyleMode aPostRestyle) { + // When an animation is disassociated with style it enters an odd state + // where its composite order is undefined until it first transitions + // out of the idle state. + // + // Even if the composite order isn't defined we don't want it to be random + // in case we need to determine the order to dispatch events associated + // with an animation in this state. To solve this we treat the animation as + // if it had been added to the end of the global animation list so that + // its sort order is defined. We'll update this index again once the + // animation leaves the idle state. + mAnimationIndex = sNextAnimationIndex++; + mNeedsNewAnimationIndexWhenRun = true; + + Animation::Cancel(aPostRestyle); + + // We need to do this *after* calling Cancel() since + // Cancel() might synchronously trigger a cancel event for which + // we need an owning element to target the event at. + mOwningElement = OwningElementRef(); + } + + void Tick() override; + void QueueEvents( + const StickyTimeDuration& aActiveTime = StickyTimeDuration()); + + bool HasLowerCompositeOrderThan(const CSSAnimation& aOther) const; + + void SetAnimationIndex(uint64_t aIndex) { + MOZ_ASSERT(IsTiedToMarkup()); + if (IsRelevant() && mAnimationIndex != aIndex) { + MutationObservers::NotifyAnimationChanged(this); + PostUpdate(); + } + mAnimationIndex = aIndex; + } + + // Sets the owning element which is used for determining the composite + // order of CSSAnimation objects generated from CSS markup. + // + // @see mOwningElement + void SetOwningElement(const OwningElementRef& aElement) { + mOwningElement = aElement; + } + // True for animations that are generated from CSS markup and continue to + // reflect changes to that markup. + bool IsTiedToMarkup() const { return mOwningElement.IsSet(); } + + void MaybeQueueCancelEvent(const StickyTimeDuration& aActiveTime) override { + QueueEvents(aActiveTime); + } + + CSSAnimationProperties GetOverriddenProperties() const { + return mOverriddenProperties; + } + void AddOverriddenProperties(CSSAnimationProperties aProperties) { + mOverriddenProperties |= aProperties; + } + + protected: + virtual ~CSSAnimation() { + MOZ_ASSERT(!mOwningElement.IsSet(), + "Owning element should be cleared " + "before a CSS animation is destroyed"); + } + + // Animation overrides + void UpdateTiming(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) override; + + // Returns the duration from the start of the animation's source effect's + // active interval to the point where the animation actually begins playback. + // This is zero unless the animation's source effect has a negative delay in + // which case it is the absolute value of that delay. + // This is used for setting the elapsedTime member of CSS AnimationEvents. + TimeDuration InitialAdvance() const { + return mEffect ? std::max(TimeDuration(), + mEffect->NormalizedTiming().Delay() * -1) + : TimeDuration(); + } + + RefPtr<nsAtom> mAnimationName; + + // The (pseudo-)element whose computed animation-name refers to this + // animation (if any). + // + // This is used for determining the relative composite order of animations + // generated from CSS markup. + // + // Typically this will be the same as the target element of the keyframe + // effect associated with this animation. However, it can differ in the + // following circumstances: + // + // a) If script removes or replaces the effect of this animation, + // b) If this animation is cancelled (e.g. by updating the + // animation-name property or removing the owning element from the + // document), + // c) If this object is generated from script using the CSSAnimation + // constructor. + // + // For (b) and (c) the owning element will return !IsSet(). + OwningElementRef mOwningElement; + + // When true, indicates that when this animation next leaves the idle state, + // its animation index should be updated. + bool mNeedsNewAnimationIndexWhenRun; + + // Phase and current iteration from the previous time we queued events. + // This is used to determine what new events to dispatch. + ComputedTiming::AnimationPhase mPreviousPhase; + uint64_t mPreviousIteration; + + // Properties that would normally be defined by the cascade but which have + // since been explicitly set via the Web Animations API. + CSSAnimationProperties mOverriddenProperties = CSSAnimationProperties::None; +}; + +// A subclass of KeyframeEffect that reports when specific properties have been +// overridden via the Web Animations API. +class CSSAnimationKeyframeEffect : public KeyframeEffect { + public: + CSSAnimationKeyframeEffect(Document* aDocument, + OwningAnimationTarget&& aTarget, + TimingParams&& aTiming, + const KeyframeEffectParams& aOptions) + : KeyframeEffect(aDocument, std::move(aTarget), std::move(aTiming), + aOptions) {} + + void GetTiming(EffectTiming& aRetVal) const override; + void GetComputedTimingAsDict(ComputedEffectTiming& aRetVal) const override; + void UpdateTiming(const OptionalEffectTiming& aTiming, + ErrorResult& aRv) override; + void SetKeyframes(JSContext* aContext, JS::Handle<JSObject*> aKeyframes, + ErrorResult& aRv) override; + void SetComposite(const CompositeOperation& aComposite) override; + + private: + CSSAnimation* GetOwningCSSAnimation() { + return mAnimation ? mAnimation->AsCSSAnimation() : nullptr; + } + const CSSAnimation* GetOwningCSSAnimation() const { + return mAnimation ? mAnimation->AsCSSAnimation() : nullptr; + } + + // Flushes styles if our owning animation is a CSSAnimation + void MaybeFlushUnanimatedStyle() const; +}; + +} // namespace dom + +} // namespace mozilla + +#endif // mozilla_dom_CSSAnimation_h diff --git a/dom/animation/CSSPseudoElement.cpp b/dom/animation/CSSPseudoElement.cpp new file mode 100644 index 0000000000..9dfdd9d4f6 --- /dev/null +++ b/dom/animation/CSSPseudoElement.cpp @@ -0,0 +1,90 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/CSSPseudoElement.h" +#include "mozilla/dom/CSSPseudoElementBinding.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/AnimationComparator.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_WRAPPERCACHE(CSSPseudoElement, mOriginatingElement) + +CSSPseudoElement::CSSPseudoElement(dom::Element* aElement, + PseudoStyleType aType) + : mOriginatingElement(aElement), mPseudoType(aType) { + MOZ_ASSERT(aElement); + MOZ_ASSERT(AnimationUtils::IsSupportedPseudoForAnimations(aType), + "Unexpected Pseudo Type"); +} + +CSSPseudoElement::~CSSPseudoElement() { + // Element might have been unlinked already, so we have to do null check. + if (mOriginatingElement) { + mOriginatingElement->RemoveProperty( + GetCSSPseudoElementPropertyAtom(mPseudoType)); + } +} + +ParentObject CSSPseudoElement::GetParentObject() const { + return mOriginatingElement->GetParentObject(); +} + +JSObject* CSSPseudoElement::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return CSSPseudoElement_Binding::Wrap(aCx, this, aGivenProto); +} + +/* static */ +already_AddRefed<CSSPseudoElement> CSSPseudoElement::GetCSSPseudoElement( + dom::Element* aElement, PseudoStyleType aType) { + if (!aElement) { + return nullptr; + } + + nsAtom* propName = CSSPseudoElement::GetCSSPseudoElementPropertyAtom(aType); + RefPtr<CSSPseudoElement> pseudo = + static_cast<CSSPseudoElement*>(aElement->GetProperty(propName)); + if (pseudo) { + return pseudo.forget(); + } + + // CSSPseudoElement is a purely external interface created on-demand, and + // when all references from script to the pseudo are dropped, we can drop the + // CSSPseudoElement object, so use a non-owning reference from Element to + // CSSPseudoElement. + pseudo = new CSSPseudoElement(aElement, aType); + nsresult rv = aElement->SetProperty(propName, pseudo, nullptr, true); + if (NS_FAILED(rv)) { + NS_WARNING("SetProperty failed"); + return nullptr; + } + return pseudo.forget(); +} + +/* static */ +nsAtom* CSSPseudoElement::GetCSSPseudoElementPropertyAtom( + PseudoStyleType aType) { + switch (aType) { + case PseudoStyleType::before: + return nsGkAtoms::cssPseudoElementBeforeProperty; + + case PseudoStyleType::after: + return nsGkAtoms::cssPseudoElementAfterProperty; + + case PseudoStyleType::marker: + return nsGkAtoms::cssPseudoElementMarkerProperty; + + default: + MOZ_ASSERT_UNREACHABLE( + "Should not try to get CSSPseudoElement " + "other than ::before, ::after or ::marker"); + return nullptr; + } +} + +} // namespace mozilla::dom diff --git a/dom/animation/CSSPseudoElement.h b/dom/animation/CSSPseudoElement.h new file mode 100644 index 0000000000..4b5fbc917c --- /dev/null +++ b/dom/animation/CSSPseudoElement.h @@ -0,0 +1,73 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_CSSPseudoElement_h +#define mozilla_dom_CSSPseudoElement_h + +#include "js/TypeDecls.h" +#include "mozilla/Attributes.h" +#include "mozilla/dom/BindingDeclarations.h" +#include "mozilla/RefPtr.h" +#include "nsCSSPseudoElements.h" +#include "nsWrapperCache.h" + +namespace mozilla::dom { + +class Animation; +class Element; +class UnrestrictedDoubleOrKeyframeAnimationOptions; + +class CSSPseudoElement final : public nsWrapperCache { + public: + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(CSSPseudoElement) + NS_DECL_CYCLE_COLLECTION_NATIVE_WRAPPERCACHE_CLASS(CSSPseudoElement) + + protected: + virtual ~CSSPseudoElement(); + + public: + ParentObject GetParentObject() const; + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + PseudoStyleType GetType() const { return mPseudoType; } + void GetType(nsString& aRetVal) const { + MOZ_ASSERT(nsCSSPseudoElements::GetPseudoAtom(mPseudoType), + "All pseudo-types allowed by this class should have a" + " corresponding atom"); + // Our atoms use one colon and we would like to return two colons syntax + // for the returned pseudo type string, so serialize this to the + // non-deprecated two colon syntax. + aRetVal.Assign(char16_t(':')); + aRetVal.Append( + nsDependentAtomString(nsCSSPseudoElements::GetPseudoAtom(mPseudoType))); + } + dom::Element* Element() const { return mOriginatingElement.get(); } + + // Given an element:pseudoType pair, returns the CSSPseudoElement stored as a + // property on |aElement|. If there is no CSSPseudoElement for the specified + // pseudo-type on element, a new CSSPseudoElement will be created and stored + // on the element. + static already_AddRefed<CSSPseudoElement> GetCSSPseudoElement( + dom::Element* aElement, PseudoStyleType aType); + + private: + // Only ::before, ::after and ::marker are supported. + CSSPseudoElement(dom::Element* aElement, PseudoStyleType aType); + + static nsAtom* GetCSSPseudoElementPropertyAtom(PseudoStyleType aType); + + // mOriginatingElement needs to be an owning reference since if script is + // holding on to the pseudo-element, it needs to continue to be able to refer + // to the originating element. + RefPtr<dom::Element> mOriginatingElement; + PseudoStyleType mPseudoType; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_CSSPseudoElement_h diff --git a/dom/animation/CSSTransition.cpp b/dom/animation/CSSTransition.cpp new file mode 100644 index 0000000000..38f9c9386a --- /dev/null +++ b/dom/animation/CSSTransition.cpp @@ -0,0 +1,333 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "CSSTransition.h" + +#include "mozilla/AnimationEventDispatcher.h" +#include "mozilla/dom/CSSTransitionBinding.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/TimeStamp.h" +#include "nsPresContext.h" + +namespace mozilla::dom { + +JSObject* CSSTransition::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return dom::CSSTransition_Binding::Wrap(aCx, this, aGivenProto); +} + +void CSSTransition::GetTransitionProperty(nsString& aRetVal) const { + MOZ_ASSERT(eCSSProperty_UNKNOWN != mTransitionProperty, + "Transition Property should be initialized"); + aRetVal = + NS_ConvertUTF8toUTF16(nsCSSProps::GetStringValue(mTransitionProperty)); +} + +AnimationPlayState CSSTransition::PlayStateFromJS() const { + FlushUnanimatedStyle(); + return Animation::PlayStateFromJS(); +} + +bool CSSTransition::PendingFromJS() const { + // Transitions don't become pending again after they start running but, if + // while the transition is still pending, style is updated in such a way + // that the transition will be canceled, we need to report false here. + // Hence we need to flush, but only when we're pending. + if (Pending()) { + FlushUnanimatedStyle(); + } + return Animation::PendingFromJS(); +} + +void CSSTransition::PlayFromJS(ErrorResult& aRv) { + FlushUnanimatedStyle(); + Animation::PlayFromJS(aRv); +} + +void CSSTransition::UpdateTiming(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) { + if (mNeedsNewAnimationIndexWhenRun && + PlayState() != AnimationPlayState::Idle) { + mAnimationIndex = sNextAnimationIndex++; + mNeedsNewAnimationIndexWhenRun = false; + } + + Animation::UpdateTiming(aSeekFlag, aSyncNotifyFlag); +} + +void CSSTransition::QueueEvents(const StickyTimeDuration& aActiveTime) { + if (!mOwningElement.IsSet()) { + return; + } + + nsPresContext* presContext = mOwningElement.GetPresContext(); + if (!presContext) { + return; + } + + static constexpr StickyTimeDuration zeroDuration = StickyTimeDuration(); + + TransitionPhase currentPhase; + StickyTimeDuration intervalStartTime; + StickyTimeDuration intervalEndTime; + + if (!mEffect) { + currentPhase = GetAnimationPhaseWithoutEffect<TransitionPhase>(*this); + } else { + ComputedTiming computedTiming = mEffect->GetComputedTiming(); + + currentPhase = static_cast<TransitionPhase>(computedTiming.mPhase); + intervalStartTime = IntervalStartTime(computedTiming.mActiveDuration); + intervalEndTime = IntervalEndTime(computedTiming.mActiveDuration); + } + + if (mPendingState != PendingState::NotPending && + (mPreviousTransitionPhase == TransitionPhase::Idle || + mPreviousTransitionPhase == TransitionPhase::Pending)) { + currentPhase = TransitionPhase::Pending; + } + + if (currentPhase == mPreviousTransitionPhase) { + return; + } + + // TimeStamps to use for ordering the events when they are dispatched. We + // use a TimeStamp so we can compare events produced by different elements, + // perhaps even with different timelines. + // The zero timestamp is for transitionrun events where we ignore the delay + // for the purpose of ordering events. + TimeStamp zeroTimeStamp = AnimationTimeToTimeStamp(zeroDuration); + TimeStamp startTimeStamp = ElapsedTimeToTimeStamp(intervalStartTime); + TimeStamp endTimeStamp = ElapsedTimeToTimeStamp(intervalEndTime); + + AutoTArray<AnimationEventInfo, 3> events; + + auto appendTransitionEvent = [&](EventMessage aMessage, + const StickyTimeDuration& aElapsedTime, + const TimeStamp& aScheduledEventTimeStamp) { + double elapsedTime = aElapsedTime.ToSeconds(); + if (aMessage == eTransitionCancel) { + // 0 is an inappropriate value for this callsite. What we need to do is + // use a single random value for all increasing times reportable. + // That is to say, whenever elapsedTime goes negative (because an + // animation restarts, something rewinds the animation, or otherwise) + // a new random value for the mix-in must be generated. + elapsedTime = nsRFPService::ReduceTimePrecisionAsSecsRFPOnly( + elapsedTime, 0, mRTPCallerType); + } + events.AppendElement(AnimationEventInfo( + TransitionProperty(), mOwningElement.Target(), aMessage, elapsedTime, + aScheduledEventTimeStamp, this)); + }; + + // Handle cancel events first + if ((mPreviousTransitionPhase != TransitionPhase::Idle && + mPreviousTransitionPhase != TransitionPhase::After) && + currentPhase == TransitionPhase::Idle) { + appendTransitionEvent(eTransitionCancel, aActiveTime, + GetTimelineCurrentTimeAsTimeStamp()); + } + + // All other events + switch (mPreviousTransitionPhase) { + case TransitionPhase::Idle: + if (currentPhase == TransitionPhase::Pending || + currentPhase == TransitionPhase::Before) { + appendTransitionEvent(eTransitionRun, intervalStartTime, zeroTimeStamp); + } else if (currentPhase == TransitionPhase::Active) { + appendTransitionEvent(eTransitionRun, intervalStartTime, zeroTimeStamp); + appendTransitionEvent(eTransitionStart, intervalStartTime, + startTimeStamp); + } else if (currentPhase == TransitionPhase::After) { + appendTransitionEvent(eTransitionRun, intervalStartTime, zeroTimeStamp); + appendTransitionEvent(eTransitionStart, intervalStartTime, + startTimeStamp); + appendTransitionEvent(eTransitionEnd, intervalEndTime, endTimeStamp); + } + break; + + case TransitionPhase::Pending: + case TransitionPhase::Before: + if (currentPhase == TransitionPhase::Active) { + appendTransitionEvent(eTransitionStart, intervalStartTime, + startTimeStamp); + } else if (currentPhase == TransitionPhase::After) { + appendTransitionEvent(eTransitionStart, intervalStartTime, + startTimeStamp); + appendTransitionEvent(eTransitionEnd, intervalEndTime, endTimeStamp); + } + break; + + case TransitionPhase::Active: + if (currentPhase == TransitionPhase::After) { + appendTransitionEvent(eTransitionEnd, intervalEndTime, endTimeStamp); + } else if (currentPhase == TransitionPhase::Before) { + appendTransitionEvent(eTransitionEnd, intervalStartTime, + startTimeStamp); + } + break; + + case TransitionPhase::After: + if (currentPhase == TransitionPhase::Active) { + appendTransitionEvent(eTransitionStart, intervalEndTime, + startTimeStamp); + } else if (currentPhase == TransitionPhase::Before) { + appendTransitionEvent(eTransitionStart, intervalEndTime, + startTimeStamp); + appendTransitionEvent(eTransitionEnd, intervalStartTime, endTimeStamp); + } + break; + } + mPreviousTransitionPhase = currentPhase; + + if (!events.IsEmpty()) { + presContext->AnimationEventDispatcher()->QueueEvents(std::move(events)); + } +} + +void CSSTransition::Tick() { + Animation::Tick(); + QueueEvents(); +} + +nsCSSPropertyID CSSTransition::TransitionProperty() const { + MOZ_ASSERT(eCSSProperty_UNKNOWN != mTransitionProperty, + "Transition property should be initialized"); + return mTransitionProperty; +} + +AnimationValue CSSTransition::ToValue() const { + MOZ_ASSERT(!mTransitionToValue.IsNull(), + "Transition ToValue should be initialized"); + return mTransitionToValue; +} + +bool CSSTransition::HasLowerCompositeOrderThan( + const CSSTransition& aOther) const { + MOZ_ASSERT(IsTiedToMarkup() && aOther.IsTiedToMarkup(), + "Should only be called for CSS transitions that are sorted " + "as CSS transitions (i.e. tied to CSS markup)"); + + // 0. Object-equality case + if (&aOther == this) { + return false; + } + + // 1. Sort by document order + if (!mOwningElement.Equals(aOther.mOwningElement)) { + return mOwningElement.LessThan( + const_cast<CSSTransition*>(this)->CachedChildIndexRef(), + aOther.mOwningElement, + const_cast<CSSTransition*>(&aOther)->CachedChildIndexRef()); + } + + // 2. (Same element and pseudo): Sort by transition generation + if (mAnimationIndex != aOther.mAnimationIndex) { + return mAnimationIndex < aOther.mAnimationIndex; + } + + // 3. (Same transition generation): Sort by transition property + return nsCSSProps::GetStringValue(TransitionProperty()) < + nsCSSProps::GetStringValue(aOther.TransitionProperty()); +} + +/* static */ +Nullable<TimeDuration> CSSTransition::GetCurrentTimeAt( + const AnimationTimeline& aTimeline, const TimeStamp& aBaseTime, + const TimeDuration& aStartTime, double aPlaybackRate) { + Nullable<TimeDuration> result; + + Nullable<TimeDuration> timelineTime = aTimeline.ToTimelineTime(aBaseTime); + if (!timelineTime.IsNull()) { + result.SetValue( + (timelineTime.Value() - aStartTime).MultDouble(aPlaybackRate)); + } + + return result; +} + +double CSSTransition::CurrentValuePortion() const { + if (!GetEffect()) { + return 0.0; + } + + // Transitions use a fill mode of 'backwards' so GetComputedTiming will + // never return a null time progress due to being *before* the animation + // interval. However, it might be possible that we're behind on flushing + // causing us to get called *after* the animation interval. So, just in + // case, we override the fill mode to 'both' to ensure the progress + // is never null. + TimingParams timingToUse = GetEffect()->SpecifiedTiming(); + timingToUse.SetFill(dom::FillMode::Both); + ComputedTiming computedTiming = GetEffect()->GetComputedTiming(&timingToUse); + + if (computedTiming.mProgress.IsNull()) { + return 0.0; + } + + // 'transition-timing-function' corresponds to the effect timing while + // the transition keyframes have a linear timing function so we can ignore + // them for the purposes of calculating the value portion. + return computedTiming.mProgress.Value(); +} + +void CSSTransition::UpdateStartValueFromReplacedTransition() { + MOZ_ASSERT(mEffect && mEffect->AsKeyframeEffect() && + mEffect->AsKeyframeEffect()->HasAnimationOfPropertySet( + nsCSSPropertyIDSet::CompositorAnimatables()), + "Should be called for compositor-runnable transitions"); + + if (!mReplacedTransition) { + return; + } + + // We don't set |mReplacedTransition| if the timeline of this transition is + // different from the document timeline. The timeline of Animation may be + // null via script, so if it's null, it must be different from the document + // timeline (because document timeline is readonly so we cannot change it by + // script). Therefore, we check this assertion if mReplacedTransition is + // valid. + MOZ_ASSERT(mTimeline, + "Should have a timeline if we are replacing transition start " + "values"); + + ComputedTiming computedTiming = AnimationEffect::GetComputedTimingAt( + CSSTransition::GetCurrentTimeAt(*mTimeline, TimeStamp::Now(), + mReplacedTransition->mStartTime, + mReplacedTransition->mPlaybackRate), + mReplacedTransition->mTiming, mReplacedTransition->mPlaybackRate, + Animation::ProgressTimelinePosition::NotBoundary); + + if (!computedTiming.mProgress.IsNull()) { + double valuePosition = StyleComputedTimingFunction::GetPortion( + mReplacedTransition->mTimingFunction, computedTiming.mProgress.Value(), + computedTiming.mBeforeFlag); + + const AnimationValue& replacedFrom = mReplacedTransition->mFromValue; + const AnimationValue& replacedTo = mReplacedTransition->mToValue; + AnimationValue startValue; + startValue.mServo = + Servo_AnimationValues_Interpolate(replacedFrom.mServo, + replacedTo.mServo, valuePosition) + .Consume(); + + mEffect->AsKeyframeEffect()->ReplaceTransitionStartValue( + std::move(startValue)); + } + + mReplacedTransition.reset(); +} + +void CSSTransition::SetEffectFromStyle(KeyframeEffect* aEffect) { + MOZ_ASSERT(aEffect->IsValidTransition()); + + Animation::SetEffectNoUpdate(aEffect); + mTransitionProperty = aEffect->Properties()[0].mProperty; + mTransitionToValue = aEffect->Properties()[0].mSegments[0].mToValue; +} + +} // namespace mozilla::dom diff --git a/dom/animation/CSSTransition.h b/dom/animation/CSSTransition.h new file mode 100644 index 0000000000..ec246f8c26 --- /dev/null +++ b/dom/animation/CSSTransition.h @@ -0,0 +1,230 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_CSSTransition_h +#define mozilla_dom_CSSTransition_h + +#include "mozilla/ComputedTiming.h" +#include "mozilla/dom/Animation.h" +#include "mozilla/StyleAnimationValue.h" +#include "AnimationCommon.h" + +class nsIGlobalObject; + +namespace mozilla { +namespace dom { + +class CSSTransition final : public Animation { + public: + explicit CSSTransition(nsIGlobalObject* aGlobal) + : Animation(aGlobal), + mPreviousTransitionPhase(TransitionPhase::Idle), + mNeedsNewAnimationIndexWhenRun(false), + mTransitionProperty(eCSSProperty_UNKNOWN) {} + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + CSSTransition* AsCSSTransition() override { return this; } + const CSSTransition* AsCSSTransition() const override { return this; } + + // CSSTransition interface + void GetTransitionProperty(nsString& aRetVal) const; + + // Animation interface overrides + AnimationPlayState PlayStateFromJS() const override; + bool PendingFromJS() const override; + void PlayFromJS(ErrorResult& aRv) override; + + // A variant of Play() that avoids posting style updates since this method + // is expected to be called whilst already updating style. + void PlayFromStyle() { + ErrorResult rv; + PlayNoUpdate(rv, Animation::LimitBehavior::Continue); + // play() should not throw when LimitBehavior is Continue + MOZ_ASSERT(!rv.Failed(), "Unexpected exception playing transition"); + } + + void CancelFromStyle(PostRestyleMode aPostRestyle) { + // The animation index to use for compositing will be established when + // this transition next transitions out of the idle state but we still + // update it now so that the sort order of this transition remains + // defined until that moment. + // + // See longer explanation in CSSAnimation::CancelFromStyle. + mAnimationIndex = sNextAnimationIndex++; + mNeedsNewAnimationIndexWhenRun = true; + + Animation::Cancel(aPostRestyle); + + // It is important we do this *after* calling Cancel(). + // This is because Cancel() will end up posting a restyle and + // that restyle should target the *transitions* level of the cascade. + // However, once we clear the owning element, CascadeLevel() will begin + // returning CascadeLevel::Animations. + mOwningElement = OwningElementRef(); + } + + void SetEffectFromStyle(KeyframeEffect*); + + void Tick() override; + + nsCSSPropertyID TransitionProperty() const; + AnimationValue ToValue() const; + + bool HasLowerCompositeOrderThan(const CSSTransition& aOther) const; + EffectCompositor::CascadeLevel CascadeLevel() const override { + return IsTiedToMarkup() ? EffectCompositor::CascadeLevel::Transitions + : EffectCompositor::CascadeLevel::Animations; + } + + void SetCreationSequence(uint64_t aIndex) { + MOZ_ASSERT(IsTiedToMarkup()); + mAnimationIndex = aIndex; + } + + // Sets the owning element which is used for determining the composite + // oder of CSSTransition objects generated from CSS markup. + // + // @see mOwningElement + void SetOwningElement(const OwningElementRef& aElement) { + mOwningElement = aElement; + } + // True for transitions that are generated from CSS markup and continue to + // reflect changes to that markup. + bool IsTiedToMarkup() const { return mOwningElement.IsSet(); } + + // Return the animation current time based on a given TimeStamp, a given + // start time and a given playbackRate on a given timeline. This is useful + // when we estimate the current animated value running on the compositor + // because the animation on the compositor may be running ahead while + // main-thread is busy. + static Nullable<TimeDuration> GetCurrentTimeAt( + const AnimationTimeline& aTimeline, const TimeStamp& aBaseTime, + const TimeDuration& aStartTime, double aPlaybackRate); + + void MaybeQueueCancelEvent(const StickyTimeDuration& aActiveTime) override { + QueueEvents(aActiveTime); + } + + // Compute the portion of the *value* space that we should be through + // at the current time. (The input to the transition timing function + // has time units, the output has value units.) + double CurrentValuePortion() const; + + const AnimationValue& StartForReversingTest() const { + return mStartForReversingTest; + } + double ReversePortion() const { return mReversePortion; } + + void SetReverseParameters(AnimationValue&& aStartForReversingTest, + double aReversePortion) { + mStartForReversingTest = std::move(aStartForReversingTest); + mReversePortion = aReversePortion; + } + + struct ReplacedTransitionProperties { + TimeDuration mStartTime; + double mPlaybackRate; + TimingParams mTiming; + Maybe<StyleComputedTimingFunction> mTimingFunction; + AnimationValue mFromValue, mToValue; + }; + void SetReplacedTransition( + ReplacedTransitionProperties&& aReplacedTransition) { + mReplacedTransition.emplace(std::move(aReplacedTransition)); + } + + // For a new transition interrupting an existing transition on the + // compositor, update the start value to match the value of the replaced + // transitions at the current time. + void UpdateStartValueFromReplacedTransition(); + + protected: + virtual ~CSSTransition() { + MOZ_ASSERT(!mOwningElement.IsSet(), + "Owning element should be cleared " + "before a CSS transition is destroyed"); + } + + // Animation overrides + void UpdateTiming(SeekFlag aSeekFlag, + SyncNotifyFlag aSyncNotifyFlag) override; + + void QueueEvents(const StickyTimeDuration& activeTime = StickyTimeDuration()); + + enum class TransitionPhase; + + // The (pseudo-)element whose computed transition-property refers to this + // transition (if any). + // + // This is used for determining the relative composite order of transitions + // generated from CSS markup. + // + // Typically this will be the same as the target element of the keyframe + // effect associated with this transition. However, it can differ in the + // following circumstances: + // + // a) If script removes or replaces the effect of this transition, + // b) If this transition is cancelled (e.g. by updating the + // transition-property or removing the owning element from the document), + // c) If this object is generated from script using the CSSTransition + // constructor. + // + // For (b) and (c) the owning element will return !IsSet(). + OwningElementRef mOwningElement; + + // The 'transition phase' used to determine which transition events need + // to be queued on this tick. + // See: https://drafts.csswg.org/css-transitions-2/#transition-phase + enum class TransitionPhase { + Idle = static_cast<int>(ComputedTiming::AnimationPhase::Idle), + Before = static_cast<int>(ComputedTiming::AnimationPhase::Before), + Active = static_cast<int>(ComputedTiming::AnimationPhase::Active), + After = static_cast<int>(ComputedTiming::AnimationPhase::After), + Pending + }; + TransitionPhase mPreviousTransitionPhase; + + // When true, indicates that when this transition next leaves the idle state, + // its animation index should be updated. + bool mNeedsNewAnimationIndexWhenRun; + + // Store the transition property and to-value here since we need that + // information in order to determine if there is an existing transition + // for a given style change. We can't store that information on the + // effect however since it can be replaced using the Web Animations API. + nsCSSPropertyID mTransitionProperty; + AnimationValue mTransitionToValue; + + // This is the start value to be used for a check for whether a + // transition is being reversed. Normally the same as + // mEffect->mProperties[0].mSegments[0].mFromValue, except when this + // transition started as the reversal of another in-progress transition + // or when the effect has been mutated using the Web Animations API. + // + // Needed so we can handle two reverses in a row. + AnimationValue mStartForReversingTest; + + // Likewise, the portion (in value space) of the "full" reversed + // transition that we're actually covering. For example, if a :hover + // effect has a transition that moves the element 10px to the right + // (by changing 'left' from 0px to 10px), and the mouse moves in to + // the element (starting the transition) but then moves out after the + // transition has advanced 4px, the second transition (from 10px/4px + // to 0px) will have mReversePortion of 0.4. (If the mouse then moves + // in again when the transition is back to 2px, the mReversePortion + // for the third transition (from 0px/2px to 10px) will be 0.8. + double mReversePortion = 1.0; + + Maybe<ReplacedTransitionProperties> mReplacedTransition; +}; + +} // namespace dom + +} // namespace mozilla + +#endif // mozilla_dom_CSSTransition_h diff --git a/dom/animation/ComputedTiming.h b/dom/animation/ComputedTiming.h new file mode 100644 index 0000000000..fe98277bb3 --- /dev/null +++ b/dom/animation/ComputedTiming.h @@ -0,0 +1,71 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_ComputedTiming_h +#define mozilla_ComputedTiming_h + +#include "mozilla/dom/Nullable.h" +#include "mozilla/StickyTimeDuration.h" + +#include "mozilla/dom/AnimationEffectBinding.h" // FillMode + +namespace mozilla { + +/** + * Stores the results of calculating the timing properties of an animation + * at a given sample time. + */ +struct ComputedTiming { + // The total duration of the animation including all iterations. + // Will equal StickyTimeDuration::Forever() if the animation repeats + // indefinitely. + StickyTimeDuration mActiveDuration; + // The time within the active interval. + StickyTimeDuration mActiveTime; + // The effect end time in local time (i.e. an offset from the effect's + // start time). Will equal StickyTimeDuration::Forever() if the animation + // plays indefinitely. + StickyTimeDuration mEndTime; + // Progress towards the end of the current iteration. If the effect is + // being sampled backwards, this will go from 1.0 to 0.0. + // Will be null if the animation is neither animating nor + // filling at the sampled time. + dom::Nullable<double> mProgress; + // Zero-based iteration index (meaningless if mProgress is null). + uint64_t mCurrentIteration = 0; + // Unlike TimingParams::mIterations, this value is + // guaranteed to be in the range [0, Infinity]. + double mIterations = 1.0; + double mIterationStart = 0.0; + StickyTimeDuration mDuration; + + // This is the computed fill mode so it is never auto + dom::FillMode mFill = dom::FillMode::None; + bool FillsForwards() const { + MOZ_ASSERT(mFill != dom::FillMode::Auto, + "mFill should not be Auto in ComputedTiming."); + return mFill == dom::FillMode::Both || mFill == dom::FillMode::Forwards; + } + bool FillsBackwards() const { + MOZ_ASSERT(mFill != dom::FillMode::Auto, + "mFill should not be Auto in ComputedTiming."); + return mFill == dom::FillMode::Both || mFill == dom::FillMode::Backwards; + } + + enum class AnimationPhase { + Idle, // Not sampled (null sample time) + Before, // Sampled prior to the start of the active interval + Active, // Sampled within the active interval + After // Sampled after (or at) the end of the active interval + }; + AnimationPhase mPhase = AnimationPhase::Idle; + + bool mBeforeFlag = false; +}; + +} // namespace mozilla + +#endif // mozilla_ComputedTiming_h diff --git a/dom/animation/DocumentTimeline.cpp b/dom/animation/DocumentTimeline.cpp new file mode 100644 index 0000000000..1ccd65365a --- /dev/null +++ b/dom/animation/DocumentTimeline.cpp @@ -0,0 +1,311 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "DocumentTimeline.h" +#include "mozilla/dom/DocumentInlines.h" +#include "mozilla/dom/DocumentTimelineBinding.h" +#include "AnimationUtils.h" +#include "nsContentUtils.h" +#include "nsDOMMutationObserver.h" +#include "nsDOMNavigationTiming.h" +#include "nsPresContext.h" +#include "nsRefreshDriver.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentTimeline) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentTimeline, + AnimationTimeline) + tmp->UnregisterFromRefreshDriver(); + if (tmp->isInList()) { + tmp->remove(); + } + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentTimeline, + AnimationTimeline) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(DocumentTimeline, + AnimationTimeline) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentTimeline) +NS_INTERFACE_MAP_END_INHERITING(AnimationTimeline) + +NS_IMPL_ADDREF_INHERITED(DocumentTimeline, AnimationTimeline) +NS_IMPL_RELEASE_INHERITED(DocumentTimeline, AnimationTimeline) + +DocumentTimeline::DocumentTimeline(Document* aDocument, + const TimeDuration& aOriginTime) + : AnimationTimeline(aDocument->GetParentObject(), + aDocument->GetScopeObject()->GetRTPCallerType()), + mDocument(aDocument), + mIsObservingRefreshDriver(false), + mOriginTime(aOriginTime) { + if (mDocument) { + mDocument->Timelines().insertBack(this); + } + // Ensure mLastRefreshDriverTime is valid. + UpdateLastRefreshDriverTime(); +} + +DocumentTimeline::~DocumentTimeline() { + MOZ_ASSERT(!mIsObservingRefreshDriver, + "Timeline should have disassociated" + " from the refresh driver before being destroyed"); + if (isInList()) { + remove(); + } +} + +JSObject* DocumentTimeline::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return DocumentTimeline_Binding::Wrap(aCx, this, aGivenProto); +} + +/* static */ +already_AddRefed<DocumentTimeline> DocumentTimeline::Constructor( + const GlobalObject& aGlobal, const DocumentTimelineOptions& aOptions, + ErrorResult& aRv) { + Document* doc = AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + if (!doc) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + TimeDuration originTime = + TimeDuration::FromMilliseconds(aOptions.mOriginTime); + + if (originTime == TimeDuration::Forever() || + originTime == -TimeDuration::Forever()) { + aRv.ThrowTypeError<dom::MSG_TIME_VALUE_OUT_OF_RANGE>("Origin time"); + return nullptr; + } + RefPtr<DocumentTimeline> timeline = new DocumentTimeline(doc, originTime); + + return timeline.forget(); +} + +Nullable<TimeDuration> DocumentTimeline::GetCurrentTimeAsDuration() const { + return ToTimelineTime(GetCurrentTimeStamp()); +} + +bool DocumentTimeline::TracksWallclockTime() const { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + return !refreshDriver || !refreshDriver->IsTestControllingRefreshesEnabled(); +} + +TimeStamp DocumentTimeline::GetCurrentTimeStamp() const { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + return refreshDriver ? refreshDriver->MostRecentRefresh() + : mLastRefreshDriverTime; +} + +void DocumentTimeline::UpdateLastRefreshDriverTime() { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + TimeStamp refreshTime = + refreshDriver ? refreshDriver->MostRecentRefresh() : TimeStamp(); + + // Always return the same object to benefit from return-value optimization. + TimeStamp result = + !refreshTime.IsNull() ? refreshTime : mLastRefreshDriverTime; + + nsDOMNavigationTiming* timing = mDocument->GetNavigationTiming(); + // If we don't have a refresh driver and we've never had one use the + // timeline's zero time. + // In addition, it's possible that our refresh driver's timestamp is behind + // from the navigation start time because the refresh driver timestamp is + // sent through an IPC call whereas the navigation time is set by calling + // TimeStamp::Now() directly. In such cases we also use the timeline's zero + // time. + if (timing && + (result.IsNull() || result < timing->GetNavigationStartTimeStamp())) { + result = timing->GetNavigationStartTimeStamp(); + // Also, let this time represent the current refresh time. This way + // we'll save it as the last refresh time and skip looking up + // navigation start time each time. + refreshTime = result; + } + + if (!refreshTime.IsNull()) { + mLastRefreshDriverTime = refreshTime; + } +} + +Nullable<TimeDuration> DocumentTimeline::ToTimelineTime( + const TimeStamp& aTimeStamp) const { + Nullable<TimeDuration> result; // Initializes to null + if (aTimeStamp.IsNull()) { + return result; + } + + nsDOMNavigationTiming* timing = mDocument->GetNavigationTiming(); + if (MOZ_UNLIKELY(!timing)) { + return result; + } + + result.SetValue(aTimeStamp - timing->GetNavigationStartTimeStamp() - + mOriginTime); + return result; +} + +void DocumentTimeline::NotifyAnimationUpdated(Animation& aAnimation) { + AnimationTimeline::NotifyAnimationUpdated(aAnimation); + + if (!mIsObservingRefreshDriver && !mAnimationOrder.isEmpty()) { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + if (refreshDriver) { + MOZ_ASSERT(isInList(), + "We should not register with the refresh driver if we are not" + " in the document's list of timelines"); + + ObserveRefreshDriver(refreshDriver); + } + } +} + +void DocumentTimeline::MostRecentRefreshTimeUpdated() { + MOZ_ASSERT(mIsObservingRefreshDriver); + MOZ_ASSERT(GetRefreshDriver(), + "Should be able to reach refresh driver from within WillRefresh"); + + nsAutoAnimationMutationBatch mb(mDocument); + + bool ticked = Tick(); + if (!ticked) { + // We already assert that GetRefreshDriver() is non-null at the beginning + // of this function but we check it again here to be sure that ticking + // animations does not have any side effects that cause us to lose the + // connection with the refresh driver, such as triggering the destruction + // of mDocument's PresShell. + MOZ_ASSERT(GetRefreshDriver(), + "Refresh driver should still be valid at end of WillRefresh"); + UnregisterFromRefreshDriver(); + } +} + +void DocumentTimeline::WillRefresh(mozilla::TimeStamp aTime) { + UpdateLastRefreshDriverTime(); + MostRecentRefreshTimeUpdated(); +} + +void DocumentTimeline::NotifyTimerAdjusted(TimeStamp aTime) { + MostRecentRefreshTimeUpdated(); +} + +void DocumentTimeline::ObserveRefreshDriver(nsRefreshDriver* aDriver) { + MOZ_ASSERT(!mIsObservingRefreshDriver); + // Set the mIsObservingRefreshDriver flag before calling AddRefreshObserver + // since it might end up calling NotifyTimerAdjusted which calls + // MostRecentRefreshTimeUpdated which has an assertion for + // mIsObserveingRefreshDriver check. + mIsObservingRefreshDriver = true; + aDriver->AddRefreshObserver(this, FlushType::Style, + "DocumentTimeline animations"); + aDriver->AddTimerAdjustmentObserver(this); +} + +void DocumentTimeline::NotifyRefreshDriverCreated(nsRefreshDriver* aDriver) { + MOZ_ASSERT(!mIsObservingRefreshDriver, + "Timeline should not be observing the refresh driver before" + " it is created"); + + if (!mAnimationOrder.isEmpty()) { + MOZ_ASSERT(isInList(), + "We should not register with the refresh driver if we are not" + " in the document's list of timelines"); + ObserveRefreshDriver(aDriver); + // Although we have started observing the refresh driver, it's possible we + // could perform a paint before the first refresh driver tick happens. To + // ensure we're in a consistent state in that case we run the first tick + // manually. + MostRecentRefreshTimeUpdated(); + } +} + +void DocumentTimeline::DisconnectRefreshDriver(nsRefreshDriver* aDriver) { + MOZ_ASSERT(mIsObservingRefreshDriver); + + aDriver->RemoveRefreshObserver(this, FlushType::Style); + aDriver->RemoveTimerAdjustmentObserver(this); + mIsObservingRefreshDriver = false; +} + +void DocumentTimeline::NotifyRefreshDriverDestroying(nsRefreshDriver* aDriver) { + if (!mIsObservingRefreshDriver) { + return; + } + + DisconnectRefreshDriver(aDriver); +} + +void DocumentTimeline::RemoveAnimation(Animation* aAnimation) { + AnimationTimeline::RemoveAnimation(aAnimation); + + if (!mIsObservingRefreshDriver || !mAnimationOrder.isEmpty()) { + return; + } + + UnregisterFromRefreshDriver(); +} + +void DocumentTimeline::NotifyAnimationContentVisibilityChanged( + Animation* aAnimation, bool aIsVisible) { + AnimationTimeline::NotifyAnimationContentVisibilityChanged(aAnimation, + aIsVisible); + + if (mIsObservingRefreshDriver && mAnimationOrder.isEmpty()) { + UnregisterFromRefreshDriver(); + } + + if (!mIsObservingRefreshDriver && !mAnimationOrder.isEmpty()) { + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + if (refreshDriver) { + MOZ_ASSERT(isInList(), + "We should not register with the refresh driver if we are not" + " in the document's list of timelines"); + + ObserveRefreshDriver(refreshDriver); + } + } +} + +TimeStamp DocumentTimeline::ToTimeStamp( + const TimeDuration& aTimeDuration) const { + TimeStamp result; + nsDOMNavigationTiming* timing = mDocument->GetNavigationTiming(); + if (MOZ_UNLIKELY(!timing)) { + return result; + } + + result = + timing->GetNavigationStartTimeStamp() + (aTimeDuration + mOriginTime); + return result; +} + +nsRefreshDriver* DocumentTimeline::GetRefreshDriver() const { + nsPresContext* presContext = mDocument->GetPresContext(); + if (MOZ_UNLIKELY(!presContext)) { + return nullptr; + } + + return presContext->RefreshDriver(); +} + +void DocumentTimeline::UnregisterFromRefreshDriver() { + if (!mIsObservingRefreshDriver) { + return; + } + + nsRefreshDriver* refreshDriver = GetRefreshDriver(); + if (!refreshDriver) { + return; + } + DisconnectRefreshDriver(refreshDriver); +} + +} // namespace mozilla::dom diff --git a/dom/animation/DocumentTimeline.h b/dom/animation/DocumentTimeline.h new file mode 100644 index 0000000000..b0c8c86546 --- /dev/null +++ b/dom/animation/DocumentTimeline.h @@ -0,0 +1,97 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_DocumentTimeline_h +#define mozilla_dom_DocumentTimeline_h + +#include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentTimelineBinding.h" +#include "mozilla/LinkedList.h" +#include "mozilla/TimeStamp.h" +#include "AnimationTimeline.h" +#include "nsDOMNavigationTiming.h" // for DOMHighResTimeStamp +#include "nsRefreshDriver.h" +#include "nsRefreshObservers.h" + +struct JSContext; + +namespace mozilla::dom { + +class DocumentTimeline final : public AnimationTimeline, + public nsARefreshObserver, + public nsATimerAdjustmentObserver, + public LinkedListElement<DocumentTimeline> { + public: + DocumentTimeline(Document* aDocument, const TimeDuration& aOriginTime); + + protected: + virtual ~DocumentTimeline(); + + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(DocumentTimeline, + AnimationTimeline) + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + static already_AddRefed<DocumentTimeline> Constructor( + const GlobalObject& aGlobal, const DocumentTimelineOptions& aOptions, + ErrorResult& aRv); + + // AnimationTimeline methods + + // This is deliberately _not_ called GetCurrentTime since that would clash + // with a macro defined in winbase.h + virtual Nullable<TimeDuration> GetCurrentTimeAsDuration() const override; + + bool TracksWallclockTime() const override; + Nullable<TimeDuration> ToTimelineTime( + const TimeStamp& aTimeStamp) const override; + TimeStamp ToTimeStamp(const TimeDuration& aTimelineTime) const override; + + void NotifyAnimationUpdated(Animation& aAnimation) override; + + void RemoveAnimation(Animation* aAnimation) override; + void NotifyAnimationContentVisibilityChanged(Animation* aAnimation, + bool aIsVisible) override; + + // nsARefreshObserver methods + void WillRefresh(TimeStamp aTime) override; + // nsATimerAdjustmentObserver methods + void NotifyTimerAdjusted(TimeStamp aTime) override; + + void NotifyRefreshDriverCreated(nsRefreshDriver* aDriver); + void NotifyRefreshDriverDestroying(nsRefreshDriver* aDriver); + + Document* GetDocument() const override { return mDocument; } + + void UpdateLastRefreshDriverTime(); + + bool IsMonotonicallyIncreasing() const override { return true; } + + protected: + TimeStamp GetCurrentTimeStamp() const; + nsRefreshDriver* GetRefreshDriver() const; + void UnregisterFromRefreshDriver(); + void MostRecentRefreshTimeUpdated(); + void ObserveRefreshDriver(nsRefreshDriver* aDriver); + void DisconnectRefreshDriver(nsRefreshDriver* aDriver); + + RefPtr<Document> mDocument; + + // The most recently used refresh driver time. This is used in cases where + // we don't have a refresh driver (e.g. because we are in a display:none + // iframe). + TimeStamp mLastRefreshDriverTime; + bool mIsObservingRefreshDriver; + + TimeDuration mOriginTime; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_DocumentTimeline_h diff --git a/dom/animation/EffectCompositor.cpp b/dom/animation/EffectCompositor.cpp new file mode 100644 index 0000000000..095ec63baf --- /dev/null +++ b/dom/animation/EffectCompositor.cpp @@ -0,0 +1,969 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "EffectCompositor.h" + +#include <bitset> +#include <initializer_list> + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/Element.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "mozilla/AnimationComparator.h" +#include "mozilla/AnimationPerformanceWarning.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/AutoRestore.h" +#include "mozilla/ComputedStyleInlines.h" +#include "mozilla/EffectSet.h" +#include "mozilla/LayerAnimationInfo.h" +#include "mozilla/PresShell.h" +#include "mozilla/PresShellInlines.h" +#include "mozilla/RestyleManager.h" +#include "mozilla/ServoBindings.h" // Servo_GetProperties_Overriding_Animation +#include "mozilla/ServoStyleSet.h" +#include "mozilla/StaticPrefs_layers.h" +#include "mozilla/StyleAnimationValue.h" +#include "nsContentUtils.h" +#include "nsCSSPseudoElements.h" +#include "nsCSSPropertyIDSet.h" +#include "nsCSSProps.h" +#include "nsDisplayItemTypes.h" +#include "nsAtom.h" +#include "nsLayoutUtils.h" +#include "nsTArray.h" +#include "PendingAnimationTracker.h" + +using mozilla::dom::Animation; +using mozilla::dom::Element; +using mozilla::dom::KeyframeEffect; + +namespace mozilla { + +NS_IMPL_CYCLE_COLLECTION_CLASS(EffectCompositor) + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN(EffectCompositor) + for (auto& elementSet : tmp->mElementsToRestyle) { + elementSet.Clear(); + } +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN(EffectCompositor) + for (const auto& elementSet : tmp->mElementsToRestyle) { + for (const auto& key : elementSet.Keys()) { + CycleCollectionNoteChild(cb, key.mElement, + "EffectCompositor::mElementsToRestyle[]", + cb.Flags()); + } + } +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +/* static */ +bool EffectCompositor::AllowCompositorAnimationsOnFrame( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aWarning /* out */) { + if (aFrame->RefusedAsyncAnimation()) { + return false; + } + + if (!nsLayoutUtils::AreAsyncAnimationsEnabled()) { + if (StaticPrefs::layers_offmainthreadcomposition_log_animations()) { + nsCString message; + message.AppendLiteral( + "Performance warning: Async animations are " + "disabled"); + AnimationUtils::LogAsyncAnimationFailure(message); + } + return false; + } + + // Disable async animations if we have a rendering observer that + // depends on our content (svg masking, -moz-element etc) so that + // it gets updated correctly. + nsIContent* content = aFrame->GetContent(); + while (content) { + if (content->HasRenderingObservers()) { + aWarning = AnimationPerformanceWarning::Type::HasRenderingObserver; + return false; + } + content = content->GetParent(); + } + + return true; +} + +// Helper function to factor out the common logic from +// GetAnimationsForCompositor and HasAnimationsForCompositor. +// +// Takes an optional array to fill with eligible animations. +// +// Returns true if there are eligible animations, false otherwise. +bool FindAnimationsForCompositor( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet, + nsTArray<RefPtr<dom::Animation>>* aMatches /*out*/) { + // Do not process any animations on the compositor when in print or print + // preview. + if (aFrame->PresContext()->IsPrintingOrPrintPreview()) { + return false; + } + + MOZ_ASSERT( + aPropertySet.IsSubsetOf(LayerAnimationInfo::GetCSSPropertiesFor( + DisplayItemType::TYPE_TRANSFORM)) || + aPropertySet.IsSubsetOf(LayerAnimationInfo::GetCSSPropertiesFor( + DisplayItemType::TYPE_OPACITY)) || + aPropertySet.IsSubsetOf(LayerAnimationInfo::GetCSSPropertiesFor( + DisplayItemType::TYPE_BACKGROUND_COLOR)), + "Should be the subset of transform-like properties, or opacity, " + "or background color"); + + MOZ_ASSERT(!aMatches || aMatches->IsEmpty(), + "Matches array, if provided, should be empty"); + + EffectSet* effects = EffectSet::GetForFrame(aFrame, aPropertySet); + if (!effects || effects->IsEmpty()) { + return false; + } + + // First check for newly-started transform animations that should be + // synchronized with geometric animations. We need to do this before any + // other early returns (the one above is ok) since we can only check this + // state when the animation is newly-started. + if (aPropertySet.Intersects(LayerAnimationInfo::GetCSSPropertiesFor( + DisplayItemType::TYPE_TRANSFORM))) { + PendingAnimationTracker* tracker = + aFrame->PresContext()->Document()->GetPendingAnimationTracker(); + if (tracker) { + tracker->MarkAnimationsThatMightNeedSynchronization(); + } + } + + AnimationPerformanceWarning::Type warning = + AnimationPerformanceWarning::Type::None; + if (!EffectCompositor::AllowCompositorAnimationsOnFrame(aFrame, warning)) { + if (warning != AnimationPerformanceWarning::Type::None) { + EffectCompositor::SetPerformanceWarning( + aFrame, aPropertySet, AnimationPerformanceWarning(warning)); + } + return false; + } + + // The animation cascade will almost always be up-to-date by this point + // but there are some cases such as when we are restoring the refresh driver + // from test control after seeking where it might not be the case. + // + // Those cases are probably not important but just to be safe, let's make + // sure the cascade is up to date since if it *is* up to date, this is + // basically a no-op. + Maybe<NonOwningAnimationTarget> pseudoElement = + EffectCompositor::GetAnimationElementAndPseudoForFrame( + nsLayoutUtils::GetStyleFrame(aFrame)); + MOZ_ASSERT(pseudoElement, + "We have a valid element for the frame, if we don't we should " + "have bailed out at above the call to EffectSet::Get"); + EffectCompositor::MaybeUpdateCascadeResults(pseudoElement->mElement, + pseudoElement->mPseudoType); + + bool foundRunningAnimations = false; + for (KeyframeEffect* effect : *effects) { + AnimationPerformanceWarning::Type effectWarning = + AnimationPerformanceWarning::Type::None; + KeyframeEffect::MatchForCompositor matchResult = + effect->IsMatchForCompositor(aPropertySet, aFrame, *effects, + effectWarning); + if (effectWarning != AnimationPerformanceWarning::Type::None) { + EffectCompositor::SetPerformanceWarning( + aFrame, aPropertySet, AnimationPerformanceWarning(effectWarning)); + } + + if (matchResult == + KeyframeEffect::MatchForCompositor::NoAndBlockThisProperty) { + // For a given |aFrame|, we don't want some animations of |aPropertySet| + // to run on the compositor and others to run on the main thread, so if + // any need to be synchronized with the main thread, run them all there. + if (aMatches) { + aMatches->Clear(); + } + return false; + } + + if (matchResult == KeyframeEffect::MatchForCompositor::No) { + continue; + } + + if (aMatches) { + aMatches->AppendElement(effect->GetAnimation()); + } + + if (matchResult == KeyframeEffect::MatchForCompositor::Yes) { + foundRunningAnimations = true; + } + } + + // If all animations we added were not currently playing animations, don't + // send them to the compositor. + if (aMatches && !foundRunningAnimations) { + aMatches->Clear(); + } + + MOZ_ASSERT(!foundRunningAnimations || !aMatches || !aMatches->IsEmpty(), + "If return value is true, matches array should be non-empty"); + + if (aMatches && foundRunningAnimations) { + aMatches->Sort(AnimationPtrComparator<RefPtr<dom::Animation>>()); + } + + return foundRunningAnimations; +} + +void EffectCompositor::RequestRestyle(dom::Element* aElement, + PseudoStyleType aPseudoType, + RestyleType aRestyleType, + CascadeLevel aCascadeLevel) { + if (!mPresContext) { + // Pres context will be null after the effect compositor is disconnected. + return; + } + + // Ignore animations on orphaned elements and elements in documents without + // a pres shell (e.g. XMLHttpRequest responseXML documents). + if (!nsContentUtils::GetPresShellForContent(aElement)) { + return; + } + + auto& elementsToRestyle = mElementsToRestyle[aCascadeLevel]; + PseudoElementHashEntry::KeyType key = {aElement, aPseudoType}; + + bool& restyleEntry = elementsToRestyle.LookupOrInsert(key, false); + if (aRestyleType == RestyleType::Throttled) { + mPresContext->PresShell()->SetNeedThrottledAnimationFlush(); + } else { + // Update hashtable first in case PostRestyleForAnimation mutates it + // and invalidates the restyleEntry reference. + // (It shouldn't, but just to be sure.) + bool skipRestyle = std::exchange(restyleEntry, true); + if (!skipRestyle) { + PostRestyleForAnimation(aElement, aPseudoType, aCascadeLevel); + } + } + + if (aRestyleType == RestyleType::Layer) { + mPresContext->RestyleManager()->IncrementAnimationGeneration(); + if (auto* effectSet = EffectSet::Get(aElement, aPseudoType)) { + effectSet->UpdateAnimationGeneration(mPresContext); + } + } +} + +void EffectCompositor::PostRestyleForAnimation(dom::Element* aElement, + PseudoStyleType aPseudoType, + CascadeLevel aCascadeLevel) { + if (!mPresContext) { + return; + } + + // FIXME: Bug 1615083 KeyframeEffect::SetTarget() and + // KeyframeEffect::SetPseudoElement() may set a non-existing pseudo element, + // and we still have to update its style, based on the wpt. However, we don't + // have the generated element here, so we failed the wpt. + // + // See wpt for more info: web-animations/interfaces/KeyframeEffect/target.html + Element* element = + AnimationUtils::GetElementForRestyle(aElement, aPseudoType); + if (!element) { + return; + } + + RestyleHint hint = aCascadeLevel == CascadeLevel::Transitions + ? RestyleHint::RESTYLE_CSS_TRANSITIONS + : RestyleHint::RESTYLE_CSS_ANIMATIONS; + + MOZ_ASSERT(NS_IsMainThread(), + "Restyle request during restyling should be requested only on " + "the main-thread. e.g. after the parallel traversal"); + if (ServoStyleSet::IsInServoTraversal() || mIsInPreTraverse) { + MOZ_ASSERT(hint == RestyleHint::RESTYLE_CSS_ANIMATIONS || + hint == RestyleHint::RESTYLE_CSS_TRANSITIONS); + + // We can't call Servo_NoteExplicitHints here since AtomicRefCell does not + // allow us mutate ElementData of the |aElement| in SequentialTask. + // Instead we call Servo_NoteExplicitHints for the element in PreTraverse() + // which will be called right before the second traversal that we do for + // updating CSS animations. + // In that case PreTraverse() will return true so that we know to do the + // second traversal so we don't need to post any restyle requests to the + // PresShell. + return; + } + + MOZ_ASSERT(!mPresContext->RestyleManager()->IsInStyleRefresh()); + + mPresContext->PresShell()->RestyleForAnimation(element, hint); +} + +void EffectCompositor::PostRestyleForThrottledAnimations() { + for (size_t i = 0; i < kCascadeLevelCount; i++) { + CascadeLevel cascadeLevel = CascadeLevel(i); + auto& elementSet = mElementsToRestyle[cascadeLevel]; + + for (auto iter = elementSet.Iter(); !iter.Done(); iter.Next()) { + bool& postedRestyle = iter.Data(); + if (postedRestyle) { + continue; + } + + PostRestyleForAnimation(iter.Key().mElement, iter.Key().mPseudoType, + cascadeLevel); + postedRestyle = true; + } + } +} + +void EffectCompositor::ClearRestyleRequestsFor(Element* aElement) { + MOZ_ASSERT(aElement); + + auto& elementsToRestyle = mElementsToRestyle[CascadeLevel::Animations]; + + PseudoStyleType pseudoType = aElement->GetPseudoElementType(); + if (pseudoType == PseudoStyleType::NotPseudo) { + PseudoElementHashEntry::KeyType notPseudoKey = {aElement, + PseudoStyleType::NotPseudo}; + PseudoElementHashEntry::KeyType beforePseudoKey = {aElement, + PseudoStyleType::before}; + PseudoElementHashEntry::KeyType afterPseudoKey = {aElement, + PseudoStyleType::after}; + PseudoElementHashEntry::KeyType markerPseudoKey = {aElement, + PseudoStyleType::marker}; + + elementsToRestyle.Remove(notPseudoKey); + elementsToRestyle.Remove(beforePseudoKey); + elementsToRestyle.Remove(afterPseudoKey); + elementsToRestyle.Remove(markerPseudoKey); + } else if (AnimationUtils::IsSupportedPseudoForAnimations(pseudoType)) { + Element* parentElement = aElement->GetParentElement(); + MOZ_ASSERT(parentElement); + PseudoElementHashEntry::KeyType key = {parentElement, pseudoType}; + elementsToRestyle.Remove(key); + } +} + +void EffectCompositor::UpdateEffectProperties(const ComputedStyle* aStyle, + Element* aElement, + PseudoStyleType aPseudoType) { + EffectSet* effectSet = EffectSet::Get(aElement, aPseudoType); + if (!effectSet) { + return; + } + + // Style context (Gecko) or computed values (Stylo) change might cause CSS + // cascade level, e.g removing !important, so we should update the cascading + // result. + effectSet->MarkCascadeNeedsUpdate(); + + for (KeyframeEffect* effect : *effectSet) { + effect->UpdateProperties(aStyle); + } +} + +namespace { +class EffectCompositeOrderComparator { + public: + bool Equals(const KeyframeEffect* a, const KeyframeEffect* b) const { + return a == b; + } + + bool LessThan(const KeyframeEffect* a, const KeyframeEffect* b) const { + MOZ_ASSERT(a->GetAnimation() && b->GetAnimation()); + MOZ_ASSERT( + Equals(a, b) || + a->GetAnimation()->HasLowerCompositeOrderThan(*b->GetAnimation()) != + b->GetAnimation()->HasLowerCompositeOrderThan(*a->GetAnimation())); + return a->GetAnimation()->HasLowerCompositeOrderThan(*b->GetAnimation()); + } +}; +} // namespace + +static void ComposeSortedEffects( + const nsTArray<KeyframeEffect*>& aSortedEffects, + const EffectSet* aEffectSet, EffectCompositor::CascadeLevel aCascadeLevel, + StyleAnimationValueMap* aAnimationValues) { + const bool isTransition = + aCascadeLevel == EffectCompositor::CascadeLevel::Transitions; + nsCSSPropertyIDSet propertiesToSkip; + // Transitions should be overridden by running animations of the same + // property per https://drafts.csswg.org/css-transitions/#application: + // + // > Implementations must add this value to the cascade if and only if that + // > property is not currently undergoing a CSS Animation on the same element. + // + // FIXME(emilio, bug 1606176): This should assert that + // aEffectSet->PropertiesForAnimationsLevel() is up-to-date, and it may not + // follow the spec in those cases. There are various places where we get style + // without flushing that would trigger the below assertion. + // + // MOZ_ASSERT_IF(aEffectSet, !aEffectSet->CascadeNeedsUpdate()); + if (aEffectSet) { + propertiesToSkip = + isTransition ? aEffectSet->PropertiesForAnimationsLevel() + : aEffectSet->PropertiesForAnimationsLevel().Inverse(); + } + + for (KeyframeEffect* effect : aSortedEffects) { + auto* animation = effect->GetAnimation(); + MOZ_ASSERT(!isTransition || animation->CascadeLevel() == aCascadeLevel); + animation->ComposeStyle(*aAnimationValues, propertiesToSkip); + } +} + +bool EffectCompositor::GetServoAnimationRule( + const dom::Element* aElement, PseudoStyleType aPseudoType, + CascadeLevel aCascadeLevel, StyleAnimationValueMap* aAnimationValues) { + MOZ_ASSERT(aAnimationValues); + // Gecko_GetAnimationRule should have already checked this + MOZ_ASSERT(nsContentUtils::GetPresShellForContent(aElement), + "Should not be trying to run animations on elements in documents" + " without a pres shell (e.g. XMLHttpRequest documents)"); + + EffectSet* effectSet = EffectSet::Get(aElement, aPseudoType); + if (!effectSet) { + return false; + } + + const bool isTransition = aCascadeLevel == CascadeLevel::Transitions; + + // Get a list of effects sorted by composite order. + nsTArray<KeyframeEffect*> sortedEffectList(effectSet->Count()); + for (KeyframeEffect* effect : *effectSet) { + if (isTransition && + effect->GetAnimation()->CascadeLevel() != aCascadeLevel) { + // We may need to use transition rules for the animations level for the + // case of missing keyframes in animations, but we don't ever need to look + // at non-transition levels to build a transition rule. When the effect + // set information is out of date (see above), this avoids creating bogus + // transition rules, see bug 1605610. + continue; + } + sortedEffectList.AppendElement(effect); + } + + if (sortedEffectList.IsEmpty()) { + return false; + } + + sortedEffectList.Sort(EffectCompositeOrderComparator()); + + ComposeSortedEffects(sortedEffectList, effectSet, aCascadeLevel, + aAnimationValues); + + MOZ_ASSERT(effectSet == EffectSet::Get(aElement, aPseudoType), + "EffectSet should not change while composing style"); + + return true; +} + +bool EffectCompositor::ComposeServoAnimationRuleForEffect( + KeyframeEffect& aEffect, CascadeLevel aCascadeLevel, + StyleAnimationValueMap* aAnimationValues) { + MOZ_ASSERT(aAnimationValues); + MOZ_ASSERT(mPresContext && mPresContext->IsDynamic(), + "Should not be in print preview"); + + NonOwningAnimationTarget target = aEffect.GetAnimationTarget(); + if (!target) { + return false; + } + + // Don't try to compose animations for elements in documents without a pres + // shell (e.g. XMLHttpRequest documents). + if (!nsContentUtils::GetPresShellForContent(target.mElement)) { + return false; + } + + // GetServoAnimationRule is called as part of the regular style resolution + // where the cascade results are updated in the pre-traversal as needed. + // This function, however, is only called when committing styles so we + // need to ensure the cascade results are up-to-date manually. + MaybeUpdateCascadeResults(target.mElement, target.mPseudoType); + + EffectSet* effectSet = EffectSet::Get(target.mElement, target.mPseudoType); + + // Get a list of effects sorted by composite order up to and including + // |aEffect|, even if it is not in the EffectSet. + auto comparator = EffectCompositeOrderComparator(); + nsTArray<KeyframeEffect*> sortedEffectList(effectSet ? effectSet->Count() + 1 + : 1); + if (effectSet) { + for (KeyframeEffect* effect : *effectSet) { + if (comparator.LessThan(effect, &aEffect)) { + sortedEffectList.AppendElement(effect); + } + } + sortedEffectList.Sort(comparator); + } + sortedEffectList.AppendElement(&aEffect); + + ComposeSortedEffects(sortedEffectList, effectSet, aCascadeLevel, + aAnimationValues); + + MOZ_ASSERT(effectSet == EffectSet::Get(target.mElement, target.mPseudoType), + "EffectSet should not change while composing style"); + + return true; +} + +bool EffectCompositor::HasPendingStyleUpdates() const { + for (auto& elementSet : mElementsToRestyle) { + if (elementSet.Count()) { + return true; + } + } + + return false; +} + +/* static */ +bool EffectCompositor::HasAnimationsForCompositor(const nsIFrame* aFrame, + DisplayItemType aType) { + return FindAnimationsForCompositor( + aFrame, LayerAnimationInfo::GetCSSPropertiesFor(aType), nullptr); +} + +/* static */ +nsTArray<RefPtr<dom::Animation>> EffectCompositor::GetAnimationsForCompositor( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet) { + nsTArray<RefPtr<dom::Animation>> result; + +#ifdef DEBUG + bool foundSome = +#endif + FindAnimationsForCompositor(aFrame, aPropertySet, &result); + MOZ_ASSERT(!foundSome || !result.IsEmpty(), + "If return value is true, matches array should be non-empty"); + + return result; +} + +/* static */ +void EffectCompositor::ClearIsRunningOnCompositor(const nsIFrame* aFrame, + DisplayItemType aType) { + EffectSet* effects = EffectSet::GetForFrame(aFrame, aType); + if (!effects) { + return; + } + + const nsCSSPropertyIDSet& propertySet = + LayerAnimationInfo::GetCSSPropertiesFor(aType); + for (KeyframeEffect* effect : *effects) { + effect->SetIsRunningOnCompositor(propertySet, false); + } +} + +/* static */ +void EffectCompositor::MaybeUpdateCascadeResults(Element* aElement, + PseudoStyleType aPseudoType) { + EffectSet* effects = EffectSet::Get(aElement, aPseudoType); + if (!effects || !effects->CascadeNeedsUpdate()) { + return; + } + + UpdateCascadeResults(*effects, aElement, aPseudoType); + + MOZ_ASSERT(!effects->CascadeNeedsUpdate(), "Failed to update cascade state"); +} + +/* static */ +Maybe<NonOwningAnimationTarget> +EffectCompositor::GetAnimationElementAndPseudoForFrame(const nsIFrame* aFrame) { + // Always return the same object to benefit from return-value optimization. + Maybe<NonOwningAnimationTarget> result; + + PseudoStyleType pseudoType = aFrame->Style()->GetPseudoType(); + + if (pseudoType != PseudoStyleType::NotPseudo && + !AnimationUtils::IsSupportedPseudoForAnimations(pseudoType)) { + return result; + } + + nsIContent* content = aFrame->GetContent(); + if (!content) { + return result; + } + + if (AnimationUtils::IsSupportedPseudoForAnimations(pseudoType)) { + content = content->GetParent(); + if (!content) { + return result; + } + } + + if (!content->IsElement()) { + return result; + } + + result.emplace(content->AsElement(), pseudoType); + + return result; +} + +/* static */ +nsCSSPropertyIDSet EffectCompositor::GetOverriddenProperties( + EffectSet& aEffectSet, Element* aElement, PseudoStyleType aPseudoType) { + MOZ_ASSERT(aElement, "Should have an element to get style data from"); + + nsCSSPropertyIDSet result; + + Element* elementForRestyle = + AnimationUtils::GetElementForRestyle(aElement, aPseudoType); + if (!elementForRestyle) { + return result; + } + + static constexpr size_t compositorAnimatableCount = + nsCSSPropertyIDSet::CompositorAnimatableCount(); + AutoTArray<nsCSSPropertyID, compositorAnimatableCount> propertiesToTrack; + { + nsCSSPropertyIDSet propertiesToTrackAsSet; + for (KeyframeEffect* effect : aEffectSet) { + for (const AnimationProperty& property : effect->Properties()) { + if (nsCSSProps::PropHasFlags(property.mProperty, + CSSPropFlags::CanAnimateOnCompositor) && + !propertiesToTrackAsSet.HasProperty(property.mProperty)) { + propertiesToTrackAsSet.AddProperty(property.mProperty); + propertiesToTrack.AppendElement(property.mProperty); + } + } + // Skip iterating over the rest of the effects if we've already + // found all the compositor-animatable properties. + if (propertiesToTrack.Length() == compositorAnimatableCount) { + break; + } + } + } + + if (propertiesToTrack.IsEmpty()) { + return result; + } + + Servo_GetProperties_Overriding_Animation(elementForRestyle, + &propertiesToTrack, &result); + return result; +} + +/* static */ +void EffectCompositor::UpdateCascadeResults(EffectSet& aEffectSet, + Element* aElement, + PseudoStyleType aPseudoType) { + MOZ_ASSERT(EffectSet::Get(aElement, aPseudoType) == &aEffectSet, + "Effect set should correspond to the specified (pseudo-)element"); + if (aEffectSet.IsEmpty()) { + aEffectSet.MarkCascadeUpdated(); + return; + } + + // Get a list of effects sorted by composite order. + nsTArray<KeyframeEffect*> sortedEffectList(aEffectSet.Count()); + for (KeyframeEffect* effect : aEffectSet) { + sortedEffectList.AppendElement(effect); + } + sortedEffectList.Sort(EffectCompositeOrderComparator()); + + // Get properties that override the *animations* level of the cascade. + // + // We only do this for properties that we can animate on the compositor + // since we will apply other properties on the main thread where the usual + // cascade applies. + nsCSSPropertyIDSet overriddenProperties = + GetOverriddenProperties(aEffectSet, aElement, aPseudoType); + + nsCSSPropertyIDSet& propertiesWithImportantRules = + aEffectSet.PropertiesWithImportantRules(); + nsCSSPropertyIDSet& propertiesForAnimationsLevel = + aEffectSet.PropertiesForAnimationsLevel(); + + static constexpr nsCSSPropertyIDSet compositorAnimatables = + nsCSSPropertyIDSet::CompositorAnimatables(); + // Record which compositor-animatable properties were originally set so we can + // compare for changes later. + nsCSSPropertyIDSet prevCompositorPropertiesWithImportantRules = + propertiesWithImportantRules.Intersect(compositorAnimatables); + + nsCSSPropertyIDSet prevPropertiesForAnimationsLevel = + propertiesForAnimationsLevel; + + propertiesWithImportantRules.Empty(); + propertiesForAnimationsLevel.Empty(); + + nsCSSPropertyIDSet propertiesForTransitionsLevel; + + for (const KeyframeEffect* effect : sortedEffectList) { + MOZ_ASSERT(effect->GetAnimation(), + "Effects on a target element should have an Animation"); + CascadeLevel cascadeLevel = effect->GetAnimation()->CascadeLevel(); + + for (const AnimationProperty& prop : effect->Properties()) { + if (overriddenProperties.HasProperty(prop.mProperty)) { + propertiesWithImportantRules.AddProperty(prop.mProperty); + } + + switch (cascadeLevel) { + case EffectCompositor::CascadeLevel::Animations: + propertiesForAnimationsLevel.AddProperty(prop.mProperty); + break; + case EffectCompositor::CascadeLevel::Transitions: + propertiesForTransitionsLevel.AddProperty(prop.mProperty); + break; + } + } + } + + aEffectSet.MarkCascadeUpdated(); + + nsPresContext* presContext = nsContentUtils::GetContextForContent(aElement); + if (!presContext) { + return; + } + + // If properties for compositor are newly overridden by !important rules, or + // released from being overridden by !important rules, we need to update + // layers for animations level because it's a trigger to send animations to + // the compositor or pull animations back from the compositor. + if (!prevCompositorPropertiesWithImportantRules.Equals( + propertiesWithImportantRules.Intersect(compositorAnimatables))) { + presContext->EffectCompositor()->RequestRestyle( + aElement, aPseudoType, EffectCompositor::RestyleType::Layer, + EffectCompositor::CascadeLevel::Animations); + } + + // If we have transition properties and if the same propery for animations + // level is newly added or removed, we need to update the transition level + // rule since the it will be added/removed from the rule tree. + nsCSSPropertyIDSet changedPropertiesForAnimationLevel = + prevPropertiesForAnimationsLevel.Xor(propertiesForAnimationsLevel); + nsCSSPropertyIDSet commonProperties = propertiesForTransitionsLevel.Intersect( + changedPropertiesForAnimationLevel); + if (!commonProperties.IsEmpty()) { + EffectCompositor::RestyleType restyleType = + changedPropertiesForAnimationLevel.Intersects(compositorAnimatables) + ? EffectCompositor::RestyleType::Standard + : EffectCompositor::RestyleType::Layer; + presContext->EffectCompositor()->RequestRestyle( + aElement, aPseudoType, restyleType, + EffectCompositor::CascadeLevel::Transitions); + } +} + +/* static */ +void EffectCompositor::SetPerformanceWarning( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet, + const AnimationPerformanceWarning& aWarning) { + EffectSet* effects = EffectSet::GetForFrame(aFrame, aPropertySet); + if (!effects) { + return; + } + + for (KeyframeEffect* effect : *effects) { + effect->SetPerformanceWarning(aPropertySet, aWarning); + } +} + +bool EffectCompositor::PreTraverse(ServoTraversalFlags aFlags) { + return PreTraverseInSubtree(aFlags, nullptr); +} + +bool EffectCompositor::PreTraverseInSubtree(ServoTraversalFlags aFlags, + Element* aRoot) { + MOZ_ASSERT(NS_IsMainThread()); + MOZ_ASSERT(!aRoot || nsContentUtils::GetPresShellForContent(aRoot), + "Traversal root, if provided, should be bound to a display " + "document"); + + // Convert the root element to the parent element if the root element is + // pseudo since we check each element in mElementsToRestyle is in the subtree + // of the root element later in this function, but for pseudo elements the + // element in mElementsToRestyle is the parent of the pseudo. + if (aRoot && (aRoot->IsGeneratedContentContainerForBefore() || + aRoot->IsGeneratedContentContainerForAfter() || + aRoot->IsGeneratedContentContainerForMarker())) { + aRoot = aRoot->GetParentElement(); + } + + AutoRestore<bool> guard(mIsInPreTraverse); + mIsInPreTraverse = true; + + // We need to force flush all throttled animations if we also have + // non-animation restyles (since we'll want the up-to-date animation style + // when we go to process them so we can trigger transitions correctly), and + // if we are currently flushing all throttled animation restyles. + bool flushThrottledRestyles = + (aRoot && aRoot->HasDirtyDescendantsForServo()) || + (aFlags & ServoTraversalFlags::FlushThrottledAnimations); + + using ElementsToRestyleIterType = + nsTHashMap<PseudoElementHashEntry, bool>::ConstIterator; + auto getNeededRestyleTarget = + [&](const ElementsToRestyleIterType& aIter) -> NonOwningAnimationTarget { + NonOwningAnimationTarget returnTarget; + + // If aIter.Data() is false, the element only requested a throttled + // (skippable) restyle, so we can skip it if flushThrottledRestyles is not + // true. + if (!flushThrottledRestyles && !aIter.Data()) { + return returnTarget; + } + + const NonOwningAnimationTarget& target = aIter.Key(); + + // Skip elements in documents without a pres shell. Normally we filter out + // such elements in RequestRestyle but it can happen that, after adding + // them to mElementsToRestyle, they are transferred to a different document. + // + // We will drop them from mElementsToRestyle at the end of the next full + // document restyle (at the end of this function) but for consistency with + // how we treat such elements in RequestRestyle, we just ignore them here. + if (!nsContentUtils::GetPresShellForContent(target.mElement)) { + return returnTarget; + } + + // Ignore restyles that aren't in the flattened tree subtree rooted at + // aRoot. + if (aRoot && !nsContentUtils::ContentIsFlattenedTreeDescendantOfForStyle( + target.mElement, aRoot)) { + return returnTarget; + } + + returnTarget = target; + return returnTarget; + }; + + bool foundElementsNeedingRestyle = false; + + nsTArray<NonOwningAnimationTarget> elementsWithCascadeUpdates; + for (size_t i = 0; i < kCascadeLevelCount; ++i) { + CascadeLevel cascadeLevel = CascadeLevel(i); + auto& elementSet = mElementsToRestyle[cascadeLevel]; + for (auto iter = elementSet.ConstIter(); !iter.Done(); iter.Next()) { + const NonOwningAnimationTarget& target = getNeededRestyleTarget(iter); + if (!target.mElement) { + continue; + } + + EffectSet* effects = EffectSet::Get(target.mElement, target.mPseudoType); + if (!effects || !effects->CascadeNeedsUpdate()) { + continue; + } + + elementsWithCascadeUpdates.AppendElement(target); + } + } + + for (const NonOwningAnimationTarget& target : elementsWithCascadeUpdates) { + MaybeUpdateCascadeResults(target.mElement, target.mPseudoType); + } + elementsWithCascadeUpdates.Clear(); + + for (size_t i = 0; i < kCascadeLevelCount; ++i) { + CascadeLevel cascadeLevel = CascadeLevel(i); + auto& elementSet = mElementsToRestyle[cascadeLevel]; + for (auto iter = elementSet.Iter(); !iter.Done(); iter.Next()) { + const NonOwningAnimationTarget& target = getNeededRestyleTarget(iter); + if (!target.mElement) { + continue; + } + + if (target.mElement->GetComposedDoc() != mPresContext->Document()) { + iter.Remove(); + continue; + } + + // We need to post restyle hints even if the target is not in EffectSet to + // ensure the final restyling for removed animations. + // We can't call PostRestyleEvent directly here since we are still in the + // middle of the servo traversal. + mPresContext->RestyleManager()->PostRestyleEventForAnimations( + target.mElement, target.mPseudoType, + cascadeLevel == CascadeLevel::Transitions + ? RestyleHint::RESTYLE_CSS_TRANSITIONS + : RestyleHint::RESTYLE_CSS_ANIMATIONS); + + foundElementsNeedingRestyle = true; + + auto* effects = EffectSet::Get(target.mElement, target.mPseudoType); + if (!effects) { + // Drop EffectSets that have been destroyed. + iter.Remove(); + continue; + } + + for (KeyframeEffect* effect : *effects) { + effect->GetAnimation()->WillComposeStyle(); + } + + // Remove the element from the list of elements to restyle since we are + // about to restyle it. + iter.Remove(); + } + + // If this is a full document restyle, then unconditionally clear + // elementSet in case there are any elements that didn't match above + // because they were moved to a document without a pres shell after + // posting an animation restyle. + if (!aRoot && flushThrottledRestyles) { + elementSet.Clear(); + } + } + + return foundElementsNeedingRestyle; +} + +void EffectCompositor::NoteElementForReducing( + const NonOwningAnimationTarget& aTarget) { + if (!StaticPrefs::dom_animations_api_autoremove_enabled()) { + return; + } + + Unused << mElementsToReduce.put( + OwningAnimationTarget{aTarget.mElement, aTarget.mPseudoType}); +} + +static void ReduceEffectSet(EffectSet& aEffectSet) { + // Get a list of effects sorted by composite order. + nsTArray<KeyframeEffect*> sortedEffectList(aEffectSet.Count()); + for (KeyframeEffect* effect : aEffectSet) { + sortedEffectList.AppendElement(effect); + } + sortedEffectList.Sort(EffectCompositeOrderComparator()); + + nsCSSPropertyIDSet setProperties; + + // Iterate in reverse + for (auto iter = sortedEffectList.rbegin(); iter != sortedEffectList.rend(); + ++iter) { + MOZ_ASSERT(*iter && (*iter)->GetAnimation(), + "Effect in an EffectSet should have an animation"); + KeyframeEffect& effect = **iter; + Animation& animation = *effect.GetAnimation(); + if (animation.IsRemovable() && + effect.GetPropertySet().IsSubsetOf(setProperties)) { + animation.Remove(); + } else if (animation.IsReplaceable()) { + setProperties |= effect.GetPropertySet(); + } + } +} + +void EffectCompositor::ReduceAnimations() { + for (auto iter = mElementsToReduce.iter(); !iter.done(); iter.next()) { + const OwningAnimationTarget& target = iter.get(); + auto* effectSet = EffectSet::Get(target.mElement, target.mPseudoType); + if (effectSet) { + ReduceEffectSet(*effectSet); + } + } + + mElementsToReduce.clear(); +} + +} // namespace mozilla diff --git a/dom/animation/EffectCompositor.h b/dom/animation/EffectCompositor.h new file mode 100644 index 0000000000..34c2688486 --- /dev/null +++ b/dom/animation/EffectCompositor.h @@ -0,0 +1,258 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_EffectCompositor_h +#define mozilla_EffectCompositor_h + +#include "mozilla/AnimationPerformanceWarning.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/EnumeratedArray.h" +#include "mozilla/HashTable.h" +#include "mozilla/Maybe.h" +#include "mozilla/OwningNonNull.h" +#include "mozilla/PseudoElementHashEntry.h" +#include "mozilla/RefPtr.h" +#include "mozilla/ServoTypes.h" +#include "nsCSSPropertyID.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTHashMap.h" +#include "nsTArray.h" + +class nsCSSPropertyIDSet; +class nsAtom; +class nsIFrame; +class nsPresContext; +enum class DisplayItemType : uint8_t; + +namespace mozilla { + +class ComputedStyle; +class EffectSet; +class RestyleTracker; +struct StyleAnimationValue; +struct StyleAnimationValueMap; +struct AnimationProperty; +struct NonOwningAnimationTarget; + +namespace dom { +class Animation; +class Element; +class KeyframeEffect; +} // namespace dom + +class EffectCompositor { + public: + explicit EffectCompositor(nsPresContext* aPresContext) + : mPresContext(aPresContext) {} + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(EffectCompositor) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(EffectCompositor) + + void Disconnect() { mPresContext = nullptr; } + + // Animations can be applied at two different levels in the CSS cascade: + enum class CascadeLevel : uint32_t { + // The animations sheet (CSS animations, script-generated animations, + // and CSS transitions that are no longer tied to CSS markup) + Animations = 0, + // The transitions sheet (CSS transitions that are tied to CSS markup) + Transitions = 1 + }; + // We don't define this as part of CascadeLevel as then we'd have to add + // explicit checks for the Count enum value everywhere CascadeLevel is used. + static const size_t kCascadeLevelCount = + static_cast<size_t>(CascadeLevel::Transitions) + 1; + + // NOTE: This can return null after Disconnect(). + nsPresContext* PresContext() const { return mPresContext; } + + enum class RestyleType { + // Animation style has changed but the compositor is applying the same + // change so we might be able to defer updating the main thread until it + // becomes necessary. + Throttled, + // Animation style has changed and needs to be updated on the main thread. + Standard, + // Animation style has changed and needs to be updated on the main thread + // as well as forcing animations on layers to be updated. + // This is needed in cases such as when an animation becomes paused or has + // its playback rate changed. In such cases, although the computed style + // and refresh driver time might not change, we still need to ensure the + // corresponding animations on layers are updated to reflect the new + // configuration of the animation. + Layer + }; + + // Notifies the compositor that the animation rule for the specified + // (pseudo-)element at the specified cascade level needs to be updated. + // The specified steps taken to update the animation rule depend on + // |aRestyleType| whose values are described above. + void RequestRestyle(dom::Element* aElement, PseudoStyleType aPseudoType, + RestyleType aRestyleType, CascadeLevel aCascadeLevel); + + // Schedule an animation restyle. This is called automatically by + // RequestRestyle when necessary. However, it is exposed here since we also + // need to perform this step when triggering transitions *without* also + // invalidating the animation style rule (which RequestRestyle would do). + void PostRestyleForAnimation(dom::Element* aElement, + PseudoStyleType aPseudoType, + CascadeLevel aCascadeLevel); + + // Posts an animation restyle for any elements whose animation style rule + // is out of date but for which an animation restyle has not yet been + // posted because updates on the main thread are throttled. + void PostRestyleForThrottledAnimations(); + + // Clear all pending restyle requests for the given (pseudo-) element (and its + // ::before, ::after and ::marker elements if the given element is not + // pseudo). + void ClearRestyleRequestsFor(dom::Element* aElement); + + // Called when computed style on the specified (pseudo-) element might + // have changed so that any context-sensitive values stored within + // animation effects (e.g. em-based endpoints used in keyframe effects) + // can be re-resolved to computed values. + void UpdateEffectProperties(const ComputedStyle* aStyle, + dom::Element* aElement, + PseudoStyleType aPseudoType); + + // Get the animation rule for the appropriate level of the cascade for + // a (pseudo-)element. Called from the Servo side. + // + // The animation rule is stored in |StyleAnimationValueMap|. + // We need to be careful while doing any modification because it may cause + // some thread-safe issues. + bool GetServoAnimationRule(const dom::Element* aElement, + PseudoStyleType aPseudoType, + CascadeLevel aCascadeLevel, + StyleAnimationValueMap* aAnimationValues); + + // A variant on GetServoAnimationRule that composes all the effects for an + // element up to and including |aEffect|. + // + // Note that |aEffect| might not be in the EffectSet since we can use this for + // committing the computed style of a removed Animation. + bool ComposeServoAnimationRuleForEffect( + dom::KeyframeEffect& aEffect, CascadeLevel aCascadeLevel, + StyleAnimationValueMap* aAnimationValues); + + bool HasPendingStyleUpdates() const; + + static bool HasAnimationsForCompositor(const nsIFrame* aFrame, + DisplayItemType aType); + + static nsTArray<RefPtr<dom::Animation>> GetAnimationsForCompositor( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet); + + static void ClearIsRunningOnCompositor(const nsIFrame* aFrame, + DisplayItemType aType); + + // Update animation cascade results for the specified (pseudo-)element + // but only if we have marked the cascade as needing an update due a + // the change in the set of effects or a change in one of the effects' + // "in effect" state. + // + // This method does NOT detect if other styles that apply above the + // animation level of the cascade have changed. + static void MaybeUpdateCascadeResults(dom::Element* aElement, + PseudoStyleType aPseudoType); + + // Update the mPropertiesWithImportantRules and + // mPropertiesForAnimationsLevel members of the given EffectSet, and also + // request any restyles required by changes to the cascade result. + // + // NOTE: This can be expensive so we should only call it if styles that apply + // above the animation level of the cascade might have changed. For all + // other cases we should call MaybeUpdateCascadeResults. + // + // This is typically reserved for internal callers but is public here since + // when we detect changes to the cascade on the Servo side we can't call + // MarkCascadeNeedsUpdate during the traversal so instead we call this as part + // of a follow-up sequential task. + static void UpdateCascadeResults(EffectSet& aEffectSet, + dom::Element* aElement, + PseudoStyleType aPseudoType); + + // Helper to fetch the corresponding element and pseudo-type from a frame. + // + // For frames corresponding to pseudo-elements, the returned element is the + // element on which we store the animations (i.e. the EffectSet and/or + // AnimationCollection), *not* the generated content. + // + // For display:table content, which maintains a distinction between primary + // frame (table wrapper frame) and style frame (inner table frame), animations + // are stored on the content associated with the _style_ frame even though + // some (particularly transform-like animations) may be applied to the + // _primary_ frame. As a result, callers will typically want to pass the style + // frame to this function. + // + // Returns an empty result when a suitable element cannot be found including + // when the frame represents a pseudo-element on which we do not support + // animations. + static Maybe<NonOwningAnimationTarget> GetAnimationElementAndPseudoForFrame( + const nsIFrame* aFrame); + + // Associates a performance warning with effects on |aFrame| that animate + // properties in |aPropertySet|. + static void SetPerformanceWarning( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet, + const AnimationPerformanceWarning& aWarning); + + // Do a bunch of stuff that we should avoid doing during the parallel + // traversal (e.g. changing member variables) for all elements that we expect + // to restyle on the next traversal. + // + // Returns true if there are elements needing a restyle for animation. + bool PreTraverse(ServoTraversalFlags aFlags); + + // Similar to the above but for all elements in the subtree rooted + // at aElement. + bool PreTraverseInSubtree(ServoTraversalFlags aFlags, dom::Element* aRoot); + + // Record a (pseudo-)element that may have animations that can be removed. + void NoteElementForReducing(const NonOwningAnimationTarget& aTarget); + + bool NeedsReducing() const { return !mElementsToReduce.empty(); } + void ReduceAnimations(); + + // Returns true if any type of compositor animations on |aFrame| allow + // runnning on the compositor. + // Sets the reason in |aWarning| if the result is false. + static bool AllowCompositorAnimationsOnFrame( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aWarning /* out */); + + private: + ~EffectCompositor() = default; + + // Get the properties in |aEffectSet| that we are able to animate on the + // compositor but which are also specified at a higher level in the cascade + // than the animations level. + static nsCSSPropertyIDSet GetOverriddenProperties( + EffectSet& aEffectSet, dom::Element* aElement, + PseudoStyleType aPseudoType); + + static nsPresContext* GetPresContext(dom::Element* aElement); + + nsPresContext* mPresContext; + + // Elements with a pending animation restyle. The associated bool value is + // true if a pending animation restyle has also been dispatched. For + // animations that can be throttled, we will add an entry to the hashtable to + // indicate that the style rule on the element is out of date but without + // posting a restyle to update it. + EnumeratedArray<CascadeLevel, CascadeLevel(kCascadeLevelCount), + nsTHashMap<PseudoElementHashEntry, bool>> + mElementsToRestyle; + + bool mIsInPreTraverse = false; + + HashSet<OwningAnimationTarget> mElementsToReduce; +}; + +} // namespace mozilla + +#endif // mozilla_EffectCompositor_h diff --git a/dom/animation/EffectSet.cpp b/dom/animation/EffectSet.cpp new file mode 100644 index 0000000000..160b1322e8 --- /dev/null +++ b/dom/animation/EffectSet.cpp @@ -0,0 +1,132 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "EffectSet.h" +#include "mozilla/dom/Element.h" // For Element +#include "mozilla/RestyleManager.h" +#include "mozilla/LayerAnimationInfo.h" +#include "nsCSSPseudoElements.h" // For PseudoStyleType +#include "nsCycleCollectionNoteChild.h" // For CycleCollectionNoteChild +#include "nsPresContext.h" +#include "nsLayoutUtils.h" +#include "ElementAnimationData.h" + +namespace mozilla { + +void EffectSet::Traverse(nsCycleCollectionTraversalCallback& aCallback) { + for (const auto& key : mEffects) { + CycleCollectionNoteChild(aCallback, key, "EffectSet::mEffects[]", + aCallback.Flags()); + } +} + +/* static */ +EffectSet* EffectSet::Get(const dom::Element* aElement, + PseudoStyleType aPseudoType) { + if (auto* data = aElement->GetAnimationData()) { + return data->GetEffectSetFor(aPseudoType); + } + return nullptr; +} + +/* static */ +EffectSet* EffectSet::GetOrCreate(dom::Element* aElement, + PseudoStyleType aPseudoType) { + return &aElement->EnsureAnimationData().EnsureEffectSetFor(aPseudoType); +} + +/* static */ +EffectSet* EffectSet::GetForFrame(const nsIFrame* aFrame, + const nsCSSPropertyIDSet& aProperties) { + MOZ_ASSERT(aFrame); + + // Transform animations are run on the primary frame (but stored on the + // content associated with the style frame). + const nsIFrame* frameToQuery = nullptr; + if (aProperties.IsSubsetOf(nsCSSPropertyIDSet::TransformLikeProperties())) { + // Make sure to return nullptr if we're looking for transform animations on + // the inner table frame. + if (!aFrame->IsFrameOfType(nsIFrame::eSupportsCSSTransforms)) { + return nullptr; + } + frameToQuery = nsLayoutUtils::GetStyleFrame(aFrame); + } else { + MOZ_ASSERT( + !aProperties.Intersects(nsCSSPropertyIDSet::TransformLikeProperties()), + "We should have only transform properties or no transform properties"); + // We don't need to explicitly return nullptr when |aFrame| is NOT the style + // frame since there will be no effect set in that case. + frameToQuery = aFrame; + } + + Maybe<NonOwningAnimationTarget> target = + EffectCompositor::GetAnimationElementAndPseudoForFrame(frameToQuery); + if (!target) { + return nullptr; + } + + return Get(target->mElement, target->mPseudoType); +} + +/* static */ +EffectSet* EffectSet::GetForFrame(const nsIFrame* aFrame, + DisplayItemType aDisplayItemType) { + return EffectSet::GetForFrame( + aFrame, LayerAnimationInfo::GetCSSPropertiesFor(aDisplayItemType)); +} + +/* static */ +EffectSet* EffectSet::GetForStyleFrame(const nsIFrame* aStyleFrame) { + Maybe<NonOwningAnimationTarget> target = + EffectCompositor::GetAnimationElementAndPseudoForFrame(aStyleFrame); + + if (!target) { + return nullptr; + } + + return Get(target->mElement, target->mPseudoType); +} + +/* static */ +EffectSet* EffectSet::GetForEffect(const dom::KeyframeEffect* aEffect) { + NonOwningAnimationTarget target = aEffect->GetAnimationTarget(); + if (!target) { + return nullptr; + } + + return EffectSet::Get(target.mElement, target.mPseudoType); +} + +/* static */ +void EffectSet::DestroyEffectSet(dom::Element* aElement, + PseudoStyleType aPseudoType) { + if (auto* data = aElement->GetAnimationData()) { + data->ClearEffectSetFor(aPseudoType); + } +} + +void EffectSet::UpdateAnimationGeneration(nsPresContext* aPresContext) { + mAnimationGeneration = + aPresContext->RestyleManager()->GetAnimationGeneration(); +} + +void EffectSet::AddEffect(dom::KeyframeEffect& aEffect) { + if (!mEffects.EnsureInserted(&aEffect)) { + return; + } + + MarkCascadeNeedsUpdate(); +} + +void EffectSet::RemoveEffect(dom::KeyframeEffect& aEffect) { + if (!mEffects.EnsureRemoved(&aEffect)) { + return; + } + + MarkCascadeNeedsUpdate(); +} + +} // namespace mozilla diff --git a/dom/animation/EffectSet.h b/dom/animation/EffectSet.h new file mode 100644 index 0000000000..c743676794 --- /dev/null +++ b/dom/animation/EffectSet.h @@ -0,0 +1,246 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_EffectSet_h +#define mozilla_EffectSet_h + +#include "mozilla/DebugOnly.h" +#include "mozilla/EffectCompositor.h" +#include "mozilla/EnumeratedArray.h" +#include "mozilla/TimeStamp.h" +#include "mozilla/dom/KeyframeEffect.h" +#include "nsHashKeys.h" // For nsPtrHashKey +#include "nsTHashSet.h" + +class nsPresContext; +enum class DisplayItemType : uint8_t; + +namespace mozilla { + +namespace dom { +class Element; +} // namespace dom + +enum class PseudoStyleType : uint8_t; + +// A wrapper around a hashset of AnimationEffect objects to handle +// storing the set as a property of an element. +class EffectSet { + public: + EffectSet() + : mCascadeNeedsUpdate(false), + mMayHaveOpacityAnim(false), + mMayHaveTransformAnim(false) { + MOZ_COUNT_CTOR(EffectSet); + } + + ~EffectSet() { + MOZ_ASSERT(!IsBeingEnumerated(), + "Effect set should not be destroyed while it is being " + "enumerated"); + MOZ_COUNT_DTOR(EffectSet); + } + + // Methods for supporting cycle-collection + void Traverse(nsCycleCollectionTraversalCallback& aCallback); + + static EffectSet* Get(const dom::Element* aElement, + PseudoStyleType aPseudoType); + static EffectSet* GetOrCreate(dom::Element* aElement, + PseudoStyleType aPseudoType); + + static EffectSet* GetForFrame(const nsIFrame* aFrame, + const nsCSSPropertyIDSet& aProperties); + static EffectSet* GetForFrame(const nsIFrame* aFrame, + DisplayItemType aDisplayItemType); + // Gets the EffectSet associated with the specified frame's content. + // + // Typically the specified frame should be a "style frame". + // + // That is because display:table content: + // + // - makes a distinction between the primary frame and style frame, + // - associates the EffectSet with the style frame's content, + // - applies transform animations to the primary frame. + // + // In such a situation, passing in the primary frame here will return nullptr + // despite the fact that it has a transform animation applied to it. + // + // GetForFrame, above, handles this by automatically looking up the + // EffectSet on the corresponding style frame when querying transform + // properties. Unless you are sure you know what you are doing, you should + // try using GetForFrame first. + // + // If you decide to use this, consider documenting why you are sure it is ok + // to use this. + static EffectSet* GetForStyleFrame(const nsIFrame* aStyleFrame); + + static EffectSet* GetForEffect(const dom::KeyframeEffect* aEffect); + + static void DestroyEffectSet(dom::Element* aElement, + PseudoStyleType aPseudoType); + + void AddEffect(dom::KeyframeEffect& aEffect); + void RemoveEffect(dom::KeyframeEffect& aEffect); + + void SetMayHaveOpacityAnimation() { mMayHaveOpacityAnim = true; } + bool MayHaveOpacityAnimation() const { return mMayHaveOpacityAnim; } + void SetMayHaveTransformAnimation() { mMayHaveTransformAnim = true; } + bool MayHaveTransformAnimation() const { return mMayHaveTransformAnim; } + + private: + using OwningEffectSet = nsTHashSet<nsRefPtrHashKey<dom::KeyframeEffect>>; + + public: + // A simple iterator to support iterating over the effects in this object in + // range-based for loops. + // + // This allows us to avoid exposing mEffects directly and saves the + // caller from having to dereference hashtable iterators using + // the rather complicated: iter.Get()->GetKey(). + // + // XXX Except for the active iterator checks, this could be replaced by the + // STL-style iterators of nsTHashSet directly now. + class Iterator { + public: + explicit Iterator(EffectSet& aEffectSet) + : Iterator(aEffectSet, aEffectSet.mEffects.begin()) {} + + Iterator() = delete; + Iterator(const Iterator&) = delete; + Iterator(Iterator&&) = delete; + Iterator& operator=(const Iterator&) = delete; + Iterator& operator=(Iterator&&) = delete; + + static Iterator EndIterator(EffectSet& aEffectSet) { + return {aEffectSet, aEffectSet.mEffects.end()}; + } + +#ifdef DEBUG + ~Iterator() { + MOZ_ASSERT(mEffectSet.mActiveIterators > 0); + mEffectSet.mActiveIterators--; + } +#endif + + bool operator!=(const Iterator& aOther) const { + return mHashIterator != aOther.mHashIterator; + } + + Iterator& operator++() { + ++mHashIterator; + return *this; + } + + dom::KeyframeEffect* operator*() { return *mHashIterator; } + + private: + Iterator(EffectSet& aEffectSet, + OwningEffectSet::const_iterator aHashIterator) + : +#ifdef DEBUG + mEffectSet(aEffectSet), +#endif + mHashIterator(std::move(aHashIterator)) { +#ifdef DEBUG + mEffectSet.mActiveIterators++; +#endif + } + +#ifdef DEBUG + EffectSet& mEffectSet; +#endif + OwningEffectSet::const_iterator mHashIterator; + }; + + friend class Iterator; + + Iterator begin() { return Iterator(*this); } + Iterator end() { return Iterator::EndIterator(*this); } +#ifdef DEBUG + bool IsBeingEnumerated() const { return mActiveIterators != 0; } +#endif + + bool IsEmpty() const { return mEffects.IsEmpty(); } + + size_t Count() const { return mEffects.Count(); } + + const TimeStamp& LastOverflowAnimationSyncTime() const { + return mLastOverflowAnimationSyncTime; + } + void UpdateLastOverflowAnimationSyncTime(const TimeStamp& aRefreshTime) { + mLastOverflowAnimationSyncTime = aRefreshTime; + } + + bool CascadeNeedsUpdate() const { return mCascadeNeedsUpdate; } + void MarkCascadeNeedsUpdate() { mCascadeNeedsUpdate = true; } + void MarkCascadeUpdated() { mCascadeNeedsUpdate = false; } + + void UpdateAnimationGeneration(nsPresContext* aPresContext); + uint64_t GetAnimationGeneration() const { return mAnimationGeneration; } + + const nsCSSPropertyIDSet& PropertiesWithImportantRules() const { + return mPropertiesWithImportantRules; + } + nsCSSPropertyIDSet& PropertiesWithImportantRules() { + return mPropertiesWithImportantRules; + } + nsCSSPropertyIDSet PropertiesForAnimationsLevel() const { + return mPropertiesForAnimationsLevel; + } + nsCSSPropertyIDSet& PropertiesForAnimationsLevel() { + return mPropertiesForAnimationsLevel; + } + + private: + OwningEffectSet mEffects; + + // Refresh driver timestamp from the moment when the animations which produce + // overflow change hints in this effect set were last updated. + + // This is used for animations whose main-thread restyling is throttled either + // because they are running on the compositor or because they are not visible. + // We still need to update them on the main thread periodically, however (e.g. + // so scrollbars can be updated), so this tracks the last time we did that. + TimeStamp mLastOverflowAnimationSyncTime; + + // RestyleManager keeps track of the number of animation restyles. + // 'mini-flushes' (see nsTransitionManager::UpdateAllThrottledStyles()). + // mAnimationGeneration is the sequence number of the last flush where a + // transition/animation changed. We keep a similar count on the + // corresponding layer so we can check that the layer is up to date with + // the animation manager. + uint64_t mAnimationGeneration = 0; + + // Specifies the compositor-animatable properties that are overridden by + // !important rules. + nsCSSPropertyIDSet mPropertiesWithImportantRules; + // Specifies the properties for which the result will be added to the + // animations level of the cascade and hence should be skipped when we are + // composing the animation style for the transitions level of the cascede. + nsCSSPropertyIDSet mPropertiesForAnimationsLevel; + +#ifdef DEBUG + // Track how many iterators are referencing this effect set when we are + // destroyed, we can assert that nothing is still pointing to us. + uint64_t mActiveIterators = 0; +#endif + + // Dirty flag to represent when the mPropertiesWithImportantRules and + // mPropertiesForAnimationsLevel on effects in this set might need to be + // updated. + // + // Set to true any time the set of effects is changed or when + // one the effects goes in or out of the "in effect" state. + bool mCascadeNeedsUpdate = false; + + bool mMayHaveOpacityAnim = false; + bool mMayHaveTransformAnim = false; +}; + +} // namespace mozilla + +#endif // mozilla_EffectSet_h diff --git a/dom/animation/ElementAnimationData.cpp b/dom/animation/ElementAnimationData.cpp new file mode 100644 index 0000000000..d87caf8a58 --- /dev/null +++ b/dom/animation/ElementAnimationData.cpp @@ -0,0 +1,126 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "ElementAnimationData.h" +#include "mozilla/AnimationCollection.h" +#include "mozilla/TimelineCollection.h" +#include "mozilla/EffectSet.h" +#include "mozilla/dom/CSSTransition.h" +#include "mozilla/dom/CSSAnimation.h" +#include "mozilla/dom/ScrollTimeline.h" +#include "mozilla/dom/ViewTimeline.h" + +namespace mozilla { + +void ElementAnimationData::Traverse(nsCycleCollectionTraversalCallback& cb) { + mElementData.Traverse(cb); + mBeforeData.Traverse(cb); + mAfterData.Traverse(cb); + mMarkerData.Traverse(cb); +} + +void ElementAnimationData::ClearAllAnimationCollections() { + for (auto* data : {&mElementData, &mBeforeData, &mAfterData, &mMarkerData}) { + data->mAnimations = nullptr; + data->mTransitions = nullptr; + data->mScrollTimelines = nullptr; + data->mViewTimelines = nullptr; + data->mProgressTimelineScheduler = nullptr; + } +} + +ElementAnimationData::PerElementOrPseudoData::PerElementOrPseudoData() = + default; +ElementAnimationData::PerElementOrPseudoData::~PerElementOrPseudoData() = + default; + +void ElementAnimationData::PerElementOrPseudoData::Traverse( + nsCycleCollectionTraversalCallback& cb) { + // We only care about mEffectSet. The animation collections are managed by the + // pres context and go away when presentation of the document goes away. + if (mEffectSet) { + mEffectSet->Traverse(cb); + } +} + +EffectSet& ElementAnimationData::PerElementOrPseudoData::DoEnsureEffectSet() { + MOZ_ASSERT(!mEffectSet); + mEffectSet = MakeUnique<EffectSet>(); + return *mEffectSet; +} + +CSSTransitionCollection& +ElementAnimationData::PerElementOrPseudoData::DoEnsureTransitions( + dom::Element& aOwner, PseudoStyleType aType) { + MOZ_ASSERT(!mTransitions); + mTransitions = MakeUnique<CSSTransitionCollection>(aOwner, aType); + return *mTransitions; +} + +CSSAnimationCollection& +ElementAnimationData::PerElementOrPseudoData::DoEnsureAnimations( + dom::Element& aOwner, PseudoStyleType aType) { + MOZ_ASSERT(!mAnimations); + mAnimations = MakeUnique<CSSAnimationCollection>(aOwner, aType); + return *mAnimations; +} + +ScrollTimelineCollection& +ElementAnimationData::PerElementOrPseudoData::DoEnsureScrollTimelines( + dom::Element& aOwner, PseudoStyleType aType) { + MOZ_ASSERT(!mScrollTimelines); + mScrollTimelines = MakeUnique<ScrollTimelineCollection>(aOwner, aType); + return *mScrollTimelines; +} + +ViewTimelineCollection& +ElementAnimationData::PerElementOrPseudoData::DoEnsureViewTimelines( + dom::Element& aOwner, PseudoStyleType aType) { + MOZ_ASSERT(!mViewTimelines); + mViewTimelines = MakeUnique<ViewTimelineCollection>(aOwner, aType); + return *mViewTimelines; +} + +dom::ProgressTimelineScheduler& +ElementAnimationData::PerElementOrPseudoData::DoEnsureProgressTimelineScheduler( + dom::Element& aOwner, PseudoStyleType aType) { + MOZ_ASSERT(!mProgressTimelineScheduler); + mProgressTimelineScheduler = MakeUnique<dom::ProgressTimelineScheduler>(); + return *mProgressTimelineScheduler; +} + +void ElementAnimationData::PerElementOrPseudoData::DoClearEffectSet() { + MOZ_ASSERT(mEffectSet); + mEffectSet = nullptr; +} + +void ElementAnimationData::PerElementOrPseudoData::DoClearTransitions() { + MOZ_ASSERT(mTransitions); + mTransitions = nullptr; +} + +void ElementAnimationData::PerElementOrPseudoData::DoClearAnimations() { + MOZ_ASSERT(mAnimations); + mAnimations = nullptr; +} + +void ElementAnimationData::PerElementOrPseudoData::DoClearScrollTimelines() { + MOZ_ASSERT(mScrollTimelines); + mScrollTimelines = nullptr; +} + +void ElementAnimationData::PerElementOrPseudoData::DoClearViewTimelines() { + MOZ_ASSERT(mViewTimelines); + mViewTimelines = nullptr; +} + +void ElementAnimationData::PerElementOrPseudoData:: + DoClearProgressTimelineScheduler() { + MOZ_ASSERT(mProgressTimelineScheduler); + mProgressTimelineScheduler = nullptr; +} + +} // namespace mozilla diff --git a/dom/animation/ElementAnimationData.h b/dom/animation/ElementAnimationData.h new file mode 100644 index 0000000000..9183f0f008 --- /dev/null +++ b/dom/animation/ElementAnimationData.h @@ -0,0 +1,262 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_ElementAnimationData_h +#define mozilla_ElementAnimationData_h + +#include "mozilla/UniquePtr.h" +#include "mozilla/PseudoStyleType.h" + +class nsCycleCollectionTraversalCallback; + +namespace mozilla { +enum class PseudoStyleType : uint8_t; +class EffectSet; +template <typename Animation> +class AnimationCollection; +template <typename TimelineType> +class TimelineCollection; +namespace dom { +class Element; +class CSSAnimation; +class CSSTransition; +class ProgressTimelineScheduler; +class ScrollTimeline; +class ViewTimeline; +} // namespace dom +using CSSAnimationCollection = AnimationCollection<dom::CSSAnimation>; +using CSSTransitionCollection = AnimationCollection<dom::CSSTransition>; +using ScrollTimelineCollection = TimelineCollection<dom::ScrollTimeline>; +using ViewTimelineCollection = TimelineCollection<dom::ViewTimeline>; + +// The animation data for a given element (and its pseudo-elements). +class ElementAnimationData { + struct PerElementOrPseudoData { + UniquePtr<EffectSet> mEffectSet; + UniquePtr<CSSAnimationCollection> mAnimations; + UniquePtr<CSSTransitionCollection> mTransitions; + + // Note: scroll-timeline-name is applied to elements which could be + // scroll containers, or replaced elements. view-timeline-name is applied to + // all elements. However, the named timeline is referenceable in + // animation-timeline by the tree order scope. + // Spec: https://drafts.csswg.org/scroll-animations-1/#timeline-scope. + // + // So it should be fine to create timeline objects only on the elements and + // pseudo elements which support animations. + UniquePtr<ScrollTimelineCollection> mScrollTimelines; + UniquePtr<ViewTimelineCollection> mViewTimelines; + + // This is different from |mScrollTimelines|. We use this to schedule all + // scroll-driven animations (which use anonymous/named scroll timelines or + // anonymous/name view timelines) for a specific scroll source (which is the + // element with nsIScrollableFrame). + // + // TimelineCollection owns and manages the named progress timeline generated + // by specifying scroll-timeline-name property and view-timeline-name + // property on this element. However, the anonymous progress timelines (e.g. + // animation-timeline:scroll()) are owned by Animation objects only. + // + // Note: + // 1. For named scroll timelines. The element which specifies + // scroll-timeline-name is the scroll source. However, for named view + // timelines, the element which specifies view-timeline-name may not be + // the scroll source because we use its nearest scroll container as the + // scroll source. + // 2. For anonymous progress timelines, we don't keep their timeline obejcts + // in TimelineCollection. + // So, per 1) and 2), we use |mProgressTimelineScheduler| for the scroll + // source element to schedule scroll-driven animations. + UniquePtr<dom::ProgressTimelineScheduler> mProgressTimelineScheduler; + + PerElementOrPseudoData(); + ~PerElementOrPseudoData(); + + EffectSet& DoEnsureEffectSet(); + CSSTransitionCollection& DoEnsureTransitions(dom::Element&, + PseudoStyleType); + CSSAnimationCollection& DoEnsureAnimations(dom::Element&, PseudoStyleType); + ScrollTimelineCollection& DoEnsureScrollTimelines(dom::Element&, + PseudoStyleType); + ViewTimelineCollection& DoEnsureViewTimelines(dom::Element&, + PseudoStyleType); + dom::ProgressTimelineScheduler& DoEnsureProgressTimelineScheduler( + dom::Element&, PseudoStyleType); + + void DoClearEffectSet(); + void DoClearTransitions(); + void DoClearAnimations(); + void DoClearScrollTimelines(); + void DoClearViewTimelines(); + void DoClearProgressTimelineScheduler(); + + void Traverse(nsCycleCollectionTraversalCallback&); + }; + + PerElementOrPseudoData mElementData; + + // TODO(emilio): Maybe this should be a hash map eventually, once we allow + // animating all pseudo-elements. + PerElementOrPseudoData mBeforeData; + PerElementOrPseudoData mAfterData; + PerElementOrPseudoData mMarkerData; + + const PerElementOrPseudoData& DataFor(PseudoStyleType aType) const { + switch (aType) { + case PseudoStyleType::NotPseudo: + break; + case PseudoStyleType::before: + return mBeforeData; + case PseudoStyleType::after: + return mAfterData; + case PseudoStyleType::marker: + return mMarkerData; + default: + MOZ_ASSERT_UNREACHABLE( + "Should not try to get animation effects for " + "a pseudo other that :before, :after or ::marker"); + break; + } + return mElementData; + } + + PerElementOrPseudoData& DataFor(PseudoStyleType aType) { + const auto& data = + const_cast<const ElementAnimationData*>(this)->DataFor(aType); + return const_cast<PerElementOrPseudoData&>(data); + } + + public: + void Traverse(nsCycleCollectionTraversalCallback&); + + void ClearAllAnimationCollections(); + + EffectSet* GetEffectSetFor(PseudoStyleType aType) const { + return DataFor(aType).mEffectSet.get(); + } + + void ClearEffectSetFor(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (data.mEffectSet) { + data.DoClearEffectSet(); + } + } + + EffectSet& EnsureEffectSetFor(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (auto* set = data.mEffectSet.get()) { + return *set; + } + return data.DoEnsureEffectSet(); + } + + CSSTransitionCollection* GetTransitionCollection(PseudoStyleType aType) { + return DataFor(aType).mTransitions.get(); + } + + void ClearTransitionCollectionFor(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (data.mTransitions) { + data.DoClearTransitions(); + } + } + + CSSTransitionCollection& EnsureTransitionCollection(dom::Element& aOwner, + PseudoStyleType aType) { + auto& data = DataFor(aType); + if (auto* collection = data.mTransitions.get()) { + return *collection; + } + return data.DoEnsureTransitions(aOwner, aType); + } + + CSSAnimationCollection* GetAnimationCollection(PseudoStyleType aType) { + return DataFor(aType).mAnimations.get(); + } + + void ClearAnimationCollectionFor(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (data.mAnimations) { + data.DoClearAnimations(); + } + } + + CSSAnimationCollection& EnsureAnimationCollection(dom::Element& aOwner, + PseudoStyleType aType) { + auto& data = DataFor(aType); + if (auto* collection = data.mAnimations.get()) { + return *collection; + } + return data.DoEnsureAnimations(aOwner, aType); + } + + ScrollTimelineCollection* GetScrollTimelineCollection(PseudoStyleType aType) { + return DataFor(aType).mScrollTimelines.get(); + } + + void ClearScrollTimelineCollectionFor(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (data.mScrollTimelines) { + data.DoClearScrollTimelines(); + } + } + + ScrollTimelineCollection& EnsureScrollTimelineCollection( + dom::Element& aOwner, PseudoStyleType aType) { + auto& data = DataFor(aType); + if (auto* collection = data.mScrollTimelines.get()) { + return *collection; + } + return data.DoEnsureScrollTimelines(aOwner, aType); + } + + ViewTimelineCollection* GetViewTimelineCollection(PseudoStyleType aType) { + return DataFor(aType).mViewTimelines.get(); + } + + void ClearViewTimelineCollectionFor(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (data.mViewTimelines) { + data.DoClearViewTimelines(); + } + } + + ViewTimelineCollection& EnsureViewTimelineCollection(dom::Element& aOwner, + PseudoStyleType aType) { + auto& data = DataFor(aType); + if (auto* collection = data.mViewTimelines.get()) { + return *collection; + } + return data.DoEnsureViewTimelines(aOwner, aType); + } + + dom::ProgressTimelineScheduler* GetProgressTimelineScheduler( + PseudoStyleType aType) { + return DataFor(aType).mProgressTimelineScheduler.get(); + } + + void ClearProgressTimelineScheduler(PseudoStyleType aType) { + auto& data = DataFor(aType); + if (data.mProgressTimelineScheduler) { + data.DoClearProgressTimelineScheduler(); + } + } + + dom::ProgressTimelineScheduler& EnsureProgressTimelineScheduler( + dom::Element& aOwner, PseudoStyleType aType) { + auto& data = DataFor(aType); + if (auto* collection = data.mProgressTimelineScheduler.get()) { + return *collection; + } + return data.DoEnsureProgressTimelineScheduler(aOwner, aType); + } + + ElementAnimationData() = default; +}; + +} // namespace mozilla + +#endif diff --git a/dom/animation/Keyframe.h b/dom/animation/Keyframe.h new file mode 100644 index 0000000000..304358d61e --- /dev/null +++ b/dom/animation/Keyframe.h @@ -0,0 +1,83 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_Keyframe_h +#define mozilla_dom_Keyframe_h + +#include "nsCSSPropertyID.h" +#include "nsCSSValue.h" +#include "nsTArray.h" +#include "mozilla/dom/BaseKeyframeTypesBinding.h" // CompositeOperationOrAuto +#include "mozilla/ServoStyleConsts.h" +#include "mozilla/Maybe.h" +#include "mozilla/RefPtr.h" + +namespace mozilla { +struct StyleLockedDeclarationBlock; + +/** + * A property-value pair specified on a keyframe. + */ +struct PropertyValuePair { + explicit PropertyValuePair(nsCSSPropertyID aProperty) + : mProperty(aProperty) {} + PropertyValuePair(nsCSSPropertyID aProperty, + RefPtr<StyleLockedDeclarationBlock>&& aValue) + : mProperty(aProperty), mServoDeclarationBlock(std::move(aValue)) { + MOZ_ASSERT(mServoDeclarationBlock, "Should be valid property value"); + } + + nsCSSPropertyID mProperty; + + // The specified value when using the Servo backend. + RefPtr<StyleLockedDeclarationBlock> mServoDeclarationBlock; + +#ifdef DEBUG + // Flag to indicate that when we call StyleAnimationValue::ComputeValues on + // this value we should behave as if that function had failed. + bool mSimulateComputeValuesFailure = false; +#endif + + bool operator==(const PropertyValuePair&) const; +}; + +/** + * A single keyframe. + * + * This is the canonical form in which keyframe effects are stored and + * corresponds closely to the type of objects returned via the getKeyframes() + * API. + * + * Before computing an output animation value, however, we flatten these frames + * down to a series of per-property value arrays where we also resolve any + * overlapping shorthands/longhands, convert specified CSS values to computed + * values, etc. + * + * When the target element or computed style changes, however, we rebuild these + * per-property arrays from the original list of keyframes objects. As a result, + * these objects represent the master definition of the effect's values. + */ +struct Keyframe { + Keyframe() = default; + Keyframe(const Keyframe& aOther) = default; + Keyframe(Keyframe&& aOther) = default; + + Keyframe& operator=(const Keyframe& aOther) = default; + Keyframe& operator=(Keyframe&& aOther) = default; + + Maybe<double> mOffset; + static constexpr double kComputedOffsetNotSet = -1.0; + double mComputedOffset = kComputedOffsetNotSet; + Maybe<StyleComputedTimingFunction> mTimingFunction; // Nothing() here means + // "linear" + dom::CompositeOperationOrAuto mComposite = + dom::CompositeOperationOrAuto::Auto; + CopyableTArray<PropertyValuePair> mPropertyValues; +}; + +} // namespace mozilla + +#endif // mozilla_dom_Keyframe_h diff --git a/dom/animation/KeyframeEffect.cpp b/dom/animation/KeyframeEffect.cpp new file mode 100644 index 0000000000..2f0d886ac1 --- /dev/null +++ b/dom/animation/KeyframeEffect.cpp @@ -0,0 +1,2117 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/KeyframeEffect.h" + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/KeyframeAnimationOptionsBinding.h" +// For UnrestrictedDoubleOrKeyframeAnimationOptions; +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/dom/MutationObservers.h" +#include "mozilla/layers/AnimationInfo.h" +#include "mozilla/AnimationUtils.h" +#include "mozilla/AutoRestore.h" +#include "mozilla/ComputedStyleInlines.h" +#include "mozilla/EffectSet.h" +#include "mozilla/FloatingPoint.h" // For IsFinite +#include "mozilla/LayerAnimationInfo.h" +#include "mozilla/LookAndFeel.h" // For LookAndFeel::GetInt +#include "mozilla/KeyframeUtils.h" +#include "mozilla/PresShell.h" +#include "mozilla/PresShellInlines.h" +#include "mozilla/ServoBindings.h" +#include "mozilla/StaticPrefs_dom.h" +#include "mozilla/StaticPrefs_gfx.h" +#include "mozilla/StaticPrefs_layers.h" +#include "nsComputedDOMStyle.h" // nsComputedDOMStyle::GetComputedStyle +#include "nsContentUtils.h" +#include "nsCSSPropertyIDSet.h" +#include "nsCSSProps.h" // For nsCSSProps::PropHasFlags +#include "nsCSSPseudoElements.h" // For PseudoStyleType +#include "nsDOMMutationObserver.h" // For nsAutoAnimationMutationBatch +#include "nsIFrame.h" +#include "nsIFrameInlines.h" +#include "nsIScrollableFrame.h" +#include "nsPresContextInlines.h" +#include "nsRefreshDriver.h" +#include "js/PropertyAndElement.h" // JS_DefineProperty +#include "WindowRenderer.h" + +namespace mozilla { + +void AnimationProperty::SetPerformanceWarning( + const AnimationPerformanceWarning& aWarning, const dom::Element* aElement) { + if (mPerformanceWarning && *mPerformanceWarning == aWarning) { + return; + } + + mPerformanceWarning = Some(aWarning); + + nsAutoString localizedString; + if (StaticPrefs::layers_offmainthreadcomposition_log_animations() && + mPerformanceWarning->ToLocalizedString(localizedString)) { + nsAutoCString logMessage = NS_ConvertUTF16toUTF8(localizedString); + AnimationUtils::LogAsyncAnimationFailure(logMessage, aElement); + } +} + +bool PropertyValuePair::operator==(const PropertyValuePair& aOther) const { + if (mProperty != aOther.mProperty) { + return false; + } + if (mServoDeclarationBlock == aOther.mServoDeclarationBlock) { + return true; + } + if (!mServoDeclarationBlock || !aOther.mServoDeclarationBlock) { + return false; + } + return Servo_DeclarationBlock_Equals(mServoDeclarationBlock, + aOther.mServoDeclarationBlock); +} + +namespace dom { + +NS_IMPL_CYCLE_COLLECTION_INHERITED(KeyframeEffect, AnimationEffect, + mTarget.mElement) + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(KeyframeEffect, AnimationEffect) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(KeyframeEffect) +NS_INTERFACE_MAP_END_INHERITING(AnimationEffect) + +NS_IMPL_ADDREF_INHERITED(KeyframeEffect, AnimationEffect) +NS_IMPL_RELEASE_INHERITED(KeyframeEffect, AnimationEffect) + +KeyframeEffect::KeyframeEffect(Document* aDocument, + OwningAnimationTarget&& aTarget, + TimingParams&& aTiming, + const KeyframeEffectParams& aOptions) + : AnimationEffect(aDocument, std::move(aTiming)), + mTarget(std::move(aTarget)), + mEffectOptions(aOptions) {} + +KeyframeEffect::KeyframeEffect(Document* aDocument, + OwningAnimationTarget&& aTarget, + const KeyframeEffect& aOther) + : AnimationEffect(aDocument, TimingParams{aOther.SpecifiedTiming()}), + mTarget(std::move(aTarget)), + mEffectOptions{aOther.IterationComposite(), aOther.Composite(), + mTarget.mPseudoType}, + mKeyframes(aOther.mKeyframes.Clone()), + mProperties(aOther.mProperties.Clone()), + mBaseValues(aOther.mBaseValues.Clone()) {} + +JSObject* KeyframeEffect::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return KeyframeEffect_Binding::Wrap(aCx, this, aGivenProto); +} + +IterationCompositeOperation KeyframeEffect::IterationComposite() const { + return mEffectOptions.mIterationComposite; +} + +void KeyframeEffect::SetIterationComposite( + const IterationCompositeOperation& aIterationComposite) { + if (mEffectOptions.mIterationComposite == aIterationComposite) { + return; + } + + if (mAnimation && mAnimation->IsRelevant()) { + MutationObservers::NotifyAnimationChanged(mAnimation); + } + + mEffectOptions.mIterationComposite = aIterationComposite; + RequestRestyle(EffectCompositor::RestyleType::Layer); +} + +CompositeOperation KeyframeEffect::Composite() const { + return mEffectOptions.mComposite; +} + +void KeyframeEffect::SetComposite(const CompositeOperation& aComposite) { + if (mEffectOptions.mComposite == aComposite) { + return; + } + + mEffectOptions.mComposite = aComposite; + + if (mAnimation && mAnimation->IsRelevant()) { + MutationObservers::NotifyAnimationChanged(mAnimation); + } + + if (mTarget) { + RefPtr<const ComputedStyle> computedStyle = + GetTargetComputedStyle(Flush::None); + if (computedStyle) { + UpdateProperties(computedStyle); + } + } +} + +void KeyframeEffect::NotifySpecifiedTimingUpdated() { + // Use the same document for a pseudo element and its parent element. + // Use nullptr if we don't have mTarget, so disable the mutation batch. + nsAutoAnimationMutationBatch mb(mTarget ? mTarget.mElement->OwnerDoc() + : nullptr); + + if (mAnimation) { + mAnimation->NotifyEffectTimingUpdated(); + + if (mAnimation->IsRelevant()) { + MutationObservers::NotifyAnimationChanged(mAnimation); + } + + RequestRestyle(EffectCompositor::RestyleType::Layer); + } +} + +void KeyframeEffect::NotifyAnimationTimingUpdated( + PostRestyleMode aPostRestyle) { + UpdateTargetRegistration(); + + // If the effect is not relevant it will be removed from the target + // element's effect set. However, effects not in the effect set + // will not be included in the set of candidate effects for running on + // the compositor and hence they won't have their compositor status + // updated. As a result, we need to make sure we clear their compositor + // status here. + bool isRelevant = mAnimation && mAnimation->IsRelevant(); + if (!isRelevant) { + ResetIsRunningOnCompositor(); + } + + // Request restyle if necessary. + if (aPostRestyle == PostRestyleMode::IfNeeded && mAnimation && + !mProperties.IsEmpty() && HasComputedTimingChanged()) { + EffectCompositor::RestyleType restyleType = + CanThrottle() ? EffectCompositor::RestyleType::Throttled + : EffectCompositor::RestyleType::Standard; + RequestRestyle(restyleType); + } + + // Detect changes to "in effect" status since we need to recalculate the + // animation cascade for this element whenever that changes. + // Note that updating mInEffectOnLastAnimationTimingUpdate has to be done + // after above CanThrottle() call since the function uses the flag inside it. + bool inEffect = IsInEffect(); + if (inEffect != mInEffectOnLastAnimationTimingUpdate) { + MarkCascadeNeedsUpdate(); + mInEffectOnLastAnimationTimingUpdate = inEffect; + } + + // If we're no longer "in effect", our ComposeStyle method will never be + // called and we will never have a chance to update mProgressOnLastCompose + // and mCurrentIterationOnLastCompose. + // We clear them here to ensure that if we later become "in effect" we will + // request a restyle (above). + if (!inEffect) { + mProgressOnLastCompose.SetNull(); + mCurrentIterationOnLastCompose = 0; + } +} + +static bool KeyframesEqualIgnoringComputedOffsets( + const nsTArray<Keyframe>& aLhs, const nsTArray<Keyframe>& aRhs) { + if (aLhs.Length() != aRhs.Length()) { + return false; + } + + for (size_t i = 0, len = aLhs.Length(); i < len; ++i) { + const Keyframe& a = aLhs[i]; + const Keyframe& b = aRhs[i]; + if (a.mOffset != b.mOffset || a.mTimingFunction != b.mTimingFunction || + a.mPropertyValues != b.mPropertyValues) { + return false; + } + } + return true; +} + +// https://drafts.csswg.org/web-animations/#dom-keyframeeffect-setkeyframes +void KeyframeEffect::SetKeyframes(JSContext* aContext, + JS::Handle<JSObject*> aKeyframes, + ErrorResult& aRv) { + nsTArray<Keyframe> keyframes = KeyframeUtils::GetKeyframesFromObject( + aContext, mDocument, aKeyframes, "KeyframeEffect.setKeyframes", aRv); + if (aRv.Failed()) { + return; + } + + RefPtr<const ComputedStyle> style = GetTargetComputedStyle(Flush::None); + SetKeyframes(std::move(keyframes), style, nullptr /* AnimationTimeline */); +} + +void KeyframeEffect::SetKeyframes(nsTArray<Keyframe>&& aKeyframes, + const ComputedStyle* aStyle, + const AnimationTimeline* aTimeline) { + if (KeyframesEqualIgnoringComputedOffsets(aKeyframes, mKeyframes)) { + return; + } + + mKeyframes = std::move(aKeyframes); + KeyframeUtils::DistributeKeyframes(mKeyframes); + + if (mAnimation && mAnimation->IsRelevant()) { + MutationObservers::NotifyAnimationChanged(mAnimation); + } + + // We need to call UpdateProperties() unless the target element doesn't have + // style (e.g. the target element is not associated with any document). + if (aStyle) { + UpdateProperties(aStyle, aTimeline); + } +} + +void KeyframeEffect::ReplaceTransitionStartValue(AnimationValue&& aStartValue) { + if (!aStartValue.mServo) { + return; + } + + // A typical transition should have a single property and a single segment. + // + // (And for atypical transitions, that is, those updated by script, we don't + // apply the replacing behavior.) + if (mProperties.Length() != 1 || mProperties[0].mSegments.Length() != 1) { + return; + } + + // Likewise, check that the keyframes are of the expected shape. + if (mKeyframes.Length() != 2 || mKeyframes[0].mPropertyValues.Length() != 1) { + return; + } + + // Check that the value we are about to substitute in is actually for the + // same property. + if (Servo_AnimationValue_GetPropertyId(aStartValue.mServo) != + mProperties[0].mProperty) { + return; + } + + mKeyframes[0].mPropertyValues[0].mServoDeclarationBlock = + Servo_AnimationValue_Uncompute(aStartValue.mServo).Consume(); + mProperties[0].mSegments[0].mFromValue = std::move(aStartValue); +} + +static bool IsEffectiveProperty(const EffectSet& aEffects, + nsCSSPropertyID aProperty) { + return !aEffects.PropertiesWithImportantRules().HasProperty(aProperty) || + !aEffects.PropertiesForAnimationsLevel().HasProperty(aProperty); +} + +const AnimationProperty* KeyframeEffect::GetEffectiveAnimationOfProperty( + nsCSSPropertyID aProperty, const EffectSet& aEffects) const { + MOZ_ASSERT(mTarget && &aEffects == EffectSet::Get(mTarget.mElement, + mTarget.mPseudoType)); + + for (const AnimationProperty& property : mProperties) { + if (aProperty != property.mProperty) { + continue; + } + + const AnimationProperty* result = nullptr; + // Only include the property if it is not overridden by !important rules in + // the transitions level. + if (IsEffectiveProperty(aEffects, property.mProperty)) { + result = &property; + } + return result; + } + return nullptr; +} + +bool KeyframeEffect::HasEffectiveAnimationOfPropertySet( + const nsCSSPropertyIDSet& aPropertySet, const EffectSet& aEffectSet) const { + for (const AnimationProperty& property : mProperties) { + if (aPropertySet.HasProperty(property.mProperty) && + IsEffectiveProperty(aEffectSet, property.mProperty)) { + return true; + } + } + return false; +} + +nsCSSPropertyIDSet KeyframeEffect::GetPropertiesForCompositor( + EffectSet& aEffects, const nsIFrame* aFrame) const { + MOZ_ASSERT(&aEffects == + EffectSet::Get(mTarget.mElement, mTarget.mPseudoType)); + + nsCSSPropertyIDSet properties; + + if (!mAnimation || !mAnimation->IsRelevant()) { + return properties; + } + + static constexpr nsCSSPropertyIDSet compositorAnimatables = + nsCSSPropertyIDSet::CompositorAnimatables(); + static constexpr nsCSSPropertyIDSet transformLikeProperties = + nsCSSPropertyIDSet::TransformLikeProperties(); + + nsCSSPropertyIDSet transformSet; + AnimationPerformanceWarning::Type dummyWarning; + + for (const AnimationProperty& property : mProperties) { + if (!compositorAnimatables.HasProperty(property.mProperty)) { + continue; + } + + // Transform-like properties are combined together on the compositor so we + // need to evaluate them as a group. We build up a separate set here then + // evaluate it as a separate step below. + if (transformLikeProperties.HasProperty(property.mProperty)) { + transformSet.AddProperty(property.mProperty); + continue; + } + + KeyframeEffect::MatchForCompositor matchResult = IsMatchForCompositor( + nsCSSPropertyIDSet{property.mProperty}, aFrame, aEffects, dummyWarning); + if (matchResult == + KeyframeEffect::MatchForCompositor::NoAndBlockThisProperty || + matchResult == KeyframeEffect::MatchForCompositor::No) { + continue; + } + properties.AddProperty(property.mProperty); + } + + if (!transformSet.IsEmpty()) { + KeyframeEffect::MatchForCompositor matchResult = + IsMatchForCompositor(transformSet, aFrame, aEffects, dummyWarning); + if (matchResult == KeyframeEffect::MatchForCompositor::Yes || + matchResult == KeyframeEffect::MatchForCompositor::IfNeeded) { + properties |= transformSet; + } + } + + return properties; +} + +nsCSSPropertyIDSet KeyframeEffect::GetPropertySet() const { + nsCSSPropertyIDSet result; + + for (const AnimationProperty& property : mProperties) { + result.AddProperty(property.mProperty); + } + + return result; +} + +#ifdef DEBUG +bool SpecifiedKeyframeArraysAreEqual(const nsTArray<Keyframe>& aA, + const nsTArray<Keyframe>& aB) { + if (aA.Length() != aB.Length()) { + return false; + } + + for (size_t i = 0; i < aA.Length(); i++) { + const Keyframe& a = aA[i]; + const Keyframe& b = aB[i]; + if (a.mOffset != b.mOffset || a.mTimingFunction != b.mTimingFunction || + a.mPropertyValues != b.mPropertyValues) { + return false; + } + } + + return true; +} +#endif + +static bool HasCurrentColor( + const nsTArray<AnimationPropertySegment>& aSegments) { + for (const AnimationPropertySegment& segment : aSegments) { + if ((!segment.mFromValue.IsNull() && segment.mFromValue.IsCurrentColor()) || + (!segment.mToValue.IsNull() && segment.mToValue.IsCurrentColor())) { + return true; + } + } + return false; +} +void KeyframeEffect::UpdateProperties(const ComputedStyle* aStyle, + const AnimationTimeline* aTimeline) { + MOZ_ASSERT(aStyle); + + nsTArray<AnimationProperty> properties = BuildProperties(aStyle); + + bool propertiesChanged = mProperties != properties; + + // We need to update base styles even if any properties are not changed at all + // since base styles might have been changed due to parent style changes, etc. + bool baseStylesChanged = false; + EnsureBaseStyles(aStyle, properties, aTimeline, + !propertiesChanged ? &baseStylesChanged : nullptr); + + if (!propertiesChanged) { + if (baseStylesChanged) { + RequestRestyle(EffectCompositor::RestyleType::Layer); + } + // Check if we need to update the cumulative change hint because we now have + // style data. + if (mNeedsStyleData && mTarget && mTarget.mElement->HasServoData()) { + CalculateCumulativeChangeHint(aStyle); + } + return; + } + + // Preserve the state of the mIsRunningOnCompositor flag. + nsCSSPropertyIDSet runningOnCompositorProperties; + + for (const AnimationProperty& property : mProperties) { + if (property.mIsRunningOnCompositor) { + runningOnCompositorProperties.AddProperty(property.mProperty); + } + } + + mProperties = std::move(properties); + UpdateEffectSet(); + + mHasCurrentColor = false; + + for (AnimationProperty& property : mProperties) { + property.mIsRunningOnCompositor = + runningOnCompositorProperties.HasProperty(property.mProperty); + + if (property.mProperty == eCSSProperty_background_color && + !mHasCurrentColor) { + if (HasCurrentColor(property.mSegments)) { + mHasCurrentColor = true; + break; + } + } + } + + CalculateCumulativeChangeHint(aStyle); + + MarkCascadeNeedsUpdate(); + + if (mAnimation) { + mAnimation->NotifyEffectPropertiesUpdated(); + } + + RequestRestyle(EffectCompositor::RestyleType::Layer); +} + +void KeyframeEffect::EnsureBaseStyles( + const ComputedStyle* aComputedValues, + const nsTArray<AnimationProperty>& aProperties, + const AnimationTimeline* aTimeline, bool* aBaseStylesChanged) { + if (aBaseStylesChanged != nullptr) { + *aBaseStylesChanged = false; + } + + if (!mTarget) { + return; + } + + BaseValuesHashmap previousBaseStyles; + if (aBaseStylesChanged != nullptr) { + previousBaseStyles = std::move(mBaseValues); + } + + mBaseValues.Clear(); + + nsPresContext* presContext = + nsContentUtils::GetContextForContent(mTarget.mElement); + // If |aProperties| is empty we're not going to dereference |presContext| so + // we don't care if it is nullptr. + // + // We could just return early when |aProperties| is empty and save looking up + // the pres context, but that won't save any effort normally since we don't + // call this function if we have no keyframes to begin with. Furthermore, the + // case where |presContext| is nullptr is so rare (we've only ever seen in + // fuzzing, and even then we've never been able to reproduce it reliably) + // it's not worth the runtime cost of an extra branch. + MOZ_ASSERT(presContext || aProperties.IsEmpty(), + "Typically presContext should not be nullptr but if it is" + " we should have also failed to calculate the computed values" + " passed-in as aProperties"); + + if (!aTimeline) { + // If we pass a valid timeline, we use it (note: this happens when we create + // a new animation or replace the old one, for CSS Animations and CSS + // Transitions). Otherwise, we check the timeline from |mAnimation|. + aTimeline = mAnimation ? mAnimation->GetTimeline() : nullptr; + } + + RefPtr<const ComputedStyle> baseComputedStyle; + for (const AnimationProperty& property : aProperties) { + EnsureBaseStyle(property, presContext, aComputedValues, aTimeline, + baseComputedStyle); + } + + if (aBaseStylesChanged != nullptr && + std::any_of( + mBaseValues.cbegin(), mBaseValues.cend(), [&](const auto& entry) { + return AnimationValue(entry.GetData()) != + AnimationValue(previousBaseStyles.Get(entry.GetKey())); + })) { + *aBaseStylesChanged = true; + } +} + +void KeyframeEffect::EnsureBaseStyle( + const AnimationProperty& aProperty, nsPresContext* aPresContext, + const ComputedStyle* aComputedStyle, const AnimationTimeline* aTimeline, + RefPtr<const ComputedStyle>& aBaseComputedStyle) { + auto needBaseStyleForScrollTimeline = + [this](const AnimationProperty& aProperty, + const AnimationTimeline* aTimeline) { + static constexpr TimeDuration zeroDuration; + const TimingParams& timing = NormalizedTiming(); + // For scroll-timeline with a positive delay, it's possible to scroll + // back and forth between delay phase and active phase, so we need to + // keep its base style and maybe use it to override the animations in + // delay on the compositor. + return aTimeline && aTimeline->IsScrollTimeline() && + nsCSSPropertyIDSet::CompositorAnimatables().HasProperty( + aProperty.mProperty) && + (timing.Delay() > zeroDuration || + timing.EndDelay() > zeroDuration); + }; + auto hasAdditiveValues = [](const AnimationProperty& aProperty) { + for (const AnimationPropertySegment& segment : aProperty.mSegments) { + if (!segment.HasReplaceableValues()) { + return true; + } + } + return false; + }; + + // Note: Check base style for compositor (i.e. for scroll-driven animations) + // first because it is much cleaper. + const bool needBaseStyle = + needBaseStyleForScrollTimeline(aProperty, aTimeline) || + hasAdditiveValues(aProperty); + if (!needBaseStyle) { + return; + } + + if (!aBaseComputedStyle) { + MOZ_ASSERT(mTarget, "Should have a valid target"); + + Element* animatingElement = AnimationUtils::GetElementForRestyle( + mTarget.mElement, mTarget.mPseudoType); + if (!animatingElement) { + return; + } + aBaseComputedStyle = aPresContext->StyleSet()->GetBaseContextForElement( + animatingElement, aComputedStyle); + } + RefPtr<StyleAnimationValue> baseValue = + Servo_ComputedValues_ExtractAnimationValue(aBaseComputedStyle, + aProperty.mProperty) + .Consume(); + mBaseValues.InsertOrUpdate(aProperty.mProperty, std::move(baseValue)); +} + +void KeyframeEffect::WillComposeStyle() { + ComputedTiming computedTiming = GetComputedTiming(); + mProgressOnLastCompose = computedTiming.mProgress; + mCurrentIterationOnLastCompose = computedTiming.mCurrentIteration; +} + +void KeyframeEffect::ComposeStyleRule(StyleAnimationValueMap& aAnimationValues, + const AnimationProperty& aProperty, + const AnimationPropertySegment& aSegment, + const ComputedTiming& aComputedTiming) { + auto* opaqueTable = + reinterpret_cast<RawServoAnimationValueTable*>(&mBaseValues); + Servo_AnimationCompose(&aAnimationValues, opaqueTable, aProperty.mProperty, + &aSegment, &aProperty.mSegments.LastElement(), + &aComputedTiming, mEffectOptions.mIterationComposite); +} + +void KeyframeEffect::ComposeStyle(StyleAnimationValueMap& aComposeResult, + const nsCSSPropertyIDSet& aPropertiesToSkip) { + ComputedTiming computedTiming = GetComputedTiming(); + + // If the progress is null, we don't have fill data for the current + // time so we shouldn't animate. + if (computedTiming.mProgress.IsNull()) { + return; + } + + for (size_t propIdx = 0, propEnd = mProperties.Length(); propIdx != propEnd; + ++propIdx) { + const AnimationProperty& prop = mProperties[propIdx]; + + MOZ_ASSERT(prop.mSegments[0].mFromKey == 0.0, "incorrect first from key"); + MOZ_ASSERT(prop.mSegments[prop.mSegments.Length() - 1].mToKey == 1.0, + "incorrect last to key"); + + if (aPropertiesToSkip.HasProperty(prop.mProperty)) { + continue; + } + + MOZ_ASSERT(prop.mSegments.Length() > 0, + "property should not be in animations if it has no segments"); + + // FIXME: Maybe cache the current segment? + const AnimationPropertySegment *segment = prop.mSegments.Elements(), + *segmentEnd = + segment + prop.mSegments.Length(); + while (segment->mToKey <= computedTiming.mProgress.Value()) { + MOZ_ASSERT(segment->mFromKey <= segment->mToKey, "incorrect keys"); + if ((segment + 1) == segmentEnd) { + break; + } + ++segment; + MOZ_ASSERT(segment->mFromKey == (segment - 1)->mToKey, "incorrect keys"); + } + MOZ_ASSERT(segment->mFromKey <= segment->mToKey, "incorrect keys"); + MOZ_ASSERT(segment >= prop.mSegments.Elements() && + size_t(segment - prop.mSegments.Elements()) < + prop.mSegments.Length(), + "out of array bounds"); + + ComposeStyleRule(aComposeResult, prop, *segment, computedTiming); + } + + // If the animation produces a change hint that affects the overflow region, + // we need to record the current time to unthrottle the animation + // periodically when the animation is being throttled because it's scrolled + // out of view. + if (HasPropertiesThatMightAffectOverflow()) { + nsPresContext* presContext = + nsContentUtils::GetContextForContent(mTarget.mElement); + EffectSet* effectSet = + EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + if (presContext && effectSet) { + TimeStamp now = presContext->RefreshDriver()->MostRecentRefresh(); + effectSet->UpdateLastOverflowAnimationSyncTime(now); + } + } +} + +bool KeyframeEffect::IsRunningOnCompositor() const { + // We consider animation is running on compositor if there is at least + // one property running on compositor. + // Animation.IsRunningOnCompotitor will return more fine grained + // information in bug 1196114. + for (const AnimationProperty& property : mProperties) { + if (property.mIsRunningOnCompositor) { + return true; + } + } + return false; +} + +void KeyframeEffect::SetIsRunningOnCompositor(nsCSSPropertyID aProperty, + bool aIsRunning) { + MOZ_ASSERT( + nsCSSProps::PropHasFlags(aProperty, CSSPropFlags::CanAnimateOnCompositor), + "Property being animated on compositor is a recognized " + "compositor-animatable property"); + for (AnimationProperty& property : mProperties) { + if (property.mProperty == aProperty) { + property.mIsRunningOnCompositor = aIsRunning; + // We currently only set a performance warning message when animations + // cannot be run on the compositor, so if this animation is running + // on the compositor we don't need a message. + if (aIsRunning) { + property.mPerformanceWarning.reset(); + } else if (mAnimation && mAnimation->IsPartialPrerendered()) { + ResetPartialPrerendered(); + } + return; + } + } +} + +void KeyframeEffect::SetIsRunningOnCompositor( + const nsCSSPropertyIDSet& aPropertySet, bool aIsRunning) { + for (AnimationProperty& property : mProperties) { + if (aPropertySet.HasProperty(property.mProperty)) { + MOZ_ASSERT(nsCSSProps::PropHasFlags(property.mProperty, + CSSPropFlags::CanAnimateOnCompositor), + "Property being animated on compositor is a recognized " + "compositor-animatable property"); + property.mIsRunningOnCompositor = aIsRunning; + // We currently only set a performance warning message when animations + // cannot be run on the compositor, so if this animation is running + // on the compositor we don't need a message. + if (aIsRunning) { + property.mPerformanceWarning.reset(); + } + } + } + + if (!aIsRunning && mAnimation && mAnimation->IsPartialPrerendered()) { + ResetPartialPrerendered(); + } +} + +void KeyframeEffect::ResetIsRunningOnCompositor() { + for (AnimationProperty& property : mProperties) { + property.mIsRunningOnCompositor = false; + } + + if (mAnimation && mAnimation->IsPartialPrerendered()) { + ResetPartialPrerendered(); + } +} + +void KeyframeEffect::ResetPartialPrerendered() { + MOZ_ASSERT(mAnimation && mAnimation->IsPartialPrerendered()); + + nsIFrame* frame = GetPrimaryFrame(); + if (!frame) { + return; + } + + nsIWidget* widget = frame->GetNearestWidget(); + if (!widget) { + return; + } + + if (WindowRenderer* windowRenderer = widget->GetWindowRenderer()) { + windowRenderer->RemovePartialPrerenderedAnimation( + mAnimation->IdOnCompositor(), mAnimation); + } +} + +static const KeyframeEffectOptions& KeyframeEffectOptionsFromUnion( + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions) { + MOZ_ASSERT(aOptions.IsKeyframeEffectOptions()); + return aOptions.GetAsKeyframeEffectOptions(); +} + +static const KeyframeEffectOptions& KeyframeEffectOptionsFromUnion( + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions) { + MOZ_ASSERT(aOptions.IsKeyframeAnimationOptions()); + return aOptions.GetAsKeyframeAnimationOptions(); +} + +template <class OptionsType> +static KeyframeEffectParams KeyframeEffectParamsFromUnion( + const OptionsType& aOptions, CallerType aCallerType, ErrorResult& aRv) { + KeyframeEffectParams result; + if (aOptions.IsUnrestrictedDouble()) { + return result; + } + + const KeyframeEffectOptions& options = + KeyframeEffectOptionsFromUnion(aOptions); + + // If dom.animations-api.compositing.enabled is turned off, + // iterationComposite and composite are the default value 'replace' in the + // dictionary. + result.mIterationComposite = options.mIterationComposite; + result.mComposite = options.mComposite; + + result.mPseudoType = PseudoStyleType::NotPseudo; + if (DOMStringIsNull(options.mPseudoElement)) { + return result; + } + + Maybe<PseudoStyleType> pseudoType = + nsCSSPseudoElements::GetPseudoType(options.mPseudoElement); + if (!pseudoType) { + // Per the spec, we throw SyntaxError for syntactically invalid pseudos. + aRv.ThrowSyntaxError( + nsPrintfCString("'%s' is a syntactically invalid pseudo-element.", + NS_ConvertUTF16toUTF8(options.mPseudoElement).get())); + return result; + } + + result.mPseudoType = *pseudoType; + if (!AnimationUtils::IsSupportedPseudoForAnimations(result.mPseudoType)) { + // Per the spec, we throw SyntaxError for unsupported pseudos. + aRv.ThrowSyntaxError( + nsPrintfCString("'%s' is an unsupported pseudo-element.", + NS_ConvertUTF16toUTF8(options.mPseudoElement).get())); + } + + return result; +} + +template <class OptionsType> +/* static */ +already_AddRefed<KeyframeEffect> KeyframeEffect::ConstructKeyframeEffect( + const GlobalObject& aGlobal, Element* aTarget, + JS::Handle<JSObject*> aKeyframes, const OptionsType& aOptions, + ErrorResult& aRv) { + // We should get the document from `aGlobal` instead of the current Realm + // to make this works in Xray case. + // + // In all non-Xray cases, `aGlobal` matches the current Realm, so this + // matches the spec behavior. + // + // In Xray case, the new objects should be created using the document of + // the target global, but the KeyframeEffect constructors are called in the + // caller's compartment to access `aKeyframes` object. + Document* doc = AnimationUtils::GetDocumentFromGlobal(aGlobal.Get()); + if (!doc) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + + KeyframeEffectParams effectOptions = + KeyframeEffectParamsFromUnion(aOptions, aGlobal.CallerType(), aRv); + // An invalid Pseudo-element aborts all further steps. + if (aRv.Failed()) { + return nullptr; + } + + TimingParams timingParams = TimingParams::FromOptionsUnion(aOptions, aRv); + if (aRv.Failed()) { + return nullptr; + } + + RefPtr<KeyframeEffect> effect = new KeyframeEffect( + doc, OwningAnimationTarget(aTarget, effectOptions.mPseudoType), + std::move(timingParams), effectOptions); + + effect->SetKeyframes(aGlobal.Context(), aKeyframes, aRv); + if (aRv.Failed()) { + return nullptr; + } + + return effect.forget(); +} + +nsTArray<AnimationProperty> KeyframeEffect::BuildProperties( + const ComputedStyle* aStyle) { + MOZ_ASSERT(aStyle); + + nsTArray<AnimationProperty> result; + // If mTarget is false (i.e. mTarget.mElement is null), return an empty + // property array. + if (!mTarget) { + return result; + } + + // When GetComputedKeyframeValues or GetAnimationPropertiesFromKeyframes + // calculate computed values from |mKeyframes|, they could possibly + // trigger a subsequent restyle in which we rebuild animations. If that + // happens we could find that |mKeyframes| is overwritten while it is + // being iterated over. Normally that shouldn't happen but just in case we + // make a copy of |mKeyframes| first and iterate over that instead. + auto keyframesCopy(mKeyframes.Clone()); + + result = KeyframeUtils::GetAnimationPropertiesFromKeyframes( + keyframesCopy, mTarget.mElement, mTarget.mPseudoType, aStyle, + mEffectOptions.mComposite); + +#ifdef DEBUG + MOZ_ASSERT(SpecifiedKeyframeArraysAreEqual(mKeyframes, keyframesCopy), + "Apart from the computed offset members, the keyframes array" + " should not be modified"); +#endif + + mKeyframes = std::move(keyframesCopy); + return result; +} + +template <typename FrameEnumFunc> +static void EnumerateContinuationsOrIBSplitSiblings(nsIFrame* aFrame, + FrameEnumFunc&& aFunc) { + while (aFrame) { + aFunc(aFrame); + aFrame = nsLayoutUtils::GetNextContinuationOrIBSplitSibling(aFrame); + } +} + +void KeyframeEffect::UpdateTarget(Element* aElement, + PseudoStyleType aPseudoType) { + OwningAnimationTarget newTarget(aElement, aPseudoType); + + if (mTarget == newTarget) { + // Assign the same target, skip it. + return; + } + + if (mTarget) { + // Call ResetIsRunningOnCompositor() prior to UnregisterTarget() since + // ResetIsRunningOnCompositor() might try to get the EffectSet associated + // with this keyframe effect to remove partial pre-render animation from + // the layer manager. + ResetIsRunningOnCompositor(); + UnregisterTarget(); + + RequestRestyle(EffectCompositor::RestyleType::Layer); + + nsAutoAnimationMutationBatch mb(mTarget.mElement->OwnerDoc()); + if (mAnimation) { + MutationObservers::NotifyAnimationRemoved(mAnimation); + } + } + + mTarget = newTarget; + + if (mTarget) { + UpdateTargetRegistration(); + RefPtr<const ComputedStyle> computedStyle = + GetTargetComputedStyle(Flush::None); + if (computedStyle) { + UpdateProperties(computedStyle); + } + + RequestRestyle(EffectCompositor::RestyleType::Layer); + + nsAutoAnimationMutationBatch mb(mTarget.mElement->OwnerDoc()); + if (mAnimation) { + MutationObservers::NotifyAnimationAdded(mAnimation); + mAnimation->ReschedulePendingTasks(); + } + } + + if (mAnimation) { + mAnimation->NotifyEffectTargetUpdated(); + } +} + +void KeyframeEffect::UpdateTargetRegistration() { + if (!mTarget) { + return; + } + + bool isRelevant = mAnimation && mAnimation->IsRelevant(); + + // Animation::IsRelevant() returns a cached value. It only updates when + // something calls Animation::UpdateRelevance. Whenever our timing changes, + // we should be notifying our Animation before calling this, so + // Animation::IsRelevant() should be up-to-date by the time we get here. + MOZ_ASSERT(isRelevant == + ((IsCurrent() || IsInEffect()) && mAnimation && + mAnimation->ReplaceState() != AnimationReplaceState::Removed), + "Out of date Animation::IsRelevant value"); + + if (isRelevant && !mInEffectSet) { + EffectSet* effectSet = + EffectSet::GetOrCreate(mTarget.mElement, mTarget.mPseudoType); + effectSet->AddEffect(*this); + mInEffectSet = true; + UpdateEffectSet(effectSet); + nsIFrame* frame = GetPrimaryFrame(); + EnumerateContinuationsOrIBSplitSiblings( + frame, [](nsIFrame* aFrame) { aFrame->MarkNeedsDisplayItemRebuild(); }); + } else if (!isRelevant && mInEffectSet) { + UnregisterTarget(); + } +} + +void KeyframeEffect::UnregisterTarget() { + if (!mInEffectSet) { + return; + } + + EffectSet* effectSet = EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + MOZ_ASSERT(effectSet, + "If mInEffectSet is true, there must be an EffectSet" + " on the target element"); + mInEffectSet = false; + if (effectSet) { + effectSet->RemoveEffect(*this); + + if (effectSet->IsEmpty()) { + EffectSet::DestroyEffectSet(mTarget.mElement, mTarget.mPseudoType); + } + } + nsIFrame* frame = GetPrimaryFrame(); + EnumerateContinuationsOrIBSplitSiblings( + frame, [](nsIFrame* aFrame) { aFrame->MarkNeedsDisplayItemRebuild(); }); +} + +void KeyframeEffect::RequestRestyle( + EffectCompositor::RestyleType aRestyleType) { + if (!mTarget) { + return; + } + nsPresContext* presContext = + nsContentUtils::GetContextForContent(mTarget.mElement); + if (presContext && mAnimation) { + presContext->EffectCompositor()->RequestRestyle( + mTarget.mElement, mTarget.mPseudoType, aRestyleType, + mAnimation->CascadeLevel()); + } +} + +already_AddRefed<const ComputedStyle> KeyframeEffect::GetTargetComputedStyle( + Flush aFlushType) const { + if (!GetRenderedDocument()) { + return nullptr; + } + + MOZ_ASSERT(mTarget, + "Should only have a document when we have a target element"); + + OwningAnimationTarget kungfuDeathGrip(mTarget.mElement, mTarget.mPseudoType); + + return aFlushType == Flush::Style + ? nsComputedDOMStyle::GetComputedStyle(mTarget.mElement, + mTarget.mPseudoType) + : nsComputedDOMStyle::GetComputedStyleNoFlush(mTarget.mElement, + mTarget.mPseudoType); +} + +#ifdef DEBUG +void DumpAnimationProperties( + const StylePerDocumentStyleData* aRawData, + nsTArray<AnimationProperty>& aAnimationProperties) { + for (auto& p : aAnimationProperties) { + printf("%s\n", nsCString(nsCSSProps::GetStringValue(p.mProperty)).get()); + for (auto& s : p.mSegments) { + nsAutoCString fromValue, toValue; + s.mFromValue.SerializeSpecifiedValue(p.mProperty, aRawData, fromValue); + s.mToValue.SerializeSpecifiedValue(p.mProperty, aRawData, toValue); + printf(" %f..%f: %s..%s\n", s.mFromKey, s.mToKey, fromValue.get(), + toValue.get()); + } + } +} +#endif + +/* static */ +already_AddRefed<KeyframeEffect> KeyframeEffect::Constructor( + const GlobalObject& aGlobal, Element* aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv) { + return ConstructKeyframeEffect(aGlobal, aTarget, aKeyframes, aOptions, aRv); +} + +/* static */ +already_AddRefed<KeyframeEffect> KeyframeEffect::Constructor( + const GlobalObject& aGlobal, Element* aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aRv) { + return ConstructKeyframeEffect(aGlobal, aTarget, aKeyframes, aOptions, aRv); +} + +/* static */ +already_AddRefed<KeyframeEffect> KeyframeEffect::Constructor( + const GlobalObject& aGlobal, KeyframeEffect& aSource, ErrorResult& aRv) { + Document* doc = AnimationUtils::GetCurrentRealmDocument(aGlobal.Context()); + if (!doc) { + aRv.Throw(NS_ERROR_FAILURE); + return nullptr; + } + + // Create a new KeyframeEffect object with aSource's target, + // iteration composite operation, composite operation, and spacing mode. + // The constructor creates a new AnimationEffect object by + // aSource's TimingParams. + // Note: we don't need to re-throw exceptions since the value specified on + // aSource's timing object can be assumed valid. + RefPtr<KeyframeEffect> effect = + new KeyframeEffect(doc, OwningAnimationTarget{aSource.mTarget}, aSource); + // Copy cumulative change hint. mCumulativeChangeHint should be the same as + // the source one because both of targets are the same. + effect->mCumulativeChangeHint = aSource.mCumulativeChangeHint; + + return effect.forget(); +} + +void KeyframeEffect::SetPseudoElement(const nsAString& aPseudoElement, + ErrorResult& aRv) { + if (DOMStringIsNull(aPseudoElement)) { + UpdateTarget(mTarget.mElement, PseudoStyleType::NotPseudo); + return; + } + + // Note: GetPseudoType() returns Some(NotPseudo) for the null string, + // so we handle null case before this. + Maybe<PseudoStyleType> pseudoType = + nsCSSPseudoElements::GetPseudoType(aPseudoElement); + if (!pseudoType || *pseudoType == PseudoStyleType::NotPseudo) { + // Per the spec, we throw SyntaxError for syntactically invalid pseudos. + aRv.ThrowSyntaxError( + nsPrintfCString("'%s' is a syntactically invalid pseudo-element.", + NS_ConvertUTF16toUTF8(aPseudoElement).get())); + return; + } + + if (!AnimationUtils::IsSupportedPseudoForAnimations(*pseudoType)) { + // Per the spec, we throw SyntaxError for unsupported pseudos. + aRv.ThrowSyntaxError( + nsPrintfCString("'%s' is an unsupported pseudo-element.", + NS_ConvertUTF16toUTF8(aPseudoElement).get())); + return; + } + + UpdateTarget(mTarget.mElement, *pseudoType); +} + +static void CreatePropertyValue( + nsCSSPropertyID aProperty, float aOffset, + const Maybe<StyleComputedTimingFunction>& aTimingFunction, + const AnimationValue& aValue, dom::CompositeOperation aComposite, + const StylePerDocumentStyleData* aRawData, + AnimationPropertyValueDetails& aResult) { + aResult.mOffset = aOffset; + + if (!aValue.IsNull()) { + nsAutoCString stringValue; + aValue.SerializeSpecifiedValue(aProperty, aRawData, stringValue); + aResult.mValue.Construct(stringValue); + } + + if (aTimingFunction) { + aResult.mEasing.Construct(); + aTimingFunction->AppendToString(aResult.mEasing.Value()); + } else { + aResult.mEasing.Construct("linear"_ns); + } + + aResult.mComposite = aComposite; +} + +void KeyframeEffect::GetProperties( + nsTArray<AnimationPropertyDetails>& aProperties, ErrorResult& aRv) const { + const StylePerDocumentStyleData* rawData = + mDocument->StyleSetForPresShellOrMediaQueryEvaluation()->RawData(); + + for (const AnimationProperty& property : mProperties) { + AnimationPropertyDetails propertyDetails; + propertyDetails.mProperty = + NS_ConvertASCIItoUTF16(nsCSSProps::GetStringValue(property.mProperty)); + propertyDetails.mRunningOnCompositor = property.mIsRunningOnCompositor; + + nsAutoString localizedString; + if (property.mPerformanceWarning && + property.mPerformanceWarning->ToLocalizedString(localizedString)) { + propertyDetails.mWarning.Construct(localizedString); + } + + if (!propertyDetails.mValues.SetCapacity(property.mSegments.Length(), + mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + for (size_t segmentIdx = 0, segmentLen = property.mSegments.Length(); + segmentIdx < segmentLen; segmentIdx++) { + const AnimationPropertySegment& segment = property.mSegments[segmentIdx]; + + binding_detail::FastAnimationPropertyValueDetails fromValue; + CreatePropertyValue(property.mProperty, segment.mFromKey, + segment.mTimingFunction, segment.mFromValue, + segment.mFromComposite, rawData, fromValue); + // We don't apply timing functions for zero-length segments, so + // don't return one here. + if (segment.mFromKey == segment.mToKey) { + fromValue.mEasing.Reset(); + } + // Even though we called SetCapacity before, this could fail, since we + // might add multiple elements to propertyDetails.mValues for an element + // of property.mSegments in the cases mentioned below. + if (!propertyDetails.mValues.AppendElement(fromValue, + mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + // Normally we can ignore the to-value for this segment since it is + // identical to the from-value from the next segment. However, we need + // to add it if either: + // a) this is the last segment, or + // b) the next segment's from-value differs. + if (segmentIdx == segmentLen - 1 || + property.mSegments[segmentIdx + 1].mFromValue != segment.mToValue) { + binding_detail::FastAnimationPropertyValueDetails toValue; + CreatePropertyValue(property.mProperty, segment.mToKey, Nothing(), + segment.mToValue, segment.mToComposite, rawData, + toValue); + // It doesn't really make sense to have a timing function on the + // last property value or before a sudden jump so we just drop the + // easing property altogether. + toValue.mEasing.Reset(); + if (!propertyDetails.mValues.AppendElement(toValue, + mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + } + } + + aProperties.AppendElement(propertyDetails); + } +} + +void KeyframeEffect::GetKeyframes(JSContext* aCx, nsTArray<JSObject*>& aResult, + ErrorResult& aRv) const { + MOZ_ASSERT(aResult.IsEmpty()); + MOZ_ASSERT(!aRv.Failed()); + + if (!aResult.SetCapacity(mKeyframes.Length(), mozilla::fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + return; + } + + bool isCSSAnimation = mAnimation && mAnimation->AsCSSAnimation(); + + // For Servo, when we have CSS Animation @keyframes with variables, we convert + // shorthands to longhands if needed, and store a reference to the unparsed + // value. When it comes time to serialize, however, what do you serialize for + // a longhand that comes from a variable reference in a shorthand? Servo says, + // "an empty string" which is not particularly helpful. + // + // We should just store shorthands as-is (bug 1391537) and then return the + // variable references, but for now, since we don't do that, and in order to + // be consistent with Gecko, we just expand the variables (assuming we have + // enough context to do so). For that we need to grab the ComputedStyle so we + // know what custom property values to provide. + RefPtr<const ComputedStyle> computedStyle; + if (isCSSAnimation) { + // The following will flush style but that's ok since if you update + // a variable's computed value, you expect to see that updated value in the + // result of getKeyframes(). + // + // If we don't have a target, the following will return null. In that case + // we might end up returning variables as-is or empty string. That should be + // acceptable however, since such a case is rare and this is only + // short-term (and unshipped) behavior until bug 1391537 is fixed. + computedStyle = GetTargetComputedStyle(Flush::Style); + } + + const StylePerDocumentStyleData* rawData = + mDocument->StyleSetForPresShellOrMediaQueryEvaluation()->RawData(); + + for (const Keyframe& keyframe : mKeyframes) { + // Set up a dictionary object for the explicit members + BaseComputedKeyframe keyframeDict; + if (keyframe.mOffset) { + keyframeDict.mOffset.SetValue(keyframe.mOffset.value()); + } + MOZ_ASSERT(keyframe.mComputedOffset != Keyframe::kComputedOffsetNotSet, + "Invalid computed offset"); + keyframeDict.mComputedOffset.Construct(keyframe.mComputedOffset); + if (keyframe.mTimingFunction) { + keyframeDict.mEasing.Truncate(); + keyframe.mTimingFunction.ref().AppendToString(keyframeDict.mEasing); + } // else if null, leave easing as its default "linear". + + // With the pref off (i.e. dom.animations-api.compositing.enabled:false), + // the dictionary-to-JS conversion will skip this member entirely. + keyframeDict.mComposite = keyframe.mComposite; + + JS::Rooted<JS::Value> keyframeJSValue(aCx); + if (!ToJSValue(aCx, keyframeDict, &keyframeJSValue)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + RefPtr<StyleLockedDeclarationBlock> customProperties; + // A workaround for CSS Animations in servo backend, custom properties in + // keyframe are stored in a servo's declaration block. Find the declaration + // block to resolve CSS variables in the keyframe. + // This workaround will be solved by bug 1391537. + if (isCSSAnimation) { + for (const PropertyValuePair& propertyValue : keyframe.mPropertyValues) { + if (propertyValue.mProperty == + nsCSSPropertyID::eCSSPropertyExtra_variable) { + customProperties = propertyValue.mServoDeclarationBlock; + break; + } + } + } + + JS::Rooted<JSObject*> keyframeObject(aCx, &keyframeJSValue.toObject()); + for (const PropertyValuePair& propertyValue : keyframe.mPropertyValues) { + nsAutoCString stringValue; + // Don't serialize the custom properties for this keyframe. + if (propertyValue.mProperty == + nsCSSPropertyID::eCSSPropertyExtra_variable) { + continue; + } + if (propertyValue.mServoDeclarationBlock) { + Servo_DeclarationBlock_SerializeOneValue( + propertyValue.mServoDeclarationBlock, propertyValue.mProperty, + &stringValue, computedStyle, customProperties, rawData); + } else { + if (auto* value = mBaseValues.GetWeak(propertyValue.mProperty)) { + Servo_AnimationValue_Serialize(value, propertyValue.mProperty, + rawData, &stringValue); + } + } + + // Basically, we need to do the mapping: + // * eCSSProperty_offset => "cssOffset" + // * eCSSProperty_float => "cssFloat" + // This means if property refers to the CSS "offset"/"float" property, + // return the string "cssOffset"/"cssFloat". (So avoid overlapping + // "offset" property in BaseKeyframe.) + // https://drafts.csswg.org/web-animations/#property-name-conversion + const char* name = nullptr; + switch (propertyValue.mProperty) { + case nsCSSPropertyID::eCSSProperty_offset: + name = "cssOffset"; + break; + case nsCSSPropertyID::eCSSProperty_float: + // FIXME: Bug 1582314: Should handle cssFloat manually if we remove it + // from nsCSSProps::PropertyIDLName(). + default: + name = nsCSSProps::PropertyIDLName(propertyValue.mProperty); + } + + JS::Rooted<JS::Value> value(aCx); + if (!NonVoidUTF8StringToJsval(aCx, stringValue, &value) || + !JS_DefineProperty(aCx, keyframeObject, name, value, + JSPROP_ENUMERATE)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + } + + aResult.AppendElement(keyframeObject); + } +} + +/* static */ const TimeDuration +KeyframeEffect::OverflowRegionRefreshInterval() { + // The amount of time we can wait between updating throttled animations + // on the main thread that influence the overflow region. + static const TimeDuration kOverflowRegionRefreshInterval = + TimeDuration::FromMilliseconds(200); + + return kOverflowRegionRefreshInterval; +} + +static bool IsDefinitivelyInvisibleDueToOpacity(const nsIFrame& aFrame) { + if (!aFrame.Style()->IsInOpacityZeroSubtree()) { + return false; + } + + // Find the root of the opacity: 0 subtree. + const nsIFrame* root = &aFrame; + while (true) { + auto* parent = root->GetInFlowParent(); + if (!parent || !parent->Style()->IsInOpacityZeroSubtree()) { + break; + } + root = parent; + } + + MOZ_ASSERT(root && root->Style()->IsInOpacityZeroSubtree()); + + // If aFrame is the root of the opacity: zero subtree, we can't prove we can + // optimize it away, because it may have an opacity animation itself. + if (root == &aFrame) { + return false; + } + + // Even if we're in an opacity: zero subtree, if the root of the subtree may + // have an opacity animation, we can't optimize us away, as we may become + // visible ourselves. + return !root->HasAnimationOfOpacity(); +} + +static bool CanOptimizeAwayDueToOpacity(const KeyframeEffect& aEffect, + const nsIFrame& aFrame) { + if (!aFrame.Style()->IsInOpacityZeroSubtree()) { + return false; + } + if (IsDefinitivelyInvisibleDueToOpacity(aFrame)) { + return true; + } + return !aEffect.HasOpacityChange() && !aFrame.HasAnimationOfOpacity(); +} + +bool KeyframeEffect::CanThrottleIfNotVisible(nsIFrame& aFrame) const { + // Unless we are newly in-effect, we can throttle the animation if the + // animation is paint only and the target frame is out of view or the document + // is in background tabs. + if (!mInEffectOnLastAnimationTimingUpdate || !CanIgnoreIfNotVisible()) { + return false; + } + + PresShell* presShell = GetPresShell(); + if (presShell && !presShell->IsActive()) { + return true; + } + + const bool isVisibilityHidden = + !aFrame.IsVisibleOrMayHaveVisibleDescendants(); + const bool canOptimizeAwayVisibility = + isVisibilityHidden && !HasVisibilityChange(); + + const bool invisible = canOptimizeAwayVisibility || + CanOptimizeAwayDueToOpacity(*this, aFrame) || + aFrame.IsScrolledOutOfView(); + if (!invisible) { + return false; + } + + // If there are no overflow change hints, we don't need to worry about + // unthrottling the animation periodically to update scrollbar positions for + // the overflow region. + if (!HasPropertiesThatMightAffectOverflow()) { + return true; + } + + // Don't throttle finite animations since the animation might suddenly + // come into view and if it was throttled it will be out-of-sync. + if (HasFiniteActiveDuration()) { + return false; + } + + return isVisibilityHidden ? CanThrottleOverflowChangesInScrollable(aFrame) + : CanThrottleOverflowChanges(aFrame); +} + +bool KeyframeEffect::CanThrottle() const { + // Unthrottle if we are not in effect or current. This will be the case when + // our owning animation has finished, is idle, or when we are in the delay + // phase (but without a backwards fill). In each case the computed progress + // value produced on each tick will be the same so we will skip requesting + // unnecessary restyles in NotifyAnimationTimingUpdated. Any calls we *do* get + // here will be because of a change in state (e.g. we are newly finished or + // newly no longer in effect) in which case we shouldn't throttle the sample. + if (!IsInEffect() || !IsCurrent()) { + return false; + } + + nsIFrame* const frame = GetStyleFrame(); + if (!frame) { + // There are two possible cases here. + // a) No target element + // b) The target element has no frame, e.g. because it is in a display:none + // subtree. + // In either case we can throttle the animation because there is no + // need to update on the main thread. + return true; + } + + // Do not throttle any animations during print preview. + if (frame->PresContext()->IsPrintingOrPrintPreview()) { + return false; + } + + if (CanThrottleIfNotVisible(*frame)) { + return true; + } + + EffectSet* effectSet = nullptr; + for (const AnimationProperty& property : mProperties) { + if (!property.mIsRunningOnCompositor) { + return false; + } + + MOZ_ASSERT(nsCSSPropertyIDSet::CompositorAnimatables().HasProperty( + property.mProperty), + "The property should be able to run on the compositor"); + if (!effectSet) { + effectSet = EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + MOZ_ASSERT(effectSet, + "CanThrottle should be called on an effect " + "associated with a target element"); + } + MOZ_ASSERT(HasEffectiveAnimationOfProperty(property.mProperty, *effectSet), + "There should be an effective animation of the property while " + "it is marked as being run on the compositor"); + + DisplayItemType displayItemType = + LayerAnimationInfo::GetDisplayItemTypeForProperty(property.mProperty); + + // Note that AnimationInfo::GetGenarationFromFrame() is supposed to work + // with the primary frame instead of the style frame. + Maybe<uint64_t> generation = layers::AnimationInfo::GetGenerationFromFrame( + GetPrimaryFrame(), displayItemType); + // Unthrottle if the animation needs to be brought up to date + if (!generation || effectSet->GetAnimationGeneration() != *generation) { + return false; + } + + // If this is a transform animation that affects the overflow region, + // we should unthrottle the animation periodically. + if (HasPropertiesThatMightAffectOverflow() && + !CanThrottleOverflowChangesInScrollable(*frame)) { + return false; + } + } + + return true; +} + +bool KeyframeEffect::CanThrottleOverflowChanges(const nsIFrame& aFrame) const { + TimeStamp now = aFrame.PresContext()->RefreshDriver()->MostRecentRefresh(); + + EffectSet* effectSet = EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + MOZ_ASSERT(effectSet, + "CanOverflowTransformChanges is expected to be called" + " on an effect in an effect set"); + MOZ_ASSERT(mAnimation, + "CanOverflowTransformChanges is expected to be called" + " on an effect with a parent animation"); + TimeStamp lastSyncTime = effectSet->LastOverflowAnimationSyncTime(); + // If this animation can cause overflow, we can throttle some of the ticks. + return (!lastSyncTime.IsNull() && + (now - lastSyncTime) < OverflowRegionRefreshInterval()); +} + +bool KeyframeEffect::CanThrottleOverflowChangesInScrollable( + nsIFrame& aFrame) const { + // If the target element is not associated with any documents, we don't care + // it. + Document* doc = GetRenderedDocument(); + if (!doc) { + return true; + } + + // If we know that the animation cannot cause overflow, + // we can just disable flushes for this animation. + + // If we have no intersection observers, we don't care about overflow. + if (!doc->HasIntersectionObservers()) { + return true; + } + + if (CanThrottleOverflowChanges(aFrame)) { + return true; + } + + // If the nearest scrollable ancestor has overflow:hidden, + // we don't care about overflow. + nsIScrollableFrame* scrollable = + nsLayoutUtils::GetNearestScrollableFrame(&aFrame); + if (!scrollable) { + return true; + } + + ScrollStyles ss = scrollable->GetScrollStyles(); + if (ss.mVertical == StyleOverflow::Hidden && + ss.mHorizontal == StyleOverflow::Hidden && + scrollable->GetLogicalScrollPosition() == nsPoint(0, 0)) { + return true; + } + + return false; +} + +nsIFrame* KeyframeEffect::GetStyleFrame() const { + nsIFrame* frame = GetPrimaryFrame(); + if (!frame) { + return nullptr; + } + + return nsLayoutUtils::GetStyleFrame(frame); +} + +nsIFrame* KeyframeEffect::GetPrimaryFrame() const { + nsIFrame* frame = nullptr; + if (!mTarget) { + return frame; + } + + if (mTarget.mPseudoType == PseudoStyleType::before) { + frame = nsLayoutUtils::GetBeforeFrame(mTarget.mElement); + } else if (mTarget.mPseudoType == PseudoStyleType::after) { + frame = nsLayoutUtils::GetAfterFrame(mTarget.mElement); + } else if (mTarget.mPseudoType == PseudoStyleType::marker) { + frame = nsLayoutUtils::GetMarkerFrame(mTarget.mElement); + } else { + frame = mTarget.mElement->GetPrimaryFrame(); + MOZ_ASSERT(mTarget.mPseudoType == PseudoStyleType::NotPseudo, + "unknown mTarget.mPseudoType"); + } + + return frame; +} + +Document* KeyframeEffect::GetRenderedDocument() const { + if (!mTarget) { + return nullptr; + } + return mTarget.mElement->GetComposedDoc(); +} + +PresShell* KeyframeEffect::GetPresShell() const { + Document* doc = GetRenderedDocument(); + if (!doc) { + return nullptr; + } + return doc->GetPresShell(); +} + +/* static */ +bool KeyframeEffect::IsGeometricProperty(const nsCSSPropertyID aProperty) { + MOZ_ASSERT(!nsCSSProps::IsShorthand(aProperty), + "Property should be a longhand property"); + + switch (aProperty) { + case eCSSProperty_bottom: + case eCSSProperty_height: + case eCSSProperty_left: + case eCSSProperty_margin_bottom: + case eCSSProperty_margin_left: + case eCSSProperty_margin_right: + case eCSSProperty_margin_top: + case eCSSProperty_padding_bottom: + case eCSSProperty_padding_left: + case eCSSProperty_padding_right: + case eCSSProperty_padding_top: + case eCSSProperty_right: + case eCSSProperty_top: + case eCSSProperty_width: + return true; + default: + return false; + } +} + +/* static */ +bool KeyframeEffect::CanAnimateTransformOnCompositor( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */) { + // In some cases, such as when we are simply collecting all the compositor + // animations regardless of the frame on which they run in order to calculate + // change hints, |aFrame| will be the style frame. However, even in that case + // we should look at the primary frame since that is where the transform will + // be applied. + const nsIFrame* primaryFrame = + nsLayoutUtils::GetPrimaryFrameFromStyleFrame(aFrame); + + // Note that testing BackfaceIsHidden() is not a sufficient test for + // what we need for animating backface-visibility correctly if we + // remove the above test for Extend3DContext(); that would require + // looking at backface-visibility on descendants as well. See bug 1186204. + if (primaryFrame->BackfaceIsHidden()) { + aPerformanceWarning = + AnimationPerformanceWarning::Type::TransformBackfaceVisibilityHidden; + return false; + } + // Async 'transform' animations of aFrames with SVG transforms is not + // supported. See bug 779599. + if (primaryFrame->IsSVGTransformed()) { + aPerformanceWarning = AnimationPerformanceWarning::Type::TransformSVG; + return false; + } + + return true; +} + +bool KeyframeEffect::ShouldBlockAsyncTransformAnimations( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */) const { + EffectSet* effectSet = EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + // The various transform properties ('transform', 'scale' etc.) get combined + // on the compositor. + // + // As a result, if we have an animation of 'scale' and 'translate', but the + // 'translate' property is covered by an !important rule, we will not be + // able to combine the result on the compositor since we won't have the + // !important rule to incorporate. In that case we should run all the + // transform-related animations on the main thread (where we have the + // !important rule). + nsCSSPropertyIDSet blockedProperties = + effectSet->PropertiesWithImportantRules().Intersect( + effectSet->PropertiesForAnimationsLevel()); + if (blockedProperties.Intersects(aPropertySet)) { + aPerformanceWarning = + AnimationPerformanceWarning::Type::TransformIsBlockedByImportantRules; + return true; + } + + MOZ_ASSERT(mAnimation); + // Note: If the geometric animations are using scroll-timeline, we don't need + // to synchronize transform animations with them. + const bool enableMainthreadSynchronizationWithGeometricAnimations = + StaticPrefs:: + dom_animations_mainthread_synchronization_with_geometric_animations() && + !mAnimation->UsingScrollTimeline(); + + for (const AnimationProperty& property : mProperties) { + // If there is a property for animations level that is overridden by + // !important rules, it should not block other animations from running + // on the compositor. + // NOTE: We don't currently check for !important rules for properties that + // don't run on the compositor. As result such properties (e.g. margin-left) + // can still block async animations even if they are overridden by + // !important rules. + if (effectSet && + effectSet->PropertiesWithImportantRules().HasProperty( + property.mProperty) && + effectSet->PropertiesForAnimationsLevel().HasProperty( + property.mProperty)) { + continue; + } + // Check for geometric properties + if (enableMainthreadSynchronizationWithGeometricAnimations && + IsGeometricProperty(property.mProperty)) { + aPerformanceWarning = + AnimationPerformanceWarning::Type::TransformWithGeometricProperties; + return true; + } + + // Check for unsupported transform animations + if (LayerAnimationInfo::GetCSSPropertiesFor(DisplayItemType::TYPE_TRANSFORM) + .HasProperty(property.mProperty)) { + if (!CanAnimateTransformOnCompositor(aFrame, aPerformanceWarning)) { + return true; + } + } + } + + return false; +} + +bool KeyframeEffect::HasGeometricProperties() const { + for (const AnimationProperty& property : mProperties) { + if (IsGeometricProperty(property.mProperty)) { + return true; + } + } + + return false; +} + +void KeyframeEffect::SetPerformanceWarning( + const nsCSSPropertyIDSet& aPropertySet, + const AnimationPerformanceWarning& aWarning) { + nsCSSPropertyIDSet curr = aPropertySet; + for (AnimationProperty& property : mProperties) { + if (!curr.HasProperty(property.mProperty)) { + continue; + } + property.SetPerformanceWarning(aWarning, mTarget.mElement); + curr.RemoveProperty(property.mProperty); + if (curr.IsEmpty()) { + return; + } + } +} + +already_AddRefed<const ComputedStyle> +KeyframeEffect::CreateComputedStyleForAnimationValue( + nsCSSPropertyID aProperty, const AnimationValue& aValue, + nsPresContext* aPresContext, const ComputedStyle* aBaseComputedStyle) { + MOZ_ASSERT(aBaseComputedStyle, + "CreateComputedStyleForAnimationValue needs to be called " + "with a valid ComputedStyle"); + + Element* elementForResolve = AnimationUtils::GetElementForRestyle( + mTarget.mElement, mTarget.mPseudoType); + // The element may be null if, for example, we target a pseudo-element that no + // longer exists. + if (!elementForResolve) { + return nullptr; + } + + ServoStyleSet* styleSet = aPresContext->StyleSet(); + return styleSet->ResolveServoStyleByAddingAnimation( + elementForResolve, aBaseComputedStyle, aValue.mServo); +} + +void KeyframeEffect::CalculateCumulativeChangeHint( + const ComputedStyle* aComputedStyle) { + mCumulativeChangeHint = nsChangeHint(0); + mNeedsStyleData = false; + + nsPresContext* presContext = + mTarget ? nsContentUtils::GetContextForContent(mTarget.mElement) + : nullptr; + if (!presContext) { + // Change hints make no sense if we're not rendered. + // + // Actually, we cannot even post them anywhere. + mNeedsStyleData = true; + return; + } + + for (const AnimationProperty& property : mProperties) { + // For opacity property we don't produce any change hints that are not + // included in nsChangeHint_Hints_CanIgnoreIfNotVisible so we can throttle + // opacity animations regardless of the change they produce. This + // optimization is particularly important since it allows us to throttle + // opacity animations with missing 0%/100% keyframes. + if (property.mProperty == eCSSProperty_opacity) { + continue; + } + + for (const AnimationPropertySegment& segment : property.mSegments) { + // In case composite operation is not 'replace' or value is null, + // we can't throttle animations which will not cause any layout changes + // on invisible elements because we can't calculate the change hint for + // such properties until we compose it. + if (!segment.HasReplaceableValues()) { + if (!nsCSSPropertyIDSet::TransformLikeProperties().HasProperty( + property.mProperty)) { + mCumulativeChangeHint = ~nsChangeHint_Hints_CanIgnoreIfNotVisible; + return; + } + // We try a little harder to optimize transform animations simply + // because they are so common (the second-most commonly animated + // property at the time of writing). So if we encounter a transform + // segment that needs composing with the underlying value, we just add + // all the change hints a transform animation is known to be able to + // generate. + mCumulativeChangeHint |= + nsChangeHint_ComprehensiveAddOrRemoveTransform | + nsChangeHint_UpdatePostTransformOverflow | + nsChangeHint_UpdateTransformLayer; + continue; + } + + RefPtr<const ComputedStyle> fromContext = + CreateComputedStyleForAnimationValue(property.mProperty, + segment.mFromValue, presContext, + aComputedStyle); + if (!fromContext) { + mCumulativeChangeHint = ~nsChangeHint_Hints_CanIgnoreIfNotVisible; + mNeedsStyleData = true; + return; + } + + RefPtr<const ComputedStyle> toContext = + CreateComputedStyleForAnimationValue(property.mProperty, + segment.mToValue, presContext, + aComputedStyle); + if (!toContext) { + mCumulativeChangeHint = ~nsChangeHint_Hints_CanIgnoreIfNotVisible; + mNeedsStyleData = true; + return; + } + + uint32_t equalStructs = 0; + nsChangeHint changeHint = + fromContext->CalcStyleDifference(*toContext, &equalStructs); + + mCumulativeChangeHint |= changeHint; + } + } +} + +void KeyframeEffect::SetAnimation(Animation* aAnimation) { + if (mAnimation == aAnimation) { + return; + } + + // Restyle for the old animation. + RequestRestyle(EffectCompositor::RestyleType::Layer); + + mAnimation = aAnimation; + + UpdateNormalizedTiming(); + + // The order of these function calls is important: + // NotifyAnimationTimingUpdated() need the updated mIsRelevant flag to check + // if it should create the effectSet or not, and MarkCascadeNeedsUpdate() + // needs a valid effectSet, so we should call them in this order. + if (mAnimation) { + mAnimation->UpdateRelevance(); + } + NotifyAnimationTimingUpdated(PostRestyleMode::IfNeeded); + if (mAnimation) { + MarkCascadeNeedsUpdate(); + } +} + +bool KeyframeEffect::CanIgnoreIfNotVisible() const { + if (!StaticPrefs::dom_animations_offscreen_throttling()) { + return false; + } + + // FIXME: For further sophisticated optimization we need to check + // change hint on the segment corresponding to computedTiming.progress. + return NS_IsHintSubset(mCumulativeChangeHint, + nsChangeHint_Hints_CanIgnoreIfNotVisible); +} + +void KeyframeEffect::MarkCascadeNeedsUpdate() { + if (!mTarget) { + return; + } + + EffectSet* effectSet = EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + if (!effectSet) { + return; + } + effectSet->MarkCascadeNeedsUpdate(); +} + +/* static */ +bool KeyframeEffect::HasComputedTimingChanged( + const ComputedTiming& aComputedTiming, + IterationCompositeOperation aIterationComposite, + const Nullable<double>& aProgressOnLastCompose, + uint64_t aCurrentIterationOnLastCompose) { + // Typically we don't need to request a restyle if the progress hasn't + // changed since the last call to ComposeStyle. The one exception is if the + // iteration composite mode is 'accumulate' and the current iteration has + // changed, since that will often produce a different result. + return aComputedTiming.mProgress != aProgressOnLastCompose || + (aIterationComposite == IterationCompositeOperation::Accumulate && + aComputedTiming.mCurrentIteration != aCurrentIterationOnLastCompose); +} + +bool KeyframeEffect::HasComputedTimingChanged() const { + ComputedTiming computedTiming = GetComputedTiming(); + return HasComputedTimingChanged( + computedTiming, mEffectOptions.mIterationComposite, + mProgressOnLastCompose, mCurrentIterationOnLastCompose); +} + +bool KeyframeEffect::ContainsAnimatedScale(const nsIFrame* aFrame) const { + // For display:table content, transform animations run on the table wrapper + // frame. If we are being passed a frame that doesn't support transforms + // (i.e. the inner table frame) we could just return false, but it possibly + // means we looked up the wrong EffectSet so for now we just assert instead. + MOZ_ASSERT(aFrame && aFrame->IsFrameOfType(nsIFrame::eSupportsCSSTransforms), + "We should be passed a frame that supports transforms"); + + if (!IsCurrent()) { + return false; + } + + if (!mAnimation || + mAnimation->ReplaceState() == AnimationReplaceState::Removed) { + return false; + } + + for (const AnimationProperty& prop : mProperties) { + if (prop.mProperty != eCSSProperty_transform && + prop.mProperty != eCSSProperty_scale && + prop.mProperty != eCSSProperty_rotate) { + continue; + } + + AnimationValue baseStyle = BaseStyle(prop.mProperty); + if (!baseStyle.IsNull()) { + gfx::MatrixScales size = baseStyle.GetScaleValue(aFrame); + if (size != gfx::MatrixScales()) { + return true; + } + } + + // This is actually overestimate because there are some cases that combining + // the base value and from/to value produces 1:1 scale. But it doesn't + // really matter. + for (const AnimationPropertySegment& segment : prop.mSegments) { + if (!segment.mFromValue.IsNull()) { + gfx::MatrixScales from = segment.mFromValue.GetScaleValue(aFrame); + if (from != gfx::MatrixScales()) { + return true; + } + } + if (!segment.mToValue.IsNull()) { + gfx::MatrixScales to = segment.mToValue.GetScaleValue(aFrame); + if (to != gfx::MatrixScales()) { + return true; + } + } + } + } + + return false; +} + +void KeyframeEffect::UpdateEffectSet(EffectSet* aEffectSet) const { + if (!mInEffectSet) { + return; + } + + EffectSet* effectSet = + aEffectSet ? aEffectSet + : EffectSet::Get(mTarget.mElement, mTarget.mPseudoType); + if (!effectSet) { + return; + } + + nsIFrame* styleFrame = GetStyleFrame(); + if (HasAnimationOfPropertySet(nsCSSPropertyIDSet::OpacityProperties())) { + effectSet->SetMayHaveOpacityAnimation(); + EnumerateContinuationsOrIBSplitSiblings(styleFrame, [](nsIFrame* aFrame) { + aFrame->SetMayHaveOpacityAnimation(); + }); + } + + nsIFrame* primaryFrame = GetPrimaryFrame(); + if (HasAnimationOfPropertySet( + nsCSSPropertyIDSet::TransformLikeProperties())) { + effectSet->SetMayHaveTransformAnimation(); + // For table frames, it's not clear if we should iterate over the + // continuations of the table wrapper or the inner table frame. + // + // Fortunately, this is not currently an issue because we only split tables + // when printing (page breaks) where we don't run animations. + EnumerateContinuationsOrIBSplitSiblings(primaryFrame, [](nsIFrame* aFrame) { + aFrame->SetMayHaveTransformAnimation(); + }); + } +} + +KeyframeEffect::MatchForCompositor KeyframeEffect::IsMatchForCompositor( + const nsCSSPropertyIDSet& aPropertySet, const nsIFrame* aFrame, + const EffectSet& aEffects, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */) const { + MOZ_ASSERT(mAnimation); + + if (!mAnimation->IsRelevant()) { + return KeyframeEffect::MatchForCompositor::No; + } + + if (mAnimation->ShouldBeSynchronizedWithMainThread(aPropertySet, aFrame, + aPerformanceWarning)) { + // For a given |aFrame|, we don't want some animations of |aProperty| to + // run on the compositor and others to run on the main thread, so if any + // need to be synchronized with the main thread, run them all there. + return KeyframeEffect::MatchForCompositor::NoAndBlockThisProperty; + } + + if (mAnimation->UsingScrollTimeline()) { + const ScrollTimeline* scrollTimeline = + mAnimation->GetTimeline()->AsScrollTimeline(); + // We don't send this animation to the compositor if + // 1. the APZ is disabled entirely or for the source, or + // 2. the associated scroll-timeline is inactive, or + // 3. the scrolling direction is not available (i.e. no scroll range). + // 4. the scroll style of the scroller is overflow:hidden. + if (!scrollTimeline->APZIsActiveForSource() || + !scrollTimeline->IsActive() || + !scrollTimeline->ScrollingDirectionIsAvailable() || + scrollTimeline->SourceScrollStyle() == StyleOverflow::Hidden) { + return KeyframeEffect::MatchForCompositor::No; + } + + // FIXME: Bug 1818346. Support OMTA for view-timeline. We disable it for now + // because we need to make view-timeline-inset animations run on the OMTA as + // well before enable this. + if (scrollTimeline->IsViewTimeline()) { + return KeyframeEffect::MatchForCompositor::No; + } + } + + if (!HasEffectiveAnimationOfPropertySet(aPropertySet, aEffects)) { + return KeyframeEffect::MatchForCompositor::No; + } + + // If we know that the animation is not visible, we don't need to send the + // animation to the compositor. + if (!aFrame->IsVisibleOrMayHaveVisibleDescendants() || + CanOptimizeAwayDueToOpacity(*this, *aFrame) || + aFrame->IsScrolledOutOfView()) { + return KeyframeEffect::MatchForCompositor::NoAndBlockThisProperty; + } + + if (aPropertySet.HasProperty(eCSSProperty_background_color)) { + if (!StaticPrefs::gfx_omta_background_color()) { + return KeyframeEffect::MatchForCompositor::No; + } + + // We don't yet support off-main-thread background-color animations on + // canvas frame or on <html> or <body> which genarate + // nsDisplayCanvasBackgroundColor or nsDisplaySolidColor display item. + if (aFrame->IsCanvasFrame() || + (aFrame->GetContent() && + (aFrame->GetContent()->IsHTMLElement(nsGkAtoms::body) || + aFrame->GetContent()->IsHTMLElement(nsGkAtoms::html)))) { + return KeyframeEffect::MatchForCompositor::No; + } + } + + // We can't run this background color animation on the compositor if there + // is any `current-color` keyframe. + if (mHasCurrentColor) { + aPerformanceWarning = AnimationPerformanceWarning::Type::HasCurrentColor; + return KeyframeEffect::MatchForCompositor::NoAndBlockThisProperty; + } + + return mAnimation->IsPlaying() ? KeyframeEffect::MatchForCompositor::Yes + : KeyframeEffect::MatchForCompositor::IfNeeded; +} + +} // namespace dom +} // namespace mozilla diff --git a/dom/animation/KeyframeEffect.h b/dom/animation/KeyframeEffect.h new file mode 100644 index 0000000000..8f8466369e --- /dev/null +++ b/dom/animation/KeyframeEffect.h @@ -0,0 +1,529 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_KeyframeEffect_h +#define mozilla_dom_KeyframeEffect_h + +#include "nsChangeHint.h" +#include "nsCSSPropertyID.h" +#include "nsCSSPropertyIDSet.h" +#include "nsCSSValue.h" +#include "nsCycleCollectionParticipant.h" +#include "nsRefPtrHashtable.h" +#include "nsTArray.h" +#include "nsWrapperCache.h" +#include "mozilla/AnimationPerformanceWarning.h" +#include "mozilla/AnimationPropertySegment.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/Attributes.h" +#include "mozilla/EffectCompositor.h" +#include "mozilla/Keyframe.h" +#include "mozilla/KeyframeEffectParams.h" +#include "mozilla/PostRestyleMode.h" +// StyleLockedDeclarationBlock and associated RefPtrTraits +#include "mozilla/ServoBindingTypes.h" +#include "mozilla/StyleAnimationValue.h" +#include "mozilla/dom/AnimationEffect.h" +#include "mozilla/dom/BindingDeclarations.h" + +struct JSContext; +class JSObject; +class nsIContent; +class nsIFrame; + +namespace mozilla { + +class AnimValuesStyleRule; +class ErrorResult; +struct AnimationRule; +struct TimingParams; +class EffectSet; +class ComputedStyle; +class PresShell; + +namespace dom { +class Element; +class GlobalObject; +class UnrestrictedDoubleOrKeyframeAnimationOptions; +class UnrestrictedDoubleOrKeyframeEffectOptions; +enum class IterationCompositeOperation : uint8_t; +enum class CompositeOperation : uint8_t; +struct AnimationPropertyDetails; +} // namespace dom + +struct AnimationProperty { + nsCSSPropertyID mProperty = eCSSProperty_UNKNOWN; + + // If true, the propery is currently being animated on the compositor. + // + // Note that when the owning Animation requests a non-throttled restyle, in + // between calling RequestRestyle on its EffectCompositor and when the + // restyle is performed, this member may temporarily become false even if + // the animation remains on the layer after the restyle. + // + // **NOTE**: This member is not included when comparing AnimationProperty + // objects for equality. + bool mIsRunningOnCompositor = false; + + Maybe<AnimationPerformanceWarning> mPerformanceWarning; + + nsTArray<AnimationPropertySegment> mSegments; + + // The copy constructor/assignment doesn't copy mIsRunningOnCompositor and + // mPerformanceWarning. + AnimationProperty() = default; + AnimationProperty(const AnimationProperty& aOther) + : mProperty(aOther.mProperty), mSegments(aOther.mSegments.Clone()) {} + AnimationProperty& operator=(const AnimationProperty& aOther) { + mProperty = aOther.mProperty; + mSegments = aOther.mSegments.Clone(); + return *this; + } + + // NOTE: This operator does *not* compare the mIsRunningOnCompositor member. + // This is because AnimationProperty objects are compared when recreating + // CSS animations to determine if mutation observer change records need to + // be created or not. However, at the point when these objects are compared + // the mIsRunningOnCompositor will not have been set on the new objects so + // we ignore this member to avoid generating spurious change records. + bool operator==(const AnimationProperty& aOther) const { + return mProperty == aOther.mProperty && mSegments == aOther.mSegments; + } + bool operator!=(const AnimationProperty& aOther) const { + return !(*this == aOther); + } + + void SetPerformanceWarning(const AnimationPerformanceWarning& aWarning, + const dom::Element* aElement); +}; + +namespace dom { + +class Animation; +class Document; + +class KeyframeEffect : public AnimationEffect { + public: + KeyframeEffect(Document* aDocument, OwningAnimationTarget&& aTarget, + TimingParams&& aTiming, const KeyframeEffectParams& aOptions); + + KeyframeEffect(Document* aDocument, OwningAnimationTarget&& aTarget, + const KeyframeEffect& aOther); + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(KeyframeEffect, + AnimationEffect) + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + KeyframeEffect* AsKeyframeEffect() override { return this; } + + bool IsValidTransition() const { + return Properties().Length() == 1 && + Properties()[0].mSegments.Length() == 1; + } + + // KeyframeEffect interface + static already_AddRefed<KeyframeEffect> Constructor( + const GlobalObject& aGlobal, Element* aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv); + + static already_AddRefed<KeyframeEffect> Constructor( + const GlobalObject& aGlobal, KeyframeEffect& aSource, ErrorResult& aRv); + + // Variant of Constructor that accepts a KeyframeAnimationOptions object + // for use with for Animatable.animate. + // Not exposed to content. + static already_AddRefed<KeyframeEffect> Constructor( + const GlobalObject& aGlobal, Element* aTarget, + JS::Handle<JSObject*> aKeyframes, + const UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aRv); + + Element* GetTarget() const { return mTarget.mElement.get(); } + NonOwningAnimationTarget GetAnimationTarget() const { + return NonOwningAnimationTarget(mTarget.mElement, mTarget.mPseudoType); + } + void GetPseudoElement(nsAString& aRetVal) const { + if (mTarget.mPseudoType == PseudoStyleType::NotPseudo) { + SetDOMStringToNull(aRetVal); + return; + } + aRetVal = nsCSSPseudoElements::PseudoTypeAsString(mTarget.mPseudoType); + } + + // These two setters call GetTargetComputedStyle which is not safe to use when + // we are in the middle of updating style. If we need to use this when + // updating style, we should pass the ComputedStyle into this method and use + // that to update the properties rather than calling + // GetComputedStyle. + void SetTarget(Element* aTarget) { + UpdateTarget(aTarget, mTarget.mPseudoType); + } + void SetPseudoElement(const nsAString& aPseudoElement, ErrorResult& aRv); + + void GetKeyframes(JSContext* aCx, nsTArray<JSObject*>& aResult, + ErrorResult& aRv) const; + void GetProperties(nsTArray<AnimationPropertyDetails>& aProperties, + ErrorResult& aRv) const; + + IterationCompositeOperation IterationComposite() const; + void SetIterationComposite( + const IterationCompositeOperation& aIterationComposite); + + CompositeOperation Composite() const; + virtual void SetComposite(const CompositeOperation& aComposite); + void SetCompositeFromStyle(const CompositeOperation& aComposite) { + KeyframeEffect::SetComposite(aComposite); + } + + void NotifySpecifiedTimingUpdated(); + void NotifyAnimationTimingUpdated(PostRestyleMode aPostRestyle); + void RequestRestyle(EffectCompositor::RestyleType aRestyleType); + void SetAnimation(Animation* aAnimation) override; + virtual void SetKeyframes(JSContext* aContext, + JS::Handle<JSObject*> aKeyframes, ErrorResult& aRv); + void SetKeyframes(nsTArray<Keyframe>&& aKeyframes, + const ComputedStyle* aStyle, + const AnimationTimeline* aTimeline); + + // Replace the start value of the transition. This is used for updating + // transitions running on the compositor. + void ReplaceTransitionStartValue(AnimationValue&& aStartValue); + + // Returns the set of properties affected by this effect regardless of + // whether any of these properties is overridden by an !important rule. + nsCSSPropertyIDSet GetPropertySet() const; + + // Returns true if the effect includes a property in |aPropertySet| regardless + // of whether any property in the set is overridden by an !important rule. + bool HasAnimationOfPropertySet(const nsCSSPropertyIDSet& aPropertySet) const { + return GetPropertySet().Intersects(aPropertySet); + } + + // GetEffectiveAnimationOfProperty returns AnimationProperty corresponding + // to a given CSS property if the effect includes the property and the + // property is not overridden by !important rules. + // Also EffectiveAnimationOfProperty returns true under the same condition. + // + // |aEffect| should be the EffectSet containing this KeyframeEffect since + // this function is typically called for all KeyframeEffects on an element + // so that we can avoid multiple calls of EffectSet::GetEffect(). + // + // Note that does not consider the interaction between related transform + // properties where an !important rule on another transform property may + // cause all transform properties to be run on the main thread. That check is + // performed by GetPropertiesForCompositor. + bool HasEffectiveAnimationOfProperty(nsCSSPropertyID aProperty, + const EffectSet& aEffect) const { + return GetEffectiveAnimationOfProperty(aProperty, aEffect) != nullptr; + } + const AnimationProperty* GetEffectiveAnimationOfProperty( + nsCSSPropertyID aProperty, const EffectSet& aEffect) const; + + // Similar to HasEffectiveAnimationOfProperty, above, but for + // an nsCSSPropertyIDSet. Returns true if this keyframe effect has at least + // one property in |aPropertySet| that is not overridden by an !important + // rule. + // + // Note that does not consider the interaction between related transform + // properties where an !important rule on another transform property may + // cause all transform properties to be run on the main thread. That check is + // performed by GetPropertiesForCompositor. + bool HasEffectiveAnimationOfPropertySet( + const nsCSSPropertyIDSet& aPropertySet, + const EffectSet& aEffectSet) const; + + // Returns all the effective animated CSS properties that can be animated on + // the compositor and are not overridden by a higher cascade level. + // + // NOTE: This function is basically called for all KeyframeEffects on an + // element thus it takes |aEffects| to avoid multiple calls of + // EffectSet::GetEffect(). + // + // NOTE(2): This function does NOT check that animations are permitted on + // |aFrame|. It is the responsibility of the caller to first call + // EffectCompositor::AllowCompositorAnimationsOnFrame for |aFrame|, or use + // nsLayoutUtils::GetAnimationPropertiesForCompositor instead. + nsCSSPropertyIDSet GetPropertiesForCompositor(EffectSet& aEffects, + const nsIFrame* aFrame) const; + + const nsTArray<AnimationProperty>& Properties() const { return mProperties; } + + // Update |mProperties| by recalculating from |mKeyframes| using + // |aComputedStyle| to resolve specified values. + // Note: we use |aTimeline| to check if we need to ensure the base styles. + // If it is nullptr, we use the timeline from |mAnimation|. + void UpdateProperties(const ComputedStyle* aStyle, + const AnimationTimeline* aTimeline = nullptr); + + // Update various bits of state related to running ComposeStyle(). + // We need to update this outside ComposeStyle() because we should avoid + // mutating any state in ComposeStyle() since it might be called during + // parallel traversal. + void WillComposeStyle(); + + // Updates |aComposeResult| with the animation values produced by this + // AnimationEffect for the current time except any properties contained + // in |aPropertiesToSkip|. + void ComposeStyle(StyleAnimationValueMap& aComposeResult, + const nsCSSPropertyIDSet& aPropertiesToSkip); + + // Returns true if at least one property is being animated on compositor. + bool IsRunningOnCompositor() const; + void SetIsRunningOnCompositor(nsCSSPropertyID aProperty, bool aIsRunning); + void SetIsRunningOnCompositor(const nsCSSPropertyIDSet& aPropertySet, + bool aIsRunning); + void ResetIsRunningOnCompositor(); + + void ResetPartialPrerendered(); + + // Returns true if this effect, applied to |aFrame|, contains properties + // that mean we shouldn't run transform compositor animations on this element. + // + // For example, if we have an animation of geometric properties like 'left' + // and 'top' on an element, we force all 'transform' animations running at + // the same time on the same element to run on the main thread. + // + // When returning true, |aPerformanceWarning| stores the reason why + // we shouldn't run the transform animations. + bool ShouldBlockAsyncTransformAnimations( + const nsIFrame* aFrame, const nsCSSPropertyIDSet& aPropertySet, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */) const; + bool HasGeometricProperties() const; + bool AffectsGeometry() const override { + return mTarget && HasGeometricProperties(); + } + + Document* GetRenderedDocument() const; + PresShell* GetPresShell() const; + + // Associates a warning with the animated property set on the specified frame + // indicating why, for example, the property could not be animated on the + // compositor. |aParams| and |aParamsLength| are optional parameters which + // will be used to generate a localized message for devtools. + void SetPerformanceWarning(const nsCSSPropertyIDSet& aPropertySet, + const AnimationPerformanceWarning& aWarning); + + // Cumulative change hint on each segment for each property. + // This is used for deciding the animation is paint-only. + void CalculateCumulativeChangeHint(const ComputedStyle* aStyle); + + // Returns true if all of animation properties' change hints + // can ignore painting if the animation is not visible. + // See nsChangeHint_Hints_CanIgnoreIfNotVisible in nsChangeHint.h + // in detail which change hint can be ignored. + bool CanIgnoreIfNotVisible() const; + + // Returns true if the effect is current state and has scale animation. + // |aFrame| is used for calculation of scale values. + bool ContainsAnimatedScale(const nsIFrame* aFrame) const; + + AnimationValue BaseStyle(nsCSSPropertyID aProperty) const { + AnimationValue result; + bool hasProperty = false; + // We cannot use getters_AddRefs on StyleAnimationValue because it is + // an incomplete type, so Get() doesn't work. Instead, use GetWeak, and + // then assign the raw pointer to a RefPtr. + result.mServo = mBaseValues.GetWeak(aProperty, &hasProperty); + MOZ_ASSERT(hasProperty || result.IsNull()); + return result; + } + + enum class MatchForCompositor { + // This animation matches and should run on the compositor if possible. + Yes, + // This (not currently playing) animation matches and can be run on the + // compositor if there are other animations for this property that return + // 'Yes'. + IfNeeded, + // This animation does not match or can't be run on the compositor. + No, + // This animation does not match or can't be run on the compositor and, + // furthermore, its presence means we should not run any animations for this + // property on the compositor. + NoAndBlockThisProperty + }; + + MatchForCompositor IsMatchForCompositor( + const nsCSSPropertyIDSet& aPropertySet, const nsIFrame* aFrame, + const EffectSet& aEffects, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */) const; + + static bool HasComputedTimingChanged( + const ComputedTiming& aComputedTiming, + IterationCompositeOperation aIterationComposite, + const Nullable<double>& aProgressOnLastCompose, + uint64_t aCurrentIterationOnLastCompose); + + bool HasOpacityChange() const { + return mCumulativeChangeHint & nsChangeHint_UpdateOpacityLayer; + } + + protected: + ~KeyframeEffect() override = default; + + template <class OptionsType> + static already_AddRefed<KeyframeEffect> ConstructKeyframeEffect( + const GlobalObject& aGlobal, Element* aTarget, + JS::Handle<JSObject*> aKeyframes, const OptionsType& aOptions, + ErrorResult& aRv); + + // Build properties by recalculating from |mKeyframes| using |aComputedStyle| + // to resolve specified values. This function also applies paced spacing if + // needed. + nsTArray<AnimationProperty> BuildProperties(const ComputedStyle* aStyle); + + // Helper for SetTarget() and SetPseudoElement(). + void UpdateTarget(Element* aElement, PseudoStyleType aPseudoType); + + // This effect is registered with its target element so long as: + // + // (a) It has a target element, and + // (b) It is "relevant" (i.e. yet to finish but not idle, or finished but + // filling forwards) + // + // As a result, we need to make sure this gets called whenever anything + // changes with regards to this effects's timing including changes to the + // owning Animation's timing. + void UpdateTargetRegistration(); + + // Remove the current effect target from its EffectSet. + void UnregisterTarget(); + + // Looks up the ComputedStyle associated with the target element, if any. + // We need to be careful to *not* call this when we are updating the style + // context. That's because calling GetComputedStyle when we are in the process + // of building a ComputedStyle may trigger various forms of infinite + // recursion. + enum class Flush { + Style, + None, + }; + already_AddRefed<const ComputedStyle> GetTargetComputedStyle(Flush) const; + + // A wrapper for marking cascade update according to the current + // target and its effectSet. + void MarkCascadeNeedsUpdate(); + + void EnsureBaseStyles(const ComputedStyle* aComputedValues, + const nsTArray<AnimationProperty>& aProperties, + const AnimationTimeline* aTimeline, + bool* aBaseStylesChanged); + void EnsureBaseStyle(const AnimationProperty& aProperty, + nsPresContext* aPresContext, + const ComputedStyle* aComputedValues, + const AnimationTimeline* aTimeline, + RefPtr<const ComputedStyle>& aBaseComputedValues); + + OwningAnimationTarget mTarget; + + KeyframeEffectParams mEffectOptions; + + // The specified keyframes. + nsTArray<Keyframe> mKeyframes; + + // A set of per-property value arrays, derived from |mKeyframes|. + nsTArray<AnimationProperty> mProperties; + + // The computed progress last time we composed the style rule. This is + // used to detect when the progress is not changing (e.g. due to a step + // timing function) so we can avoid unnecessary style updates. + Nullable<double> mProgressOnLastCompose; + + // The purpose of this value is the same as mProgressOnLastCompose but + // this is used to detect when the current iteration is not changing + // in the case when iterationComposite is accumulate. + uint64_t mCurrentIterationOnLastCompose = 0; + + // We need to track when we go to or from being "in effect" since + // we need to re-evaluate the cascade of animations when that changes. + bool mInEffectOnLastAnimationTimingUpdate = false; + + // True if this effect is in the EffectSet for its target element. This is + // used as an optimization to avoid unnecessary hashmap lookups on the + // EffectSet. + bool mInEffectSet = false; + + // True if the last time we tried to update the cumulative change hint we + // didn't have style data to do so. We set this flag so that the next time + // our style context changes we know to update the cumulative change hint even + // if our properties haven't changed. + bool mNeedsStyleData = false; + + // True if there is any current-color for background color in this keyframes. + bool mHasCurrentColor = false; + + // The non-animated values for properties in this effect that contain at + // least one animation value that is composited with the underlying value + // (i.e. it uses the additive or accumulate composite mode). + using BaseValuesHashmap = + nsRefPtrHashtable<nsUint32HashKey, StyleAnimationValue>; + BaseValuesHashmap mBaseValues; + + private: + nsChangeHint mCumulativeChangeHint = nsChangeHint{0}; + + void ComposeStyleRule(StyleAnimationValueMap& aAnimationValues, + const AnimationProperty& aProperty, + const AnimationPropertySegment& aSegment, + const ComputedTiming& aComputedTiming); + + already_AddRefed<const ComputedStyle> CreateComputedStyleForAnimationValue( + nsCSSPropertyID aProperty, const AnimationValue& aValue, + nsPresContext* aPresContext, const ComputedStyle* aBaseComputedStyle); + + // Return the primary frame for the target (pseudo-)element. + nsIFrame* GetPrimaryFrame() const; + // Returns the frame which is used for styling. + nsIFrame* GetStyleFrame() const; + + bool CanThrottle() const; + bool CanThrottleOverflowChanges(const nsIFrame& aFrame) const; + bool CanThrottleOverflowChangesInScrollable(nsIFrame& aFrame) const; + bool CanThrottleIfNotVisible(nsIFrame& aFrame) const; + + // Returns true if the computedTiming has changed since the last + // composition. + bool HasComputedTimingChanged() const; + + // Returns true unless Gecko limitations prevent performing transform + // animations for |aFrame|. When returning true, the reason for the + // limitation is stored in |aOutPerformanceWarning|. + static bool CanAnimateTransformOnCompositor( + const nsIFrame* aFrame, + AnimationPerformanceWarning::Type& aPerformanceWarning /* out */); + static bool IsGeometricProperty(const nsCSSPropertyID aProperty); + + static const TimeDuration OverflowRegionRefreshInterval(); + + void UpdateEffectSet(mozilla::EffectSet* aEffectSet = nullptr) const; + + // Returns true if this effect has properties that might affect the overflow + // region. + // This function is used for updating scroll bars or notifying intersection + // observers reflected by the transform. + bool HasPropertiesThatMightAffectOverflow() const { + return mCumulativeChangeHint & + (nsChangeHint_AddOrRemoveTransform | nsChangeHint_UpdateOverflow | + nsChangeHint_UpdatePostTransformOverflow | + nsChangeHint_UpdateTransformLayer); + } + + // Returns true if this effect causes visibility change. + // (i.e. 'visibility: hidden' -> 'visibility: visible' and vice versa.) + bool HasVisibilityChange() const { + return mCumulativeChangeHint & nsChangeHint_VisibilityChange; + } +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_KeyframeEffect_h diff --git a/dom/animation/KeyframeEffectParams.h b/dom/animation/KeyframeEffectParams.h new file mode 100644 index 0000000000..08bb22e08e --- /dev/null +++ b/dom/animation/KeyframeEffectParams.h @@ -0,0 +1,34 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_KeyframeEffectParams_h +#define mozilla_KeyframeEffectParams_h + +#include "mozilla/dom/KeyframeEffectBinding.h" // IterationCompositeOperation +#include "mozilla/PseudoStyleType.h" // PseudoStyleType + +namespace mozilla { + +struct KeyframeEffectParams { + KeyframeEffectParams() = default; + KeyframeEffectParams(dom::IterationCompositeOperation aIterationComposite, + dom::CompositeOperation aComposite, + PseudoStyleType aPseudoType) + : mIterationComposite(aIterationComposite), + mComposite(aComposite), + mPseudoType(aPseudoType) {} + explicit KeyframeEffectParams(dom::CompositeOperation aComposite) + : mComposite(aComposite) {} + + dom::IterationCompositeOperation mIterationComposite = + dom::IterationCompositeOperation::Replace; + dom::CompositeOperation mComposite = dom::CompositeOperation::Replace; + PseudoStyleType mPseudoType = PseudoStyleType::NotPseudo; +}; + +} // namespace mozilla + +#endif // mozilla_KeyframeEffectParams_h diff --git a/dom/animation/KeyframeUtils.cpp b/dom/animation/KeyframeUtils.cpp new file mode 100644 index 0000000000..35234e0a9d --- /dev/null +++ b/dom/animation/KeyframeUtils.cpp @@ -0,0 +1,1255 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/KeyframeUtils.h" + +#include <algorithm> // For std::stable_sort, std::min +#include <utility> + +#include "jsapi.h" // For most JSAPI +#include "js/ForOfIterator.h" // For JS::ForOfIterator +#include "js/PropertyAndElement.h" // JS_Enumerate, JS_GetProperty, JS_GetPropertyById +#include "mozilla/ComputedStyle.h" +#include "mozilla/ErrorResult.h" +#include "mozilla/RangedArray.h" +#include "mozilla/ServoBindingTypes.h" +#include "mozilla/ServoBindings.h" +#include "mozilla/ServoCSSParser.h" +#include "mozilla/StaticPrefs_dom.h" +#include "mozilla/StyleAnimationValue.h" +#include "mozilla/TimingParams.h" +#include "mozilla/dom/BaseKeyframeTypesBinding.h" // For FastBaseKeyframe etc. +#include "mozilla/dom/BindingCallContext.h" +#include "mozilla/dom/Document.h" // For Document::AreWebAnimationsImplicitKeyframesEnabled +#include "mozilla/dom/Element.h" +#include "mozilla/dom/KeyframeEffect.h" // For PropertyValuesPair etc. +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/dom/Nullable.h" +#include "nsCSSPropertyIDSet.h" +#include "nsCSSProps.h" +#include "nsCSSPseudoElements.h" // For PseudoStyleType +#include "nsClassHashtable.h" +#include "nsContentUtils.h" // For GetContextForContent +#include "nsIScriptError.h" +#include "nsPresContextInlines.h" +#include "nsTArray.h" + +using mozilla::dom::Nullable; + +namespace mozilla { + +// ------------------------------------------------------------------ +// +// Internal data types +// +// ------------------------------------------------------------------ + +// For the aAllowList parameter of AppendStringOrStringSequence and +// GetPropertyValuesPairs. +enum class ListAllowance { eDisallow, eAllow }; + +/** + * A property-values pair obtained from the open-ended properties + * discovered on a regular keyframe or property-indexed keyframe object. + * + * Single values (as required by a regular keyframe, and as also supported + * on property-indexed keyframes) are stored as the only element in + * mValues. + */ +struct PropertyValuesPair { + nsCSSPropertyID mProperty; + nsTArray<nsCString> mValues; +}; + +/** + * An additional property (for a property-values pair) found on a + * BaseKeyframe or BasePropertyIndexedKeyframe object. + */ +struct AdditionalProperty { + nsCSSPropertyID mProperty; + size_t mJsidIndex; // Index into |ids| in GetPropertyValuesPairs. + + struct PropertyComparator { + bool Equals(const AdditionalProperty& aLhs, + const AdditionalProperty& aRhs) const { + return aLhs.mProperty == aRhs.mProperty; + } + bool LessThan(const AdditionalProperty& aLhs, + const AdditionalProperty& aRhs) const { + return nsCSSProps::PropertyIDLNameSortPosition(aLhs.mProperty) < + nsCSSProps::PropertyIDLNameSortPosition(aRhs.mProperty); + } + }; +}; + +/** + * Data for a segment in a keyframe animation of a given property + * whose value is a StyleAnimationValue. + * + * KeyframeValueEntry is used in GetAnimationPropertiesFromKeyframes + * to gather data for each individual segment. + */ +struct KeyframeValueEntry { + nsCSSPropertyID mProperty; + AnimationValue mValue; + + float mOffset; + Maybe<StyleComputedTimingFunction> mTimingFunction; + dom::CompositeOperation mComposite; + + struct PropertyOffsetComparator { + static bool Equals(const KeyframeValueEntry& aLhs, + const KeyframeValueEntry& aRhs) { + return aLhs.mProperty == aRhs.mProperty && aLhs.mOffset == aRhs.mOffset; + } + static bool LessThan(const KeyframeValueEntry& aLhs, + const KeyframeValueEntry& aRhs) { + // First, sort by property IDL name. + int32_t order = nsCSSProps::PropertyIDLNameSortPosition(aLhs.mProperty) - + nsCSSProps::PropertyIDLNameSortPosition(aRhs.mProperty); + if (order != 0) { + return order < 0; + } + + // Then, by offset. + return aLhs.mOffset < aRhs.mOffset; + } + }; +}; + +class ComputedOffsetComparator { + public: + static bool Equals(const Keyframe& aLhs, const Keyframe& aRhs) { + return aLhs.mComputedOffset == aRhs.mComputedOffset; + } + + static bool LessThan(const Keyframe& aLhs, const Keyframe& aRhs) { + return aLhs.mComputedOffset < aRhs.mComputedOffset; + } +}; + +// ------------------------------------------------------------------ +// +// Internal helper method declarations +// +// ------------------------------------------------------------------ + +static void GetKeyframeListFromKeyframeSequence( + JSContext* aCx, dom::Document* aDocument, JS::ForOfIterator& aIterator, + nsTArray<Keyframe>& aResult, const char* aContext, ErrorResult& aRv); + +static bool ConvertKeyframeSequence(JSContext* aCx, dom::Document* aDocument, + JS::ForOfIterator& aIterator, + const char* aContext, + nsTArray<Keyframe>& aResult); + +static bool GetPropertyValuesPairs(JSContext* aCx, + JS::Handle<JSObject*> aObject, + ListAllowance aAllowLists, + nsTArray<PropertyValuesPair>& aResult); + +static bool AppendStringOrStringSequenceToArray(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ListAllowance aAllowLists, + nsTArray<nsCString>& aValues); + +static bool AppendValueAsString(JSContext* aCx, nsTArray<nsCString>& aValues, + JS::Handle<JS::Value> aValue); + +static Maybe<PropertyValuePair> MakePropertyValuePair( + nsCSSPropertyID aProperty, const nsACString& aStringValue, + dom::Document* aDocument); + +static bool HasValidOffsets(const nsTArray<Keyframe>& aKeyframes); + +#ifdef DEBUG +static void MarkAsComputeValuesFailureKey(PropertyValuePair& aPair); + +#endif + +static nsTArray<ComputedKeyframeValues> GetComputedKeyframeValues( + const nsTArray<Keyframe>& aKeyframes, dom::Element* aElement, + PseudoStyleType aPseudoType, const ComputedStyle* aComputedValues); + +static void BuildSegmentsFromValueEntries( + nsTArray<KeyframeValueEntry>& aEntries, + nsTArray<AnimationProperty>& aResult); + +static void GetKeyframeListFromPropertyIndexedKeyframe( + JSContext* aCx, dom::Document* aDocument, JS::Handle<JS::Value> aValue, + nsTArray<Keyframe>& aResult, ErrorResult& aRv); + +static bool HasImplicitKeyframeValues(const nsTArray<Keyframe>& aKeyframes, + dom::Document* aDocument); + +static void DistributeRange(const Range<Keyframe>& aRange); + +// ------------------------------------------------------------------ +// +// Public API +// +// ------------------------------------------------------------------ + +/* static */ +nsTArray<Keyframe> KeyframeUtils::GetKeyframesFromObject( + JSContext* aCx, dom::Document* aDocument, JS::Handle<JSObject*> aFrames, + const char* aContext, ErrorResult& aRv) { + MOZ_ASSERT(!aRv.Failed()); + + nsTArray<Keyframe> keyframes; + + if (!aFrames) { + // The argument was explicitly null meaning no keyframes. + return keyframes; + } + + // At this point we know we have an object. We try to convert it to a + // sequence of keyframes first, and if that fails due to not being iterable, + // we try to convert it to a property-indexed keyframe. + JS::Rooted<JS::Value> objectValue(aCx, JS::ObjectValue(*aFrames)); + JS::ForOfIterator iter(aCx); + if (!iter.init(objectValue, JS::ForOfIterator::AllowNonIterable)) { + aRv.Throw(NS_ERROR_FAILURE); + return keyframes; + } + + if (iter.valueIsIterable()) { + GetKeyframeListFromKeyframeSequence(aCx, aDocument, iter, keyframes, + aContext, aRv); + } else { + GetKeyframeListFromPropertyIndexedKeyframe(aCx, aDocument, objectValue, + keyframes, aRv); + } + + if (aRv.Failed()) { + MOZ_ASSERT(keyframes.IsEmpty(), + "Should not set any keyframes when there is an error"); + return keyframes; + } + + if (!dom::Document::AreWebAnimationsImplicitKeyframesEnabled(aCx, nullptr) && + HasImplicitKeyframeValues(keyframes, aDocument)) { + keyframes.Clear(); + aRv.ThrowNotSupportedError( + "Animation to or from an underlying value is not yet supported"); + } + + return keyframes; +} + +/* static */ +void KeyframeUtils::DistributeKeyframes(nsTArray<Keyframe>& aKeyframes) { + if (aKeyframes.IsEmpty()) { + return; + } + + // If the first keyframe has an unspecified offset, fill it in with 0%. + // If there is only a single keyframe, then it gets 100%. + if (aKeyframes.Length() > 1) { + Keyframe& firstElement = aKeyframes[0]; + firstElement.mComputedOffset = firstElement.mOffset.valueOr(0.0); + // We will fill in the last keyframe's offset below + } else { + Keyframe& lastElement = aKeyframes.LastElement(); + lastElement.mComputedOffset = lastElement.mOffset.valueOr(1.0); + } + + // Fill in remaining missing offsets. + const Keyframe* const last = &aKeyframes.LastElement(); + const RangedPtr<Keyframe> begin(aKeyframes.Elements(), aKeyframes.Length()); + RangedPtr<Keyframe> keyframeA = begin; + while (keyframeA != last) { + // Find keyframe A and keyframe B *between* which we will apply spacing. + RangedPtr<Keyframe> keyframeB = keyframeA + 1; + while (keyframeB->mOffset.isNothing() && keyframeB != last) { + ++keyframeB; + } + keyframeB->mComputedOffset = keyframeB->mOffset.valueOr(1.0); + + // Fill computed offsets in (keyframe A, keyframe B). + DistributeRange(Range<Keyframe>(keyframeA, keyframeB + 1)); + keyframeA = keyframeB; + } +} + +/* static */ +nsTArray<AnimationProperty> KeyframeUtils::GetAnimationPropertiesFromKeyframes( + const nsTArray<Keyframe>& aKeyframes, dom::Element* aElement, + PseudoStyleType aPseudoType, const ComputedStyle* aStyle, + dom::CompositeOperation aEffectComposite) { + nsTArray<AnimationProperty> result; + + const nsTArray<ComputedKeyframeValues> computedValues = + GetComputedKeyframeValues(aKeyframes, aElement, aPseudoType, aStyle); + if (computedValues.IsEmpty()) { + // In rare cases GetComputedKeyframeValues might fail and return an empty + // array, in which case we likewise return an empty array from here. + return result; + } + + MOZ_ASSERT(aKeyframes.Length() == computedValues.Length(), + "Array length mismatch"); + + nsTArray<KeyframeValueEntry> entries(aKeyframes.Length()); + + const size_t len = aKeyframes.Length(); + for (size_t i = 0; i < len; ++i) { + const Keyframe& frame = aKeyframes[i]; + for (auto& value : computedValues[i]) { + MOZ_ASSERT(frame.mComputedOffset != Keyframe::kComputedOffsetNotSet, + "Invalid computed offset"); + KeyframeValueEntry* entry = entries.AppendElement(); + entry->mOffset = frame.mComputedOffset; + entry->mProperty = value.mProperty; + entry->mValue = value.mValue; + entry->mTimingFunction = frame.mTimingFunction; + // The following assumes that CompositeOperation is a strict subset of + // CompositeOperationOrAuto. + entry->mComposite = + frame.mComposite == dom::CompositeOperationOrAuto::Auto + ? aEffectComposite + : static_cast<dom::CompositeOperation>(frame.mComposite); + } + } + + BuildSegmentsFromValueEntries(entries, result); + return result; +} + +/* static */ +bool KeyframeUtils::IsAnimatableProperty(nsCSSPropertyID aProperty) { + // Regardless of the backend type, treat the 'display' property as not + // animatable. (Servo will report it as being animatable, since it is + // in fact animatable by SMIL.) + if (aProperty == eCSSProperty_display) { + return false; + } + return Servo_Property_IsAnimatable(aProperty); +} + +// ------------------------------------------------------------------ +// +// Internal helpers +// +// ------------------------------------------------------------------ + +/** + * Converts a JS object to an IDL sequence<Keyframe>. + * + * @param aCx The JSContext corresponding to |aIterator|. + * @param aDocument The document to use when parsing CSS properties. + * @param aIterator An already-initialized ForOfIterator for the JS + * object to iterate over as a sequence. + * @param aResult The array into which the resulting Keyframe objects will be + * appended. + * @param aContext The context string to prepend to thrown exceptions. + * @param aRv Out param to store any errors thrown by this function. + */ +static void GetKeyframeListFromKeyframeSequence( + JSContext* aCx, dom::Document* aDocument, JS::ForOfIterator& aIterator, + nsTArray<Keyframe>& aResult, const char* aContext, ErrorResult& aRv) { + MOZ_ASSERT(!aRv.Failed()); + MOZ_ASSERT(aResult.IsEmpty()); + + // Convert the object in aIterator to a sequence of keyframes producing + // an array of Keyframe objects. + if (!ConvertKeyframeSequence(aCx, aDocument, aIterator, aContext, aResult)) { + aResult.Clear(); + aRv.NoteJSContextException(aCx); + return; + } + + // If the sequence<> had zero elements, we won't generate any + // keyframes. + if (aResult.IsEmpty()) { + return; + } + + // Check that the keyframes are loosely sorted and with values all + // between 0% and 100%. + if (!HasValidOffsets(aResult)) { + aResult.Clear(); + aRv.ThrowTypeError<dom::MSG_INVALID_KEYFRAME_OFFSETS>(); + return; + } +} + +/** + * Converts a JS object wrapped by the given JS::ForIfIterator to an + * IDL sequence<Keyframe> and stores the resulting Keyframe objects in + * aResult. + */ +static bool ConvertKeyframeSequence(JSContext* aCx, dom::Document* aDocument, + JS::ForOfIterator& aIterator, + const char* aContext, + nsTArray<Keyframe>& aResult) { + JS::Rooted<JS::Value> value(aCx); + // Parsing errors should only be reported after we have finished iterating + // through all values. If we have any early returns while iterating, we should + // ignore parsing errors. + IgnoredErrorResult parseEasingResult; + + for (;;) { + bool done; + if (!aIterator.next(&value, &done)) { + return false; + } + if (done) { + break; + } + // Each value found when iterating the object must be an object + // or null/undefined (which gets treated as a default {} dictionary + // value). + if (!value.isObject() && !value.isNullOrUndefined()) { + dom::ThrowErrorMessage<dom::MSG_NOT_OBJECT>( + aCx, aContext, "Element of sequence<Keyframe> argument"); + return false; + } + + // Convert the JS value into a BaseKeyframe dictionary value. + dom::binding_detail::FastBaseKeyframe keyframeDict; + dom::BindingCallContext callCx(aCx, aContext); + if (!keyframeDict.Init(callCx, value, + "Element of sequence<Keyframe> argument")) { + // This may happen if the value type of the member of BaseKeyframe is + // invalid. e.g. `offset` only accept a double value, so if we provide a + // string, we enter this branch. + // Besides, keyframeDict.Init() should throw a Type Error message already, + // so we don't have to do it again. + return false; + } + + Keyframe* keyframe = aResult.AppendElement(fallible); + if (!keyframe) { + return false; + } + + if (!keyframeDict.mOffset.IsNull()) { + keyframe->mOffset.emplace(keyframeDict.mOffset.Value()); + } + + if (StaticPrefs::dom_animations_api_compositing_enabled()) { + keyframe->mComposite = keyframeDict.mComposite; + } + + // Look for additional property-values pairs on the object. + nsTArray<PropertyValuesPair> propertyValuePairs; + if (value.isObject()) { + JS::Rooted<JSObject*> object(aCx, &value.toObject()); + if (!GetPropertyValuesPairs(aCx, object, ListAllowance::eDisallow, + propertyValuePairs)) { + return false; + } + } + + if (!parseEasingResult.Failed()) { + keyframe->mTimingFunction = + TimingParams::ParseEasing(keyframeDict.mEasing, parseEasingResult); + // Even if the above fails, we still need to continue reading off all the + // properties since checking the validity of easing should be treated as + // a separate step that happens *after* all the other processing in this + // loop since (since it is never likely to be handled by WebIDL unlike the + // rest of this loop). + } + + for (PropertyValuesPair& pair : propertyValuePairs) { + MOZ_ASSERT(pair.mValues.Length() == 1); + + Maybe<PropertyValuePair> valuePair = + MakePropertyValuePair(pair.mProperty, pair.mValues[0], aDocument); + if (!valuePair) { + continue; + } + keyframe->mPropertyValues.AppendElement(std::move(valuePair.ref())); + +#ifdef DEBUG + // When we go to convert keyframes into arrays of property values we + // call StyleAnimation::ComputeValues. This should normally return true + // but in order to test the case where it does not, BaseKeyframeDict + // includes a chrome-only member that can be set to indicate that + // ComputeValues should fail for shorthand property values on that + // keyframe. + if (nsCSSProps::IsShorthand(pair.mProperty) && + keyframeDict.mSimulateComputeValuesFailure) { + MarkAsComputeValuesFailureKey(keyframe->mPropertyValues.LastElement()); + } +#endif + } + } + + // Throw any errors we encountered while parsing 'easing' properties. + if (parseEasingResult.MaybeSetPendingException(aCx)) { + return false; + } + + return true; +} + +/** + * Reads the property-values pairs from the specified JS object. + * + * @param aObject The JS object to look at. + * @param aAllowLists If eAllow, values will be converted to + * (DOMString or sequence<DOMString); if eDisallow, values + * will be converted to DOMString. + * @param aResult The array into which the enumerated property-values + * pairs will be stored. + * @return false on failure or JS exception thrown while interacting + * with aObject; true otherwise. + */ +static bool GetPropertyValuesPairs(JSContext* aCx, + JS::Handle<JSObject*> aObject, + ListAllowance aAllowLists, + nsTArray<PropertyValuesPair>& aResult) { + nsTArray<AdditionalProperty> properties; + + // Iterate over all the properties on aObject and append an + // entry to properties for them. + // + // We don't compare the jsids that we encounter with those for + // the explicit dictionary members, since we know that none + // of the CSS property IDL names clash with them. + JS::Rooted<JS::IdVector> ids(aCx, JS::IdVector(aCx)); + if (!JS_Enumerate(aCx, aObject, &ids)) { + return false; + } + for (size_t i = 0, n = ids.length(); i < n; i++) { + nsAutoJSCString propName; + if (!propName.init(aCx, ids[i])) { + return false; + } + + // Basically, we have to handle "cssOffset" and "cssFloat" specially here: + // "cssOffset" => eCSSProperty_offset + // "cssFloat" => eCSSProperty_float + // This means if the attribute is the string "cssOffset"/"cssFloat", we use + // CSS "offset"/"float" property. + // https://drafts.csswg.org/web-animations/#property-name-conversion + nsCSSPropertyID property = nsCSSPropertyID::eCSSProperty_UNKNOWN; + if (propName.EqualsLiteral("cssOffset")) { + property = nsCSSPropertyID::eCSSProperty_offset; + } else if (propName.EqualsLiteral("cssFloat")) { + property = nsCSSPropertyID::eCSSProperty_float; + } else if (!propName.EqualsLiteral("offset") && + !propName.EqualsLiteral("float")) { + property = nsCSSProps::LookupPropertyByIDLName( + propName, CSSEnabledState::ForAllContent); + } + + if (KeyframeUtils::IsAnimatableProperty(property)) { + AdditionalProperty* p = properties.AppendElement(); + p->mProperty = property; + p->mJsidIndex = i; + } + } + + // Sort the entries by IDL name and then get each value and + // convert it either to a DOMString or to a + // (DOMString or sequence<DOMString>), depending on aAllowLists, + // and build up aResult. + properties.Sort(AdditionalProperty::PropertyComparator()); + + for (AdditionalProperty& p : properties) { + JS::Rooted<JS::Value> value(aCx); + if (!JS_GetPropertyById(aCx, aObject, ids[p.mJsidIndex], &value)) { + return false; + } + PropertyValuesPair* pair = aResult.AppendElement(); + pair->mProperty = p.mProperty; + if (!AppendStringOrStringSequenceToArray(aCx, value, aAllowLists, + pair->mValues)) { + return false; + } + } + + return true; +} + +/** + * Converts aValue to DOMString, if aAllowLists is eDisallow, or + * to (DOMString or sequence<DOMString>) if aAllowLists is aAllow. + * The resulting strings are appended to aValues. + */ +static bool AppendStringOrStringSequenceToArray(JSContext* aCx, + JS::Handle<JS::Value> aValue, + ListAllowance aAllowLists, + nsTArray<nsCString>& aValues) { + if (aAllowLists == ListAllowance::eAllow && aValue.isObject()) { + // The value is an object, and we want to allow lists; convert + // aValue to (DOMString or sequence<DOMString>). + JS::ForOfIterator iter(aCx); + if (!iter.init(aValue, JS::ForOfIterator::AllowNonIterable)) { + return false; + } + if (iter.valueIsIterable()) { + // If the object is iterable, convert it to sequence<DOMString>. + JS::Rooted<JS::Value> element(aCx); + for (;;) { + bool done; + if (!iter.next(&element, &done)) { + return false; + } + if (done) { + break; + } + if (!AppendValueAsString(aCx, aValues, element)) { + return false; + } + } + return true; + } + } + + // Either the object is not iterable, or aAllowLists doesn't want + // a list; convert it to DOMString. + if (!AppendValueAsString(aCx, aValues, aValue)) { + return false; + } + + return true; +} + +/** + * Converts aValue to DOMString and appends it to aValues. + */ +static bool AppendValueAsString(JSContext* aCx, nsTArray<nsCString>& aValues, + JS::Handle<JS::Value> aValue) { + return ConvertJSValueToString(aCx, aValue, dom::eStringify, dom::eStringify, + *aValues.AppendElement()); +} + +static void ReportInvalidPropertyValueToConsole( + nsCSSPropertyID aProperty, const nsACString& aInvalidPropertyValue, + dom::Document* aDoc) { + AutoTArray<nsString, 2> params; + params.AppendElement(NS_ConvertUTF8toUTF16(aInvalidPropertyValue)); + CopyASCIItoUTF16(nsCSSProps::GetStringValue(aProperty), + *params.AppendElement()); + nsContentUtils::ReportToConsole(nsIScriptError::warningFlag, "Animation"_ns, + aDoc, nsContentUtils::eDOM_PROPERTIES, + "InvalidKeyframePropertyValue", params); +} + +/** + * Construct a PropertyValuePair parsing the given string into a suitable + * nsCSSValue object. + * + * @param aProperty The CSS property. + * @param aStringValue The property value to parse. + * @param aDocument The document to use when parsing. + * @return The constructed PropertyValuePair, or Nothing() if |aStringValue| is + * an invalid property value. + */ +static Maybe<PropertyValuePair> MakePropertyValuePair( + nsCSSPropertyID aProperty, const nsACString& aStringValue, + dom::Document* aDocument) { + MOZ_ASSERT(aDocument); + Maybe<PropertyValuePair> result; + + ServoCSSParser::ParsingEnvironment env = + ServoCSSParser::GetParsingEnvironment(aDocument); + RefPtr<StyleLockedDeclarationBlock> servoDeclarationBlock = + ServoCSSParser::ParseProperty(aProperty, aStringValue, env); + + if (servoDeclarationBlock) { + result.emplace(aProperty, std::move(servoDeclarationBlock)); + } else { + ReportInvalidPropertyValueToConsole(aProperty, aStringValue, aDocument); + } + return result; +} + +/** + * Checks that the given keyframes are loosely ordered (each keyframe's + * offset that is not null is greater than or equal to the previous + * non-null offset) and that all values are within the range [0.0, 1.0]. + * + * @return true if the keyframes' offsets are correctly ordered and + * within range; false otherwise. + */ +static bool HasValidOffsets(const nsTArray<Keyframe>& aKeyframes) { + double offset = 0.0; + for (const Keyframe& keyframe : aKeyframes) { + if (keyframe.mOffset) { + double thisOffset = keyframe.mOffset.value(); + if (thisOffset < offset || thisOffset > 1.0f) { + return false; + } + offset = thisOffset; + } + } + return true; +} + +#ifdef DEBUG +/** + * Takes a property-value pair for a shorthand property and modifies the + * value to indicate that when we call StyleAnimationValue::ComputeValues on + * that value we should behave as if that function had failed. + * + * @param aPair The PropertyValuePair to modify. |aPair.mProperty| must be + * a shorthand property. + */ +static void MarkAsComputeValuesFailureKey(PropertyValuePair& aPair) { + MOZ_ASSERT(nsCSSProps::IsShorthand(aPair.mProperty), + "Only shorthand property values can be marked as failure values"); + + aPair.mSimulateComputeValuesFailure = true; +} + +#endif + +/** + * The variation of the above function. This is for Servo backend. + */ +static nsTArray<ComputedKeyframeValues> GetComputedKeyframeValues( + const nsTArray<Keyframe>& aKeyframes, dom::Element* aElement, + PseudoStyleType aPseudoType, const ComputedStyle* aComputedStyle) { + MOZ_ASSERT(aElement); + + nsTArray<ComputedKeyframeValues> result; + + nsPresContext* presContext = nsContentUtils::GetContextForContent(aElement); + if (!presContext) { + // This has been reported to happen with some combinations of content + // (particularly involving resize events and layout flushes? See bug 1407898 + // and bug 1408420) but no reproducible steps have been found. + // For now we just return an empty array. + return result; + } + + result = presContext->StyleSet()->GetComputedKeyframeValuesFor( + aKeyframes, aElement, aPseudoType, aComputedStyle); + return result; +} + +static void AppendInitialSegment(AnimationProperty* aAnimationProperty, + const KeyframeValueEntry& aFirstEntry) { + AnimationPropertySegment* segment = + aAnimationProperty->mSegments.AppendElement(); + segment->mFromKey = 0.0f; + segment->mToKey = aFirstEntry.mOffset; + segment->mToValue = aFirstEntry.mValue; + segment->mToComposite = aFirstEntry.mComposite; +} + +static void AppendFinalSegment(AnimationProperty* aAnimationProperty, + const KeyframeValueEntry& aLastEntry) { + AnimationPropertySegment* segment = + aAnimationProperty->mSegments.AppendElement(); + segment->mFromKey = aLastEntry.mOffset; + segment->mFromValue = aLastEntry.mValue; + segment->mFromComposite = aLastEntry.mComposite; + segment->mToKey = 1.0f; + segment->mTimingFunction = aLastEntry.mTimingFunction; +} + +// Returns a newly created AnimationProperty if one was created to fill-in the +// missing keyframe, nullptr otherwise (if we decided not to fill the keyframe +// becase we don't support implicit keyframes). +static AnimationProperty* HandleMissingInitialKeyframe( + nsTArray<AnimationProperty>& aResult, const KeyframeValueEntry& aEntry) { + MOZ_ASSERT(aEntry.mOffset != 0.0f, + "The offset of the entry should not be 0.0"); + + // If the preference for implicit keyframes is not enabled, don't fill in the + // missing keyframe. + if (!StaticPrefs::dom_animations_api_implicit_keyframes_enabled()) { + return nullptr; + } + + AnimationProperty* result = aResult.AppendElement(); + result->mProperty = aEntry.mProperty; + + AppendInitialSegment(result, aEntry); + + return result; +} + +static void HandleMissingFinalKeyframe( + nsTArray<AnimationProperty>& aResult, const KeyframeValueEntry& aEntry, + AnimationProperty* aCurrentAnimationProperty) { + MOZ_ASSERT(aEntry.mOffset != 1.0f, + "The offset of the entry should not be 1.0"); + + // If the preference for implicit keyframes is not enabled, don't fill + // in the missing keyframe. + if (!StaticPrefs::dom_animations_api_implicit_keyframes_enabled()) { + // If we have already appended a new entry for the property so we have to + // remove it. + if (aCurrentAnimationProperty) { + aResult.RemoveLastElement(); + } + return; + } + + // If |aCurrentAnimationProperty| is nullptr, that means this is the first + // entry for the property, we have to append a new AnimationProperty for this + // property. + if (!aCurrentAnimationProperty) { + aCurrentAnimationProperty = aResult.AppendElement(); + aCurrentAnimationProperty->mProperty = aEntry.mProperty; + + // If we have only one entry whose offset is neither 1 nor 0 for this + // property, we need to append the initial segment as well. + if (aEntry.mOffset != 0.0f) { + AppendInitialSegment(aCurrentAnimationProperty, aEntry); + } + } + AppendFinalSegment(aCurrentAnimationProperty, aEntry); +} + +/** + * Builds an array of AnimationProperty objects to represent the keyframe + * animation segments in aEntries. + */ +static void BuildSegmentsFromValueEntries( + nsTArray<KeyframeValueEntry>& aEntries, + nsTArray<AnimationProperty>& aResult) { + if (aEntries.IsEmpty()) { + return; + } + + // Sort the KeyframeValueEntry objects so that all entries for a given + // property are together, and the entries are sorted by offset otherwise. + std::stable_sort(aEntries.begin(), aEntries.end(), + &KeyframeValueEntry::PropertyOffsetComparator::LessThan); + + // For a given index i, we want to generate a segment from aEntries[i] + // to aEntries[j], if: + // + // * j > i, + // * aEntries[i + 1]'s offset/property is different from aEntries[i]'s, and + // * aEntries[j - 1]'s offset/property is different from aEntries[j]'s. + // + // That will eliminate runs of same offset/property values where there's no + // point generating zero length segments in the middle of the animation. + // + // Additionally we need to generate a zero length segment at offset 0 and at + // offset 1, if we have multiple values for a given property at that offset, + // since we need to retain the very first and very last value so they can + // be used for reverse and forward filling. + // + // Typically, for each property in |aEntries|, we expect there to be at least + // one KeyframeValueEntry with offset 0.0, and at least one with offset 1.0. + // However, since it is possible that when building |aEntries|, the call to + // StyleAnimationValue::ComputeValues might fail, this can't be guaranteed. + // Furthermore, if additive animation is disabled, the following loop takes + // care to identify properties that lack a value at offset 0.0/1.0 and drops + // those properties from |aResult|. + + nsCSSPropertyID lastProperty = eCSSProperty_UNKNOWN; + AnimationProperty* animationProperty = nullptr; + + size_t i = 0, n = aEntries.Length(); + + while (i < n) { + // If we've reached the end of the array of entries, synthesize a final (and + // initial) segment if necessary. + if (i + 1 == n) { + if (aEntries[i].mOffset != 1.0f) { + HandleMissingFinalKeyframe(aResult, aEntries[i], animationProperty); + } else if (aEntries[i].mOffset == 1.0f && !animationProperty) { + // If the last entry with offset 1 and no animation property, that means + // it is the only entry for this property so append a single segment + // from 0 offset to |aEntry[i].offset|. + Unused << HandleMissingInitialKeyframe(aResult, aEntries[i]); + } + animationProperty = nullptr; + break; + } + + MOZ_ASSERT(aEntries[i].mProperty != eCSSProperty_UNKNOWN && + aEntries[i + 1].mProperty != eCSSProperty_UNKNOWN, + "Each entry should specify a valid property"); + + // No keyframe for this property at offset 0. + if (aEntries[i].mProperty != lastProperty && aEntries[i].mOffset != 0.0f) { + // If we don't support additive animation we can't fill in the missing + // keyframes and we should just skip this property altogether. Since the + // entries are sorted by offset for a given property, and since we don't + // update |lastProperty|, we will keep hitting this condition until we + // change property. + animationProperty = HandleMissingInitialKeyframe(aResult, aEntries[i]); + if (animationProperty) { + lastProperty = aEntries[i].mProperty; + } else { + // Skip this entry if we did not handle the missing entry. + ++i; + continue; + } + } + + // Skip this entry if the next entry has the same offset except for initial + // and final ones. We will handle missing keyframe in the next loop + // if the property is changed on the next entry. + if (aEntries[i].mProperty == aEntries[i + 1].mProperty && + aEntries[i].mOffset == aEntries[i + 1].mOffset && + aEntries[i].mOffset != 1.0f && aEntries[i].mOffset != 0.0f) { + ++i; + continue; + } + + // No keyframe for this property at offset 1. + if (aEntries[i].mProperty != aEntries[i + 1].mProperty && + aEntries[i].mOffset != 1.0f) { + HandleMissingFinalKeyframe(aResult, aEntries[i], animationProperty); + // Move on to new property. + animationProperty = nullptr; + ++i; + continue; + } + + // Starting from i + 1, determine the next [i, j] interval from which to + // generate a segment. Basically, j is i + 1, but there are some special + // cases for offset 0 and 1, so we need to handle them specifically. + // Note: From this moment, we make sure [i + 1] is valid and + // there must be an initial entry (i.e. mOffset = 0.0) and + // a final entry (i.e. mOffset = 1.0). Besides, all the entries + // with the same offsets except for initial/final ones are filtered + // out already. + size_t j = i + 1; + if (aEntries[i].mOffset == 0.0f && aEntries[i + 1].mOffset == 0.0f) { + // We need to generate an initial zero-length segment. + MOZ_ASSERT(aEntries[i].mProperty == aEntries[i + 1].mProperty); + while (j + 1 < n && aEntries[j + 1].mOffset == 0.0f && + aEntries[j + 1].mProperty == aEntries[j].mProperty) { + ++j; + } + } else if (aEntries[i].mOffset == 1.0f) { + if (aEntries[i + 1].mOffset == 1.0f && + aEntries[i + 1].mProperty == aEntries[i].mProperty) { + // We need to generate a final zero-length segment. + while (j + 1 < n && aEntries[j + 1].mOffset == 1.0f && + aEntries[j + 1].mProperty == aEntries[j].mProperty) { + ++j; + } + } else { + // New property. + MOZ_ASSERT(aEntries[i].mProperty != aEntries[i + 1].mProperty); + animationProperty = nullptr; + ++i; + continue; + } + } + + // If we've moved on to a new property, create a new AnimationProperty + // to insert segments into. + if (aEntries[i].mProperty != lastProperty) { + MOZ_ASSERT(aEntries[i].mOffset == 0.0f); + MOZ_ASSERT(!animationProperty); + animationProperty = aResult.AppendElement(); + animationProperty->mProperty = aEntries[i].mProperty; + lastProperty = aEntries[i].mProperty; + } + + MOZ_ASSERT(animationProperty, "animationProperty should be valid pointer."); + + // Now generate the segment. + AnimationPropertySegment* segment = + animationProperty->mSegments.AppendElement(); + segment->mFromKey = aEntries[i].mOffset; + segment->mToKey = aEntries[j].mOffset; + segment->mFromValue = aEntries[i].mValue; + segment->mToValue = aEntries[j].mValue; + segment->mTimingFunction = aEntries[i].mTimingFunction; + segment->mFromComposite = aEntries[i].mComposite; + segment->mToComposite = aEntries[j].mComposite; + + i = j; + } +} + +/** + * Converts a JS object representing a property-indexed keyframe into + * an array of Keyframe objects. + * + * @param aCx The JSContext for |aValue|. + * @param aDocument The document to use when parsing CSS properties. + * @param aValue The JS object. + * @param aResult The array into which the resulting AnimationProperty + * objects will be appended. + * @param aRv Out param to store any errors thrown by this function. + */ +static void GetKeyframeListFromPropertyIndexedKeyframe( + JSContext* aCx, dom::Document* aDocument, JS::Handle<JS::Value> aValue, + nsTArray<Keyframe>& aResult, ErrorResult& aRv) { + MOZ_ASSERT(aValue.isObject()); + MOZ_ASSERT(aResult.IsEmpty()); + MOZ_ASSERT(!aRv.Failed()); + + // Convert the object to a property-indexed keyframe dictionary to + // get its explicit dictionary members. + dom::binding_detail::FastBasePropertyIndexedKeyframe keyframeDict; + // XXXbz Pass in the method name from callers and set up a BindingCallContext? + if (!keyframeDict.Init(aCx, aValue, "BasePropertyIndexedKeyframe argument")) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + // Get all the property--value-list pairs off the object. + JS::Rooted<JSObject*> object(aCx, &aValue.toObject()); + nsTArray<PropertyValuesPair> propertyValuesPairs; + if (!GetPropertyValuesPairs(aCx, object, ListAllowance::eAllow, + propertyValuesPairs)) { + aRv.Throw(NS_ERROR_FAILURE); + return; + } + + // Create a set of keyframes for each property. + nsTHashMap<nsFloatHashKey, Keyframe> processedKeyframes; + for (const PropertyValuesPair& pair : propertyValuesPairs) { + size_t count = pair.mValues.Length(); + if (count == 0) { + // No animation values for this property. + continue; + } + + // If we only have one value, we should animate from the underlying value + // but not if the pref for supporting implicit keyframes is disabled. + if (!StaticPrefs::dom_animations_api_implicit_keyframes_enabled() && + count == 1) { + aRv.ThrowNotSupportedError( + "Animation to or from an underlying value is not yet supported"); + return; + } + + size_t n = pair.mValues.Length() - 1; + size_t i = 0; + + for (const nsCString& stringValue : pair.mValues) { + // For single-valued lists, the single value should be added to a + // keyframe with offset 1. + double offset = n ? i++ / double(n) : 1; + Keyframe& keyframe = processedKeyframes.LookupOrInsert(offset); + if (keyframe.mPropertyValues.IsEmpty()) { + keyframe.mComputedOffset = offset; + } + + Maybe<PropertyValuePair> valuePair = + MakePropertyValuePair(pair.mProperty, stringValue, aDocument); + if (!valuePair) { + continue; + } + keyframe.mPropertyValues.AppendElement(std::move(valuePair.ref())); + } + } + + aResult.SetCapacity(processedKeyframes.Count()); + std::transform(processedKeyframes.begin(), processedKeyframes.end(), + MakeBackInserter(aResult), [](auto& entry) { + return std::move(*entry.GetModifiableData()); + }); + + aResult.Sort(ComputedOffsetComparator()); + + // Fill in any specified offsets + // + // This corresponds to step 5, "Otherwise," branch, substeps 5-6 of + // https://drafts.csswg.org/web-animations/#processing-a-keyframes-argument + const FallibleTArray<Nullable<double>>* offsets = nullptr; + AutoTArray<Nullable<double>, 1> singleOffset; + auto& offset = keyframeDict.mOffset; + if (offset.IsDouble()) { + singleOffset.AppendElement(offset.GetAsDouble()); + // dom::Sequence is a fallible but AutoTArray is infallible and we need to + // point to one or the other. Fortunately, fallible and infallible array + // types can be implicitly converted provided they are const. + const FallibleTArray<Nullable<double>>& asFallibleArray = singleOffset; + offsets = &asFallibleArray; + } else if (offset.IsDoubleOrNullSequence()) { + offsets = &offset.GetAsDoubleOrNullSequence(); + } + // If offset.IsNull() is true, then we want to leave the mOffset member of + // each keyframe with its initialized value of null. By leaving |offsets| + // as nullptr here, we skip updating mOffset below. + + size_t offsetsToFill = + offsets ? std::min(offsets->Length(), aResult.Length()) : 0; + for (size_t i = 0; i < offsetsToFill; i++) { + if (!offsets->ElementAt(i).IsNull()) { + aResult[i].mOffset.emplace(offsets->ElementAt(i).Value()); + } + } + + // Check that the keyframes are loosely sorted and that any specified offsets + // are between 0.0 and 1.0 inclusive. + // + // This corresponds to steps 6-7 of + // https://drafts.csswg.org/web-animations/#processing-a-keyframes-argument + // + // In the spec, TypeErrors arising from invalid offsets and easings are thrown + // at the end of the procedure since it assumes we initially store easing + // values as strings and then later parse them. + // + // However, we will parse easing members immediately when we process them + // below. In order to maintain the relative order in which TypeErrors are + // thrown according to the spec, namely exceptions arising from invalid + // offsets are thrown before exceptions arising from invalid easings, we check + // the offsets here. + if (!HasValidOffsets(aResult)) { + aResult.Clear(); + aRv.ThrowTypeError<dom::MSG_INVALID_KEYFRAME_OFFSETS>(); + return; + } + + // Fill in any easings. + // + // This corresponds to step 5, "Otherwise," branch, substeps 7-11 of + // https://drafts.csswg.org/web-animations/#processing-a-keyframes-argument + FallibleTArray<Maybe<StyleComputedTimingFunction>> easings; + auto parseAndAppendEasing = [&](const nsACString& easingString, + ErrorResult& aRv) { + auto easing = TimingParams::ParseEasing(easingString, aRv); + if (!aRv.Failed() && !easings.AppendElement(std::move(easing), fallible)) { + aRv.Throw(NS_ERROR_OUT_OF_MEMORY); + } + }; + + auto& easing = keyframeDict.mEasing; + if (easing.IsUTF8String()) { + parseAndAppendEasing(easing.GetAsUTF8String(), aRv); + if (aRv.Failed()) { + aResult.Clear(); + return; + } + } else { + for (const auto& easingString : easing.GetAsUTF8StringSequence()) { + parseAndAppendEasing(easingString, aRv); + if (aRv.Failed()) { + aResult.Clear(); + return; + } + } + } + + // If |easings| is empty, then we are supposed to fill it in with the value + // "linear" and then repeat the list as necessary. + // + // However, for Keyframe.mTimingFunction we represent "linear" as a None + // value. Since we have not assigned 'mTimingFunction' for any of the + // keyframes in |aResult| they will already have their initial None value + // (i.e. linear). As a result, if |easings| is empty, we don't need to do + // anything. + if (!easings.IsEmpty()) { + for (size_t i = 0; i < aResult.Length(); i++) { + aResult[i].mTimingFunction = easings[i % easings.Length()]; + } + } + + // Fill in any composite operations. + // + // This corresponds to step 5, "Otherwise," branch, substep 12 of + // https://drafts.csswg.org/web-animations/#processing-a-keyframes-argument + if (StaticPrefs::dom_animations_api_compositing_enabled()) { + const FallibleTArray<dom::CompositeOperationOrAuto>* compositeOps = nullptr; + AutoTArray<dom::CompositeOperationOrAuto, 1> singleCompositeOp; + auto& composite = keyframeDict.mComposite; + if (composite.IsCompositeOperationOrAuto()) { + singleCompositeOp.AppendElement( + composite.GetAsCompositeOperationOrAuto()); + const FallibleTArray<dom::CompositeOperationOrAuto>& asFallibleArray = + singleCompositeOp; + compositeOps = &asFallibleArray; + } else if (composite.IsCompositeOperationOrAutoSequence()) { + compositeOps = &composite.GetAsCompositeOperationOrAutoSequence(); + } + + // Fill in and repeat as needed. + if (compositeOps && !compositeOps->IsEmpty()) { + size_t length = compositeOps->Length(); + for (size_t i = 0; i < aResult.Length(); i++) { + aResult[i].mComposite = compositeOps->ElementAt(i % length); + } + } + } +} + +/** + * Returns true if the supplied set of keyframes has keyframe values for + * any property for which it does not also supply a value for the 0% and 100% + * offsets. The check is not entirely accurate but should detect most common + * cases. + * + * @param aKeyframes The set of keyframes to analyze. + * @param aDocument The document to use when parsing keyframes so we can + * try to detect where we have an invalid value at 0%/100%. + */ +static bool HasImplicitKeyframeValues(const nsTArray<Keyframe>& aKeyframes, + dom::Document* aDocument) { + // We are looking to see if that every property referenced in |aKeyframes| + // has a valid property at offset 0.0 and 1.0. The check as to whether a + // property is valid or not, however, is not precise. We only check if the + // property can be parsed, NOT whether it can also be converted to a + // StyleAnimationValue since doing that requires a target element bound to + // a document which we might not always have at the point where we want to + // perform this check. + // + // This is only a temporary measure until we ship implicit keyframes and + // remove the corresponding pref. + // So as long as this check catches most cases, and we don't do anything + // horrible in one of the cases we can't detect, it should be sufficient. + + nsCSSPropertyIDSet properties; // All properties encountered. + nsCSSPropertyIDSet propertiesWithFromValue; // Those with a defined 0% value. + nsCSSPropertyIDSet propertiesWithToValue; // Those with a defined 100% value. + + auto addToPropertySets = [&](nsCSSPropertyID aProperty, double aOffset) { + properties.AddProperty(aProperty); + if (aOffset == 0.0) { + propertiesWithFromValue.AddProperty(aProperty); + } else if (aOffset == 1.0) { + propertiesWithToValue.AddProperty(aProperty); + } + }; + + for (size_t i = 0, len = aKeyframes.Length(); i < len; i++) { + const Keyframe& frame = aKeyframes[i]; + + // We won't have called DistributeKeyframes when this is called so + // we can't use frame.mComputedOffset. Instead we do a rough version + // of that algorithm that substitutes null offsets with 0.0 for the first + // frame, 1.0 for the last frame, and 0.5 for everything else. + double computedOffset = i == len - 1 ? 1.0 : i == 0 ? 0.0 : 0.5; + double offsetToUse = frame.mOffset ? frame.mOffset.value() : computedOffset; + + for (const PropertyValuePair& pair : frame.mPropertyValues) { + if (nsCSSProps::IsShorthand(pair.mProperty)) { + MOZ_ASSERT(pair.mServoDeclarationBlock); + CSSPROPS_FOR_SHORTHAND_SUBPROPERTIES(prop, pair.mProperty, + CSSEnabledState::ForAllContent) { + addToPropertySets(*prop, offsetToUse); + } + } else { + addToPropertySets(pair.mProperty, offsetToUse); + } + } + } + + return !propertiesWithFromValue.Equals(properties) || + !propertiesWithToValue.Equals(properties); +} + +/** + * Distribute the offsets of all keyframes in between the endpoints of the + * given range. + * + * @param aRange The sequence of keyframes between whose endpoints we should + * distribute offsets. + */ +static void DistributeRange(const Range<Keyframe>& aRange) { + const Range<Keyframe> rangeToAdjust = + Range<Keyframe>(aRange.begin() + 1, aRange.end() - 1); + const size_t n = aRange.length() - 1; + const double startOffset = aRange[0].mComputedOffset; + const double diffOffset = aRange[n].mComputedOffset - startOffset; + for (auto iter = rangeToAdjust.begin(); iter != rangeToAdjust.end(); ++iter) { + size_t index = iter - aRange.begin(); + iter->mComputedOffset = startOffset + double(index) / n * diffOffset; + } +} + +} // namespace mozilla diff --git a/dom/animation/KeyframeUtils.h b/dom/animation/KeyframeUtils.h new file mode 100644 index 0000000000..311a091df8 --- /dev/null +++ b/dom/animation/KeyframeUtils.h @@ -0,0 +1,110 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_KeyframeUtils_h +#define mozilla_KeyframeUtils_h + +#include "mozilla/KeyframeEffectParams.h" // For CompositeOperation +#include "nsCSSPropertyID.h" +#include "nsTArrayForwardDeclare.h" // For nsTArray +#include "js/RootingAPI.h" // For JS::Handle + +struct JSContext; +class JSObject; + +namespace mozilla { +struct AnimationProperty; +class ComputedStyle; + +enum class PseudoStyleType : uint8_t; +class ErrorResult; +struct Keyframe; +struct PropertyStyleAnimationValuePair; + +namespace dom { +class Document; +class Element; +} // namespace dom +} // namespace mozilla + +namespace mozilla { + +// Represents the set of property-value pairs on a Keyframe converted to +// computed values. +using ComputedKeyframeValues = nsTArray<PropertyStyleAnimationValuePair>; + +/** + * Utility methods for processing keyframes. + */ +class KeyframeUtils { + public: + /** + * Converts a JS value representing a property-indexed keyframe or a sequence + * of keyframes to an array of Keyframe objects. + * + * @param aCx The JSContext that corresponds to |aFrames|. + * @param aDocument The document to use when parsing CSS properties. + * @param aFrames The JS value, provided as an optional IDL |object?| value, + * that is the keyframe list specification. + * @param aContext Information about who is trying to get keyframes from the + * object, for use in error reporting. This must be be a non-null + * pointer representing a null-terminated ASCII string. + * @param aRv (out) Out-param to hold any error returned by this function. + * Must be initially empty. + * @return The set of processed keyframes. If an error occurs, aRv will be + * filled-in with the appropriate error code and an empty array will be + * returned. + */ + static nsTArray<Keyframe> GetKeyframesFromObject( + JSContext* aCx, dom::Document* aDocument, JS::Handle<JSObject*> aFrames, + const char* aContext, ErrorResult& aRv); + + /** + * Calculate the computed offset of keyframes by evenly distributing keyframes + * with a missing offset. + * + * @see + * https://drafts.csswg.org/web-animations/#calculating-computed-keyframes + * + * @param aKeyframes The set of keyframes to adjust. + */ + static void DistributeKeyframes(nsTArray<Keyframe>& aKeyframes); + + /** + * Converts an array of Keyframe objects into an array of AnimationProperty + * objects. This involves creating an array of computed values for each + * longhand property and determining the offset and timing function to use + * for each value. + * + * @param aKeyframes The input keyframes. + * @param aElement The context element. + * @param aStyle The computed style values. + * @param aEffectComposite The composite operation specified on the effect. + * For any keyframes in |aKeyframes| that do not specify a composite + * operation, this value will be used. + * @return The set of animation properties. If an error occurs, the returned + * array will be empty. + */ + static nsTArray<AnimationProperty> GetAnimationPropertiesFromKeyframes( + const nsTArray<Keyframe>& aKeyframes, dom::Element* aElement, + PseudoStyleType aPseudoType, const ComputedStyle* aStyle, + dom::CompositeOperation aEffectComposite); + + /** + * Check if the property or, for shorthands, one or more of + * its subproperties, is animatable. + * + * @param aProperty The property to check. + * @param aBackend The style backend, Servo or Gecko, that should determine + * if the property is animatable or not. + * @return true if |aProperty| is animatable. + */ + static bool IsAnimatableProperty(nsCSSPropertyID aProperty); +}; + +} // namespace mozilla + +#endif // mozilla_KeyframeUtils_h diff --git a/dom/animation/PendingAnimationTracker.cpp b/dom/animation/PendingAnimationTracker.cpp new file mode 100644 index 0000000000..9993f0888a --- /dev/null +++ b/dom/animation/PendingAnimationTracker.cpp @@ -0,0 +1,193 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "PendingAnimationTracker.h" + +#include "mozilla/PresShell.h" +#include "mozilla/dom/AnimationEffect.h" +#include "mozilla/dom/AnimationTimeline.h" +#include "mozilla/dom/Nullable.h" +#include "nsIFrame.h" +#include "nsTransitionManager.h" // For CSSTransition + +using mozilla::dom::Nullable; + +namespace mozilla { + +NS_IMPL_CYCLE_COLLECTION(PendingAnimationTracker, mPlayPendingSet, + mPausePendingSet, mDocument) + +PendingAnimationTracker::PendingAnimationTracker(dom::Document* aDocument) + : mDocument(aDocument) {} + +void PendingAnimationTracker::AddPending(dom::Animation& aAnimation, + AnimationSet& aSet) { + aSet.Insert(&aAnimation); + + // Schedule a paint. Otherwise animations that don't trigger a paint by + // themselves (e.g. CSS animations with an empty keyframes rule) won't + // start until something else paints. + EnsurePaintIsScheduled(); +} + +void PendingAnimationTracker::RemovePending(dom::Animation& aAnimation, + AnimationSet& aSet) { + aSet.Remove(&aAnimation); +} + +bool PendingAnimationTracker::IsWaiting(const dom::Animation& aAnimation, + const AnimationSet& aSet) const { + return aSet.Contains(const_cast<dom::Animation*>(&aAnimation)); +} + +void PendingAnimationTracker::TriggerPendingAnimationsOnNextTick( + const TimeStamp& aReadyTime) { + auto triggerAnimationsAtReadyTime = [aReadyTime]( + AnimationSet& aAnimationSet) { + for (auto iter = aAnimationSet.begin(), end = aAnimationSet.end(); + iter != end; ++iter) { + dom::Animation* animation = *iter; + dom::AnimationTimeline* timeline = animation->GetTimeline(); + + // If the animation does not have a timeline, just drop it from the map. + // The animation will detect that it is not being tracked and will trigger + // itself on the next tick where it has a timeline. + if (!timeline) { + aAnimationSet.Remove(iter); + continue; + } + + MOZ_ASSERT(timeline->IsMonotonicallyIncreasing(), + "The non-monotonicially-increasing timeline should be in " + "ScrollTimelineAnimationTracker"); + + // When the timeline's refresh driver is under test control, its values + // have no correspondance to wallclock times so we shouldn't try to + // convert aReadyTime (which is a wallclock time) to a timeline value. + // Instead, the animation will be started/paused when the refresh driver + // is next advanced since this will trigger a call to + // TriggerPendingAnimationsNow. + if (!timeline->TracksWallclockTime()) { + continue; + } + + Nullable<TimeDuration> readyTime = timeline->ToTimelineTime(aReadyTime); + animation->TriggerOnNextTick(readyTime); + + aAnimationSet.Remove(iter); + } + }; + + triggerAnimationsAtReadyTime(mPlayPendingSet); + triggerAnimationsAtReadyTime(mPausePendingSet); + + mHasPlayPendingGeometricAnimations = + mPlayPendingSet.Count() ? CheckState::Indeterminate : CheckState::Absent; +} + +void PendingAnimationTracker::TriggerPendingAnimationsNow() { + auto triggerAndClearAnimations = [](AnimationSet& aAnimationSet) { + for (const auto& animation : aAnimationSet) { + animation->TriggerNow(); + } + aAnimationSet.Clear(); + }; + + triggerAndClearAnimations(mPlayPendingSet); + triggerAndClearAnimations(mPausePendingSet); + + mHasPlayPendingGeometricAnimations = CheckState::Absent; +} + +static bool IsTransition(const dom::Animation& aAnimation) { + const dom::CSSTransition* transition = aAnimation.AsCSSTransition(); + return transition && transition->IsTiedToMarkup(); +} + +void PendingAnimationTracker::MarkAnimationsThatMightNeedSynchronization() { + // We only set mHasPlayPendingGeometricAnimations to "present" in this method + // and nowhere else. After setting the state to "present", if there is any + // change to the set of play-pending animations we will reset + // mHasPlayPendingGeometricAnimations to either "indeterminate" or "absent". + // + // As a result, if mHasPlayPendingGeometricAnimations is "present", we can + // assume that this method has already been called for the current set of + // play-pending animations and it is not necessary to run this method again. + // + // If mHasPlayPendingGeometricAnimations is "absent", then we can also skip + // the body of this method since there are no notifications to be sent. + // + // Therefore, the only case we need to be concerned about is the + // "indeterminate" case. For all other cases we can return early. + // + // Note that *without* this optimization, starting animations would become + // O(n^2) in the case where each animation is on a different element and + // contains a compositor-animatable property since we would end up iterating + // over all animations in the play-pending set for each target element. + if (mHasPlayPendingGeometricAnimations != CheckState::Indeterminate) { + return; + } + + // We only synchronize CSS transitions with other CSS transitions (and we only + // synchronize non-transition animations with non-transition animations) + // since typically the author will not trigger both CSS animations and + // CSS transitions simultaneously and expect them to be synchronized. + // + // If we try to synchronize CSS transitions with non-transitions then for some + // content we will end up degrading performance by forcing animations to run + // on the main thread that really don't need to. + + mHasPlayPendingGeometricAnimations = CheckState::Absent; + for (const auto& animation : mPlayPendingSet) { + if (animation->GetEffect() && animation->GetEffect()->AffectsGeometry()) { + mHasPlayPendingGeometricAnimations &= ~CheckState::Absent; + mHasPlayPendingGeometricAnimations |= IsTransition(*animation) + ? CheckState::TransitionsPresent + : CheckState::AnimationsPresent; + + // If we have both transitions and animations we don't need to look any + // further. + if (mHasPlayPendingGeometricAnimations == + (CheckState::TransitionsPresent | CheckState::AnimationsPresent)) { + break; + } + } + } + + if (mHasPlayPendingGeometricAnimations == CheckState::Absent) { + return; + } + + for (const auto& animation : mPlayPendingSet) { + bool isTransition = IsTransition(*animation); + if ((isTransition && + mHasPlayPendingGeometricAnimations & CheckState::TransitionsPresent) || + (!isTransition && + mHasPlayPendingGeometricAnimations & CheckState::AnimationsPresent)) { + animation->NotifyGeometricAnimationsStartingThisFrame(); + } + } +} + +void PendingAnimationTracker::EnsurePaintIsScheduled() { + if (!mDocument) { + return; + } + + PresShell* presShell = mDocument->GetPresShell(); + if (!presShell) { + return; + } + + nsIFrame* rootFrame = presShell->GetRootFrame(); + if (!rootFrame) { + return; + } + + rootFrame->SchedulePaintWithoutInvalidatingObservers(); +} + +} // namespace mozilla diff --git a/dom/animation/PendingAnimationTracker.h b/dom/animation/PendingAnimationTracker.h new file mode 100644 index 0000000000..5fb277b7ef --- /dev/null +++ b/dom/animation/PendingAnimationTracker.h @@ -0,0 +1,113 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_PendingAnimationTracker_h +#define mozilla_PendingAnimationTracker_h + +#include "mozilla/dom/Animation.h" +#include "mozilla/TypedEnumBits.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTHashSet.h" + +class nsIFrame; + +namespace mozilla { + +namespace dom { +class Document; +} + +/** + * Handle the pending animations which use document-timeline or null-timeline + * while playing or pausing. + */ +class PendingAnimationTracker final { + public: + explicit PendingAnimationTracker(dom::Document* aDocument); + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING(PendingAnimationTracker) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(PendingAnimationTracker) + + void AddPlayPending(dom::Animation& aAnimation) { + // We'd like to assert here that IsWaitingToPause(aAnimation) is false but + // if |aAnimation| was tracked here as a pause-pending animation when it was + // removed from |mDocument|, then re-attached to |mDocument|, and then + // played again, we could end up here with IsWaitingToPause returning true. + // + // However, that should be harmless since all it means is that we'll call + // Animation::TriggerOnNextTick or Animation::TriggerNow twice, both of + // which will handle the redundant call gracefully. + AddPending(aAnimation, mPlayPendingSet); + mHasPlayPendingGeometricAnimations = CheckState::Indeterminate; + } + void RemovePlayPending(dom::Animation& aAnimation) { + RemovePending(aAnimation, mPlayPendingSet); + mHasPlayPendingGeometricAnimations = CheckState::Indeterminate; + } + bool IsWaitingToPlay(const dom::Animation& aAnimation) const { + return IsWaiting(aAnimation, mPlayPendingSet); + } + + void AddPausePending(dom::Animation& aAnimation) { + // As with AddPausePending, we'd like to assert that + // IsWaitingToPlay(aAnimation) is false but there are some circumstances + // where this can be true. Fortunately adding the animation to both pending + // sets should be harmless. + AddPending(aAnimation, mPausePendingSet); + } + void RemovePausePending(dom::Animation& aAnimation) { + RemovePending(aAnimation, mPausePendingSet); + } + bool IsWaitingToPause(const dom::Animation& aAnimation) const { + return IsWaiting(aAnimation, mPausePendingSet); + } + + void TriggerPendingAnimationsOnNextTick(const TimeStamp& aReadyTime); + void TriggerPendingAnimationsNow(); + bool HasPendingAnimations() const { + return mPlayPendingSet.Count() > 0 || mPausePendingSet.Count() > 0; + } + + /** + * Looks amongst the set of play-pending animations, and, if there are + * animations that affect geometric properties, notifies all play-pending + * animations so that they can be synchronized, if needed. + */ + void MarkAnimationsThatMightNeedSynchronization(); + + private: + ~PendingAnimationTracker() = default; + + void EnsurePaintIsScheduled(); + + using AnimationSet = nsTHashSet<nsRefPtrHashKey<dom::Animation>>; + + void AddPending(dom::Animation& aAnimation, AnimationSet& aSet); + void RemovePending(dom::Animation& aAnimation, AnimationSet& aSet); + bool IsWaiting(const dom::Animation& aAnimation, + const AnimationSet& aSet) const; + + AnimationSet mPlayPendingSet; + AnimationSet mPausePendingSet; + RefPtr<dom::Document> mDocument; + + public: + enum class CheckState { + Indeterminate = 0, + Absent = 1 << 0, + AnimationsPresent = 1 << 1, + TransitionsPresent = 1 << 2, + }; + + private: + CheckState mHasPlayPendingGeometricAnimations = CheckState::Indeterminate; +}; + +MOZ_MAKE_ENUM_CLASS_BITWISE_OPERATORS(PendingAnimationTracker::CheckState) + +} // namespace mozilla + +#endif // mozilla_PendingAnimationTracker_h diff --git a/dom/animation/PostRestyleMode.h b/dom/animation/PostRestyleMode.h new file mode 100644 index 0000000000..7ddf8df778 --- /dev/null +++ b/dom/animation/PostRestyleMode.h @@ -0,0 +1,16 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_PostRestyleMode_h +#define mozilla_PostRestyleMode_h + +namespace mozilla { + +enum class PostRestyleMode { IfNeeded, Never }; + +} // namespace mozilla + +#endif // mozilla_PostRestyleMode_h diff --git a/dom/animation/PseudoElementHashEntry.h b/dom/animation/PseudoElementHashEntry.h new file mode 100644 index 0000000000..b532d86105 --- /dev/null +++ b/dom/animation/PseudoElementHashEntry.h @@ -0,0 +1,51 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_PseudoElementHashEntry_h +#define mozilla_PseudoElementHashEntry_h + +#include "mozilla/dom/Element.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/HashFunctions.h" +#include "PLDHashTable.h" + +namespace mozilla { + +// A hash entry that uses a RefPtr<dom::Element>, PseudoStyleType pair +class PseudoElementHashEntry : public PLDHashEntryHdr { + public: + typedef NonOwningAnimationTarget KeyType; + typedef const NonOwningAnimationTarget* KeyTypePointer; + + explicit PseudoElementHashEntry(KeyTypePointer aKey) + : mElement(aKey->mElement), mPseudoType(aKey->mPseudoType) {} + PseudoElementHashEntry(PseudoElementHashEntry&& aOther) = default; + + ~PseudoElementHashEntry() = default; + + KeyType GetKey() const { return {mElement, mPseudoType}; } + bool KeyEquals(KeyTypePointer aKey) const { + return mElement == aKey->mElement && mPseudoType == aKey->mPseudoType; + } + + static KeyTypePointer KeyToPointer(KeyType& aKey) { return &aKey; } + static PLDHashNumber HashKey(KeyTypePointer aKey) { + if (!aKey) return 0; + + // Convert the scoped enum into an integer while adding it to hash. + static_assert(sizeof(PseudoStyleType) == sizeof(uint8_t), ""); + return mozilla::HashGeneric(aKey->mElement, + static_cast<uint8_t>(aKey->mPseudoType)); + } + enum { ALLOW_MEMMOVE = true }; + + RefPtr<dom::Element> mElement; + PseudoStyleType mPseudoType; +}; + +} // namespace mozilla + +#endif // mozilla_PseudoElementHashEntry_h diff --git a/dom/animation/ScrollTimeline.cpp b/dom/animation/ScrollTimeline.cpp new file mode 100644 index 0000000000..eeb9571494 --- /dev/null +++ b/dom/animation/ScrollTimeline.cpp @@ -0,0 +1,295 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "ScrollTimeline.h" + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/ElementInlines.h" +#include "mozilla/AnimationTarget.h" +#include "mozilla/DisplayPortUtils.h" +#include "mozilla/ElementAnimationData.h" +#include "mozilla/PresShell.h" +#include "nsIFrame.h" +#include "nsIScrollableFrame.h" +#include "nsLayoutUtils.h" + +namespace mozilla::dom { + +// --------------------------------- +// Methods of ScrollTimeline +// --------------------------------- + +NS_IMPL_CYCLE_COLLECTION_CLASS(ScrollTimeline) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(ScrollTimeline, + AnimationTimeline) + tmp->Teardown(); + NS_IMPL_CYCLE_COLLECTION_UNLINK(mDocument) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mSource.mElement) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(ScrollTimeline, + AnimationTimeline) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocument) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mSource.mElement) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED_0(ScrollTimeline, + AnimationTimeline) + +ScrollTimeline::ScrollTimeline(Document* aDocument, const Scroller& aScroller, + StyleScrollAxis aAxis) + : AnimationTimeline(aDocument->GetParentObject(), + aDocument->GetScopeObject()->GetRTPCallerType()), + mDocument(aDocument), + mSource(aScroller), + mAxis(aAxis) { + MOZ_ASSERT(aDocument); + RegisterWithScrollSource(); +} + +/* static */ std::pair<const Element*, PseudoStyleType> +ScrollTimeline::FindNearestScroller(Element* aSubject, + PseudoStyleType aPseudoType) { + MOZ_ASSERT(aSubject); + Element* subject = + AnimationUtils::GetElementForRestyle(aSubject, aPseudoType); + + Element* curr = subject->GetFlattenedTreeParentElement(); + Element* root = subject->OwnerDoc()->GetDocumentElement(); + while (curr && curr != root) { + const ComputedStyle* style = Servo_Element_GetMaybeOutOfDateStyle(curr); + MOZ_ASSERT(style, "The ancestor should be styled."); + if (style->StyleDisplay()->IsScrollableOverflow()) { + break; + } + curr = curr->GetFlattenedTreeParentElement(); + } + // If there is no scroll container, we use root. + if (!curr) { + return {root, PseudoStyleType::NotPseudo}; + } + return AnimationUtils::GetElementPseudoPair(curr); +} + +/* static */ +already_AddRefed<ScrollTimeline> ScrollTimeline::MakeAnonymous( + Document* aDocument, const NonOwningAnimationTarget& aTarget, + StyleScrollAxis aAxis, StyleScroller aScroller) { + MOZ_ASSERT(aTarget); + Scroller scroller; + switch (aScroller) { + case StyleScroller::Root: + scroller = Scroller::Root(aTarget.mElement->OwnerDoc()); + break; + + case StyleScroller::Nearest: { + auto [element, pseudo] = + FindNearestScroller(aTarget.mElement, aTarget.mPseudoType); + scroller = Scroller::Nearest(const_cast<Element*>(element), pseudo); + break; + } + case StyleScroller::SelfElement: + scroller = Scroller::Self(aTarget.mElement, aTarget.mPseudoType); + break; + } + + // Each use of scroll() corresponds to its own instance of ScrollTimeline in + // the Web Animations API, even if multiple elements use scroll() to refer to + // the same scroll container with the same arguments. + // https://drafts.csswg.org/scroll-animations-1/#scroll-notation + return MakeAndAddRef<ScrollTimeline>(aDocument, scroller, aAxis); +} + +/* static*/ already_AddRefed<ScrollTimeline> ScrollTimeline::MakeNamed( + Document* aDocument, Element* aReferenceElement, + PseudoStyleType aPseudoType, const StyleScrollTimeline& aStyleTimeline) { + MOZ_ASSERT(NS_IsMainThread()); + + Scroller scroller = Scroller::Named(aReferenceElement, aPseudoType); + return MakeAndAddRef<ScrollTimeline>(aDocument, std::move(scroller), + aStyleTimeline.GetAxis()); +} + +Nullable<TimeDuration> ScrollTimeline::GetCurrentTimeAsDuration() const { + // If no layout box, this timeline is inactive. + if (!mSource || !mSource.mElement->GetPrimaryFrame()) { + return nullptr; + } + + // if this is not a scroller container, this timeline is inactive. + const nsIScrollableFrame* scrollFrame = GetScrollFrame(); + if (!scrollFrame) { + return nullptr; + } + + const auto orientation = Axis(); + + // If there is no scrollable overflow, then the ScrollTimeline is inactive. + // https://drafts.csswg.org/scroll-animations-1/#scrolltimeline-interface + if (!scrollFrame->GetAvailableScrollingDirections().contains(orientation)) { + return nullptr; + } + + const bool isHorizontal = orientation == layers::ScrollDirection::eHorizontal; + const nsPoint& scrollPosition = scrollFrame->GetScrollPosition(); + const Maybe<ScrollOffsets>& offsets = + ComputeOffsets(scrollFrame, orientation); + if (!offsets) { + return nullptr; + } + + // Note: For RTL, scrollPosition.x or scrollPosition.y may be negative, + // e.g. the range of its value is [0, -range], so we have to use the + // absolute value. + nscoord position = + std::abs(isHorizontal ? scrollPosition.x : scrollPosition.y); + double progress = static_cast<double>(position - offsets->mStart) / + static_cast<double>(offsets->mEnd - offsets->mStart); + return TimeDuration::FromMilliseconds(progress * + PROGRESS_TIMELINE_DURATION_MILLISEC); +} + +layers::ScrollDirection ScrollTimeline::Axis() const { + MOZ_ASSERT(mSource && mSource.mElement->GetPrimaryFrame()); + + const WritingMode wm = mSource.mElement->GetPrimaryFrame()->GetWritingMode(); + return mAxis == StyleScrollAxis::Horizontal || + (!wm.IsVertical() && mAxis == StyleScrollAxis::Inline) || + (wm.IsVertical() && mAxis == StyleScrollAxis::Block) + ? layers::ScrollDirection::eHorizontal + : layers::ScrollDirection::eVertical; +} + +StyleOverflow ScrollTimeline::SourceScrollStyle() const { + MOZ_ASSERT(mSource && mSource.mElement->GetPrimaryFrame()); + + const nsIScrollableFrame* scrollFrame = GetScrollFrame(); + MOZ_ASSERT(scrollFrame); + + const ScrollStyles scrollStyles = scrollFrame->GetScrollStyles(); + + return Axis() == layers::ScrollDirection::eHorizontal + ? scrollStyles.mHorizontal + : scrollStyles.mVertical; +} + +bool ScrollTimeline::APZIsActiveForSource() const { + MOZ_ASSERT(mSource); + return gfxPlatform::AsyncPanZoomEnabled() && + !nsLayoutUtils::ShouldDisableApzForElement(mSource.mElement) && + DisplayPortUtils::HasNonMinimalNonZeroDisplayPort(mSource.mElement); +} + +bool ScrollTimeline::ScrollingDirectionIsAvailable() const { + const nsIScrollableFrame* scrollFrame = GetScrollFrame(); + MOZ_ASSERT(scrollFrame); + return scrollFrame->GetAvailableScrollingDirections().contains(Axis()); +} + +void ScrollTimeline::ReplacePropertiesWith(const Element* aReferenceElement, + PseudoStyleType aPseudoType, + const StyleScrollTimeline& aNew) { + MOZ_ASSERT(aReferenceElement == mSource.mElement && + aPseudoType == mSource.mPseudoType); + mAxis = aNew.GetAxis(); + + for (auto* anim = mAnimationOrder.getFirst(); anim; + anim = static_cast<LinkedListElement<Animation>*>(anim)->getNext()) { + MOZ_ASSERT(anim->GetTimeline() == this); + // Set this so we just PostUpdate() for this animation. + anim->SetTimeline(this); + } +} + +Maybe<ScrollTimeline::ScrollOffsets> ScrollTimeline::ComputeOffsets( + const nsIScrollableFrame* aScrollFrame, + layers::ScrollDirection aOrientation) const { + const nsRect& scrollRange = aScrollFrame->GetScrollRange(); + nscoord range = aOrientation == layers::ScrollDirection::eHorizontal + ? scrollRange.width + : scrollRange.height; + MOZ_ASSERT(range > 0); + return Some(ScrollOffsets{0, range}); +} + +void ScrollTimeline::RegisterWithScrollSource() { + if (!mSource) { + return; + } + + auto& scheduler = + ProgressTimelineScheduler::Ensure(mSource.mElement, mSource.mPseudoType); + scheduler.AddTimeline(this); +} + +void ScrollTimeline::UnregisterFromScrollSource() { + if (!mSource) { + return; + } + + auto* scheduler = + ProgressTimelineScheduler::Get(mSource.mElement, mSource.mPseudoType); + if (!scheduler) { + return; + } + + scheduler->RemoveTimeline(this); + if (scheduler->IsEmpty()) { + ProgressTimelineScheduler::Destroy(mSource.mElement, mSource.mPseudoType); + } +} + +const nsIScrollableFrame* ScrollTimeline::GetScrollFrame() const { + if (!mSource) { + return nullptr; + } + + switch (mSource.mType) { + case Scroller::Type::Root: + if (const PresShell* presShell = + mSource.mElement->OwnerDoc()->GetPresShell()) { + return presShell->GetRootScrollFrameAsScrollable(); + } + return nullptr; + case Scroller::Type::Nearest: + case Scroller::Type::Name: + case Scroller::Type::Self: + return nsLayoutUtils::FindScrollableFrameFor(mSource.mElement); + } + + MOZ_ASSERT_UNREACHABLE("Unsupported scroller type"); + return nullptr; +} + +// ------------------------------------ +// Methods of ProgressTimelineScheduler +// ------------------------------------ +/* static */ ProgressTimelineScheduler* ProgressTimelineScheduler::Get( + const Element* aElement, PseudoStyleType aPseudoType) { + MOZ_ASSERT(aElement); + auto* data = aElement->GetAnimationData(); + if (!data) { + return nullptr; + } + + return data->GetProgressTimelineScheduler(aPseudoType); +} + +/* static */ ProgressTimelineScheduler& ProgressTimelineScheduler::Ensure( + Element* aElement, PseudoStyleType aPseudoType) { + MOZ_ASSERT(aElement); + return aElement->EnsureAnimationData().EnsureProgressTimelineScheduler( + *aElement, aPseudoType); +} + +/* static */ +void ProgressTimelineScheduler::Destroy(const Element* aElement, + PseudoStyleType aPseudoType) { + auto* data = aElement->GetAnimationData(); + MOZ_ASSERT(data); + data->ClearProgressTimelineScheduler(aPseudoType); +} + +} // namespace mozilla::dom diff --git a/dom/animation/ScrollTimeline.h b/dom/animation/ScrollTimeline.h new file mode 100644 index 0000000000..129872f61b --- /dev/null +++ b/dom/animation/ScrollTimeline.h @@ -0,0 +1,281 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_ScrollTimeline_h +#define mozilla_dom_ScrollTimeline_h + +#include "mozilla/dom/AnimationTimeline.h" +#include "mozilla/dom/Document.h" +#include "mozilla/HashTable.h" +#include "mozilla/PairHash.h" +#include "mozilla/ServoStyleConsts.h" +#include "mozilla/WritingModes.h" + +#define PROGRESS_TIMELINE_DURATION_MILLISEC 100000 + +class nsIScrollableFrame; + +namespace mozilla { +class ElementAnimationData; +struct NonOwningAnimationTarget; +namespace dom { +class Element; + +/** + * Implementation notes + * -------------------- + * + * ScrollTimelines do not observe refreshes the way DocumentTimelines do. + * This is because the refresh driver keeps ticking while it has registered + * refresh observers. For a DocumentTimeline, it's appropriate to keep the + * refresh driver ticking as long as there are active animations, since the + * animations need to be sampled on every frame. Scroll-linked animations, + * however, only need to be sampled when scrolling has occurred, so keeping + * the refresh driver ticking is wasteful. + * + * As a result, we schedule an animation restyle when + * 1) there are any scroll offsets updated (from APZ or script), via + * nsIScrollableFrame, or + * 2) there are any possible scroll range updated during the frame reflow. + * + * ------------- + * | Animation | + * ------------- + * ^ + * | Call Animation::Tick() if there are any scroll updates. + * | + * ------------------ + * | ScrollTimeline | + * ------------------ + * ^ + * | Try schedule the scroll-driven animations, if there are any scroll + * | offsets changed or the scroll range changed [1]. + * | + * ---------------------- + * | nsIScrollableFrame | + * ---------------------- + * + * [1] nsIScrollableFrame uses its associated dom::Element to lookup the + * ScrollTimelineSet, and iterates the set to schedule the animations + * linked to the ScrollTimelines. + */ +class ScrollTimeline : public AnimationTimeline { + template <typename T, typename... Args> + friend already_AddRefed<T> mozilla::MakeAndAddRef(Args&&... aArgs); + + public: + struct Scroller { + // FIXME: Bug 1765211. Perhaps we only need root and a specific element. + // This depends on how we fix this bug. + enum class Type : uint8_t { + Root, + Nearest, + Name, + Self, + }; + Type mType = Type::Root; + RefPtr<Element> mElement; + PseudoStyleType mPseudoType; + + // We use the owner doc of the animation target. This may be different from + // |mDocument| after we implement ScrollTimeline interface for script. + static Scroller Root(const Document* aOwnerDoc) { + // For auto, we use scrolling element as the default scroller. + // However, it's mutable, and we would like to keep things simple, so + // we always register the ScrollTimeline to the document element (i.e. + // root element) because the content of the root scroll frame is the root + // element. + return {Type::Root, aOwnerDoc->GetDocumentElement(), + PseudoStyleType::NotPseudo}; + } + + static Scroller Nearest(Element* aElement, PseudoStyleType aPseudoType) { + return {Type::Nearest, aElement, aPseudoType}; + } + + static Scroller Named(Element* aElement, PseudoStyleType aPseudoType) { + return {Type::Name, aElement, aPseudoType}; + } + + static Scroller Self(Element* aElement, PseudoStyleType aPseudoType) { + return {Type::Self, aElement, aPseudoType}; + } + + explicit operator bool() const { return mElement; } + bool operator==(const Scroller& aOther) const { + return mType == aOther.mType && mElement == aOther.mElement && + mPseudoType == aOther.mPseudoType; + } + }; + + static already_AddRefed<ScrollTimeline> MakeAnonymous( + Document* aDocument, const NonOwningAnimationTarget& aTarget, + StyleScrollAxis aAxis, StyleScroller aScroller); + + // Note: |aReferfenceElement| is used as the scroller which specifies + // scroll-timeline-name property. + static already_AddRefed<ScrollTimeline> MakeNamed( + Document* aDocument, Element* aReferenceElement, + PseudoStyleType aPseudoType, const StyleScrollTimeline& aStyleTimeline); + + bool operator==(const ScrollTimeline& aOther) const { + return mDocument == aOther.mDocument && mSource == aOther.mSource && + mAxis == aOther.mAxis; + } + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(ScrollTimeline, AnimationTimeline) + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override { + // FIXME: Bug 1676794: Implement ScrollTimeline interface. + return nullptr; + } + + // AnimationTimeline methods. + Nullable<TimeDuration> GetCurrentTimeAsDuration() const override; + bool TracksWallclockTime() const override { return false; } + Nullable<TimeDuration> ToTimelineTime( + const TimeStamp& aTimeStamp) const override { + // It's unclear to us what should we do for this function now, so return + // nullptr. + return nullptr; + } + TimeStamp ToTimeStamp(const TimeDuration& aTimelineTime) const override { + // It's unclear to us what should we do for this function now, so return + // zero time. + return {}; + } + Document* GetDocument() const override { return mDocument; } + bool IsMonotonicallyIncreasing() const override { return false; } + bool IsScrollTimeline() const override { return true; } + const ScrollTimeline* AsScrollTimeline() const override { return this; } + bool IsViewTimeline() const override { return false; } + + Nullable<TimeDuration> TimelineDuration() const override { + // We are using this magic number for progress-based timeline duration + // because we don't support percentage for duration. + return TimeDuration::FromMilliseconds(PROGRESS_TIMELINE_DURATION_MILLISEC); + } + + void ScheduleAnimations() { + // FIXME: Bug 1737927: Need to check the animation mutation observers for + // animations with scroll timelines. + // nsAutoAnimationMutationBatch mb(mDocument); + + Tick(); + } + + // If the source of a ScrollTimeline is an element whose principal box does + // not exist or is not a scroll container, then its phase is the timeline + // inactive phase. It is otherwise in the active phase. This returns true if + // the timeline is in active phase. + // https://drafts.csswg.org/web-animations-1/#inactive-timeline + // Note: This function is called only for compositor animations, so we must + // have the primary frame (principal box) for the source element if it exists. + bool IsActive() const { return GetScrollFrame(); } + + Element* SourceElement() const { + MOZ_ASSERT(mSource); + return mSource.mElement; + } + + // A helper to get the physical orientation of this scroll-timeline. + layers::ScrollDirection Axis() const; + + StyleOverflow SourceScrollStyle() const; + + bool APZIsActiveForSource() const; + + bool ScrollingDirectionIsAvailable() const; + + void ReplacePropertiesWith(const Element* aReferenceElement, + PseudoStyleType aPseudoType, + const StyleScrollTimeline& aNew); + + protected: + virtual ~ScrollTimeline() { Teardown(); } + ScrollTimeline() = delete; + ScrollTimeline(Document* aDocument, const Scroller& aScroller, + StyleScrollAxis aAxis); + + struct ScrollOffsets { + nscoord mStart = 0; + nscoord mEnd = 0; + }; + virtual Maybe<ScrollOffsets> ComputeOffsets( + const nsIScrollableFrame* aScrollFrame, + layers::ScrollDirection aOrientation) const; + + // Note: This function is required to be idempotent, as it can be called from + // both cycleCollection::Unlink() and ~ScrollTimeline(). When modifying this + // function, be sure to preserve this property. + void Teardown() { UnregisterFromScrollSource(); } + + // Register this scroll timeline to the element property. + void RegisterWithScrollSource(); + + // Unregister this scroll timeline from the element property. + void UnregisterFromScrollSource(); + + const nsIScrollableFrame* GetScrollFrame() const; + + static std::pair<const Element*, PseudoStyleType> FindNearestScroller( + Element* aSubject, PseudoStyleType aPseudoType); + + RefPtr<Document> mDocument; + + // FIXME: Bug 1765211: We may have to update the source element once the + // overflow property of the scroll-container is updated when we are using + // nearest scroller. + Scroller mSource; + StyleScrollAxis mAxis; +}; + +/** + * A wrapper around a hashset of ScrollTimeline objects to handle the scheduling + * of scroll driven animations. This is used for all kinds of progress + * timelines, i.e. anonymous/named scroll timelines and anonymous/named view + * timelines. And this object is owned by the scroll source (See + * ElementAnimationData and nsGfxScrollFrame for the usage). + */ +class ProgressTimelineScheduler { + public: + ProgressTimelineScheduler() { MOZ_COUNT_CTOR(ProgressTimelineScheduler); } + ~ProgressTimelineScheduler() { MOZ_COUNT_DTOR(ProgressTimelineScheduler); } + + static ProgressTimelineScheduler* Get(const Element* aElement, + PseudoStyleType aPseudoType); + static ProgressTimelineScheduler& Ensure(Element* aElement, + PseudoStyleType aPseudoType); + static void Destroy(const Element* aElement, PseudoStyleType aPseudoType); + + void AddTimeline(ScrollTimeline* aScrollTimeline) { + Unused << mTimelines.put(aScrollTimeline); + } + void RemoveTimeline(ScrollTimeline* aScrollTimeline) { + mTimelines.remove(aScrollTimeline); + } + + bool IsEmpty() const { return mTimelines.empty(); } + + void ScheduleAnimations() const { + for (auto iter = mTimelines.iter(); !iter.done(); iter.next()) { + iter.get()->ScheduleAnimations(); + } + } + + private: + // We let Animations own its scroll timeline or view timeline if it is + // anonymous. For named progress timelines, they are created and destroyed by + // TimelineCollection. + HashSet<ScrollTimeline*> mTimelines; +}; + +} // namespace dom +} // namespace mozilla + +#endif // mozilla_dom_ScrollTimeline_h diff --git a/dom/animation/ScrollTimelineAnimationTracker.cpp b/dom/animation/ScrollTimelineAnimationTracker.cpp new file mode 100644 index 0000000000..ae46bca62b --- /dev/null +++ b/dom/animation/ScrollTimelineAnimationTracker.cpp @@ -0,0 +1,48 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "ScrollTimelineAnimationTracker.h" + +#include "mozilla/dom/Document.h" + +namespace mozilla { + +NS_IMPL_CYCLE_COLLECTION(ScrollTimelineAnimationTracker, mPendingSet, mDocument) + +void ScrollTimelineAnimationTracker::TriggerPendingAnimations() { + for (auto iter = mPendingSet.begin(), end = mPendingSet.end(); iter != end; + ++iter) { + dom::Animation* animation = *iter; + + MOZ_ASSERT(animation->GetTimeline() && + !animation->GetTimeline()->IsMonotonicallyIncreasing()); + + // FIXME: Trigger now may not be correct because the spec says: + // If a user agent determines that animation is immediately ready, it may + // schedule the task (i.e. ResumeAt()) as a microtask such that it runs at + // the next microtask checkpoint, but it must not perform the task + // synchronously. + // Note: So, for now, we put the animation into the tracker, and trigger + // them immediately until the frames are ready. Using TriggerOnNextTick() + // for scroll-driven animations may have issues because we don't tick if + // no one does scroll. + if (!animation->TryTriggerNowForFiniteTimeline()) { + // Note: We keep this animation pending even if its timeline is always + // inactive. It's pretty hard to tell its future status, for example, it's + // possible that the scroll container is in display:none subtree but the + // animating element isn't the subtree, then we need to keep tracking the + // situation until the scroll container gets framed. so in general we make + // this animation be pending (i.e. not ready) if its scroll-timeline is + // inactive, and this also matches the current spec definition. + continue; + } + + // Note: Remove() is legitimately called once per entry during the loop. + mPendingSet.Remove(iter); + } +} + +} // namespace mozilla diff --git a/dom/animation/ScrollTimelineAnimationTracker.h b/dom/animation/ScrollTimelineAnimationTracker.h new file mode 100644 index 0000000000..fec2814cec --- /dev/null +++ b/dom/animation/ScrollTimelineAnimationTracker.h @@ -0,0 +1,58 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_ScrollTimelineAnimationTracker_h +#define mozilla_ScrollTimelineAnimationTracker_h + +#include "mozilla/dom/Animation.h" +#include "nsCycleCollectionParticipant.h" +#include "nsTHashSet.h" + +namespace mozilla { + +namespace dom { +class Document; +} + +/** + * Handle the pending animations which use scroll timeline while playing or + * pausing. + */ +class ScrollTimelineAnimationTracker final { + public: + explicit ScrollTimelineAnimationTracker(dom::Document* aDocument) + : mDocument(aDocument) {} + + NS_INLINE_DECL_CYCLE_COLLECTING_NATIVE_REFCOUNTING( + ScrollTimelineAnimationTracker) + NS_DECL_CYCLE_COLLECTION_NATIVE_CLASS(ScrollTimelineAnimationTracker) + + void AddPending(dom::Animation& aAnimation) { + mPendingSet.Insert(&aAnimation); + } + + void RemovePending(dom::Animation& aAnimation) { + mPendingSet.Remove(&aAnimation); + } + + bool HasPendingAnimations() const { return mPendingSet.Count() > 0; } + + bool IsWaiting(const dom::Animation& aAnimation) const { + return mPendingSet.Contains(const_cast<dom::Animation*>(&aAnimation)); + } + + void TriggerPendingAnimations(); + + private: + ~ScrollTimelineAnimationTracker() = default; + + nsTHashSet<nsRefPtrHashKey<dom::Animation>> mPendingSet; + RefPtr<dom::Document> mDocument; +}; + +} // namespace mozilla + +#endif // mozilla_ScrollTimelineAnimationTracker_h diff --git a/dom/animation/TimingParams.cpp b/dom/animation/TimingParams.cpp new file mode 100644 index 0000000000..7ab78f52f1 --- /dev/null +++ b/dom/animation/TimingParams.cpp @@ -0,0 +1,291 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "mozilla/TimingParams.h" + +#include "mozilla/AnimationUtils.h" +#include "mozilla/dom/AnimatableBinding.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/KeyframeAnimationOptionsBinding.h" +#include "mozilla/dom/KeyframeEffectBinding.h" +#include "mozilla/ServoCSSParser.h" + +namespace mozilla { + +template <class OptionsType> +static const dom::EffectTiming& GetTimingProperties( + const OptionsType& aOptions); + +template <> +/* static */ +const dom::EffectTiming& GetTimingProperties( + const dom::UnrestrictedDoubleOrKeyframeEffectOptions& aOptions) { + MOZ_ASSERT(aOptions.IsKeyframeEffectOptions()); + return aOptions.GetAsKeyframeEffectOptions(); +} + +template <> +/* static */ +const dom::EffectTiming& GetTimingProperties( + const dom::UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions) { + MOZ_ASSERT(aOptions.IsKeyframeAnimationOptions()); + return aOptions.GetAsKeyframeAnimationOptions(); +} + +template <class OptionsType> +/* static */ +TimingParams TimingParams::FromOptionsType(const OptionsType& aOptions, + ErrorResult& aRv) { + TimingParams result; + + if (aOptions.IsUnrestrictedDouble()) { + double durationInMs = aOptions.GetAsUnrestrictedDouble(); + if (durationInMs >= 0) { + result.mDuration.emplace( + StickyTimeDuration::FromMilliseconds(durationInMs)); + } else { + nsPrintfCString error("Duration value %g is less than 0", durationInMs); + aRv.ThrowTypeError(error); + return result; + } + result.Update(); + } else { + const dom::EffectTiming& timing = GetTimingProperties(aOptions); + result = FromEffectTiming(timing, aRv); + } + + return result; +} + +/* static */ +TimingParams TimingParams::FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv) { + return FromOptionsType(aOptions, aRv); +} + +/* static */ +TimingParams TimingParams::FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aRv) { + return FromOptionsType(aOptions, aRv); +} + +/* static */ +TimingParams TimingParams::FromEffectTiming( + const dom::EffectTiming& aEffectTiming, ErrorResult& aRv) { + TimingParams result; + + Maybe<StickyTimeDuration> duration = + TimingParams::ParseDuration(aEffectTiming.mDuration, aRv); + if (aRv.Failed()) { + return result; + } + TimingParams::ValidateIterationStart(aEffectTiming.mIterationStart, aRv); + if (aRv.Failed()) { + return result; + } + TimingParams::ValidateIterations(aEffectTiming.mIterations, aRv); + if (aRv.Failed()) { + return result; + } + Maybe<StyleComputedTimingFunction> easing = + ParseEasing(aEffectTiming.mEasing, aRv); + if (aRv.Failed()) { + return result; + } + + result.mDuration = duration; + result.mDelay = TimeDuration::FromMilliseconds(aEffectTiming.mDelay); + result.mEndDelay = TimeDuration::FromMilliseconds(aEffectTiming.mEndDelay); + result.mIterations = aEffectTiming.mIterations; + result.mIterationStart = aEffectTiming.mIterationStart; + result.mDirection = aEffectTiming.mDirection; + result.mFill = aEffectTiming.mFill; + result.mFunction = std::move(easing); + + result.Update(); + + return result; +} + +/* static */ +TimingParams TimingParams::MergeOptionalEffectTiming( + const TimingParams& aSource, const dom::OptionalEffectTiming& aEffectTiming, + ErrorResult& aRv) { + MOZ_ASSERT(!aRv.Failed(), "Initially return value should be ok"); + + TimingParams result = aSource; + + // Check for errors first + + Maybe<StickyTimeDuration> duration; + if (aEffectTiming.mDuration.WasPassed()) { + duration = + TimingParams::ParseDuration(aEffectTiming.mDuration.Value(), aRv); + if (aRv.Failed()) { + return result; + } + } + + if (aEffectTiming.mIterationStart.WasPassed()) { + TimingParams::ValidateIterationStart(aEffectTiming.mIterationStart.Value(), + aRv); + if (aRv.Failed()) { + return result; + } + } + + if (aEffectTiming.mIterations.WasPassed()) { + TimingParams::ValidateIterations(aEffectTiming.mIterations.Value(), aRv); + if (aRv.Failed()) { + return result; + } + } + + Maybe<StyleComputedTimingFunction> easing; + if (aEffectTiming.mEasing.WasPassed()) { + easing = ParseEasing(aEffectTiming.mEasing.Value(), aRv); + if (aRv.Failed()) { + return result; + } + } + + // Assign values + + if (aEffectTiming.mDuration.WasPassed()) { + result.mDuration = duration; + } + if (aEffectTiming.mDelay.WasPassed()) { + result.mDelay = + TimeDuration::FromMilliseconds(aEffectTiming.mDelay.Value()); + } + if (aEffectTiming.mEndDelay.WasPassed()) { + result.mEndDelay = + TimeDuration::FromMilliseconds(aEffectTiming.mEndDelay.Value()); + } + if (aEffectTiming.mIterations.WasPassed()) { + result.mIterations = aEffectTiming.mIterations.Value(); + } + if (aEffectTiming.mIterationStart.WasPassed()) { + result.mIterationStart = aEffectTiming.mIterationStart.Value(); + } + if (aEffectTiming.mDirection.WasPassed()) { + result.mDirection = aEffectTiming.mDirection.Value(); + } + if (aEffectTiming.mFill.WasPassed()) { + result.mFill = aEffectTiming.mFill.Value(); + } + if (aEffectTiming.mEasing.WasPassed()) { + result.mFunction = easing; + } + + result.Update(); + + return result; +} + +/* static */ +Maybe<StyleComputedTimingFunction> TimingParams::ParseEasing( + const nsACString& aEasing, ErrorResult& aRv) { + auto timingFunction = StyleComputedTimingFunction::LinearKeyword(); + if (!ServoCSSParser::ParseEasing(aEasing, timingFunction)) { + aRv.ThrowTypeError<dom::MSG_INVALID_EASING_ERROR>(aEasing); + return Nothing(); + } + + if (timingFunction.IsLinearKeyword()) { + return Nothing(); + } + + return Some(std::move(timingFunction)); +} + +bool TimingParams::operator==(const TimingParams& aOther) const { + // We don't compare mActiveDuration and mEndTime because they are calculated + // from other timing parameters. + return mDuration == aOther.mDuration && mDelay == aOther.mDelay && + mEndDelay == aOther.mEndDelay && mIterations == aOther.mIterations && + mIterationStart == aOther.mIterationStart && + mDirection == aOther.mDirection && mFill == aOther.mFill && + mFunction == aOther.mFunction; +} + +// FIXME: This is a tentative way to normalize the timing which is defined in +// [web-animations-2] [1]. I borrow this implementation and some concepts for +// the edge cases from Chromium [2] so we can match the behavior with them. The +// implementation here ignores the case of percentage of start delay, end delay, +// and duration because Gecko doesn't support them. We may have to update the +// calculation if the spec issue [3] gets any update. +// +// [1] +// https://drafts.csswg.org/web-animations-2/#time-based-animation-to-a-proportional-animation +// [2] https://chromium-review.googlesource.com/c/chromium/src/+/2992387 +// [3] https://github.com/w3c/csswg-drafts/issues/4862 +TimingParams TimingParams::Normalize( + const TimeDuration& aTimelineDuration) const { + MOZ_ASSERT(aTimelineDuration, + "the timeline duration of scroll-timeline is always non-zero now"); + + TimingParams normalizedTiming(*this); + + // Handle iteration duration value of "auto" first. + // FIXME: Bug 1676794: Gecko doesn't support `animation-duration:auto` and we + // don't support JS-generated scroll animations, so we don't fall into this + // case for now. Need to check this again after we support ScrollTimeline + // interface. + if (!mDuration) { + // If the iteration duration is auto, then: + // Set start delay and end delay to 0, as it is not possible to mix time + // and proportions. + normalizedTiming.mDelay = TimeDuration(); + normalizedTiming.mEndDelay = TimeDuration(); + normalizedTiming.Update(); + return normalizedTiming; + } + + if (mEndTime.IsZero()) { + // mEndTime of zero causes division by zero so we handle it here. + // + // FIXME: The spec doesn't mention this case, so we might have to update + // this based on the spec issue, + // https://github.com/w3c/csswg-drafts/issues/7459. + normalizedTiming.mDelay = TimeDuration(); + normalizedTiming.mEndDelay = TimeDuration(); + normalizedTiming.mDuration = Some(TimeDuration()); + } else if (mEndTime == TimeDuration::Forever()) { + // The iteration count or duration may be infinite; however, start and + // end delays are strictly finite. Thus, in the limit when end time + // approaches infinity: + // start delay / end time = finite / infinite = 0 + // end delay / end time = finite / infinite = 0 + // iteration duration / end time = 1 / iteration count + // This condition can be reached by switching to a scroll timeline on + // an existing infinite duration animation. + // + // FIXME: The spec doesn't mention this case, so we might have to update + // this based on the spec issue, + // https://github.com/w3c/csswg-drafts/issues/7459. + normalizedTiming.mDelay = TimeDuration(); + normalizedTiming.mEndDelay = TimeDuration(); + normalizedTiming.mDuration = + Some(aTimelineDuration.MultDouble(1.0 / mIterations)); + } else { + // Convert to percentages then multiply by the timeline duration. + const double endTimeInSec = mEndTime.ToSeconds(); + normalizedTiming.mDelay = + aTimelineDuration.MultDouble(mDelay.ToSeconds() / endTimeInSec); + normalizedTiming.mEndDelay = + aTimelineDuration.MultDouble(mEndDelay.ToSeconds() / endTimeInSec); + normalizedTiming.mDuration = Some(StickyTimeDuration( + aTimelineDuration.MultDouble(mDuration->ToSeconds() / endTimeInSec))); + } + + normalizedTiming.Update(); + return normalizedTiming; +} + +} // namespace mozilla diff --git a/dom/animation/TimingParams.h b/dom/animation/TimingParams.h new file mode 100644 index 0000000000..4745631006 --- /dev/null +++ b/dom/animation/TimingParams.h @@ -0,0 +1,265 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_TimingParams_h +#define mozilla_TimingParams_h + +#include "X11UndefineNone.h" +#include "nsPrintfCString.h" +#include "nsStringFwd.h" +#include "nsPrintfCString.h" +#include "mozilla/dom/Nullable.h" +#include "mozilla/dom/UnionTypes.h" // For OwningUnrestrictedDoubleOrString +#include "mozilla/Maybe.h" +#include "mozilla/StickyTimeDuration.h" +#include "mozilla/TimeStamp.h" // for TimeDuration +#include "mozilla/ServoStyleConsts.h" + +#include "mozilla/dom/AnimationEffectBinding.h" // for FillMode + // and PlaybackDirection + +namespace mozilla { + +namespace dom { +class UnrestrictedDoubleOrKeyframeEffectOptions; +class UnrestrictedDoubleOrKeyframeAnimationOptions; +} // namespace dom + +struct TimingParams { + TimingParams() = default; + + TimingParams(float aDuration, float aDelay, float aIterationCount, + dom::PlaybackDirection aDirection, dom::FillMode aFillMode) + : mIterations(aIterationCount), mDirection(aDirection), mFill(aFillMode) { + mDuration.emplace(StickyTimeDuration::FromMilliseconds(aDuration)); + mDelay = TimeDuration::FromMilliseconds(aDelay); + Update(); + } + + TimingParams(const TimeDuration& aDuration, const TimeDuration& aDelay, + const TimeDuration& aEndDelay, float aIterations, + float aIterationStart, dom::PlaybackDirection aDirection, + dom::FillMode aFillMode, + const Maybe<StyleComputedTimingFunction>& aFunction) + : mDelay(aDelay), + mEndDelay(aEndDelay), + mIterations(aIterations), + mIterationStart(aIterationStart), + mDirection(aDirection), + mFill(aFillMode), + mFunction(aFunction) { + mDuration.emplace(aDuration); + Update(); + } + + template <class OptionsType> + static TimingParams FromOptionsType(const OptionsType& aOptions, + ErrorResult& aRv); + static TimingParams FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeEffectOptions& aOptions, + ErrorResult& aRv); + static TimingParams FromOptionsUnion( + const dom::UnrestrictedDoubleOrKeyframeAnimationOptions& aOptions, + ErrorResult& aRv); + static TimingParams FromEffectTiming(const dom::EffectTiming& aEffectTiming, + ErrorResult& aRv); + // Returns a copy of |aSource| where each timing property in |aSource| that + // is also specified in |aEffectTiming| is replaced with the value from + // |aEffectTiming|. + // + // If any of the values in |aEffectTiming| are invalid, |aRv.Failed()| will be + // true and an unmodified copy of |aSource| will be returned. + static TimingParams MergeOptionalEffectTiming( + const TimingParams& aSource, + const dom::OptionalEffectTiming& aEffectTiming, ErrorResult& aRv); + + // Range-checks and validates an UnrestrictedDoubleOrString or + // OwningUnrestrictedDoubleOrString object and converts to a + // StickyTimeDuration value or Nothing() if aDuration is "auto". + // Caller must check aRv.Failed(). + template <class DoubleOrString> + static Maybe<StickyTimeDuration> ParseDuration(DoubleOrString& aDuration, + ErrorResult& aRv) { + Maybe<StickyTimeDuration> result; + if (aDuration.IsUnrestrictedDouble()) { + double durationInMs = aDuration.GetAsUnrestrictedDouble(); + if (durationInMs >= 0) { + result.emplace(StickyTimeDuration::FromMilliseconds(durationInMs)); + } else { + nsPrintfCString err("Duration (%g) must be nonnegative", durationInMs); + aRv.ThrowTypeError(err); + } + } else if (!aDuration.GetAsString().EqualsLiteral("auto")) { + aRv.ThrowTypeError<dom::MSG_INVALID_DURATION_ERROR>( + NS_ConvertUTF16toUTF8(aDuration.GetAsString())); + } + return result; + } + + static void ValidateIterationStart(double aIterationStart, ErrorResult& aRv) { + if (aIterationStart < 0) { + nsPrintfCString err("Iteration start (%g) must not be negative", + aIterationStart); + aRv.ThrowTypeError(err); + } + } + + static void ValidateIterations(double aIterations, ErrorResult& aRv) { + if (std::isnan(aIterations)) { + aRv.ThrowTypeError("Iterations must not be NaN"); + return; + } + + if (aIterations < 0) { + nsPrintfCString err("Iterations (%g) must not be negative", aIterations); + aRv.ThrowTypeError(err); + } + } + + static Maybe<StyleComputedTimingFunction> ParseEasing(const nsACString&, + ErrorResult&); + + static StickyTimeDuration CalcActiveDuration( + const Maybe<StickyTimeDuration>& aDuration, double aIterations) { + // If either the iteration duration or iteration count is zero, + // Web Animations says that the active duration is zero. This is to + // ensure that the result is defined when the other argument is Infinity. + static const StickyTimeDuration zeroDuration; + if (!aDuration || aDuration->IsZero() || aIterations == 0.0) { + return zeroDuration; + } + + MOZ_ASSERT(*aDuration >= zeroDuration && aIterations >= 0.0, + "Both animation duration and ieration count should be greater " + "than zero"); + + StickyTimeDuration result = aDuration->MultDouble(aIterations); + if (result < zeroDuration) { + // If the result of multiplying above is less than zero, it's likely an + // overflow happened. we consider it's +Inf here. + return StickyTimeDuration::Forever(); + } + return result; + } + // Return the duration of the active interval calculated by duration and + // iteration count. + StickyTimeDuration ActiveDuration() const { + MOZ_ASSERT(CalcActiveDuration(mDuration, mIterations) == mActiveDuration, + "Cached value of active duration should be up to date"); + return mActiveDuration; + } + + StickyTimeDuration EndTime() const { + MOZ_ASSERT(mEndTime == CalcEndTime(), + "Cached value of end time should be up to date"); + return mEndTime; + } + + StickyTimeDuration CalcBeforeActiveBoundary() const { + static constexpr StickyTimeDuration zeroDuration; + // https://drafts.csswg.org/web-animations-1/#before-active-boundary-time + return std::max(std::min(StickyTimeDuration(mDelay), mEndTime), + zeroDuration); + } + + StickyTimeDuration CalcActiveAfterBoundary() const { + if (mActiveDuration == StickyTimeDuration::Forever()) { + return StickyTimeDuration::Forever(); + } + + static constexpr StickyTimeDuration zeroDuration; + // https://drafts.csswg.org/web-animations-1/#active-after-boundary-time + return std::max( + std::min(StickyTimeDuration(mDelay + mActiveDuration), mEndTime), + zeroDuration); + } + + bool operator==(const TimingParams& aOther) const; + bool operator!=(const TimingParams& aOther) const { + return !(*this == aOther); + } + + void SetDuration(Maybe<StickyTimeDuration>&& aDuration) { + mDuration = std::move(aDuration); + Update(); + } + void SetDuration(const Maybe<StickyTimeDuration>& aDuration) { + mDuration = aDuration; + Update(); + } + const Maybe<StickyTimeDuration>& Duration() const { return mDuration; } + + void SetDelay(const TimeDuration& aDelay) { + mDelay = aDelay; + Update(); + } + const TimeDuration& Delay() const { return mDelay; } + + void SetEndDelay(const TimeDuration& aEndDelay) { + mEndDelay = aEndDelay; + Update(); + } + const TimeDuration& EndDelay() const { return mEndDelay; } + + void SetIterations(double aIterations) { + mIterations = aIterations; + Update(); + } + double Iterations() const { return mIterations; } + + void SetIterationStart(double aIterationStart) { + mIterationStart = aIterationStart; + } + double IterationStart() const { return mIterationStart; } + + void SetDirection(dom::PlaybackDirection aDirection) { + mDirection = aDirection; + } + dom::PlaybackDirection Direction() const { return mDirection; } + + void SetFill(dom::FillMode aFill) { mFill = aFill; } + dom::FillMode Fill() const { return mFill; } + + void SetTimingFunction(Maybe<StyleComputedTimingFunction>&& aFunction) { + mFunction = std::move(aFunction); + } + const Maybe<StyleComputedTimingFunction>& TimingFunction() const { + return mFunction; + } + + // This is called only for progress-based timeline (i.e. non-monotonic + // timeline). That is, |aTimelineDuration| should be resolved already. + TimingParams Normalize(const TimeDuration& aTimelineDuration) const; + + private: + void Update() { + mActiveDuration = CalcActiveDuration(mDuration, mIterations); + mEndTime = CalcEndTime(); + } + + StickyTimeDuration CalcEndTime() const { + if (mActiveDuration == StickyTimeDuration::Forever()) { + return StickyTimeDuration::Forever(); + } + return std::max(mDelay + mActiveDuration + mEndDelay, StickyTimeDuration()); + } + + // mDuration.isNothing() represents the "auto" value + Maybe<StickyTimeDuration> mDuration; + TimeDuration mDelay; // Initializes to zero + TimeDuration mEndDelay; + double mIterations = 1.0; // Can be NaN, negative, +/-Infinity + double mIterationStart = 0.0; + dom::PlaybackDirection mDirection = dom::PlaybackDirection::Normal; + dom::FillMode mFill = dom::FillMode::Auto; + Maybe<StyleComputedTimingFunction> mFunction; + StickyTimeDuration mActiveDuration = StickyTimeDuration(); + StickyTimeDuration mEndTime = StickyTimeDuration(); +}; + +} // namespace mozilla + +#endif // mozilla_TimingParams_h diff --git a/dom/animation/ViewTimeline.cpp b/dom/animation/ViewTimeline.cpp new file mode 100644 index 0000000000..5727c5f08e --- /dev/null +++ b/dom/animation/ViewTimeline.cpp @@ -0,0 +1,166 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "ViewTimeline.h" + +#include "mozilla/dom/Animation.h" +#include "mozilla/dom/ElementInlines.h" +#include "nsIScrollableFrame.h" +#include "nsLayoutUtils.h" + +namespace mozilla::dom { + +NS_IMPL_CYCLE_COLLECTION_INHERITED(ViewTimeline, ScrollTimeline, mSubject) +NS_IMPL_ISUPPORTS_CYCLE_COLLECTION_INHERITED_0(ViewTimeline, ScrollTimeline) + +/* static */ +already_AddRefed<ViewTimeline> ViewTimeline::MakeNamed( + Document* aDocument, Element* aSubject, PseudoStyleType aPseudoType, + const StyleViewTimeline& aStyleTimeline) { + MOZ_ASSERT(NS_IsMainThread()); + + // 1. Lookup scroller. We have to find the nearest scroller from |aSubject| + // and |aPseudoType|. + auto [element, pseudo] = FindNearestScroller(aSubject, aPseudoType); + auto scroller = Scroller::Nearest(const_cast<Element*>(element), pseudo); + + // 2. Create timeline. + return MakeAndAddRef<ViewTimeline>(aDocument, scroller, + aStyleTimeline.GetAxis(), aSubject, + aPseudoType, aStyleTimeline.GetInset()); +} + +/* static */ +already_AddRefed<ViewTimeline> ViewTimeline::MakeAnonymous( + Document* aDocument, const NonOwningAnimationTarget& aTarget, + StyleScrollAxis aAxis, const StyleViewTimelineInset& aInset) { + // view() finds the nearest scroll container from the animation target. + auto [element, pseudo] = + FindNearestScroller(aTarget.mElement, aTarget.mPseudoType); + Scroller scroller = Scroller::Nearest(const_cast<Element*>(element), pseudo); + return MakeAndAddRef<ViewTimeline>(aDocument, scroller, aAxis, + aTarget.mElement, aTarget.mPseudoType, + aInset); +} + +void ViewTimeline::ReplacePropertiesWith(Element* aSubjectElement, + PseudoStyleType aPseudoType, + const StyleViewTimeline& aNew) { + mSubject = aSubjectElement; + mSubjectPseudoType = aPseudoType; + mAxis = aNew.GetAxis(); + // FIXME: Bug 1817073. We assume it is a non-animatable value for now. + mInset = aNew.GetInset(); + + for (auto* anim = mAnimationOrder.getFirst(); anim; + anim = static_cast<LinkedListElement<Animation>*>(anim)->getNext()) { + MOZ_ASSERT(anim->GetTimeline() == this); + // Set this so we just PostUpdate() for this animation. + anim->SetTimeline(this); + } +} + +Maybe<ScrollTimeline::ScrollOffsets> ViewTimeline::ComputeOffsets( + const nsIScrollableFrame* aScrollFrame, + layers::ScrollDirection aOrientation) const { + MOZ_ASSERT(mSubject); + MOZ_ASSERT(aScrollFrame); + + const Element* subjectElement = + AnimationUtils::GetElementForRestyle(mSubject, mSubjectPseudoType); + const nsIFrame* subject = subjectElement->GetPrimaryFrame(); + if (!subject) { + // No principal box of the subject, so we cannot compute the offset. This + // may happen when we clear all animation collections during unbinding from + // the tree. + return Nothing(); + } + + // In order to get the distance between the subject and the scrollport + // properly, we use the position based on the domain of the scrolled frame, + // instead of the scrollable frame. + const nsIFrame* scrolledFrame = aScrollFrame->GetScrolledFrame(); + MOZ_ASSERT(scrolledFrame); + const nsRect subjectRect(subject->GetOffsetTo(scrolledFrame), + subject->GetSize()); + + // Use scrollport size (i.e. padding box size - scrollbar size), which is used + // for calculating the view progress visibility range. + // https://drafts.csswg.org/scroll-animations/#view-progress-visibility-range + const nsRect scrollPort = aScrollFrame->GetScrollPortRect(); + + // Adjuct the positions and sizes based on the physical axis. + nscoord subjectPosition = subjectRect.y; + nscoord subjectSize = subjectRect.height; + nscoord scrollPortSize = scrollPort.height; + if (aOrientation == layers::ScrollDirection::eHorizontal) { + // |subjectPosition| should be the position of the start border edge of the + // subject, so for R-L case, we have to use XMost() as the start border + // edge of the subject, and compute its position by using the x-most side of + // the scrolled frame as the origin on the horizontal axis. + subjectPosition = scrolledFrame->GetWritingMode().IsPhysicalRTL() + ? scrolledFrame->GetSize().width - subjectRect.XMost() + : subjectRect.x; + subjectSize = subjectRect.width; + scrollPortSize = scrollPort.width; + } + + // |sideInsets.mEnd| is used to adjust the start offset, and + // |sideInsets.mStart| is used to adjust the end offset. This is because + // |sideInsets.mStart| refers to logical start side [1] of the source box + // (i.e. the box of the scrollport), where as |startOffset| refers to the + // start of the timeline, and similarly for end side/offset. [1] + // https://drafts.csswg.org/css-writing-modes-4/#css-start + const auto sideInsets = ComputeInsets(aScrollFrame, aOrientation); + + // Basically, we are computing the "cover" timeline range name, which + // represents the full range of the view progress timeline. + // https://drafts.csswg.org/scroll-animations-1/#valdef-animation-timeline-range-cover + + // Note: `subjectPosition - scrollPortSize` means the distance between the + // start border edge of the subject and the end edge of the scrollport. + nscoord startOffset = subjectPosition - scrollPortSize + sideInsets.mEnd; + // Note: `subjectPosition + subjectSize` means the position of the end border + // edge of the subject. When it touches the start edge of the scrollport, it + // is 100%. + nscoord endOffset = subjectPosition + subjectSize - sideInsets.mStart; + return Some(ScrollOffsets{startOffset, endOffset}); +} + +ScrollTimeline::ScrollOffsets ViewTimeline::ComputeInsets( + const nsIScrollableFrame* aScrollFrame, + layers::ScrollDirection aOrientation) const { + // If view-timeline-inset is auto, it indicates to use the value of + // scroll-padding. We use logical dimension to map that start/end offset to + // the corresponding scroll-padding-{inline|block}-{start|end} values. + const WritingMode wm = aScrollFrame->GetScrolledFrame()->GetWritingMode(); + const auto& scrollPadding = + LogicalMargin(wm, aScrollFrame->GetScrollPadding()); + const bool isBlockAxis = + mAxis == StyleScrollAxis::Block || + (mAxis == StyleScrollAxis::Horizontal && wm.IsVertical()) || + (mAxis == StyleScrollAxis::Vertical && !wm.IsVertical()); + + // The percentages of view-timelne-inset is relative to the corresponding + // dimension of the relevant scrollport. + // https://drafts.csswg.org/scroll-animations-1/#view-timeline-inset + const nsRect scrollPort = aScrollFrame->GetScrollPortRect(); + const nscoord percentageBasis = + aOrientation == layers::ScrollDirection::eHorizontal ? scrollPort.width + : scrollPort.height; + + nscoord startInset = + mInset.start.IsAuto() + ? (isBlockAxis ? scrollPadding.BStart(wm) : scrollPadding.IStart(wm)) + : mInset.start.AsLengthPercentage().Resolve(percentageBasis); + nscoord endInset = + mInset.end.IsAuto() + ? (isBlockAxis ? scrollPadding.BEnd(wm) : scrollPadding.IEnd(wm)) + : mInset.end.AsLengthPercentage().Resolve(percentageBasis); + return {startInset, endInset}; +} + +} // namespace mozilla::dom diff --git a/dom/animation/ViewTimeline.h b/dom/animation/ViewTimeline.h new file mode 100644 index 0000000000..a5ede6345d --- /dev/null +++ b/dom/animation/ViewTimeline.h @@ -0,0 +1,86 @@ +/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_ViewTimeline_h +#define mozilla_dom_ViewTimeline_h + +#include "mozilla/dom/ScrollTimeline.h" + +namespace mozilla::dom { + +/* + * A view progress timeline is a segment of a scroll progress timeline that are + * scoped to the scroll positions in which any part of the associated element’s + * principal box intersects its nearest ancestor scrollport. So ViewTimeline + * is a special case of ScrollTimeline. + */ +class ViewTimeline final : public ScrollTimeline { + template <typename T, typename... Args> + friend already_AddRefed<T> mozilla::MakeAndAddRef(Args&&... aArgs); + + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(ViewTimeline, ScrollTimeline) + + ViewTimeline() = delete; + + // Note: |aSubject| is used as the subject which specifies view-timeline-name + // property, and we use this subject to look up its nearest scroll container. + static already_AddRefed<ViewTimeline> MakeNamed( + Document* aDocument, Element* aSubject, PseudoStyleType aPseudoType, + const StyleViewTimeline& aStyleTimeline); + + static already_AddRefed<ViewTimeline> MakeAnonymous( + Document* aDocument, const NonOwningAnimationTarget& aTarget, + StyleScrollAxis aAxis, const StyleViewTimelineInset& aInset); + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override { + return nullptr; + } + + bool IsViewTimeline() const override { return true; } + + void ReplacePropertiesWith(Element* aSubjectElement, + PseudoStyleType aPseudoType, + const StyleViewTimeline& aNew); + + private: + ~ViewTimeline() = default; + ViewTimeline(Document* aDocument, const Scroller& aScroller, + StyleScrollAxis aAxis, Element* aSubject, + PseudoStyleType aSubjectPseudoType, + const StyleViewTimelineInset& aInset) + : ScrollTimeline(aDocument, aScroller, aAxis), + mSubject(aSubject), + mSubjectPseudoType(aSubjectPseudoType), + mInset(aInset) {} + + Maybe<ScrollOffsets> ComputeOffsets( + const nsIScrollableFrame* aScrollFrame, + layers::ScrollDirection aOrientation) const override; + + ScrollOffsets ComputeInsets(const nsIScrollableFrame* aScrollFrame, + layers::ScrollDirection aOrientation) const; + + // The subject element. + // 1. For view(), the subject element is the animation target. + // 2. For view-timeline property, the subject element is the element who + // defines this property. + RefPtr<Element> mSubject; + PseudoStyleType mSubjectPseudoType; + + // FIXME: Bug 1817073. view-timeline-inset is an animatable property. However, + // the inset from view() is not animatable, so for named view timeline, this + // value depends on the animation style. Therefore, we have to check its style + // value when using it. For now, in order to simplify the implementation, we + // make |mInset| be fixed. + StyleViewTimelineInset mInset; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_ViewTimeline_h diff --git a/dom/animation/moz.build b/dom/animation/moz.build new file mode 100644 index 0000000000..94f0f8625f --- /dev/null +++ b/dom/animation/moz.build @@ -0,0 +1,79 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Core", "DOM: Animation") + +MOCHITEST_MANIFESTS += ["test/mochitest.ini"] +MOCHITEST_CHROME_MANIFESTS += ["test/chrome.ini"] + +EXPORTS.mozilla.dom += [ + "Animation.h", + "AnimationEffect.h", + "AnimationTimeline.h", + "CSSAnimation.h", + "CSSPseudoElement.h", + "CSSTransition.h", + "DocumentTimeline.h", + "KeyframeEffect.h", + "ScrollTimeline.h", + "ViewTimeline.h", +] + +EXPORTS.mozilla += [ + "AnimationComparator.h", + "AnimationEventDispatcher.h", + "AnimationPerformanceWarning.h", + "AnimationPropertySegment.h", + "AnimationTarget.h", + "AnimationUtils.h", + "ComputedTiming.h", + "EffectCompositor.h", + "EffectSet.h", + "ElementAnimationData.h", + "Keyframe.h", + "KeyframeEffectParams.h", + "KeyframeUtils.h", + "PendingAnimationTracker.h", + "PostRestyleMode.h", + "PseudoElementHashEntry.h", + "ScrollTimelineAnimationTracker.h", + "TimingParams.h", +] + +UNIFIED_SOURCES += [ + "Animation.cpp", + "AnimationEffect.cpp", + "AnimationEventDispatcher.cpp", + "AnimationPerformanceWarning.cpp", + "AnimationTimeline.cpp", + "AnimationUtils.cpp", + "CSSAnimation.cpp", + "CSSPseudoElement.cpp", + "CSSTransition.cpp", + "DocumentTimeline.cpp", + "EffectCompositor.cpp", + "EffectSet.cpp", + "ElementAnimationData.cpp", + "KeyframeEffect.cpp", + "KeyframeUtils.cpp", + "PendingAnimationTracker.cpp", + "ScrollTimeline.cpp", + "ScrollTimelineAnimationTracker.cpp", + "TimingParams.cpp", + "ViewTimeline.cpp", +] + +LOCAL_INCLUDES += [ + "/dom/base", + "/layout/base", + "/layout/painting", + "/layout/style", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +FINAL_LIBRARY = "xul" diff --git a/dom/animation/test/chrome.ini b/dom/animation/test/chrome.ini new file mode 100644 index 0000000000..4001899a50 --- /dev/null +++ b/dom/animation/test/chrome.ini @@ -0,0 +1,29 @@ +[DEFAULT] +prefs = + dom.animations-api.autoremove.enabled=true + dom.animations-api.compositing.enabled=true + dom.animations.mainthread-synchronization-with-geometric-animations=true + gfx.omta.background-color=true + layout.css.individual-transform.enabled=true + layout.css.motion-path.enabled=true +support-files = + testcommon.js + ../../imptests/testharness.js + ../../imptests/testharnessreport.js + !/dom/animation/test/chrome/file_animate_xrays.html + +[chrome/test_animate_xrays.html] +# file_animate_xrays.html needs to go in mochitest.ini since it is served +# over HTTP +[chrome/test_keyframe_effect_xrays.html] +[chrome/test_animation_observers_async.html] +[chrome/test_animation_observers_sync.html] +[chrome/test_animation_performance_warning.html] +[chrome/test_animation_properties.html] +[chrome/test_animation_properties_display.html] +[chrome/test_cssanimation_missing_keyframes.html] +[chrome/test_generated_content_getAnimations.html] +[chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html] +[chrome/test_running_on_compositor.html] +[chrome/test_simulate_compute_values_failure.html] +skip-if = !debug diff --git a/dom/animation/test/chrome/file_animate_xrays.html b/dom/animation/test/chrome/file_animate_xrays.html new file mode 100644 index 0000000000..2fa15b1764 --- /dev/null +++ b/dom/animation/test/chrome/file_animate_xrays.html @@ -0,0 +1,18 @@ +<!doctype html> +<html> +<head> +<meta charset=utf-8> +<script> +Element.prototype.animate = function() { + throw 'Called animate() as defined in content document'; +} +for (let name of ["KeyframeEffect", "Animation"]) { + this[name] = function() { + throw `Called overridden ${name} constructor`; + }; +} +</script> +<body> +<div id="target"></div> +</body> +</html> diff --git a/dom/animation/test/chrome/test_animate_xrays.html b/dom/animation/test/chrome/test_animate_xrays.html new file mode 100644 index 0000000000..64df6db720 --- /dev/null +++ b/dom/animation/test/chrome/test_animate_xrays.html @@ -0,0 +1,40 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1414674" + target="_blank">Mozilla Bug 1414674</a> +<div id="log"></div> +<iframe id="iframe" + src="http://example.org/tests/dom/animation/test/chrome/file_animate_xrays.html"></iframe> +<script> +'use strict'; + +var win = document.getElementById('iframe').contentWindow; + +async_test(function(t) { + window.addEventListener('load', t.step_func(function() { + var target = win.document.getElementById('target'); + var anim = target.animate({opacity: [ 1, 0 ]}, 100 * MS_PER_SEC); + // The frames object should be accessible via x-ray. + var frames = anim.effect.getKeyframes(); + assert_equals(frames.length, 2, + "frames for Element.animate should be non-zero"); + assert_equals(frames[0].opacity, "1", + "first frame opacity for Element.animate should be specified value"); + assert_equals(frames[0].computedOffset, 0, + "first frame offset for Element.animate should be 0"); + assert_equals(frames[1].opacity, "0", + "last frame opacity for Element.animate should be specified value"); + assert_equals(frames[1].computedOffset, 1, + "last frame offset for Element.animate should be 1"); + t.done(); + })); +}, 'Calling animate() across x-rays'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_animation_observers_async.html b/dom/animation/test/chrome/test_animation_observers_async.html new file mode 100644 index 0000000000..7505baca53 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_observers_async.html @@ -0,0 +1,665 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title> +Test chrome-only MutationObserver animation notifications (async tests) +</title> +<!-- + + This file contains tests for animation mutation observers that require + some asynchronous steps (e.g. waiting for animation events). + + Where possible, however, we prefer to write synchronous tests since they are + less to timeout when run on automation. These synchronous tests are located + in test_animation_observers_sync.html. + +--> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<div id="log"></div> +<style> +@keyframes anim { + to { transform: translate(100px); } +} +@keyframes anotherAnim { + to { transform: translate(0px); } +} +#target { + width: 100px; + height: 100px; + background-color: yellow; + line-height: 16px; +} +</style> +<div id=container><div id=target></div></div> +<script> +var div = document.getElementById("target"); +var gRecords = []; +var gObserver = new MutationObserver(newRecords => { + gRecords.push(...newRecords); +}); + +function setupAsynchronousObserver(t, options) { + + gRecords = []; + t.add_cleanup(() => { + gObserver.disconnect(); + }); + gObserver.observe(options.subtree ? div.parentNode : div, + { animations: true, subtree: options.subtree }); +} + +// Adds an event listener and returns a Promise that is resolved when the +// event listener is called. +function await_event(aElement, aEventName) { + return new Promise(aResolve => { + function listener(aEvent) { + aElement.removeEventListener(aEventName, listener); + aResolve(); + } + aElement.addEventListener(aEventName, listener); + }); +} + +function assert_record_list(actual, expected, desc, index, listName) { + assert_equals(actual.length, expected.length, + `${desc} - record[${index}].${listName} length`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + assert_not_equals(actual.indexOf(expected[i]), -1, + `${desc} - record[${index}].${listName} contains expected Animation`); + } +} + +function assert_records(expected, desc) { + var records = gRecords; + gRecords = []; + assert_equals(records.length, expected.length, `${desc} - number of records`); + if (records.length != expected.length) { + return; + } + for (var i = 0; i < records.length; i++) { + assert_record_list(records[i].addedAnimations, expected[i].added, desc, i, "addedAnimations"); + assert_record_list(records[i].changedAnimations, expected[i].changed, desc, i, "changedAnimations"); + assert_record_list(records[i].removedAnimations, expected[i].removed, desc, i, "removedAnimations"); + } +} + +function assert_records_any_order(expected, desc) { + // Generate a unique label for each Animation object. + let animation_labels = new Map(); + let animation_counter = 0; + for (let record of gRecords) { + for (let a of [...record.addedAnimations, ...record.changedAnimations, ...record.removedAnimations]) { + if (!animation_labels.has(a)) { + animation_labels.set(a, ++animation_counter); + } + } + } + for (let record of expected) { + for (let a of [...record.added, ...record.changed, ...record.removed]) { + if (!animation_labels.has(a)) { + animation_labels.set(a, ++animation_counter); + } + } + } + + function record_label(record) { + // Generate a label of the form: + // + // <added-animations>:<changed-animations>:<removed-animations> + let added = record.addedAnimations || record.added; + let changed = record.changedAnimations || record.changed; + let removed = record.removedAnimations || record.removed; + return [added .map(a => animation_labels.get(a)).sort().join(), + changed.map(a => animation_labels.get(a)).sort().join(), + removed.map(a => animation_labels.get(a)).sort().join()] + .join(":"); + } + + // Sort records by their label. + gRecords.sort((a, b) => record_label(a) < record_label(b)); + expected.sort((a, b) => record_label(a) < record_label(b)); + + // Assert the sorted record lists are equal. + assert_records(expected, desc); +} + +// -- Tests ------------------------------------------------------------------ + +// We run all tests first targeting the div and observing the div, then again +// targeting the div and observing its parent while using the subtree:true +// MutationObserver option. + +function runTest() { + [ + { observe: div, target: div, subtree: false }, + { observe: div.parentNode, target: div, subtree: true }, + ].forEach(aOptions => { + + var e = aOptions.target; + + promise_test(t => { + setupAsynchronousObserver(t, aOptions); + // Clear all styles once test finished since we re-use the same element + // in all test cases. + t.add_cleanup(() => { + e.style = ""; + flushComputedStyle(e); + }); + + // Start a transition. + e.style = "transition: background-color 100s; background-color: lime;"; + + // Register for the end of the transition. + var transitionEnd = await_event(e, "transitionend"); + + // The transition should cause the creation of a single Animation. + var animations = e.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + return waitForFrame().then(() => { + assert_records([{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Advance until near the end of the transition, then wait for it to + // finish. + animations[0].currentTime = 99900; + }).then(() => { + return transitionEnd; + }).then(() => { + // After the transition has finished, the Animation should disappear. + assert_equals(e.getAnimations().length, 0, + "getAnimations().length after transition end"); + + // Wait for the change MutationRecord for seeking the Animation to be + // delivered, followed by the the removal MutationRecord. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after transition end"); + }); + }, `single_transition ${aOptions.subtree ? ': subtree' : ''}`); + + // Test that starting a single animation that completes normally + // dispatches an added notification and then a removed notification. + promise_test(t => { + setupAsynchronousObserver(t, aOptions); + t.add_cleanup(() => { + e.style = ""; + flushComputedStyle(e); + }); + + // Start an animation. + e.style = "animation: anim 100s;"; + + // Register for the end of the animation. + var animationEnd = await_event(e, "animationend"); + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + return waitForFrame().then(() => { + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance until near the end of the animation, then wait for it to finish. + animations[0].currentTime = 99900; + return animationEnd; + }).then(() => { + // After the animation has finished, the Animation should disappear. + assert_equals(e.getAnimations().length, 0, + "getAnimations().length after animation end"); + + // Wait for the change MutationRecord from seeking the Animation to + // be delivered, followed by a further MutationRecord for the Animation + // removal. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + }); + }, `single_animation ${aOptions.subtree ? ': subtree' : ''}`); + + // Test that starting a single animation that is cancelled by updating + // the animation-fill-mode property dispatches an added notification and + // then a removed notification. + promise_test(t => { + setupAsynchronousObserver(t, aOptions); + t.add_cleanup(() => { + e.style = ""; + flushComputedStyle(e); + }); + + // Start a short, filled animation. + e.style = "animation: anim 100s forwards;"; + + // Register for the end of the animation. + var animationEnd = await_event(e, "animationend"); + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + return waitForFrame().then(() => { + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance until near the end of the animation, then wait for it to finish. + animations[0].currentTime = 99900; + return animationEnd; + }).then(() => { + // The only MutationRecord at this point should be the change from + // seeking the Animation. + assert_records([{ added: [], changed: animations, removed: [] }], + "records after animation starts filling"); + + // Cancel the animation by setting animation-fill-mode. + e.style.animationFillMode = "none"; + // Explicitly flush style to make sure the above style change happens. + // Normally we don't need explicit style flush if there is a waitForFrame() + // call but in this particular case we are in the middle of animation events' + // callback handling and requestAnimationFrame handling so that we have no + // chance to process styling even after the requestAnimationFrame handling. + flushComputedStyle(e); + + // Wait for the single MutationRecord for the Animation removal to + // be delivered. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + }, `single_animation_cancelled_fill ${aOptions.subtree ? ': subtree' : ''}`); + + // Test that calling finish() on a paused (but otherwise finished) animation + // dispatches a changed notification. + promise_test(t => { + setupAsynchronousObserver(t, aOptions); + t.add_cleanup(() => { + e.style = ""; + flushComputedStyle(e); + }); + + // Start a long animation + e.style = "animation: anim 100s forwards"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + return waitForFrame().then(() => { + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is playing. + return animations[0].ready; + }).then(() => { + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + + // Wait for the pause to complete. + return animations[0].ready; + }).then(() => { + assert_true( + !animations[0].pending && animations[0].playState === "paused", + "playState after finishing and pausing"); + + // We should have two MutationRecords for the Animation changes: + // one for the finish, one for the pause. + assert_records([{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after finish() and pause()"); + + // Call finish() again. + animations[0].finish(); + assert_equals(animations[0].playState, "finished", + "playState after finishing from paused state"); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. Even though the currentTime does not change, the + // playState will change. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: animations, removed: [] }], + "records after finish() and pause()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + }, `finish_from_pause ${aOptions.subtree ? ': subtree' : ''}`); + + // Test that calling play() on a paused Animation dispatches a changed + // notification. + promise_test(t => { + setupAsynchronousObserver(t, aOptions); + t.add_cleanup(() => { + e.style = ""; + flushComputedStyle(e); + }); + + // Start a long, paused animation + e.style = "animation: anim 100s paused"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + return waitForFrame().then(() => { + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Wait until the animation is ready + return animations[0].ready; + }).then(() => { + // Play + animations[0].play(); + + // Wait for the single MutationRecord for the Animation change to + // be delivered. + return animations[0].ready; + }).then(() => { + assert_records([{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Redundant play + animations[0].play(); + + // Wait to ensure no change is dispatched + return waitForFrame(); + }).then(() => { + assert_records([], "records after redundant play()"); + + // Cancel the animation. + e.style = ""; + + // Wait for the single removal notification. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + }, `play ${aOptions.subtree ? ': subtree' : ''}`); + + // Test that a non-cancelling change to an animation followed immediately by a + // cancelling change will only send an animation removal notification. + promise_test(t => { + setupAsynchronousObserver(t, aOptions); + t.add_cleanup(() => { + e.style = ""; + flushComputedStyle(e); + }); + + // Start a long animation. + e.style = "animation: anim 100s;"; + + // The animation should cause the creation of a single Animation. + var animations = e.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + // Wait for the single MutationRecord for the Animation addition to + // be delivered. + return waitForFrame().then(() => {; + assert_records([{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Update the animation's delay such that it is still running. + e.style.animationDelay = "-1s"; + + // Then cancel the animation by updating its duration. + e.style.animationDuration = "0.5s"; + + // We should get a single removal notification. + return waitForFrame(); + }).then(() => { + assert_records([{ added: [], changed: [], removed: animations }], + "records after animation end"); + }); + }, `coalesce_change_cancel ${aOptions.subtree ? ': subtree' : ''}`); + + }); +} + +promise_test(t => { + setupAsynchronousObserver(t, { observe: div, subtree: true }); + t.add_cleanup(() => { + div.style = ""; + flushComputedStyle(div); + }); + + // Add style for pseudo elements + var extraStyle = document.createElement('style'); + document.head.appendChild(extraStyle); + var sheet = extraStyle.sheet; + var rules = { ".before::before": "animation: anim 100s; content: '';", + ".after::after" : "animation: anim 100s, anim 100s; " + + "content: '';"}; + for (var selector in rules) { + sheet.insertRule(selector + '{' + rules[selector] + '}', + sheet.cssRules.length); + } + + // Create a tree with two children: + // + // div + // (::before) + // (::after) + // / \ + // childA childB(::before) + var childA = document.createElement("div"); + var childB = document.createElement("div"); + + div.appendChild(childA); + div.appendChild(childB); + + // Start an animation on each (using order: childB, div, childA) + // + // We include multiple animations on some nodes so that we can test batching + // works as expected later in this test. + childB.style = "animation: anim 100s"; + div.style = "animation: anim 100s, anim 100s, anim 100s"; + childA.style = "animation: anim 100s, anim 100s"; + + // Start animations targeting to pseudo element of div and childB. + childB.classList.add("before"); + div.classList.add("after"); + div.classList.add("before"); + + // Check all animations we have in this document + var docAnims = document.getAnimations(); + assert_equals(docAnims.length, 10, "total animations"); + + var divAnimations = div.getAnimations(); + var childAAnimations = childA.getAnimations(); + var childBAnimations = childB.getAnimations(); + + var divBeforeAnimations = + docAnims.filter(x => (x.effect.target == div && + x.effect.pseudoElement == "::before")); + var divAfterAnimations = + docAnims.filter(x => (x.effect.target == div && + x.effect.pseudoElement == "::after")); + var childBPseudoAnimations = + docAnims.filter(x => (x.effect.target == childB && + x.effect.pseudoElement == "::before")); + + var seekRecords; + // The order in which we get the corresponding records is currently + // based on the order we visit these nodes when updating styles. + // + // That is because we don't do any document-level batching of animation + // mutation records when we flush styles. We may introduce that in the + // future but for now all we are interested in testing here is that the + // right records are generated, but we allow them to occur in any order. + return waitForFrame().then(() => { + assert_records_any_order( + [{ added: divAfterAnimations, changed: [], removed: [] }, + { added: childAAnimations, changed: [], removed: [] }, + { added: childBAnimations, changed: [], removed: [] }, + { added: childBPseudoAnimations, changed: [], removed: [] }, + { added: divAnimations, changed: [], removed: [] }, + { added: divBeforeAnimations, changed: [], removed: [] }], + "records after simultaneous animation start"); + + // The one case where we *do* currently perform document-level (or actually + // timeline-level) batching is when animations are updated from a refresh + // driver tick. In particular, this means that when animations finish + // naturally the removed records should be dispatched according to the + // position of the elements in the tree. + + // First, flatten the set of animations. we put the animations targeting to + // pseudo elements last. (Actually, we don't care the order in the list.) + var animations = [ ...divAnimations, + ...childAAnimations, + ...childBAnimations, + ...divBeforeAnimations, + ...divAfterAnimations, + ...childBPseudoAnimations ]; + + // Fast-forward to *just* before the end of the animation. + animations.forEach(animation => animation.currentTime = 99999); + + // Prepare the set of expected change MutationRecords, one for each + // animation that was seeked. + seekRecords = animations.map( + p => ({ added: [], changed: [p], removed: [] }) + ); + + return await_event(div, "animationend"); + }).then(() => { + // After the changed notifications, which will be dispatched in the order that + // the animations were seeked, we should get removal MutationRecords in order + // (div, div::before, div::after), childA, (childB, childB::before). + // Note: The animations targeting to the pseudo element are appended after + // the animations of its parent element. + divAnimations = [ ...divAnimations, + ...divBeforeAnimations, + ...divAfterAnimations ]; + childBAnimations = [ ...childBAnimations, ...childBPseudoAnimations ]; + assert_records(seekRecords.concat( + { added: [], changed: [], removed: divAnimations }, + { added: [], changed: [], removed: childAAnimations }, + { added: [], changed: [], removed: childBAnimations }), + "records after finishing"); + + // Clean up + div.classList.remove("before"); + div.classList.remove("after"); + div.style = ""; + childA.remove(); + childB.remove(); + extraStyle.remove(); + }); +}, "tree_ordering: subtree"); + +// Test that animations removed by auto-removal trigger an event +promise_test(async t => { + setupAsynchronousObserver(t, { observe: div, subtree: false }); + + // Start two animations such that one will be auto-removed + const animA = div.animate( + { opacity: 1 }, + { duration: 100 * MS_PER_SEC, fill: 'forwards' } + ); + const animB = div.animate( + { opacity: 1 }, + { duration: 100 * MS_PER_SEC, fill: 'forwards' } + ); + + // Wait for the MutationRecords corresponding to each addition. + await waitForNextFrame(); + + assert_records( + [ + { added: [animA], changed: [], removed: [] }, + { added: [animB], changed: [], removed: [] }, + ], + 'records after animation start' + ); + + // Finish the animations -- this should cause animA to be replaced, and + // automatically removed. + animA.finish(); + animB.finish(); + + // Wait for the MutationRecords corresponding to the timing changes and the + // subsequent removal to be delivered. + await waitForNextFrame(); + + assert_records( + [ + { added: [], changed: [animA], removed: [] }, + { added: [], changed: [animB], removed: [] }, + { added: [], changed: [], removed: [animA] }, + ], + 'records after finishing' + ); + + // Restore animA. + animA.persist(); + + // Wait for the MutationRecord corresponding to the re-addition of animA. + await waitForNextFrame(); + + assert_records( + [{ added: [animA], changed: [], removed: [] }], + 'records after persisting' + ); + + // Tidy up + animA.cancel(); + animB.cancel(); + + await waitForNextFrame(); + + assert_records( + [ + { added: [], changed: [], removed: [animA] }, + { added: [], changed: [], removed: [animB] }, + ], + 'records after tidying up end' + ); +}, 'Animations automatically removed are reported'); + +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.autoremove.enabled", true], + ["dom.animations-api.implicit-keyframes.enabled", true], + ], + }, + function() { + runTest(); + done(); + } +); +</script> diff --git a/dom/animation/test/chrome/test_animation_observers_sync.html b/dom/animation/test/chrome/test_animation_observers_sync.html new file mode 100644 index 0000000000..7a890fd2f4 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_observers_sync.html @@ -0,0 +1,1587 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title> +Test chrome-only MutationObserver animation notifications (sync tests) +</title> +<!-- + + This file contains synchronous tests for animation mutation observers. + + In general we prefer to write synchronous tests since they are less likely to + timeout when run on automation. Tests that require asynchronous steps (e.g. + waiting on events) should be added to test_animations_observers_async.html + instead. + +--> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<div id="log"></div> +<style> +@keyframes anim { + to { transform: translate(100px); } +} +@keyframes anotherAnim { + to { transform: translate(0px); } +} +</style> +<script> + +/** + * Return a new MutationObserver which observing |target| element + * with { animations: true, subtree: |subtree| } option. + * + * NOTE: This observer should be used only with takeRecords(). If any of + * MutationRecords are observed in the callback of the MutationObserver, + * it will raise an assertion. + */ +function setupSynchronousObserver(t, target, subtree) { + var observer = new MutationObserver(records => { + assert_unreached("Any MutationRecords should not be observed in this " + + "callback"); + }); + t.add_cleanup(() => { + observer.disconnect(); + }); + observer.observe(target, { animations: true, subtree }); + return observer; +} + +function assert_record_list(actual, expected, desc, index, listName) { + assert_equals(actual.length, expected.length, + `${desc} - record[${index}].${listName} length`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + assert_not_equals(actual.indexOf(expected[i]), -1, + `${desc} - record[${index}].${listName} contains expected Animation`); + } +} + +function assert_equals_records(actual, expected, desc) { + assert_equals(actual.length, expected.length, `${desc} - number of records`); + if (actual.length != expected.length) { + return; + } + for (var i = 0; i < actual.length; i++) { + assert_record_list(actual[i].addedAnimations, + expected[i].added, desc, i, "addedAnimations"); + assert_record_list(actual[i].changedAnimations, + expected[i].changed, desc, i, "changedAnimations"); + assert_record_list(actual[i].removedAnimations, + expected[i].removed, desc, i, "removedAnimations"); + } +} + +function runTest() { + [ { subtree: false }, + { subtree: true } + ].forEach(aOptions => { + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.getComputedTiming().duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ + duration: anim.effect.getComputedTiming().duration * 3 + }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.updateTiming({ duration: 'auto' }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.updateTiming({ duration: 'auto' }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value \"auto\""); + }, "change_duration_and_currenttime"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ endDelay: 10 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after endDelay is changed"); + + anim.effect.updateTiming({ endDelay: 10 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = 109 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after currentTime during endDelay"); + + anim.effect.updateTiming({ endDelay: -110 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning negative value"); + }, "change_enddelay_and_currenttime"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + endDelay: -100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after animation is added"); + }, "zero_end_time"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ iterations: 2 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after iterations is changed"); + + anim.effect.updateTiming({ iterations: 2 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.updateTiming({ iterations: 0 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ iterations: Infinity }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + }, "change_iterations"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ delay: 100 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after delay is changed"); + + anim.effect.updateTiming({ delay: 100 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.effect.updateTiming({ delay: -100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ delay: 0 }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + }, "change_delay"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + easing: "steps(2, start)" }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ easing: "steps(2, end)" }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after easing is changed"); + + anim.effect.updateTiming({ easing: "steps(2, end)" }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + }, "change_easing"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100, delay: -100 }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning negative value"); + }, "negative_delay_in_constructor"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var effect = new KeyframeEffect(null, + { opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var anim = new Animation(effect, document.timeline); + anim.play(); + assert_equals_records(observer.takeRecords(), + [], "no records after animation is added"); + }, "create_animation_without_target"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.target = div; + assert_equals_records(observer.takeRecords(), + [], "no records after setting the same target"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after setting null"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [], "records after setting redundant null"); + }, "set_redundant_animation_target"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation is removed"); + }, "set_null_animation_effect"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = new Animation(); + anim.play(); + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + }, "set_effect_on_null_effect_animation"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ marginLeft: [ "0px", "100px" ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after replace effects"); + }, "replace_effect_targeting_on_the_same_element"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ marginLeft: [ "0px", "100px" ] }, + 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.currentTime = 60 * MS_PER_SEC; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after animation is changed"); + + anim.effect = new KeyframeEffect(div, { opacity: [ 0, 1 ] }, + 50 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after replacing effects"); + }, "replace_effect_targeting_on_the_same_element_not_in_effect"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ }, 100 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.composite = "add"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after composite is changed"); + + anim.effect.composite = "add"; + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same composite"); + + }, "set_composite"); + + // Test that starting a single animation that is cancelled by calling + // cancel() dispatches an added notification and then a removed + // notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].cancel(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + + // Re-trigger the animation. + animations[0].play(); + + // Single MutationRecord for the Animation (re-)addition. + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + }, "single_animation_cancelled_api"); + + // Test that updating a property on the Animation object dispatches a changed + // notification. + [ + { prop: "playbackRate", val: 0.5 }, + { prop: "startTime", val: 50 * MS_PER_SEC }, + { prop: "currentTime", val: 50 * MS_PER_SEC }, + ].forEach(aChangeTest => { + test(t => { + // We use a forwards fill mode so that even if the change we make causes + // the animation to become finished, it will still be "relevant" so we + // won't mark it as removed. + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Update the property. + animations[0][aChangeTest.prop] = aChangeTest.val; + + // Make a redundant change. + animations[0][aChangeTest.prop] = animations[0][aChangeTest.prop]; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after animation property change"); + }, `single_animation_api_change_${aChangeTest.prop}`); + }); + + // Test that making a redundant change to currentTime while an Animation + // is pause-pending still generates a change MutationRecord since setting + // the currentTime to any value in this state aborts the pending pause. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].pause(); + + // We are now pause-pending. Even if we make a redundant change to the + // currentTime, we should still get a change record because setting the + // currentTime while pause-pending has the effect of cancelling a pause. + animations[0].currentTime = animations[0].currentTime; + + // Two MutationRecords for the Animation changes: one for pausing, one + // for aborting the pause. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after pausing then seeking"); + }, "change_currentTime_while_pause_pending"); + + // Test that calling finish() on a forwards-filling Animation dispatches + // a changed notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after finish()"); + + // Redundant finish. + animations[0].finish(); + + // Ensure no change records. + assert_equals_records(observer.takeRecords(), + [], "records after redundant finish()"); + }, "finish_with_forwards_fill"); + + // Test that calling finish() on an Animation that does not fill forwards, + // dispatches a removal notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].finish(); + + // Single MutationRecord for the Animation removal. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after finishing"); + }, "finish_without_fill"); + + // Test that calling finish() on a forwards-filling Animation dispatches + test(t => { + var div = addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animation = div.getAnimations()[0]; + assert_equals_records(observer.takeRecords(), + [{ added: [animation], changed: [], removed: []}], + "records after creation"); + animation.id = "new id"; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [animation], removed: []}], + "records after id is changed"); + + animation.id = "new id"; + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value with id"); + }, "change_id"); + + // Test that calling reverse() dispatches a changed notification. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s both" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + animations[0].reverse(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after calling reverse()"); + }, "reverse"); + + // Test that calling reverse() does *not* dispatch a changed notification + // when playbackRate == 0. + test(t => { + var div = addDiv(t, { style: "animation: anim 100s both" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the middle and set playbackRate to zero. + animations[0].currentTime = 50 * MS_PER_SEC; + animations[0].playbackRate = 0; + + // Two MutationRecords, one for each change. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after seeking and setting playbackRate"); + + animations[0].reverse(); + + // We should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after calling reverse()"); + }, "reverse_with_zero_playbackRate"); + + // Test that reverse() on an Animation does *not* dispatch a changed + // notification when it throws an exception. + test(t => { + // Start an infinite animation + var div = addDiv(t, { style: "animation: anim 10s infinite" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Shift the animation into the future such that when we call reverse + // it will try to seek to the (infinite) end. + animations[0].startTime = 100 * MS_PER_SEC; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after adjusting startTime"); + + // Reverse: should throw + assert_throws('InvalidStateError', () => { + animations[0].reverse(); + }, 'reverse() on future infinite animation throws an exception'); + + // We should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after calling reverse()"); + }, "reverse_with_exception"); + + // Test that attempting to start an animation that should already be finished + // does not send any notifications. + test(t => { + // Start an animation that should already be finished. + var div = addDiv(t, { style: "animation: anim 1s -2s;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause no Animations to be created. + var animations = div.getAnimations(); + assert_equals(animations.length, 0, + "getAnimations().length after animation start"); + + // And we should get no notifications. + assert_equals_records(observer.takeRecords(), + [], "records after attempted animation start"); + }, "already_finished"); + + test(t => { + var div = addDiv(t, { style: "animation: anim 100s, anotherAnim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var animations = div.getAnimations(); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: []}], + "records after creation"); + + div.style.animation = "anotherAnim 100s, anim 100s"; + animations = div.getAnimations(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: []}], + "records after the order is changed"); + + div.style.animation = "anotherAnim 100s, anim 100s"; + + assert_equals_records(observer.takeRecords(), + [], "no records after applying the same order"); + }, "animtion_order_change"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + iterationComposite: 'replace' }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.iterationComposite = 'accumulate'; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after iterationComposite is changed"); + + anim.effect.iterationComposite = 'accumulate'; + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same iterationComposite"); + + }, "set_iterationComposite"); + + test(t => { + var div = addDiv(t); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.setKeyframes({ opacity: 0.1 }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after keyframes are changed"); + + anim.effect.setKeyframes({ opacity: 0.1 }); + assert_equals_records(observer.takeRecords(), + [], "no record after setting the same keyframes"); + + anim.effect.setKeyframes(null); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after keyframes are set to empty"); + + }, "set_keyframes"); + + // Test that starting a single transition that is cancelled by resetting + // the transition-property property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "transition: background-color 100s; " + + "background-color: yellow;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + getComputedStyle(div).transitionProperty; + div.style.backgroundColor = "lime"; + + // The transition should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting transition-property. + div.style.transitionProperty = "none"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after transition end"); + }, "single_transition_cancelled_property"); + + // Test that starting a single transition that is cancelled by setting + // style to the currently animated value dispatches an added + // notification and then a removed notification. + test(t => { + // A long transition with a predictable value. + var div = + addDiv(t, { style: "transition: z-index 100s -51s; " + + "z-index: 10;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + getComputedStyle(div).transitionProperty; + div.style.zIndex = "100"; + + // The transition should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting the current animation value. + let value = "83"; + assert_equals(getComputedStyle(div).zIndex, value, + "half-way transition value"); + div.style.zIndex = value; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after transition end"); + }, "single_transition_cancelled_value"); + + // Test that starting a single transition that is cancelled by setting + // style to a non-interpolable value dispatches an added notification + // and then a removed notification. + test(t => { + var div = + addDiv(t, { style: "transition: line-height 100s; " + + "line-height: 16px;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + getComputedStyle(div).transitionProperty; + div.style.lineHeight = "100px"; + + // The transition should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition start"); + + // Cancel the transition by setting line-height to a non-interpolable value. + div.style.lineHeight = "normal"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after transition end"); + }, "single_transition_cancelled_noninterpolable"); + + // Test that starting a single transition and then reversing it + // dispatches an added notification, then a simultaneous removed and + // added notification, then a removed notification once finished. + test(t => { + var div = + addDiv(t, { style: "transition: background-color 100s step-start; " + + "background-color: yellow;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + getComputedStyle(div).transitionProperty; + div.style.backgroundColor = "lime"; + + var animations = div.getAnimations(); + + // The transition should cause the creation of a single Animation. + assert_equals(animations.length, 1, + "getAnimations().length after transition start"); + + var firstAnimation = animations[0]; + assert_equals_records(observer.takeRecords(), + [{ added: [firstAnimation], changed: [], removed: [] }], + "records after transition start"); + + firstAnimation.currentTime = 50 * MS_PER_SEC; + + // Reverse the transition by setting the background-color back to its + // original value. + div.style.backgroundColor = "yellow"; + + // The reversal should cause the creation of a new Animation. + animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after transition reversal"); + + var secondAnimation = animations[0]; + + assert_true(firstAnimation != secondAnimation, + "second Animation should be different from the first"); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [firstAnimation], removed: [] }, + { added: [secondAnimation], changed: [], removed: [firstAnimation] }], + "records after transition reversal"); + + // Cancel the transition. + div.style.transitionProperty = "none"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [secondAnimation] }], + "records after transition end"); + }, "single_transition_reversed"); + + // Test that multiple transitions starting and ending on an element + // at the same time get batched up into a single MutationRecord. + test(t => { + var div = + addDiv(t, { style: "transition-duration: 100s; " + + "transition-property: color, background-color, line-height" + + "background-color: yellow; line-height: 16px" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + getComputedStyle(div).transitionProperty; + + div.style.backgroundColor = "lime"; + div.style.color = "blue"; + div.style.lineHeight = "24px"; + + // The transitions should cause the creation of three Animations. + var animations = div.getAnimations(); + assert_equals(animations.length, 3, + "getAnimations().length after transition starts"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after transition starts"); + + assert_equals(animations.filter(p => p.playState == "running").length, 3, + "number of running Animations"); + + // Seek well into each animation. + animations.forEach(p => p.currentTime = 50 * MS_PER_SEC); + + // Prepare the set of expected change MutationRecords, one for each + // animation that was seeked. + var seekRecords = animations.map( + p => ({ added: [], changed: [p], removed: [] }) + ); + + // Cancel one of the transitions by setting transition-property. + div.style.transitionProperty = "background-color, line-height"; + + var colorAnimation = animations.filter(p => p.playState != "running"); + var otherAnimations = animations.filter(p => p.playState == "running"); + + assert_equals(colorAnimation.length, 1, + "number of non-running Animations after cancelling one"); + assert_equals(otherAnimations.length, 2, + "number of running Animations after cancelling one"); + + assert_equals_records(observer.takeRecords(), + seekRecords.concat({ added: [], changed: [], removed: colorAnimation }), + "records after color transition end"); + + // Cancel the remaining transitions. + div.style.transitionProperty = "none"; + getComputedStyle(div).transitionProperty; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: otherAnimations }], + "records after other transition ends"); + }, "multiple_transitions"); + + // Test that starting a single animation that is cancelled by resetting + // the animation-name property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-name. + div.style.animationName = "none"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_name"); + + // Test that starting a single animation that is cancelled by updating + // the animation-duration property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance the animation by a second. + animations[0].currentTime += 1 * MS_PER_SEC; + + // Cancel the animation by setting animation-duration to a value less + // than a second. + div.style.animationDuration = "0.1s"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_duration"); + + // Test that starting a single animation that is cancelled by updating + // the animation-delay property dispatches an added notification and + // then a removed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Cancel the animation by setting animation-delay. + div.style.animationDelay = "-200s"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_delay"); + + // Test that starting a single animation that is cancelled by updating + // the animation-iteration-count property dispatches an added notification + // and then a removed notification. + test(t => { + // A short, repeated animation. + var div = + addDiv(t, { style: "animation: anim 0.5s infinite;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Advance the animation until we are past the first iteration. + animations[0].currentTime += 1 * MS_PER_SEC; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after seeking animations"); + + // Cancel the animation by setting animation-iteration-count. + div.style.animationIterationCount = "1"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "single_animation_cancelled_iteration_count"); + + // Test that updating an animation property dispatches a changed notification. + [ + { name: "duration", prop: "animationDuration", val: "200s" }, + { name: "timing", prop: "animationTimingFunction", val: "linear" }, + { name: "iteration", prop: "animationIterationCount", val: "2" }, + { name: "direction", prop: "animationDirection", val: "reverse" }, + { name: "state", prop: "animationPlayState", val: "paused" }, + { name: "delay", prop: "animationDelay", val: "-1s" }, + { name: "fill", prop: "animationFillMode", val: "both" }, + ].forEach(aChangeTest => { + test(t => { + // Start a long animation. + var div = addDiv(t, { style: "animation: anim 100s;" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Change a property of the animation such that it keeps running. + div.style[aChangeTest.prop] = aChangeTest.val; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after animation change"); + + // Cancel the animation. + div.style.animationName = "none"; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, `single_animation_change_${aChangeTest.name}`); + }); + + // Test that calling finish() on a pause-pending (but otherwise finished) + // animation dispatches a changed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Finish and pause. + animations[0].finish(); + animations[0].pause(); + assert_true(animations[0].pending && animations[0].playState === "paused", + "playState after finishing and calling pause()"); + + // Call finish() again to abort the pause + animations[0].finish(); + assert_equals(animations[0].playState, "finished", + "playState after finishing again"); + + // Wait for three MutationRecords for the Animation changes to + // be delivered: one for each finish(), pause(), finish() operation. + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after finish(), pause(), finish()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "finish_from_pause_pending"); + + // Test that calling play() on a finished Animation that fills forwards + // dispatches a changed notification. + test(t => { + // Animation with a forwards fill + var div = + addDiv(t, { style: "animation: anim 100s forwards" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the end + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after finish()"); + + // Since we are filling forwards, calling play() should produce a + // change record since the animation remains relevant. + animations[0].play(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after play()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "play_filling_forwards"); + + // Test that calling pause() on an Animation dispatches a changed + // notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Pause + animations[0].pause(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Redundant pause + animations[0].pause(); + + assert_equals_records(observer.takeRecords(), + [], "records after redundant pause()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "pause"); + + // Test that calling pause() on an Animation that is pause-pending + // does not dispatch an additional changed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Pause + animations[0].pause(); + + // We are now pause-pending, but pause again + animations[0].pause(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }], + "records after pause()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "pause_while_pause_pending"); + + // Test that calling play() on an Animation that is pause-pending + // dispatches a changed notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Pause + animations[0].pause(); + + // We are now pause-pending. If we play() now, we will abort the pause + animations[0].play(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: animations, removed: [] }, + { added: [], changed: animations, removed: [] }], + "records after aborting a pause()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "aborted_pause"); + + // Test that calling play() on a finished Animation that does *not* fill + // forwards dispatches an addition notification. + test(t => { + var div = + addDiv(t, { style: "animation: anim 100s" }); + var observer = + setupSynchronousObserver(t, + aOptions.subtree ? div.parentNode : div, + aOptions.subtree); + + // The animation should cause the creation of a single Animation. + var animations = div.getAnimations(); + assert_equals(animations.length, 1, + "getAnimations().length after animation start"); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after animation start"); + + // Seek to the end + animations[0].finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after finish()"); + + // Since we are *not* filling forwards, calling play() is equivalent + // to creating a new animation since it becomes relevant again. + animations[0].play(); + + assert_equals_records(observer.takeRecords(), + [{ added: animations, changed: [], removed: [] }], + "records after play()"); + + // Cancel the animation. + div.style = ""; + getComputedStyle(div).animationName; + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: animations }], + "records after animation end"); + }, "play_after_finish"); + + }); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, true); + + var child = document.createElement("div"); + div.appendChild(child); + + var anim1 = div.animate({ marginLeft: [ "0px", "50px" ] }, + 100 * MS_PER_SEC); + var anim2 = child.animate({ marginLeft: [ "0px", "100px" ] }, + 50 * MS_PER_SEC); + assert_equals_records(observer.takeRecords(), + [{ added: [anim1], changed: [], removed: [] }, + { added: [anim2], changed: [], removed: [] }], + "records after animation is added"); + + // After setting a new effect, we remove the current animation, anim1, + // because it is no longer attached to |div|, and then remove the previous + // animation, anim2. Finally, add back the anim1 which is in effect on + // |child| now. In addition, we sort them by tree order and they are + // batched. + anim1.effect = anim2.effect; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim1] }, // div + { added: [anim1], changed: [], removed: [anim2] }], // child + "records after animation effects are changed"); + }, "set_effect_with_previous_animation"); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, document, true); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + + var newTarget = document.createElement("div"); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.target = null; + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after setting null"); + + anim.effect.target = div; + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after setting a target"); + + anim.effect.target = addDiv(t); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }, + { added: [anim], changed: [], removed: [] }], + "records after setting a different target"); + }, "set_animation_target"); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, true); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 200 * MS_PER_SEC, + pseudoElement: '::before' }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [anim], removed: [] }], + "records after duration is changed"); + + anim.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value"); + + anim.currentTime = anim.effect.getComputedTiming().duration * 2; + anim.finish(); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation end"); + + anim.effect.updateTiming({ + duration: anim.effect.getComputedTiming().duration * 3 + }); + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation restarted"); + + anim.effect.updateTiming({ duration: "auto" }); + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after duration set \"auto\""); + + anim.effect.updateTiming({ duration: "auto" }); + assert_equals_records(observer.takeRecords(), + [], "records after assigning same value \"auto\""); + }, "change_duration_and_currenttime_on_pseudo_elements"); + + test(t => { + var div = addDiv(t); + var observer = setupSynchronousObserver(t, div, false); + + var anim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC }); + var pAnim = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + pseudoElement: "::before" }); + + assert_equals_records(observer.takeRecords(), + [{ added: [anim], changed: [], removed: [] }], + "records after animation is added"); + + anim.finish(); + pAnim.finish(); + + assert_equals_records(observer.takeRecords(), + [{ added: [], changed: [], removed: [anim] }], + "records after animation is finished"); + }, "exclude_animations_targeting_pseudo_elements"); +} + +W3CTest.runner.expectAssertions(0, 12); // bug 1189015 +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.core.enabled", true], + ["dom.animations-api.implicit-keyframes.enabled", true], + ["dom.animations-api.timelines.enabled", true], + ], + }, + function() { + runTest(); + done(); + } +); + +</script> diff --git a/dom/animation/test/chrome/test_animation_performance_warning.html b/dom/animation/test/chrome/test_animation_performance_warning.html new file mode 100644 index 0000000000..ca0572cfaa --- /dev/null +++ b/dom/animation/test/chrome/test_animation_performance_warning.html @@ -0,0 +1,1692 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1196114 - Test metadata related to which animation properties + are running on the compositor</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +.compositable { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +@keyframes fade { + from { opacity: 1 } + to { opacity: 0 } +} +@keyframes translate { + from { transform: none } + to { transform: translate(100px) } +} +</style> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1196114" + target="_blank">Mozilla Bug 1196114</a> +<div id="log"></div> +<script> +'use strict'; + +// This is used for obtaining localized strings. +var gStringBundle; + +W3CTest.runner.requestLongerTimeout(2); + +const Services = SpecialPowers.Services; +Services.locale.requestedLocales = ["en-US"]; + +SpecialPowers.pushPrefEnv({ "set": [ + // Need to set devPixelsPerPx explicitly to gain + // consistent pixel values in warning messages + // regardless of platform DPIs. + ["layout.css.devPixelsPerPx", 1], + ["layout.animation.prerender.partial", false], + ] }, + start); + +function compare_property_state(a, b) { + if (a.property > b.property) { + return -1; + } else if (a.property < b.property) { + return 1; + } + if (a.runningOnCompositor != b.runningOnCompositor) { + return a.runningOnCompositor ? 1 : -1; + } + return a.warning > b.warning ? -1 : 1; +} + +function assert_animation_property_state_equals(actual, expected) { + assert_equals(actual.length, expected.length, 'Number of properties'); + + var sortedActual = actual.sort(compare_property_state); + var sortedExpected = expected.sort(compare_property_state); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_equals(sortedActual[i].runningOnCompositor, + sortedExpected[i].runningOnCompositor, + 'runningOnCompositor property should match'); + if (sortedExpected[i].warning instanceof RegExp) { + assert_regexp_match(sortedActual[i].warning, + sortedExpected[i].warning, + 'warning message should match'); + } else if (sortedExpected[i].warning) { + assert_equals(sortedActual[i].warning, + gStringBundle.GetStringFromName(sortedExpected[i].warning), + 'warning message should match'); + } + } +} + +// Check that the animation is running on compositor and +// warning property is not set for the CSS property regardless +// expected values. +function assert_all_properties_running_on_compositor(actual, expected) { + assert_equals(actual.length, expected.length); + + var sortedActual = actual.sort(compare_property_state); + var sortedExpected = expected.sort(compare_property_state); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_true(sortedActual[i].runningOnCompositor, + 'runningOnCompositor property should be true on ' + + sortedActual[i].property); + assert_not_exists(sortedActual[i], 'warning', + 'warning property should not be set'); + } +} + +function testBasicOperation() { + [ + { + desc: 'animations on compositor', + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + { + desc: 'animations on main thread', + frames: { + zIndex: ['0', '999'] + }, + expected: [ + { + property: 'z-index', + runningOnCompositor: false + } + ] + }, + { + desc: 'animations on both threads', + frames: { + zIndex: ['0', '999'], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'z-index', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'two animation properties on compositor thread', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'two transform-like animation properties on compositor thread', + frames: { + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true + }, + { + property: 'translate', + runningOnCompositor: true + } + ] + }, + { + desc: 'opacity on compositor with animation of geometric properties', + frames: { + width: ['100px', '200px'], + opacity: [0, 1] + }, + expected: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +// Test adding/removing a 'width' property on the same animation object. +function testKeyframesWithGeometricProperties() { + [ + { + desc: 'transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + desc: 'translate', + frames: { + translate: ['0px', '100px'] + }, + expected: { + withoutGeometric: [ + { + property: 'translate', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + desc: 'opacity and transform-like properties', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true + }, + { + property: 'translate', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false + }, + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + }, + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + // First, a transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + + // Add a 'width' property. + var keyframes = animation.effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animation.effect.setKeyframes(keyframes); + await waitForFrame(); + + // Now the transform animation is not running on compositor because of + // the 'width' property. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withGeometric); + + // Remove the 'width' property. + var keyframes = animation.effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animation.effect.setKeyframes(keyframes); + await waitForFrame(); + + // Finally, the transform animation is running on compositor. + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected.withoutGeometric); + }, 'An animation has: ' + subtest.desc); + }); +} + +// Test that the expected set of geometric properties all block transform +// animations. +function testSetOfGeometricProperties() { + const geometricProperties = [ + 'width', 'height', + 'top', 'right', 'bottom', 'left', + 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', + 'padding-top', 'padding-right', 'padding-bottom', 'padding-left' + ]; + + geometricProperties.forEach(property => { + promise_test(async t => { + const keyframes = { + [propertyToIDL(property)]: [ '100px', '200px' ], + transform: [ 'translate(0px)', 'translate(100px)' ] + }; + var animation = addDivAndAnimate(t, { class: 'compositable' }, + keyframes, 100 * MS_PER_SEC); + + await waitForPaints(); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ + { + property, + runningOnCompositor: false + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } + ]); + }, `${property} is treated as a geometric property`); + }); +} + +// Performance warning tests that set and clear a style property. +function testStyleChanges() { + [ + { + desc: 'preserve-3d transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + desc: 'preserve-3d translate', + frames: { + translate: ['0px', '100px'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + desc: 'transform with backface-visibility:hidden', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + desc: 'translate with backface-visibility:hidden', + frames: { + translate: ['0px', '100px'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + desc: 'opacity and transform-like properties with preserve-3d', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + style: 'transform-style: preserve-3d', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: true, + }, + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + desc: 'opacity and transform-like properties with ' + + 'backface-visibility:hidden', + frames: { + opacity: [0, 1], + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + style: 'backface-visibility: hidden;', + expected: [ + { + property: 'opacity', + runningOnCompositor: true + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + }, + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = subtest.style; + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.style = ''; + await waitForFrame(); + + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +// Performance warning tests that set and clear the id property +function testIdChanges() { + [ + { + desc: 'moz-element referencing a transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, + { + desc: 'moz-element referencing a translate', + frames: { + translate: ['0px', '100px'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, + { + desc: 'moz-element referencing a translate and transform', + frames: { + transform: ['translate(0px)', 'translate(100px)'], + translate: ['0px', '100px'] + }, + id: 'transformed', + createelement: 'width:100px; height:100px; background: -moz-element(#transformed)', + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasRenderingObserver' + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + if (subtest.createelement) { + addDiv(t, { style: subtest.createelement }); + } + + var animation = addDivAndAnimate(t, { class: 'compositable' }, + subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = subtest.id; + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + animation.effect.target.id = ''; + await waitForFrame(); + + assert_all_properties_running_on_compositor( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +function testMultipleAnimations() { + [ + { + desc: 'opacity and transform-like properties with preserve-3d', + style: 'transform-style: preserve-3d', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: true, + } + ] + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: true, + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + { + desc: 'opacity and transform-like properties with ' + + 'backface-visibility:hidden', + style: 'backface-visibility: hidden;', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformBackfaceVisibilityHidden' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(anim => { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + await waitForPaints(); + + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + div.style = subtest.style; + await waitForFrame(); + + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + div.style = ''; + await waitForFrame(); + + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }, 'Multiple animations: ' + subtest.desc); + }); +} + +// Test adding/removing a 'width' keyframe on the same animation object, where +// multiple animation objects belong to the same element. +// The 'width' property is added to animations[1]. +function testMultipleAnimationsWithGeometricKeyframes() { + [ + { + desc: 'transform and opacity with geometric keyframes', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + }, + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + } + ], + }, + { + desc: 'opacity and transform with geometric keyframes', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + }, + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: { + withoutGeometric: [ + { + property: 'transform', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + } + ] + }, + { + desc: 'opacity and translate with geometric keyframes', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: { + withoutGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ], + withGeometric: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: { + withoutGeometric: [ + { + property: 'translate', + runningOnCompositor: true + } + ], + withGeometric: [ + { + property: 'width', + runningOnCompositor: false, + }, + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + } + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(anim => { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + await waitForPaints(); + // First, all animations are running on compositor. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + + // Add a 'width' property to animations[1]. + var keyframes = animations[1].effect.getKeyframes(); + + keyframes[0].width = '100px'; + keyframes[1].width = '200px'; + + animations[1].effect.setKeyframes(keyframes); + await waitForFrame(); + + // Now the transform animation is not running on compositor because of + // the 'width' property. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withGeometric); + }); + + // Remove the 'width' property from animations[1]. + var keyframes = animations[1].effect.getKeyframes(); + + delete keyframes[0].width; + delete keyframes[1].width; + + animations[1].effect.setKeyframes(keyframes); + await waitForFrame(); + + // Finally, all animations are running on compositor. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected.withoutGeometric); + }); + }, 'Multiple animations with geometric property: ' + subtest.desc); + }); +} + +// Tests adding/removing 'width' animation on the same element which has async +// animations. +function testMultipleAnimationsWithGeometricAnimations() { + [ + { + desc: 'transform', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + ] + }, + { + desc: 'translate', + animations: [ + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + ] + }, + { + desc: 'opacity', + animations: [ + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + ] + }, + { + desc: 'opacity, transform, and translate', + animations: [ + { + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + expected: [ + { + property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + { + frames: { + translate: ['0px', '100px'] + }, + expected: [ + { + property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithGeometricProperties' + } + ] + }, + { + frames: { + opacity: [0, 1] + }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true, + } + ] + } + ], + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, { class: 'compositable' }); + var animations = subtest.animations.map(anim => { + var animation = div.animate(anim.frames, 100 * MS_PER_SEC); + + // Bind expected values to animation object. + animation.expected = anim.expected; + return animation; + }); + + var widthAnimation; + + await waitForPaints(); + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + + // Append 'width' animation on the same element. + widthAnimation = div.animate({ width: ['100px', '200px'] }, + 100 * MS_PER_SEC); + await waitForFrame(); + + // Now transform animations are not running on compositor because of + // the 'width' animation. + animations.forEach(anim => { + assert_animation_property_state_equals( + anim.effect.getProperties(), + anim.expected); + }); + // Remove the 'width' animation. + widthAnimation.cancel(); + await waitForFrame(); + + // Now all animations are running on compositor. + animations.forEach(anim => { + assert_all_properties_running_on_compositor( + anim.effect.getProperties(), + anim.expected); + }); + }, 'Multiple async animations and geometric animation: ' + subtest.desc); + }); +} + +function testSmallElements() { + [ + { + desc: 'opacity on small element', + frames: { + opacity: [0, 1] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' + + // We need to set transform here to try creating an + // individual frame for this opacity element. + // Without this, this small element is created on the same + // nsIFrame of mochitest iframe, i.e. the document which are + // running this test, as a result the layer corresponding + // to the frame is sent to compositor. + 'transform: translateX(100px);' }, + expected: [ + { + property: 'opacity', + runningOnCompositor: true + } + ] + }, + { + desc: 'transform on small element', + frames: { + transform: ['translate(0px)', 'translate(100px)'] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' }, + expected: [ + { + property: 'transform', + runningOnCompositor: true + } + ] + }, + { + desc: 'translate on small element', + frames: { + translate: ['0px', '100px'] + }, + style: { style: 'width: 8px; height: 8px; background-color: red;' }, + expected: [ + { + property: 'translate', + runningOnCompositor: true + } + ] + }, + ].forEach(subtest => { + promise_test(async t => { + var div = addDiv(t, subtest.style); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); +} + +function testSynchronizedAnimations() { + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + }, 'Animations created within the same tick are synchronized' + + ' (compositor animation created first)'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + const elemC = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ translate: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + const animC = elemC.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ + { property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + assert_animation_property_state_equals( + animB.effect.getProperties(), + [ + { property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + }, 'Animations created within the same tick are synchronized' + + ' (compositor animation created first/second)'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + const elemC = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animC = elemC.animate({ translate: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_property_state_equals( + animB.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + assert_animation_property_state_equals( + animC.effect.getProperties(), + [ + { property: 'translate', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + }, 'Animations created within the same tick are synchronized' + + ' (compositor animation created second/third)'); + + promise_test(async t => { + const attrs = { class: 'compositable', + style: 'transition: all 100s' }; + const elemA = addDiv(t, attrs); + const elemB = addDiv(t, attrs); + elemA.style.transform = 'translate(0px)'; + elemB.style.marginLeft = '0px'; + getComputedStyle(elemA).transform; + getComputedStyle(elemB).marginLeft; + + // Generally the sequence of steps is as follows: + // + // Tick -> requestAnimationFrame -> Style -> Paint -> Events (-> Tick...) + // + // In this test we want to set up two transitions during the "Events" + // stage but only flush style for one such that the second one is actually + // generated during the "Style" stage of the *next* tick. + // + // Web content often generates transitions in this way (that is, it doesn't + // pay regard to when style is flushed and nor should it). However, we + // still want transitions generated in this way to be synchronized. + let timeForFirstFrame; + await waitForIdle(); + + timeForFirstFrame = document.timeline.currentTime; + elemA.style.transform = 'translate(100px)'; + // Flush style to trigger first transition + getComputedStyle(elemA).transform; + elemB.style.marginLeft = '100px'; + // DON'T flush style here (this includes calling getAnimations!) + await waitForFrame(); + + assert_not_equals(timeForFirstFrame, document.timeline.currentTime, + 'Should be on the other side of a tick'); + // Wait another tick so we can let the transition be started + // by regular style resolution. + await waitForFrame(); + + const transitionA = elemA.getAnimations()[0]; + assert_animation_property_state_equals( + transitionA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + }, 'Transitions created before and after a tick are synchronized'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ], + opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + }, + { property: 'opacity', + runningOnCompositor: true + } ]); + }, 'Opacity animations on the same element continue running on the' + + ' compositor when transform animations are synchronized with geometric' + + ' animations'); + + promise_test(async t => { + const transitionElem = addDiv(t, { + style: 'margin-left: 0px; transition: margin-left 100s', + }); + getComputedStyle(transitionElem).marginLeft; + + await waitForFrame(); + + transitionElem.style.marginLeft = '100px'; + const cssTransition = transitionElem.getAnimations()[0]; + + const animationElem = addDiv(t, { + class: 'compositable', + style: 'animation: translate 100s', + }); + const cssAnimation = animationElem.getAnimations()[0]; + + await Promise.all([cssTransition.ready, cssAnimation.ready]); + + assert_animation_property_state_equals(cssAnimation.effect.getProperties(), + [{ property: 'transform', + runningOnCompositor: true }]); + }, 'CSS Animations are NOT synchronized with CSS Transitions'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + await waitForPaints(); + + let animB = elemB.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + await animB.ready; + + assert_animation_property_state_equals( + animB.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'Transform animations are NOT synchronized with geometric animations' + + ' started in the previous frame'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + await waitForPaints(); + + let animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + await animB.ready; + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'Transform animations are NOT synchronized with geometric animations' + + ' started in the next frame'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + animB.pause(); + + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', runningOnCompositor: true } ]); + }, 'Paused animations are not synchronized'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + // Seek one of the animations so that their start times will differ + animA.currentTime = 5000; + + await waitForPaints(); + + assert_not_equals(animA.startTime, animB.startTime, + 'Animations should have different start times'); + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformWithSyncGeometricAnimations' + } ]); + }, 'Animations are synchronized based on when they are started' + + ' and NOT their start time'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: false } ]); + // Restart animation + animA.pause(); + animA.play(); + await animA.ready; + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'An initially synchronized animation may be unsynchronized if restarted'); + + promise_test(async t => { + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + // Clear target effect + animB.effect.target = null; + + await waitForPaints(); + + assert_animation_property_state_equals( + animA.effect.getProperties(), + [ { property: 'transform', + runningOnCompositor: true } ]); + }, 'A geometric animation with no target element is not synchronized'); +} + +function testTooLargeFrame() { + [ + { + property: 'transform', + frames: { transform: ['translate(0px)', 'translate(100px)'] }, + }, + { + property: 'translate', + frames: { translate: ['0px', '100px'] }, + }, + ].forEach(subtest => { + promise_test(async t => { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, + 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + animation.effect.target.style = 'width: 10000px; height: 10000px'; + await waitForFrame(); + + // viewport depends on test environment. + var expectedWarning = new RegExp( + "Animation cannot be run on the compositor because the area of the frame " + + "\\(\\d+\\) is too large relative to the viewport " + + "\\(larger than \\d+\\)"); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: subtest.property, + runningOnCompositor: false, + warning: expectedWarning + } ]); + animation.effect.target.style = 'width: 100px; height: 100px'; + await waitForFrame(); + + // With WebRender we appear to stick to the previous layerization decision + // after changing the bounds back to a smaller object. + const isWebRender = + SpecialPowers.DOMWindowUtils.layerManagerType.startsWith('WebRender'); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: !isWebRender } ]); + }, subtest.property + ' on too big element - area'); + + promise_test(async t => { + var animation = addDivAndAnimate(t, + { class: 'compositable' }, + subtest.frames, + 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + animation.effect.target.style = 'width: 20000px; height: 1px'; + await waitForFrame(); + + // viewport depends on test environment. + var expectedWarning = new RegExp( + "Animation cannot be run on the compositor because the frame size " + + "\\(20000, 1\\) is too large relative to the viewport " + + "\\(larger than \\(\\d+, \\d+\\)\\) or larger than the " + + "maximum allowed value \\(\\d+, \\d+\\)"); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: subtest.property, + runningOnCompositor: false, + warning: expectedWarning + } ]); + animation.effect.target.style = 'width: 100px; height: 100px'; + await waitForFrame(); + + const isWebRender = + SpecialPowers.DOMWindowUtils.layerManagerType.startsWith('WebRender'); + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: !isWebRender } ]); + }, subtest.property + ' on too big element - dimensions'); + }); +} + +function testTransformSVG() { + [ + { + property: 'transform', + frames: { transform: ['translate(0px)', 'translate(100px)'] }, + }, + { + property: 'translate', + frames: { translate: ['0px', '100px'] }, + }, + { + property: 'rotate', + frames: { rotate: ['0deg', '45deg'] }, + }, + { + property: 'scale', + frames: { scale: ['1', '2'] }, + }, + ].forEach(subtest => { + promise_test(async t => { + var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('width', '100'); + svg.setAttribute('height', '100'); + var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + rect.setAttribute('width', '100'); + rect.setAttribute('height', '100'); + rect.setAttribute('fill', 'red'); + svg.appendChild(rect); + document.body.appendChild(svg); + t.add_cleanup(() => { + svg.remove(); + }); + + var animation = svg.animate(subtest.frames, 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + svg.setAttribute('transform', 'translate(10, 20)'); + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { + property: subtest.property, + runningOnCompositor: false, + warning: 'CompositorAnimationWarningTransformSVG' + } ]); + svg.removeAttribute('transform'); + await waitForFrame(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: subtest.property, runningOnCompositor: true } ]); + }, subtest.property + ' of nsIFrame with SVG transform'); + }); +} + +function testImportantRuleOverride() { + promise_test(async t => { + const elem = addDiv(t, { class: 'compositable' }); + const anim = elem.animate({ translate: [ '0px', '100px' ], + rotate: ['0deg', '90deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(anim); + await waitForPaints(); + + assert_animation_property_state_equals( + anim.effect.getProperties(), + [ { property: 'translate', runningOnCompositor: true }, + { property: 'rotate', runningOnCompositor: true } ] + ); + + elem.style.setProperty('rotate', '45deg', 'important'); + getComputedStyle(elem).rotate; + + await waitForFrame(); + + assert_animation_property_state_equals( + anim.effect.getProperties(), + [ + { + property: 'translate', + runningOnCompositor: false, + warning: + 'CompositorAnimationWarningTransformIsBlockedByImportantRules' + }, + { + property: 'rotate', + runningOnCompositor: false, + warning: + 'CompositorAnimationWarningTransformIsBlockedByImportantRules' + }, + ] + ); + }, 'The animations of transform-like properties are not running on the ' + + 'compositor because any of the properties has important rules'); +} + +function testCurrentColor() { + if (SpecialPowers.DOMWindowUtils.layerManagerType.startsWith('WebRender')) { + return; // skip this test until bug 1510030 landed. + } + promise_test(async t => { + const animation = addDivAndAnimate(t, { class: 'compositable' }, + { backgroundColor: [ 'currentColor', + 'red' ] }, + 100 * MS_PER_SEC); + await waitForPaints(); + + assert_animation_property_state_equals( + animation.effect.getProperties(), + [ { property: 'background-color', + runningOnCompositor: false, + warning: 'CompositorAnimationWarningHasCurrentColor' + } ]); + }, 'Background color animations with `current-color` don\'t run on the ' + + 'compositor'); +} + +function start() { + var bundleService = SpecialPowers.Cc['@mozilla.org/intl/stringbundle;1'] + .getService(SpecialPowers.Ci.nsIStringBundleService); + gStringBundle = bundleService + .createBundle("chrome://global/locale/layout_errors.properties"); + + testBasicOperation(); + testKeyframesWithGeometricProperties(); + testSetOfGeometricProperties(); + testStyleChanges(); + testIdChanges(); + testMultipleAnimations(); + testMultipleAnimationsWithGeometricKeyframes(); + testMultipleAnimationsWithGeometricAnimations(); + testSmallElements(); + testSynchronizedAnimations(); + testTooLargeFrame(); + testTransformSVG(); + testImportantRuleOverride(); + testCurrentColor(); + + promise_test(async t => { + var div = addDiv(t, { class: 'compositable', + style: 'animation: fade 100s' }); + var cssAnimation = div.getAnimations()[0]; + var scriptAnimation = div.animate({ opacity: [ 1, 0 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + assert_animation_property_state_equals( + cssAnimation.effect.getProperties(), + [ { property: 'opacity', runningOnCompositor: true } ]); + assert_animation_property_state_equals( + scriptAnimation.effect.getProperties(), + [ { property: 'opacity', runningOnCompositor: true } ]); + }, 'overridden animation'); + + done(); +} + +</script> + +</body> diff --git a/dom/animation/test/chrome/test_animation_properties.html b/dom/animation/test/chrome/test_animation_properties.html new file mode 100644 index 0000000000..b9d6dcbef1 --- /dev/null +++ b/dom/animation/test/chrome/test_animation_properties.html @@ -0,0 +1,838 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1254419 - Test the values returned by + KeyframeEffect.getProperties()</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1254419" + target="_blank">Mozilla Bug 1254419</a> +<div id="log"></div> +<style> + +:root { + --var-100px: 100px; + --var-100px-200px: 100px 200px; +} +div { + font-size: 10px; /* For calculating em-based units */ +} +</style> +<script> +'use strict'; + +var gTests = [ + + // --------------------------------------------------------------------- + // + // Tests for property-indexed specifications + // + // --------------------------------------------------------------------- + + { desc: 'a one-property two-value property-indexed specification', + frames: { left: ['10px', '20px'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] } ] + }, + { desc: 'a one-shorthand-property two-value property-indexed' + + ' specification', + frames: { margin: ['10px', '10px 20px 30px 40px'] }, + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '10px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] } ] + }, + { desc: 'a two-property (one shorthand and one of its longhand' + + ' components) two-value property-indexed specification', + frames: { marginTop: ['50px', '60px'], + margin: ['10px', '10px 20px 30px 40px'] }, + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '50px', 'replace', 'linear'), + valueFormat(1, '60px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] } ] + }, + { desc: 'a two-property property-indexed specification with different' + + ' numbers of values', + frames: { left: ['10px', '20px', '30px'], + top: ['40px', '50px'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a property-indexed specification with an invalid value', + frames: { left: ['10px', '20px', '30px', '40px', '50px'], + top: ['15px', '25px', 'invalid', '45px', '55px'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '20px', 'replace', 'linear'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(0.75, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '15px', 'replace', 'linear'), + valueFormat(0.25, '25px', 'replace', 'linear'), + valueFormat(0.75, '45px', 'replace', 'linear'), + valueFormat(1, '55px', 'replace') ] } ] + }, + { desc: 'a one-property two-value property-indexed specification that' + + ' needs to stringify its values', + frames: { opacity: [0, 1] }, + expected: [ { property: 'opacity', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] } ] + }, + { desc: 'a property-indexed keyframe where a lesser shorthand precedes' + + ' a greater shorthand', + frames: { borderLeft: [ '1px solid rgb(1, 2, 3)', + '2px solid rgb(4, 5, 6)' ], + border: [ '3px dotted rgb(7, 8, 9)', + '4px dashed rgb(10, 11, 12)' ] }, + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + { desc: 'a property-indexed keyframe where a greater shorthand precedes' + + ' a lesser shorthand', + frames: { border: [ '3px dotted rgb(7, 8, 9)', + '4px dashed rgb(10, 11, 12)' ], + borderLeft: [ '1px solid rgb(1, 2, 3)', + '2px solid rgb(4, 5, 6)' ] }, + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(4, 5, 6)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(7, 8, 9)', 'replace', 'linear'), + valueFormat(1, 'rgb(10, 11, 12)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '2px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '3px', 'replace', 'linear'), + valueFormat(1, '4px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'solid', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + + // --------------------------------------------------------------------- + // + // Tests for keyframe sequences + // + // --------------------------------------------------------------------- + + { desc: 'a keyframe sequence specification with repeated values at' + + ' offset 0/1 with different easings', + frames: [ { offset: 0.0, left: '100px', easing: 'ease' }, + { offset: 0.0, left: '200px', easing: 'ease' }, + { offset: 0.5, left: '300px', easing: 'linear' }, + { offset: 1.0, left: '400px', easing: 'ease-out' }, + { offset: 1.0, left: '500px', easing: 'step-end' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '100px', 'replace'), + valueFormat(0, '200px', 'replace', 'ease'), + valueFormat(0.5, '300px', 'replace', 'linear'), + valueFormat(1, '400px', 'replace'), + valueFormat(1, '500px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 1, left: '20px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] } ] + }, + { desc: 'a two-property two-keyframe sequence', + frames: [ { offset: 0, left: '10px', top: '30px' }, + { offset: 1, left: '20px', top: '40px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '30px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] } ] + }, + { desc: 'a one shorthand property two-keyframe sequence', + frames: [ { offset: 0, margin: '10px' }, + { offset: 1, margin: '20px 30px 40px 50px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a two-property (a shorthand and one of its component longhands)' + + ' two-keyframe sequence', + frames: [ { offset: 0, margin: '10px', marginTop: '20px' }, + { offset: 1, marginTop: '70px', + margin: '30px 40px 50px 60px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '70px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '60px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with duplicate values for a given interior' + + ' offset', + frames: [ { offset: 0.0, left: '10px' }, + { offset: 0.5, left: '20px' }, + { offset: 0.5, left: '30px' }, + { offset: 0.5, left: '40px' }, + { offset: 1.0, left: '50px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.5, '20px', 'replace'), + valueFormat(0.5, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with duplicate values for offsets 0 and 1', + frames: [ { offset: 0, left: '10px' }, + { offset: 0, left: '20px' }, + { offset: 0, left: '30px' }, + { offset: 1, left: '40px' }, + { offset: 1, left: '50px' }, + { offset: 1, left: '60px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace'), + valueFormat(0, '30px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace'), + valueFormat(1, '60px', 'replace') ] } ] + }, + { desc: 'a two-property four-keyframe sequence', + frames: [ { offset: 0, left: '10px' }, + { offset: 0, top: '20px' }, + { offset: 1, top: '30px' }, + { offset: 1, left: '40px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '40px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a one-property keyframe sequence with some omitted offsets', + frames: [ { offset: 0.00, left: '10px' }, + { offset: 0.25, left: '20px' }, + { left: '30px' }, + { left: '40px' }, + { offset: 1.00, left: '50px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '20px', 'replace', 'linear'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(0.75, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a two-property keyframe sequence with some omitted offsets', + frames: [ { offset: 0.00, left: '10px', top: '20px' }, + { offset: 0.25, left: '30px' }, + { left: '40px' }, + { left: '50px', top: '60px' }, + { offset: 1.00, left: '70px', top: '80px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '30px', 'replace', 'linear'), + valueFormat(0.5, '40px', 'replace', 'linear'), + valueFormat(0.75, '50px', 'replace', 'linear'), + valueFormat(1, '70px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(0.75, '60px', 'replace', 'linear'), + valueFormat(1, '80px', 'replace') ] } ] + }, + { desc: 'a one-property keyframe sequence with all omitted offsets', + frames: [ { left: '10px' }, + { left: '20px' }, + { left: '30px' }, + { left: '40px' }, + { left: '50px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(0.25, '20px', 'replace', 'linear'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(0.75, '40px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence with different easing values, but the' + + ' same easing value for a given offset', + frames: [ { offset: 0.0, easing: 'ease', left: '10px'}, + { offset: 0.0, easing: 'ease', top: '20px'}, + { offset: 0.5, easing: 'linear', left: '30px' }, + { offset: 0.5, easing: 'linear', top: '40px' }, + { offset: 1.0, easing: 'step-end', left: '50px' }, + { offset: 1.0, easing: 'step-end', top: '60px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'ease'), + valueFormat(0.5, '30px', 'replace', 'linear'), + valueFormat(1, '50px', 'replace') ] }, + { property: 'top', + values: [ valueFormat(0, '20px', 'replace', 'ease'), + valueFormat(0.5, '40px', 'replace', 'linear'), + valueFormat(1, '60px', 'replace') ] } ] + }, + { desc: 'a one-property two-keyframe sequence that needs to' + + ' stringify its values', + frames: [ { offset: 0, opacity: 0 }, + { offset: 1, opacity: 1 } ], + expected: [ { property: 'opacity', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where shorthand precedes longhand', + frames: [ { offset: 0, margin: '10px', marginRight: '20px' }, + { offset: 1, margin: '30px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where longhand precedes shorthand', + frames: [ { offset: 0, marginRight: '20px', margin: '10px' }, + { offset: 1, margin: '30px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a keyframe sequence where lesser shorthand precedes greater' + + ' shorthand', + frames: [ { offset: 0, borderLeft: '1px solid rgb(1, 2, 3)', + border: '2px dotted rgb(4, 5, 6)' }, + { offset: 1, border: '3px dashed rgb(7, 8, 9)' } ], + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + { desc: 'a keyframe sequence where greater shorthand precedes' + + ' lesser shorthand', + frames: [ { offset: 0, border: '2px dotted rgb(4, 5, 6)', + borderLeft: '1px solid rgb(1, 2, 3)' }, + { offset: 1, border: '3px dashed rgb(7, 8, 9)' } ], + expected: [ { property: 'border-bottom-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-left-color', + values: [ valueFormat(0, 'rgb(1, 2, 3)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-right-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-top-color', + values: [ valueFormat(0, 'rgb(4, 5, 6)', 'replace', 'linear'), + valueFormat(1, 'rgb(7, 8, 9)', 'replace') ] }, + { property: 'border-bottom-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-left-width', + values: [ valueFormat(0, '1px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-right-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-top-width', + values: [ valueFormat(0, '2px', 'replace', 'linear'), + valueFormat(1, '3px', 'replace') ] }, + { property: 'border-bottom-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-left-style', + values: [ valueFormat(0, 'solid', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-right-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-top-style', + values: [ valueFormat(0, 'dotted', 'replace', 'linear'), + valueFormat(1, 'dashed', 'replace') ] }, + { property: 'border-image-outset', + values: [ valueFormat(0, '0', 'replace', 'linear'), + valueFormat(1, '0', 'replace') ] }, + { property: 'border-image-repeat', + values: [ valueFormat(0, 'stretch', 'replace', 'linear'), + valueFormat(1, 'stretch', 'replace') ] }, + { property: 'border-image-slice', + values: [ valueFormat(0, '100%', 'replace', 'linear'), + valueFormat(1, '100%', 'replace') ] }, + { property: 'border-image-source', + values: [ valueFormat(0, 'none', 'replace', 'linear'), + valueFormat(1, 'none', 'replace') ] }, + { property: 'border-image-width', + values: [ valueFormat(0, '1', 'replace', 'linear'), + valueFormat(1, '1', 'replace') ] }, + ] + }, + + // --------------------------------------------------------------------- + // + // Tests for unit conversion + // + // --------------------------------------------------------------------- + + { desc: 'em units are resolved to px values', + frames: { left: ['10em', '20em'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '100px', 'replace', 'linear'), + valueFormat(1, '200px', 'replace') ] } ] + }, + { desc: 'calc() expressions are resolved to the equivalent units', + frames: { left: ['calc(10em + 10px)', 'calc(10em + 10%)'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '110px', 'replace', 'linear'), + valueFormat(1, 'calc(10% + 100px)', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for CSS variable handling conversion + // + // --------------------------------------------------------------------- + + { desc: 'CSS variables are resolved to their corresponding values', + frames: { left: ['10px', 'var(--var-100px)'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '100px', 'replace') ] } ] + }, + { desc: 'CSS variables in calc() expressions are resolved', + frames: { left: ['10px', 'calc(var(--var-100px) / 2 - 10%)'] }, + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, 'calc(-10% + 50px)', 'replace') ] } ] + }, + { desc: 'CSS variables in shorthands are resolved to their corresponding' + + ' values', + frames: { margin: ['10px', 'var(--var-100px-200px)'] }, + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '100px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '200px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '100px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '200px', 'replace') ] } ] + }, + + // --------------------------------------------------------------------- + // + // Tests for properties that parse correctly but which we fail to + // convert to computed values. + // + // --------------------------------------------------------------------- + + { desc: 'a missing property in initial keyframe', + frames: [ { }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] } ] + }, + { desc: 'a missing property in initial keyframe and there are some ' + + 'keyframes with the same offset', + frames: [ { }, + { margin: '10px', offset: 0.5 }, + { margin: '20px', offset: 0.5 }, + { margin: '30px'} ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, '30px', 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe', + frames: [ { margin: '5px' }, + { } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe and there are some ' + + 'keyframes with the same offsets', + frames: [ { margin: '5px' }, + { margin: '10px', offset: 0.5 }, + { margin: '20px', offset: 0.5 }, + { } ], + expected: [ { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(0.5, '10px', 'replace'), + valueFormat(0.5, '20px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe where it forms the last' + + ' segment in the series', + frames: [ { margin: '5px' }, + { marginLeft: '5px', + marginRight: '5px', + marginBottom: '5px' } ], + expected: [ { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'a missing property in initial keyframe along with other values', + frames: [ { left: '10px' }, + { margin: '5px', left: '20px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-top', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '5px', 'replace') ] } ] + }, + { desc: 'a missing property in final keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { left: '20px' } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '20px', 'replace') ] }, + { property: 'margin-top', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-right', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-bottom', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] }, + { property: 'margin-left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'missing properties in both of initial and final keyframe', + frames: [ { left: '5px', offset: 0.5 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + { desc: 'missing propertes in both of initial and final keyframe along ' + + 'with other values', + frames: [ { left: '5px', offset: 0 }, + { right: '5px', offset: 0.5 }, + { left: '10px', offset: 1 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '5px', 'replace', 'linear'), + valueFormat(1, '10px', 'replace') ] }, + { property: 'right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(0.5, '5px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + + { desc: 'a missing property in final keyframe with duplicate offset ' + + + 'along with other values', + frames: [ { left: '5px', right: '5px', offset: 0 }, + { left: '8px', right: '8px', offset: 0 }, + { left: '10px', offset: 1 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '5px', 'replace'), + valueFormat(0, '8px', 'replace', 'linear'), + valueFormat(1, '10px', 'replace') ] }, + { property: 'right', + values: [ valueFormat(0, '5px', 'replace'), + valueFormat(0, '8px', 'replace', 'linear'), + valueFormat(1, undefined, 'replace') ] } ] + }, + + { desc: 'a missing property in initial keyframe with duplicate offset ' + + 'along with other values', + frames: [ { left: '10px', offset: 0 }, + { left: '8px', right: '8px', offset: 1 }, + { left: '5px', right: '5px', offset: 1 } ], + expected: [ { property: 'left', + values: [ valueFormat(0, '10px', 'replace', 'linear'), + valueFormat(1, '8px', 'replace'), + valueFormat(1, '5px', 'replace') ] }, + { property: 'right', + values: [ valueFormat(0, undefined, 'replace', 'linear'), + valueFormat(1, '8px', 'replace'), + valueFormat(1, '5px', 'replace') ] } ] + }, +]; + +SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.core.enabled", true], + ["dom.animations-api.implicit-keyframes.enabled", true], + ], + }, + function() { + gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + // Flush styles since getProperties currently does not. Rather, it + // returns the actual properties in use at the current time. + // However, we want to test what these properties will look like + // after the next restyle. + getComputedStyle(div).opacity; + assert_properties_equal( + animation.effect.getProperties(), + subtest.expected + ); + }, subtest.desc); + }); + + done(); + } +); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_animation_properties_display.html b/dom/animation/test/chrome/test_animation_properties_display.html new file mode 100644 index 0000000000..184fc9d3bc --- /dev/null +++ b/dom/animation/test/chrome/test_animation_properties_display.html @@ -0,0 +1,43 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1536688 - Test that 'display' is not included in + KeyframeEffect.getProperties() when using shorthand 'all'</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1536688" + target="_blank">Mozilla Bug 1536688</a> +<div id="log"></div> +<script> +'use strict'; + +SpecialPowers.pushPrefEnv( + { + set: [['dom.animations-api.core.enabled', true]], + }, + function() { + test(t => { + const div = addDiv(t); + const animation = div.animate( + { all: ['unset', 'unset'] }, + 100 * MS_PER_SEC + ); + // Flush styles since getProperties does not. + getComputedStyle(div).opacity; + + const properties = animation.effect.getProperties(); + assert_false( + properties.some(property => property.property === 'display'), + 'Should not have a property for display' + ); + }); + + done(); + } +); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_cssanimation_missing_keyframes.html b/dom/animation/test/chrome/test_cssanimation_missing_keyframes.html new file mode 100644 index 0000000000..1d19386712 --- /dev/null +++ b/dom/animation/test/chrome/test_cssanimation_missing_keyframes.html @@ -0,0 +1,73 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1339332 - Test for missing keyframes in CSS Animation</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1339332" + target="_blank">Mozilla Bug 1339332</a> +<div id="log"></div> +<style> +@keyframes missingFrom { + to { + text-align: right; + } +} +@keyframes missingBoth { + 50% { + text-align: right; + } +} +@keyframes missingTo { + from { + text-align: right; + } +} +</style> +<script> +'use strict'; + +const gTests = [ + { desc: 'missing "from" keyframe', + animationName: 'missingFrom', + expected: [{ property: 'text-align', + values: [valueFormat(0, undefined, 'replace', 'ease'), + valueFormat(1, 'right', 'replace')] } ] + }, + { desc: 'missing "to" keyframe', + animationName: 'missingTo', + expected: [{ property: 'text-align', + values: [valueFormat(0, 'right', 'replace', 'ease'), + valueFormat(1, undefined, 'replace')] } ] + }, + { desc: 'missing "from" and "to" keyframes', + animationName: 'missingBoth', + expected: [{ property: 'text-align', + values: [valueFormat(0, undefined, 'replace', 'ease'), + valueFormat(.5, 'right', 'replace', 'ease'), + valueFormat(1, undefined, 'replace')] } ] + }, +]; + +SpecialPowers.pushPrefEnv( + { set: [["dom.animations-api.core.enabled", true]] }, + function() { + gTests.forEach(function(subtest) { + test(function(t) { + const div = addDiv(t); + div.style.animation = `${ subtest.animationName } 1000s`; + const animation = div.getAnimations()[0]; + assert_properties_equal(animation.effect.getProperties(), + subtest.expected); + }, subtest.desc); + }); + + done(); + } +); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_generated_content_getAnimations.html b/dom/animation/test/chrome/test_generated_content_getAnimations.html new file mode 100644 index 0000000000..08de27f57d --- /dev/null +++ b/dom/animation/test/chrome/test_generated_content_getAnimations.html @@ -0,0 +1,83 @@ +<!DOCTYPE html> +<head> +<meta charset=utf-8> +<title>Test getAnimations() for generated-content elements</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +@keyframes anim { } +@keyframes anim2 { } +.before::before { + content: ''; + animation: anim 100s; +} +.after::after { + content: ''; + animation: anim 100s, anim2 100s; +} +</style> +</head> +<body> +<div id='root' class='before after'> + <div class='before'></div> + <div></div> +</div> +<script> +'use strict'; + +const {Cc, Ci, Cu} = SpecialPowers; + +function getWalker(node) { + var walker = Cc["@mozilla.org/inspector/deep-tree-walker;1"]. + createInstance(Ci.inIDeepTreeWalker); + walker.showAnonymousContent = true; + walker.init(node.ownerDocument, NodeFilter.SHOW_ALL); + walker.currentNode = node; + return walker; +} + +test(function(t) { + var root = document.getElementById('root'); + // Flush first to make sure the generated-content elements are ready + // in the tree. + flushComputedStyle(root); + var before = getWalker(root).firstChild(); + var after = getWalker(root).lastChild(); + + // Sanity Checks + assert_equals(document.getAnimations().length, 4, + 'All animations in this document'); + assert_equals(before.tagName, '_moz_generated_content_before', + 'First child is ::before element'); + assert_equals(after.tagName, '_moz_generated_content_after', + 'Last child is ::after element'); + + // Test Element.getAnimations() for generated-content elements + assert_equals(before.getAnimations().length, 1, + 'Animations of ::before generated-content element'); + assert_equals(after.getAnimations().length, 2, + 'Animations of ::after generated-content element'); +}, 'Element.getAnimations() used on generated-content elements'); + +test(function(t) { + var root = document.getElementById('root'); + flushComputedStyle(root); + var walker = getWalker(root); + + var animations = []; + var element = walker.currentNode; + while (element) { + if (element.getAnimations) { + animations = [...animations, ...element.getAnimations()]; + } + element = walker.nextNode(); + } + + assert_equals(animations.length, document.getAnimations().length, + 'The number of animations got by DeepTreeWalker and ' + + 'document.getAnimations() should be the same'); +}, 'Element.getAnimations() used by traversing DeepTreeWalker'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_keyframe_effect_xrays.html b/dom/animation/test/chrome/test_keyframe_effect_xrays.html new file mode 100644 index 0000000000..ca3e712ac5 --- /dev/null +++ b/dom/animation/test/chrome/test_keyframe_effect_xrays.html @@ -0,0 +1,45 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1414674" + target="_blank">Mozilla Bug 1414674</a> +<div id="log"></div> +<iframe id="iframe" + src="http://example.org/tests/dom/animation/test/chrome/file_animate_xrays.html"></iframe> +<script> +'use strict'; + +var win = document.getElementById('iframe').contentWindow; + +async_test(function(t) { + window.addEventListener('load', t.step_func(function() { + var target = win.document.getElementById('target'); + var effect = new win.KeyframeEffect(target, [ + {opacity: 1, offset: 0}, + {opacity: 0, offset: 1}, + ], {duration: 100 * MS_PER_SEC, fill: "forwards"}); + // The frames object should be accessible via x-ray. + var frames = effect.getKeyframes(); + assert_equals(frames.length, 2, + "frames for KeyframeEffect ctor should be non-zero"); + assert_equals(frames[0].opacity, "1", + "first frame opacity for KeyframeEffect ctor should be specified value"); + assert_equals(frames[0].computedOffset, 0, + "first frame offset for KeyframeEffect ctor should be 0"); + assert_equals(frames[1].opacity, "0", + "last frame opacity for KeyframeEffect ctor should be specified value"); + assert_equals(frames[1].computedOffset, 1, + "last frame offset for KeyframeEffect ctor should be 1"); + var animation = new win.Animation(effect, document.timeline); + animation.play(); + t.done(); + })); +}, 'Calling KeyframeEffect() ctor across x-rays'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html b/dom/animation/test/chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html new file mode 100644 index 0000000000..f8efaa6baf --- /dev/null +++ b/dom/animation/test/chrome/test_mutation_observer_for_element_removal_in_shadow_tree.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<div id="log"></div> +<script> + +promise_test(async t => { + // Set up a MutationObserver for animations. + const observer = new MutationObserver(() => {}); + observer.observe(document.documentElement, { + animations: true, + subtree: true, + }); + + // Create a CSS transition in a shadow tree. + let s = document.createElement('shadow-test'); + document.documentElement.appendChild(s); + s.attachShadow({mode:"open"}); + + let property = 'opacity'; + let initial = '1'; + let finalValue = '0'; + + let div = document.createElement('div'); + div.style = `${property}:${initial};transition:${property} 2s;` + + s.shadowRoot.appendChild(div); + div.offsetWidth; + + div.style[property] = finalValue; + + const eventWatcher = new EventWatcher(t, div, ['transitionstart']); + + // Trigger a CSS transition. + getComputedStyle(div)[property]; + + // Wait for a transitionend event to make sure the transition has been started. + await eventWatcher.wait_for('transitionstart'); + + // Now remove the element to notify it to the observer + div.parentNode.removeChild(div); +}); +</script> diff --git a/dom/animation/test/chrome/test_running_on_compositor.html b/dom/animation/test/chrome/test_running_on_compositor.html new file mode 100644 index 0000000000..532c11258f --- /dev/null +++ b/dom/animation/test/chrome/test_running_on_compositor.html @@ -0,0 +1,1516 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1045994 - Add a chrome-only property to inspect if an animation is + running on the compositor or not</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +<style> +@keyframes anim { + to { transform: translate(100px) } +} +@keyframes transform-starts-with-none { + 0% { transform: none } + 99% { transform: none } + 100% { transform: translate(100px) } +} +@keyframes opacity { + to { opacity: 0 } +} +@keyframes zIndex_and_translate { + to { z-index: 999; transform: translate(100px); } +} +@keyframes z-index { + to { z-index: 999; } +} +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes rotate-and-opacity { + from { transform: rotate(0deg); opacity: 1;} + to { transform: rotate(360deg); opacity: 0;} +} +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1045994" + target="_blank">Mozilla Bug 1045994</a> +<div id="log"></div> +<script> +'use strict'; + +/** Test for bug 1045994 - Add a chrome-only property to inspect if an + animation is running on the compositor or not **/ + +const omtaEnabled = isOMTAEnabled(); + +function assert_animation_is_running_on_compositor(animation, desc) { + assert_equals(animation.isRunningOnCompositor, omtaEnabled, + desc + ' at ' + animation.currentTime + 'ms'); +} + +function assert_animation_is_not_running_on_compositor(animation, desc) { + assert_equals(animation.isRunningOnCompositor, false, + desc + ' at ' + animation.currentTime + 'ms'); +} + +promise_test(async t => { + // FIXME: When we implement Element.animate, use that here instead of CSS + // so that we remove any dependency on the CSS mapping. + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + // If the animation starts at the current timeline time, we need to wait for + // one more frame to avoid receiving the fake timer-based MozAfterPaint event. + // FIXME: Bug 1419226: Drop this 'animation.ready' and 'waitForFrame'. Once + // MozAfterPaint is fired reliably, we just need to wait for a MozAfterPaint + // here. + await animation.ready; + + if (animationStartsRightNow(animation)) { + await waitForNextFrame(); + } + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' during playback'); + + div.style.animationPlayState = 'paused'; + + await animation.ready; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when paused'); +}, ''); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: z-index 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' for animation of "z-index"'); +}, 'isRunningOnCompositor is false for animation of "z-index"'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: zIndex_and_translate 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when the animation has two properties, where one can run' + + ' on the compositor, the other cannot'); +}, 'isRunningOnCompositor is true if the animation has at least one ' + + 'property can run on compositor'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.pause(); + await animation.ready; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when animation.pause() is called'); +}, 'isRunningOnCompositor is false when the animation.pause() is called'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.finish(); + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after animation.finish() is called'); + // Check that we don't set the flag back again on the next tick. + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after animation.finish() is called'); +}, 'isRunningOnCompositor is false when the animation.finish() is called'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.currentTime = 100 * MS_PER_SEC; + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after manually seeking the animation to the end'); + // Check that we don't set the flag back again on the next tick. + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after manually seeking the animation to the end'); +}, 'isRunningOnCompositor is false when manually seeking the animation to ' + + 'the end'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + animation.cancel(); + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' immediately after animation.cancel() is called'); + // Check that we don't set the flag back again on the next tick. + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' on the next tick after animation.cancel() is called'); +}, 'isRunningOnCompositor is false when animation.cancel() is called'); + +// This is to test that we don't simply clobber the flag when ticking +// animations and then set it again during painting. +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + await new Promise(resolve => { + window.requestAnimationFrame(() => { + t.step(() => { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in requestAnimationFrame callback'); + }); + + resolve(); + }); + }); +}, 'isRunningOnCompositor is true in requestAnimationFrame callback'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: anim 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + await new Promise(resolve => { + var observer = new MutationObserver(records => { + var changedAnimation; + + records.forEach(record => { + changedAnimation = + record.changedAnimations.find(changedAnim => { + return changedAnim == animation; + }); + }); + + t.step(() => { + assert_true(!!changedAnimation, 'The animation should be recorded ' + + 'as one of the changedAnimations'); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in MutationObserver callback'); + }); + + resolve(); + }); + observer.observe(div, { animations: true, subtree: false }); + t.add_cleanup(() => { + observer.disconnect(); + }); + div.style.animationDuration = "200s"; + }); +}, 'isRunningOnCompositor is true in MutationObserver callback'); + +// This is to test that we don't temporarily clear the flag when forcing +// an unthrottled sample. +promise_test(async t => { + var div = addDiv(t, { style: 'animation: rotate 100s' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + await new Promise(resolve => { + var timeAtStart = window.performance.now(); + function handleFrame() { + t.step(() => { + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' in requestAnimationFrame callback'); + }); + + // we have to wait at least 200ms because this animation is + // unthrottled on every 200ms. + // See https://hg.mozilla.org/mozilla-central/file/cafb1c90f794/layout/style/AnimationCommon.cpp#l863 + if (window.performance.now() - timeAtStart > 200) { + resolve(); + return; + } + window.requestAnimationFrame(handleFrame); + } + window.requestAnimationFrame(handleFrame); + }); +}, 'isRunningOnCompositor remains true in requestAnimationFrameCallback for ' + + 'overflow animation'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Transition reports that it is running on the compositor' + + ' during playback for opacity transition'); +}, 'isRunningOnCompositor for transitions'); + +promise_test(async t => { + var div = addDiv(t, { style: 'animation: rotate-and-opacity 100s; ' + + 'backface-visibility: hidden; ' + + 'transform: none !important;' }); + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'If an animation has a property that can run on the compositor and a ' + + 'property that cannot (due to Gecko limitations) but where the latter' + + 'property is overridden in the CSS cascade, the animation should ' + + 'still report that it is running on the compositor'); +}, 'isRunningOnCompositor is true when a property that would otherwise block ' + + 'running on the compositor is overridden in the CSS cascade'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 150 * MS_PER_SEC; + animation.effect.updateTiming({ duration: 100 * MS_PER_SEC }); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when the animation is set a shorter duration than current time'); +}, 'animation is immediately removed from compositor' + + 'when the duration is made shorter than the current time'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.currentTime = 500 * MS_PER_SEC; + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when finished'); + + animation.effect.updateTiming({ duration: 1000 * MS_PER_SEC }); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when restarted'); +}, 'animation is added to compositor' + + ' when the duration is made longer than the current time'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.updateTiming({ endDelay: 100 * MS_PER_SEC }); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when endDelay is changed'); + + animation.currentTime = 110 * MS_PER_SEC; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when currentTime is during endDelay'); +}, 'animation is removed from compositor' + + ' when current time is made longer than the duration even during endDelay'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.updateTiming({ endDelay: -200 * MS_PER_SEC }); + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when endTime is negative value'); +}, 'animation is removed from compositor' + + ' when endTime is negative value'); + +promise_test(async t => { + var animation = addDivAndAnimate(t, + {}, + { opacity: [ 0, 1 ] }, 200 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.updateTiming({ endDelay: -100 * MS_PER_SEC }); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor' + + ' when endTime is positive and endDelay is negative'); + animation.currentTime = 110 * MS_PER_SEC; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the compositor' + + ' when currentTime is after endTime'); +}, 'animation is NOT running on compositor' + + ' when endTime is positive and endDelay is negative'); + +promise_test(async t => { + var effect = new KeyframeEffect(null, + { opacity: [ 0, 1 ] }, + 100 * MS_PER_SEC); + var animation = new Animation(effect, document.timeline); + animation.play(); + + var div = addDiv(t); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation with null target reports that it is not running ' + + 'on the compositor'); + + animation.effect.target = div; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor ' + + 'after setting a valid target'); +}, 'animation is added to the compositor when setting a valid target'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation reports that it is running on the compositor'); + + animation.effect.target = null; + assert_animation_is_not_running_on_compositor(animation, + 'Animation reports that it is NOT running on the ' + + 'compositor after setting null target'); +}, 'animation is removed from the compositor when setting null target'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ opacity: [ 0, 1 ] }, + { duration: 100 * MS_PER_SEC, + delay: 100 * MS_PER_SEC, + fill: 'backwards' }); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation with fill:backwards in delay phase reports ' + + 'that it is running on the compositor'); + + animation.currentTime = 100 * MS_PER_SEC; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Animation with fill:backwards in delay phase reports ' + + 'that it is running on the compositor after delay phase'); +}, 'animation with fill:backwards in delay phase is running on the ' + + ' compositor while it is in delay phase'); + +promise_test(async t => { + const animation = addDiv(t).animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + animation.playbackRate = -1; + animation.currentTime = 200 * MS_PER_SEC; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation with negative playback rate is runnning on the' + + ' compositor even before it reaches the active interval'); +}, 'animation with negative playback rate is sent to the compositor even in' + + ' after phase'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate([{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }], 100 * MS_PER_SEC); + + var another = addDiv(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on a 100% opacity keyframe reports ' + + 'that it is running on the compositor from the begining'); + + animation.effect.target = another; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on a 100% opacity keyframe keeps ' + + 'running on the compositor after changing the target ' + + 'element'); +}, '100% opacity animations with keeps running on the ' + + 'compositor after changing the target element'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect.setKeyframes([{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }]); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + '100% opacity animation set by using setKeyframes reports ' + + 'that it is running on the compositor'); +}, '100% opacity animation set up by converting an existing animation with ' + + 'cannot be run on the compositor, is running on the compositor'); + +promise_test(async t => { + var div = addDiv(t); + var animation = div.animate({ color: ['red', 'black'] }, 100 * MS_PER_SEC); + var effect = new KeyframeEffect(div, + [{ opacity: 1, offset: 0 }, + { opacity: 1, offset: 0.99 }, + { opacity: 0, offset: 1 }], + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Color animation reports that it is not running on the ' + + 'compositor'); + + animation.effect = effect; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + '100% opacity animation set up by changing effects reports ' + + 'that it is running on the compositor'); +}, '100% opacity animation set up by changing the effects on an existing ' + + 'animation which cannot be run on the compositor, is running on the ' + + 'compositor'); + +promise_test(async t => { + var div = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has 100% opacity style with ' + + '!important flag reports that it is not running on the compositor'); + // Clear important flag from the opacity style on the target element. + div.style.setProperty("opacity", "1", ""); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation reports that it is running on the compositor after ' + + 'clearing the !important flag'); +}, 'Clearing *important* opacity style on the target element sends the ' + + 'animation to the compositor'); + +promise_test(async t => { + var div = addDiv(t); + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority opacity animation on an element ' + + 'reports that it is running on the compositor'); + assert_animation_is_running_on_compositor(lowerAnimation, + 'A lower-priority opacity animation on the same ' + + 'element also reports that it is running on the compositor'); +}, 'Opacity animations on the same element run on the compositor'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; opacity: 1' }); + + getComputedStyle(div).opacity; + + div.style.opacity = 0; + getComputedStyle(div).opacity; + + var transition = div.getAnimations()[0]; + var animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'An opacity animation on an element reports that' + + 'that it is running on the compositor'); + assert_animation_is_running_on_compositor(transition, + 'An opacity transition on the same element reports that ' + + 'it is running on the compositor'); +}, 'Both of transition and script animation on the same element run on the ' + + 'compositor'); + +promise_test(async t => { + var div = addDiv(t); + var importantOpacityElement = addDiv(t, { style: "opacity: 1 ! important" }); + + var animation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation on an element reports ' + + 'that it is running on the compositor'); + + animation.effect.target = null; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation is no longer running on the compositor after ' + + 'removing from the element'); + animation.effect.target = importantOpacityElement; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation is NOT running on the compositor even after ' + + 'being applied to a different element which has an ' + + '!important opacity declaration'); +}, 'Animation continues not running on the compositor after being ' + + 'applied to an element which has an important declaration and ' + + 'having previously been temporarily associated with no target element'); + +promise_test(async t => { + var div = addDiv(t); + var another = addDiv(t); + + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = another.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'An opacity animation on an element reports that ' + + 'it is running on the compositor'); + assert_animation_is_running_on_compositor(higherAnimation, + 'Opacity animation on a different element reports ' + + 'that it is running on the compositor'); + + lowerAnimation.effect.target = null; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(lowerAnimation, + 'Animation is no longer running on the compositor after ' + + 'being removed from the element'); + lowerAnimation.effect.target = another; + await waitForFrame(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'A lower-priority animation begins running ' + + 'on the compositor after being applied to an element ' + + 'which has a higher-priority animation'); + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority animation continues to run on the ' + + 'compositor even after a lower-priority animation is ' + + 'applied to the same element'); +}, 'Animation begins running on the compositor after being applied ' + + 'to an element which has a higher-priority animation and after ' + + 'being temporarily associated with no target element'); + +promise_test(async t => { + var div = addDiv(t); + var another = addDiv(t); + + var lowerAnimation = div.animate({ opacity: [1, 0] }, 100 * MS_PER_SEC); + var higherAnimation = another.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'An opacity animation on an element reports that ' + + 'it is running on the compositor'); + assert_animation_is_running_on_compositor(higherAnimation, + 'Opacity animation on a different element reports ' + + 'that it is running on the compositor'); + + higherAnimation.effect.target = null; + await waitForFrame(); + + assert_animation_is_not_running_on_compositor(higherAnimation, + 'Animation is no longer running on the compositor after ' + + 'being removed from the element'); + higherAnimation.effect.target = div; + await waitForFrame(); + + assert_animation_is_running_on_compositor(lowerAnimation, + 'Animation continues running on the compositor after ' + + 'a higher-priority animation applied to the same element'); + assert_animation_is_running_on_compositor(higherAnimation, + 'A higher-priority animation begins to running on the ' + + 'compositor after being applied to an element which has ' + + 'a lower-priority-animation'); +}, 'Animation begins running on the compositor after being applied ' + + 'to an element which has a lower-priority animation once after ' + + 'disassociating with an element'); + +var delayPhaseTests = [ + { + desc: 'script animation of opacity', + setupAnimation: t => { + return addDiv(t).animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'script animation of transform', + setupAnimation: t => { + return addDiv(t).animate( + { transform: ['translateX(0px)', 'translateX(100px)'] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'CSS animation of opacity', + setupAnimation: t => { + return addDiv(t, { style: 'animation: opacity 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS animation of transform', + setupAnimation: t => { + return addDiv(t, { style: 'animation: anim 100s 100s' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of opacity', + setupAnimation: t => { + var div = addDiv(t, { style: 'transition: opacity 100s 100s' }); + getComputedStyle(div).opacity; + + div.style.opacity = 0; + return div.getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform', + setupAnimation: t => { + var div = addDiv(t, { style: 'transition: transform 100s 100s' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseTests.forEach(test => { + promise_test(async t => { + var animation = test.setupAnimation(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in the delay phase'); + }, 'isRunningOnCompositor for ' + test.desc + ' is true even though ' + + 'it is in the delay phase'); +}); + +// The purpose of thie test cases is to check that +// NS_FRAME_MAY_BE_TRANSFORMED flag on the associated nsIFrame persists +// after transform style on the frame is removed. +var delayPhaseWithTransformStyleTests = [ + { + desc: 'script animation of transform with transform style', + setupAnimation: t => { + return addDiv(t, { style: 'transform: translateX(10px)' }).animate( + { transform: ['translateX(0px)', 'translateX(100px)'] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + }, + }, + { + desc: 'CSS animation of transform with transform style', + setupAnimation: t => { + return addDiv(t, { style: 'animation: anim 100s 100s;' + + 'transform: translateX(10px)' }) + .getAnimations()[0]; + }, + }, + { + desc: 'CSS transition of transform with transform style', + setupAnimation: t => { + var div = addDiv(t, { style: 'transition: transform 100s 100s;' + + 'transform: translateX(10px)'}); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + return div.getAnimations()[0]; + }, + }, +]; + +delayPhaseWithTransformStyleTests.forEach(test => { + promise_test(async t => { + var animation = test.setupAnimation(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in the delay phase'); + + // Remove the initial transform style during delay phase. + animation.effect.target.style.transform = 'none'; + await animation.ready; + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it keeps running on the ' + + 'compositor after removing the initial transform style'); + }, 'isRunningOnCompositor for ' + test.desc + ' is true after removing ' + + 'the initial transform style during the delay phase'); +}); + +var startsWithNoneTests = [ + { + desc: 'script animation of transform starts with transform:none segment', + setupAnimation: t => { + return addDiv(t).animate( + { transform: ['none', 'none', 'translateX(100px)'] }, 100 * MS_PER_SEC); + }, + }, + { + desc: 'CSS animation of transform starts with transform:none segment', + setupAnimation: t => { + return addDiv(t, + { style: 'animation: transform-starts-with-none 100s 100s' }) + .getAnimations()[0]; + }, + }, +]; + +startsWithNoneTests.forEach(test => { + promise_test(async t => { + var animation = test.setupAnimation(t); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + test.desc + ' reports that it is running on the ' + + 'compositor even though it is in transform:none segment'); + }, 'isRunningOnCompositor for ' + test.desc + ' is true even though ' + + 'it is in transform:none segment'); +}); + +promise_test(async t => { + var div = addDiv(t, { style: 'opacity: 1 ! important' }); + + var animation = div.animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has opacity:1 important style' + + 'reports that it is not running on the compositor'); + // Clear the opacity style on the target element. + div.style.setProperty("opacity", "1", ""); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animations reports that it is running on the compositor after ' + + 'clearing the opacity style on the element'); +}, 'Clearing *important* opacity style on the target element sends the ' + + 'animation to the compositor even if the animation is in the delay phase'); + +promise_test(async t => { + var opaqueDiv = addDiv(t, { style: 'opacity: 1 ! important' }); + var anotherDiv = addDiv(t); + + var animation = opaqueDiv.animate( + { opacity: [0, 1] }, + { delay: 100 * MS_PER_SEC, duration: 100 * MS_PER_SEC }); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Opacity animation on an element which has opacity:1 important style' + + 'reports that it is not running on the compositor'); + // Changing target element to another element which has no opacity style. + animation.effect.target = anotherDiv; + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animations reports that it is running on the compositor after ' + + 'changing the target element to another elemenent having no ' + + 'opacity style'); +}, 'Changing target element of opacity animation sends the animation to the ' + + 'the compositor even if the animation is in the delay phase'); + +promise_test(async t => { + var animation = + addDivAndAnimate(t, + {}, + { width: ['100px', '200px'] }, + { duration: 100 * MS_PER_SEC, delay: 100 * MS_PER_SEC }); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Width animation reports that it is not running on the compositor ' + + 'in the delay phase'); + // Changing to property runnable on the compositor. + animation.effect.setKeyframes({ opacity: [0, 1] }); + await waitForFrame(); + + assert_animation_is_running_on_compositor(animation, + 'Opacity animation reports that it is running on the compositor ' + + 'after changing the property from width property in the delay phase'); +}, 'Dynamic change to a property runnable on the compositor ' + + 'in the delay phase'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; ' + + 'opacity: 0 !important' }); + getComputedStyle(div).opacity; + + div.style.setProperty('opacity', '1', 'important'); + getComputedStyle(div).opacity; + + var animation = div.getAnimations()[0]; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Transition reports that it is running on the compositor even if the ' + + 'property is overridden by an !important rule'); +}, 'Transitions override important rules'); + +promise_test(async t => { + var div = addDiv(t, { style: 'transition: opacity 100s; ' + + 'opacity: 0 !important' }); + getComputedStyle(div).opacity; + + div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + div.style.setProperty('opacity', '1', 'important'); + getComputedStyle(div).opacity; + + var [transition, animation] = div.getAnimations(); + + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(transition, + 'Transition suppressed by an animation which is overridden by an ' + + '!important rule reports that it is NOT running on the compositor'); + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); +}, 'Neither transition nor animation does run on the compositor if the ' + + 'property is overridden by an !important rule'); + +promise_test(async t => { + var div = addDiv(t, { style: 'display: table' }); + var animation = + div.animate({ transform: ['rotate(0deg)', 'rotate(360deg)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Transform animation on display:table element should be running on the' + + ' compositor'); +}, 'Transform animation on display:table element runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t, { style: 'display: table' }); + const animation = div.animate(null, 100 * MS_PER_SEC); + const effect = new KeyframeEffect(div, + { transform: ['none', 'none']}, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + animation.effect = effect; + + await waitForNextFrame(); + await waitForPaints(); + + assert_animation_is_running_on_compositor( + animation, + 'Transform animation on table element should be running on the compositor' + ); +}, 'Empty transform effect assigned after the fact to display:table content' + + ' runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ backgroundColor: ['blue', 'green'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'background-color animation should be running on the compositor'); +}, 'background-color animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ backgroundColor: ['blue', 'green'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'background-color animation should be running on the compositor'); + + // Add a red opaque background image covering the background color animation. + div.style.backgroundImage = + 'url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAIAAAAC64' + + 'paAAAAG0lEQVR42mP8z0A%2BYKJA76jmUc2jmkc1U0EzACKcASfOgGoMAAAAAElFTkSuQmCC)'; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + // Bug 1712246. We should optimize this case eventually. + //assert_animation_is_not_running_on_compositor(animation, + // 'Opaque background image stops background-color animations from running ' + + // 'on the compositor'); +}, 'Opaque background image stops background-color animations from running ' + + ' on the compositor'); + +promise_test(async t => { + await SpecialPowers.pushPrefEnv({ + set: [["gfx.omta.background-color", false]] + }); + + const div = addDiv(t); + const animation = div.animate({ backgroundColor: ['blue', 'green'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'background-color animation should NOT be running on the compositor ' + + 'if the pref is disabled'); +}, 'background-color animation does not run on the compositor if the pref ' + + 'is disabled'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'translate animation should be running on the compositor'); +}, 'translate animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ rotate: ['0deg', '45deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'rotate animation should be running on the compositor'); +}, 'rotate animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ scale: ['1 1', '2 2'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'scale animation should be running on the compositor'); +}, 'scale animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(20px)', + 'translate(30px)'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'multiple transform-like properties animation should be running on the ' + + 'compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(property.runningOnCompositor, + property.property + ' is running on the compositor'); + }); +}, 'Multiple transform-like properties animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['none', 'none'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path animation should be running on the compositor even if ' + + 'it is always none'); +}, 'offset-path none animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetPath: ['path("M0 0l100 100")', + 'path("M0 0l200 200")'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-path animation should be running on the compositor'); +}, 'offset-path animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-distance animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-distance animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-distance animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetRotate: ['0deg', '45deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-rotate animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-rotate animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-rotate animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ offsetAnchor: ['0% 0%', '100% 100%'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'offset-anchor animation is not running on the compositor because ' + + 'offset-path is none'); + + const newAnim = div.animate({ offsetPath: ['None', 'None'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(newAnim); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'offset-anchor animation should be running on the compositor'); + assert_animation_is_running_on_compositor(newAnim, + 'new added offset-path animation should be running on the compositor'); +}, 'offset-anchor animation runs on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(0px)', + 'translate(100px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation is running on the compositor even though we do not have ' + + 'offset-path'); + + div.style.offsetPath = 'path("M50 0v100")'; + getComputedStyle(div).offsetPath; + + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation is running on the compositor'); + +}, 'Multiple transform-like properties (include motion-path) animation runs ' + + 'on the compositor'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(20px)', + 'translate(30px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + div.style.setProperty('translate', '50px', 'important'); + getComputedStyle(div).translate; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(!property.runningOnCompositor, + property.property + ' is not running on the compositor'); + }); +}, 'Multiple transform-like properties animation does not runs on the ' + + 'compositor because one of the transform-like property is overridden ' + + 'by an !important rule'); + +// FIXME: Bug 1593106: We should still run the animations on the compositor if +// offset-* doesn't have any effect. +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ translate: ['0px', '100px'], + rotate: ['0deg', '45deg'], + transform: ['translate(0px)', + 'translate(100px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + div.style.setProperty('offset-distance', '50%', 'important'); + getComputedStyle(div).offsetDistance; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_not_running_on_compositor(animation, + 'Animation overridden by an !important rule reports that it is ' + + 'NOT running on the compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(!property.runningOnCompositor, + property.property + ' is not running on the compositor'); + }); +}, 'Multiple transform-like properties animation does not runs on the ' + + 'compositor because one of the offset-* property is overridden ' + + 'by an !important rule'); + +promise_test(async t => { + const div = addDiv(t); + const animation = div.animate({ rotate: ['0deg', '45deg'], + transform: ['translate(20px)', + 'translate(30px)'], + offsetDistance: ['0%', '100%'] }, + 100 * MS_PER_SEC); + + div.style.setProperty('translate', '50px', 'important'); + getComputedStyle(div).translate; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'Animation is still running on the compositor'); + + const properties = animation.effect.getProperties(); + properties.forEach(property => { + assert_true(property.runningOnCompositor, + property.property + ' is running on the compositor'); + }); +}, 'Multiple transform-like properties animation still runs on the ' + + 'compositor because the overridden-by-!important property does not have ' + + 'animation'); + +promise_test(async t => { + // We should run the animations on the compositor for this case: + // 1. A transition of 'translate' + // 2. An !important rule on 'translate' + // 3. An animation of 'scale' + const div = addDiv(t, { style: 'translate: 100px !important;' }); + const animation = div.animate({ rotate: ['0deg', '45deg'] }, + 100 * MS_PER_SEC); + div.style.transition = 'translate 100s'; + getComputedStyle(div).transition; + + await waitForAnimationReadyToRestyle(animation); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation, + 'rotate animation should be running on the compositor'); + + div.style.setProperty('translate', '200px', 'important'); + getComputedStyle(div).translate; + + const anims = div.getAnimations(); + await waitForPaints(); + + assert_animation_is_running_on_compositor(anims[0], + `${anims[0].effect.getProperties()[0].property} animation should be ` + + `running on the compositor`); + assert_animation_is_running_on_compositor(anims[1], + `${anims[1].effect.getProperties()[0].property} animation should be ` + + `running on the compositor`); +}, 'Transform-like animations and transitions still runs on the compositor ' + + 'because the !important rule is overridden by a transition, and the ' + + 'transition property does not have animations'); + +promise_test(async t => { + const container = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const targetA = addDiv(t, { style: 'transform-style: preserve-3d' }); + const targetB = addDiv(t, { style: 'transform-style: preserve-3d' }); + const targetC = addDiv(t); + container.appendChild(targetA); + targetA.append(targetB); + targetB.append(targetC); + + const animation1 = targetA.animate({ rotate: ['0 0 1 0deg', '1 1 1 45deg'] }, + 100 * MS_PER_SEC); + + const animation2 = targetC.animate({ rotate: ['0 0 1 0deg', '0 1 1 100deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + await waitForPaints(); + + assert_animation_is_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should be running on the ' + + 'compositor'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context should be running on the ' + + 'compositor'); +}, 'Transform-like animations in the 3d rendering context should runs on the ' + + 'compositor'); + +promise_test(async t => { + const container = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const target = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const innerA = addDiv(t, { style: 'width: 50px; height: 50px;' }); + // The frame of innerB is too large, so this makes its ancenstors and children + // in the 3d context be not allowed the async animations. + const innerB = addDiv(t, { style: 'rotate: 0 1 1 100deg; ' + + 'transform-style: preserve-3d; ' + + 'text-indent: -9999em' }); + const innerB2 = addDiv(t, { style: 'rotate: 0 1 1 45deg;' }); + const innerBText = document.createTextNode("innerB"); + container.appendChild(target); + target.appendChild(innerA); + target.appendChild(innerB); + innerB.appendChild(innerBText); + innerB.appendChild(innerB2); + + const animation1 = target.animate({ rotate: ['0 0 1 0deg', '1 1 1 45deg'] }, + 100 * MS_PER_SEC); + + const animation2 = innerA.animate({ rotate: ['0 0 1 0deg', '0 1 1 100deg'] }, + 100 * MS_PER_SEC); + + const animation3 = innerB2.animate({ rotate: ['0 0 1 0deg', '0 1 1 90deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + await waitForAnimationReadyToRestyle(animation3); + await waitForPaints(); + + const isPartialPrerenderEnabled = + SpecialPowers.getBoolPref('layout.animation.prerender.partial'); + + if (isPartialPrerenderEnabled) { + assert_animation_is_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if one of its inner frames is too large'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context is still running on ' + + 'the compositor because its display item is created earlier'); + assert_animation_is_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if one of its parent frames is too large'); + } else { + assert_animation_is_not_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because one of its inner frames is too large'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context is still running on ' + + 'the compositor because its display item is created earlier'); + assert_animation_is_not_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because one of its parent frames is too large'); + } + innerBText.remove(); +}, 'Transform-like animations in the 3d rendering context should run on ' + + 'the compositor even if it is the ancestor of ones whose frames are too ' + + 'large or its ancestor is not allowed to run on the compositor'); + +promise_test(async t => { + const container = addDiv(t, { style: 'transform-style: preserve-3d;' }); + const target = addDiv(t, { style: 'transform-style: preserve-3d;' }); + // The frame of innerA is too large, so this makes its ancenstors and children + // in the 3d context be not allowed the async animations. + const innerA = addDiv(t, { style: 'transform-style: preserve-3d; ' + + 'text-indent: -9999em' }); + const innerAText = document.createTextNode("innerA"); + const innerB = addDiv(t, { style: 'width: 50px; height: 50px;' }); + container.appendChild(target); + target.appendChild(innerA); + target.appendChild(innerB); + innerA.appendChild(innerAText); + + const animation1 = target.animate({ rotate: ['0 0 1 0deg', '1 1 1 45deg'] }, + 100 * MS_PER_SEC); + + const animation2 = innerA.animate({ rotate: ['0 0 1 0deg', '0 1 1 100deg'] }, + 100 * MS_PER_SEC); + + const animation3 = innerB.animate({ rotate: ['0 0 1 0deg', '0 1 1 90deg'] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + await waitForAnimationReadyToRestyle(animation3); + await waitForPaints(); + + const isPartialPrerenderEnabled = + SpecialPowers.getBoolPref('layout.animation.prerender.partial'); + + if (isPartialPrerenderEnabled) { + assert_animation_is_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if one of its inner frames is too large'); + assert_animation_is_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if its frame size is too large'); + assert_animation_is_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should be running on ' + + 'the compositor even if its previous sibling frame is too large'); + } else { + assert_animation_is_not_running_on_compositor(animation1, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because one of its inner frames is too large'); + assert_animation_is_not_running_on_compositor(animation2, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because its frame size is too large'); + assert_animation_is_not_running_on_compositor(animation3, + 'rotate animation in the 3d rendering context should not be running on ' + + 'the compositor because its previous sibling frame is too large'); + } + innerAText.remove(); +}, 'Transform-like animations in the 3d rendering context should run on ' + + 'the compositor even if its previous sibling frame size is too large'); + +</script> +</body> diff --git a/dom/animation/test/chrome/test_simulate_compute_values_failure.html b/dom/animation/test/chrome/test_simulate_compute_values_failure.html new file mode 100644 index 0000000000..2ee041c9f8 --- /dev/null +++ b/dom/animation/test/chrome/test_simulate_compute_values_failure.html @@ -0,0 +1,371 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Bug 1276688 - Test for properties that parse correctly but which we fail + to convert to computed values</title> +<script type="application/javascript" src="../testharness.js"></script> +<script type="application/javascript" src="../testharnessreport.js"></script> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1276688" + target="_blank">Mozilla Bug 1276688</a> +<div id="log"></div> +<script> +'use strict'; + +function assert_properties_equal(actual, expected) { + assert_equals(actual.length, expected.length); + + var compareProperties = (a, b) => + a.property == b.property ? 0 : (a.property < b.property ? -1 : 1); + + var sortedActual = actual.sort(compareProperties); + var sortedExpected = expected.sort(compareProperties); + + // We want to serialize the values in the following form: + // + // { offset: 0, easing: linear, composite: replace, value: 5px }, ... + // + // So that we can just compare strings and, in the failure case, + // easily see where the differences lie. + var serializeMember = value => { + return typeof value === 'undefined' ? '<not set>' : value; + } + var serializeValues = values => + values.map(value => + '{ ' + + [ 'offset', 'value', 'easing', 'composite' ].map( + member => `${member}: ${serializeMember(value[member])}` + ).join(', ') + + ' }') + .join(', '); + + for (var i = 0; i < sortedActual.length; i++) { + assert_equals(sortedActual[i].property, + sortedExpected[i].property, + 'CSS property name should match'); + assert_equals(serializeValues(sortedActual[i].values), + serializeValues(sortedExpected[i].values), + `Values arrays do not match for ` + + `${sortedActual[i].property} property`); + } +} + +// Shorthand for constructing a value object +function value(offset, value, composite, easing) { + return { offset, value, easing, composite }; +} + +var gTests = [ + // --------------------------------------------------------------------- + // + // Tests for properties that parse correctly but which we fail to + // convert to computed values. + // + // --------------------------------------------------------------------- + + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe', + frames: [ { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe where we have enough values to create' + + ' a final segment', + frames: [ { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' }, + { margin: '5px' } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (first in series of two)', + frames: [ { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 0 }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (second in series of two)', + frames: [ { margin: '5px', offset: 0 }, + { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial overlapping keyframes (second in series of three)', + frames: [ { margin: '5px', offset: 0 }, + { margin: '5px', offset: 0, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 0 }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace'), + value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe', + frames: [ { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe where it forms the last segment in the series', + frames: [ { margin: '5px' }, + { margin: '5px', + marginLeft: '5px', + marginRight: '5px', + marginBottom: '5px', + // margin-top sorts last and only it will be missing since + // the other longhand components are specified + simulateComputeValuesFailure: true } ], + expected: [ { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe where we have enough values to create' + + ' an initial segment', + frames: [ { margin: '5px' }, + { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true } ], + expected: [ ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (first in series of two)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 1 } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (second in series of two)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1 }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final overlapping keyframes (second in series of three)', + frames: [ { margin: '5px' }, + { margin: '5px', offset: 1 }, + { margin: '5px', offset: 1, + simulateComputeValuesFailure: true }, + { margin: '5px', offset: 1 } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' intermediate keyframe', + frames: [ { margin: '5px' }, + { margin: '5px', simulateComputeValuesFailure: true }, + { margin: '5px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe along with other values', + // simulateComputeValuesFailure only applies to shorthands so we can set + // it on the same keyframe and it will only apply to |margin| and not + // |left|. + frames: [ { margin: '77%', left: '10px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '20px' } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' initial keyframe along with other values where those' + + ' values sort after the property with missing values', + frames: [ { margin: '77%', right: '10px', + simulateComputeValuesFailure: true }, + { margin: '5px', right: '20px' } ], + expected: [ { property: 'right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', left: '20px', + simulateComputeValuesFailure: true } ], + expected: [ { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' final keyframe along with other values where those' + + ' values sort after the property with missing values', + frames: [ { margin: '5px', right: '10px' }, + { margin: '5px', right: '20px', + simulateComputeValuesFailure: true } ], + expected: [ { property: 'right', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '20px', 'replace') ] } ], + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' an intermediate keyframe along with other values', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', left: '20px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(0.5, '20px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, + { desc: 'a property that can\'t be resolved to computed values in' + + ' an intermediate keyframe by itself', + frames: [ { margin: '5px', left: '10px' }, + { margin: '5px', + simulateComputeValuesFailure: true }, + { margin: '5px', left: '30px' } ], + expected: [ { property: 'margin-top', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-right', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-bottom', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'margin-left', + values: [ value(0, '5px', 'replace', 'linear'), + value(1, '5px', 'replace') ] }, + { property: 'left', + values: [ value(0, '10px', 'replace', 'linear'), + value(1, '30px', 'replace') ] } ] + }, +]; + +setup({explicit_done: true}); + +SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.core.enabled", false], + ["dom.animations-api.implicit-keyframes.enabled", false], + ], + }, + function() { + gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + var animation = div.animate(subtest.frames, 100 * MS_PER_SEC); + assert_properties_equal( + animation.effect.getProperties(), + subtest.expected + ); + }, subtest.desc); + }); + + done(); + } +); +</script> +</body> diff --git a/dom/animation/test/crashtests/1134538.html b/dom/animation/test/crashtests/1134538.html new file mode 100644 index 0000000000..136d63deee --- /dev/null +++ b/dom/animation/test/crashtests/1134538.html @@ -0,0 +1,8 @@ +<!doctype html> +<div contenteditable=true style="transition-property: width;"></div> +<style> +html { + transition-delay: 18446744073709551584s; + transform: rotate(0deg); +} +</style> diff --git a/dom/animation/test/crashtests/1216842-1.html b/dom/animation/test/crashtests/1216842-1.html new file mode 100644 index 0000000000..6ac40b2fb8 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-1.html @@ -0,0 +1,35 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces negative values (compositor thread)</title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffect( + target, + { opacity: [0, 1] }, + { + fill: "forwards", + /* The function produces negative values in (0, 0.766312060) */ + easing: "cubic-bezier(0,-0.5,1,-0.5)", + duration: 100, + iterations: 0.75 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-2.html b/dom/animation/test/crashtests/1216842-2.html new file mode 100644 index 0000000000..7bae8a3116 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-2.html @@ -0,0 +1,35 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces values greater than 1 (compositor thread)</title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffect( + target, + { opacity: [0, 1] }, + { + fill: "forwards", + /* The function produces values greater than 1 in (0.23368794, 1) */ + easing: "cubic-bezier(0,1.5,1,1.5)", + duration: 100, + iterations: 0.25 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-3.html b/dom/animation/test/crashtests/1216842-3.html new file mode 100644 index 0000000000..1bc2179a86 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-3.html @@ -0,0 +1,27 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces values greater than 1 (main-thread)</title> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffect( + target, + { color: ["red", "blue"] }, + { + fill: "forwards", + /* The function produces values greater than 1 in (0.23368794, 1) */ + easing: "cubic-bezier(0,1.5,1,1.5)", + duration: 100 + } + ); + var animation = new Animation(effect, document.timeline); + animation.pause(); + animation.currentTime = 250; + document.documentElement.className = ""; + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-4.html b/dom/animation/test/crashtests/1216842-4.html new file mode 100644 index 0000000000..eba13c396a --- /dev/null +++ b/dom/animation/test/crashtests/1216842-4.html @@ -0,0 +1,27 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1216842: effect-level easing function produces negative values (main-thread)</title> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffect( + target, + { color: ["red", "blue"] }, + { + fill: "forwards", + /* The function produces negative values in (0, 0.766312060) */ + easing: "cubic-bezier(0,-0.5,1,-0.5)", + duration: 100 + } + ); + var animation = new Animation(effect, document.timeline); + animation.pause(); + animation.currentTime = 250; + document.documentElement.className = ""; + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-5.html b/dom/animation/test/crashtests/1216842-5.html new file mode 100644 index 0000000000..73b6f22c4b --- /dev/null +++ b/dom/animation/test/crashtests/1216842-5.html @@ -0,0 +1,38 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title> + Bug 1216842: effect-level easing function produces negative values passed + to step-end function (compositor thread) + </title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffect( + target, + { opacity: [0, 1], easing: "step-end" }, + { + fill: "forwards", + /* The function produces negative values in (0, 0.766312060) */ + easing: "cubic-bezier(0,-0.5,1,-0.5)", + duration: 100, + iterations: 0.75 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1216842-6.html b/dom/animation/test/crashtests/1216842-6.html new file mode 100644 index 0000000000..aaf80eeec3 --- /dev/null +++ b/dom/animation/test/crashtests/1216842-6.html @@ -0,0 +1,38 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title> + Bug 1216842: effect-level easing function produces values greater than 1 + which are passed to step-end function (compositor thread) + </title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var target = document.getElementById("target"); + var effect = + new KeyframeEffect( + target, + { opacity: [0, 1], easing: "step-end" }, + { + fill: "forwards", + /* The function produces values greater than 1 in (0.23368794, 1) */ + easing: "cubic-bezier(0,1.5,1,1.5)", + duration: 100, + iterations: 0.25 /* To finish in the extraporation range */ + } + ); + var animation = new Animation(effect, document.timeline); + animation.play(); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1239889-1.html b/dom/animation/test/crashtests/1239889-1.html new file mode 100644 index 0000000000..aa10ff3ab8 --- /dev/null +++ b/dom/animation/test/crashtests/1239889-1.html @@ -0,0 +1,16 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title>Bug 1239889</title> + </head> + <body> + </body> + <script> + var div = document.createElement('div'); + var effect = new KeyframeEffect(div, { opacity: [0, 1] }); + requestAnimationFrame(() => { + document.body.appendChild(div); + document.documentElement.classList.remove("reftest-wait"); + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1244595-1.html b/dom/animation/test/crashtests/1244595-1.html new file mode 100644 index 0000000000..13b2e2d7e7 --- /dev/null +++ b/dom/animation/test/crashtests/1244595-1.html @@ -0,0 +1,3 @@ +<div id=target><script> + var player = target.animate([{background: 'green'}, {background: 'green'}]); +</script> diff --git a/dom/animation/test/crashtests/1272475-1.html b/dom/animation/test/crashtests/1272475-1.html new file mode 100644 index 0000000000..e0b0495388 --- /dev/null +++ b/dom/animation/test/crashtests/1272475-1.html @@ -0,0 +1,20 @@ +<!doctype html> +<html> + <head> + <title>Bug 1272475 - scale function with an extreme large value</title> + <script> + function test() { + var div = document.createElement("div"); + div.setAttribute("style", "width: 1px; height: 1px; " + + "background: red;"); + document.body.appendChild(div); + div.animate([ { "transform": "scale(8)" }, + { "transform": "scale(9.5e+307)" }, + { "transform": "scale(32)" } ], + { "duration": 1000, "fill": "both" }); + } + </script> + </head> + <body onload="test()"> + </body> +</html> diff --git a/dom/animation/test/crashtests/1272475-2.html b/dom/animation/test/crashtests/1272475-2.html new file mode 100644 index 0000000000..da0e8605bd --- /dev/null +++ b/dom/animation/test/crashtests/1272475-2.html @@ -0,0 +1,20 @@ +<!doctype html> +<html> + <head> + <title>Bug 1272475 - rotate function with an extreme large value</title> + <script> + function test() { + var div = document.createElement("div"); + div.setAttribute("style", "width: 100px; height: 100px; " + + "background: red;"); + document.body.appendChild(div); + div.animate([ { "transform": "rotate(8rad)" }, + { "transform": "rotate(9.5e+307rad)" }, + { "transform": "rotate(32rad)" } ], + { "duration": 1000, "fill": "both" }); + } + </script> + </head> + <body onload="test()"> + </body> +</html> diff --git a/dom/animation/test/crashtests/1277272-1-inner.html b/dom/animation/test/crashtests/1277272-1-inner.html new file mode 100644 index 0000000000..2565aa6eb8 --- /dev/null +++ b/dom/animation/test/crashtests/1277272-1-inner.html @@ -0,0 +1,19 @@ +<!doctype html> +<head> +<script> +function start() { + var animation = document.body.animate([{marks: 'crop'},{marks: 'crop'}], 12); + document.write('<html><body></body></html>'); + + setTimeout(function() { animation.play(); }, 4); + setTimeout(function() { + animation.timeline = undefined; + + SpecialPowers.Cu.forceGC(); + window.top.continueTest(); + }, 5); +} +</script> +</head> +<body onload="start()"></body> +</html> diff --git a/dom/animation/test/crashtests/1277272-1.html b/dom/animation/test/crashtests/1277272-1.html new file mode 100644 index 0000000000..71b6c24148 --- /dev/null +++ b/dom/animation/test/crashtests/1277272-1.html @@ -0,0 +1,25 @@ +<!doctype html> +<html class="reftest-wait"> +<head> +<script> +var count = 0; + +function start() { + if (++count > 10) { + document.documentElement.className = ""; + return; + } + + var frame = document.getElementById("frame"); + frame.src = "./1277272-1-inner.html"; +} + +function continueTest() { + setTimeout(start.bind(window), 1); +} + +</script> +</head> +<body onload="start()"></body> +<iframe id="frame"> +</html> diff --git a/dom/animation/test/crashtests/1278485-1.html b/dom/animation/test/crashtests/1278485-1.html new file mode 100644 index 0000000000..e7347f5d84 --- /dev/null +++ b/dom/animation/test/crashtests/1278485-1.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<script> + +function boom() +{ + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, -1e+39, 0, 0)" }); + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, 1e+39, 0, 0)" }); + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, 0, 0, -1e+39)" }); + document.body.animate([], + { duration: 6, + easing: "cubic-bezier(0, 0, 0, 1e+39)" }); +} + +</script> +</head> +<body onload="boom();"></body> +</html> diff --git a/dom/animation/test/crashtests/1282691-1.html b/dom/animation/test/crashtests/1282691-1.html new file mode 100644 index 0000000000..ab6e1a26c0 --- /dev/null +++ b/dom/animation/test/crashtests/1282691-1.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html class=reftest-wait> +<meta charset=utf-8> +<script> + +function boom() { + const div = document.createElement('div'); + const anim = div.animate([{}], {}); + document.documentElement.appendChild(div); + anim.pause(); + document.documentElement.removeChild(div); + + requestAnimationFrame(() => { + document.documentElement.appendChild(div); + anim.play(); + document.documentElement.className = ''; + }); +} + +</script> +</head> +<body onload="boom();"></body> +</html> diff --git a/dom/animation/test/crashtests/1291413-1.html b/dom/animation/test/crashtests/1291413-1.html new file mode 100644 index 0000000000..691a746c6e --- /dev/null +++ b/dom/animation/test/crashtests/1291413-1.html @@ -0,0 +1,20 @@ +<!doctype html> +<html class=reftest-wait> +<script> +window.onload = () => { + const div = document.createElement('div'); + + document.documentElement.appendChild(div); + const anim = div.animate([], 1000); + + anim.ready.then(() => { + anim.pause(); + anim.reverse(); + anim.playbackRate = 0; + anim.ready.then(() => { + document.documentElement.className = ''; + }); + }); +}; +</script> +</html> diff --git a/dom/animation/test/crashtests/1291413-2.html b/dom/animation/test/crashtests/1291413-2.html new file mode 100644 index 0000000000..fca3a93800 --- /dev/null +++ b/dom/animation/test/crashtests/1291413-2.html @@ -0,0 +1,21 @@ +<!doctype html> +<html class=reftest-wait> +<script> +window.onload = () => { + const div = document.createElement('div'); + + document.documentElement.appendChild(div); + const anim = div.animate([], 1000); + + anim.ready.then(() => { + anim.pause(); + anim.reverse(); + anim.playbackRate = 0; + anim.playbackRate = 1; + anim.ready.then(() => { + document.documentElement.className = ''; + }); + }); +}; +</script> +</html> diff --git a/dom/animation/test/crashtests/1304886-1.html b/dom/animation/test/crashtests/1304886-1.html new file mode 100644 index 0000000000..703ef902b9 --- /dev/null +++ b/dom/animation/test/crashtests/1304886-1.html @@ -0,0 +1,14 @@ +<!DOCTYPE html> +<html> +<script> +window.onload=function(){ + var e = document.createElement("div"); + document.documentElement.appendChild(e); + e.animate([{"font":"status-bar"}, + {"font":"unset"}], + {duration:6, + iterationStart:4, + iterationComposite:"accumulate"}); +}; +</script> +</html> diff --git a/dom/animation/test/crashtests/1309198-1.html b/dom/animation/test/crashtests/1309198-1.html new file mode 100644 index 0000000000..7fad5782c0 --- /dev/null +++ b/dom/animation/test/crashtests/1309198-1.html @@ -0,0 +1,40 @@ +<script> +function start() { + o53=document.createElement('frameset'); + o254=document.createElement('iframe'); + o280=document.createElement('audio'); + o317=document.documentElement; + o508=document.createElement('li'); + o508.appendChild(o317); + o590=document.createElement('li'); + o594=document.createElement('track'); + o280.appendChild(o594); + o647=document.createElement('ol'); + o654=document.createElement('li'); + o647.appendChild(o654); + o654.insertAdjacentHTML('beforebegin','<iframe>'); + document.write('<html><body></body></html>'); + o955=document.documentElement; + document.documentElement.appendChild(o647); + o590.appendChild(o955); + document.close(); + document.write('<html><body></body></html>'); + document.documentElement.appendChild(o590); + document.documentElement.appendChild(o254); + o280.controls^=1; + SpecialPowers.forceGC(); + o317.insertAdjacentHTML('afterend','<iframe>'); + document.documentElement.appendChild(o280); + o2695=document.implementation.createHTMLDocument(); + o2695.body.appendChild(o254); + o53.onerror=f0; + document.documentElement.appendChild(o508); + o2803=frames[1].document; + o2803.getAnimations(); +} +function f0() { + o2803.write('<html><body></body></html>'); + SpecialPowers.forceCC(); +} +</script> +<body onload="start()"></body> diff --git a/dom/animation/test/crashtests/1322291-1.html b/dom/animation/test/crashtests/1322291-1.html new file mode 100644 index 0000000000..87def99ba8 --- /dev/null +++ b/dom/animation/test/crashtests/1322291-1.html @@ -0,0 +1,24 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<script> +document.addEventListener("DOMContentLoaded", boom); +function boom(){ + let o1 = (function(){ + let e=document.createElement("frameset"); + document.documentElement.appendChild(e); + return e; + })(); + let a1 = o1.animate({ "transform": "rotate3d(22,73,26,374grad)" }, + { duration: 10, delay: 100 }); + + // We need to wait the end of the delay to ensure that the animation is + // composited on the compositor, but there is no event for script animation + // that fires after the delay phase finished. So we wait for finished promise + // instead. + a1.finished.then(function() { + document.documentElement.className = ""; + }); +} +</script> +</body> +</html> diff --git a/dom/animation/test/crashtests/1322291-2.html b/dom/animation/test/crashtests/1322291-2.html new file mode 100644 index 0000000000..b93d53224d --- /dev/null +++ b/dom/animation/test/crashtests/1322291-2.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<style> +div { + width: 100px; + height: 100px; + background-color: red; +} +</style> +<body> +<div id=o_0></div> +<script> +function boom(){ + var anim = o_0.animate([ + {}, + {"transform": "scale(2)"}, + {"transform": "none"} + ], { + duration: 100, + iterationStart: 0.5, + }); + // We need to wait for finished promise just like we do in 1322291-1.html. + anim.finished.then(function() { + document.documentElement.classList.remove("reftest-wait"); + }); +} +document.addEventListener("DOMContentLoaded", boom); +</script> + +</body> +</html> diff --git a/dom/animation/test/crashtests/1322382-1.html b/dom/animation/test/crashtests/1322382-1.html new file mode 100644 index 0000000000..6ca9c1b836 --- /dev/null +++ b/dom/animation/test/crashtests/1322382-1.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<style> +details { + background-color: blue; + width: 100px; + height: 100px; +} +</style> +<html> +<details id=o1><div></div></details> +<script> +window.onload = function(){ + o1.animate([{'transform': 'none'}], 100); +}; +</script> +</html> diff --git a/dom/animation/test/crashtests/1323114-1.html b/dom/animation/test/crashtests/1323114-1.html new file mode 100644 index 0000000000..344fd87db0 --- /dev/null +++ b/dom/animation/test/crashtests/1323114-1.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html> +<body> +<div id=a /> +<script> +addEventListener("DOMContentLoaded", function(){ + a.animate([{"transform": "matrix3d(25,8788,-69,-24,-3,85,52,3,63,0,12,36810,-68,15,82,0) rotate(77rad)"}], + {fill: "both", iterationStart: 45, iterationComposite: "accumulate"}); +}); +</script> +</body> +</html> diff --git a/dom/animation/test/crashtests/1323114-2.html b/dom/animation/test/crashtests/1323114-2.html new file mode 100644 index 0000000000..527d05effa --- /dev/null +++ b/dom/animation/test/crashtests/1323114-2.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<style> +div { + width: 100px; + height: 100px; + background-color: blue; + transform: rotate(45deg); +} +</style> +<div id="div"></div> +<script> +addEventListener('DOMContentLoaded', function (){ + var target = document.getElementById('div'); + target.animate([{ transform: 'translateX(100px)', composite: 'accumulate' }, + { transform: 'none' }], + 1000); +}); +</script> diff --git a/dom/animation/test/crashtests/1323119-1.html b/dom/animation/test/crashtests/1323119-1.html new file mode 100644 index 0000000000..fd979822b8 --- /dev/null +++ b/dom/animation/test/crashtests/1323119-1.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<body> +<script> +addEventListener("DOMContentLoaded", function() { + let a = document.createElement("th"); + document.documentElement.appendChild(a); + a.animate([{"mask": "repeat-y "}], 484); + document.documentElement.classList.remove("reftest-wait"); +}); +</script> +</body> +</html> diff --git a/dom/animation/test/crashtests/1324554-1.html b/dom/animation/test/crashtests/1324554-1.html new file mode 100644 index 0000000000..b3f9435a18 --- /dev/null +++ b/dom/animation/test/crashtests/1324554-1.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + <title>Bug 1324554 - missing final keyframes and zero-length segments together</title> + </head> + <script> + function go() { + var div = document.getElementById('target'); + div.animate([ { "flex": "none" }, + { "flex": "initial", offset: 0.5 }, + { "flex": "0.0 ", offset: 0.5 }, + {} ]); + } + </script> + <body onload="go()"> + <div id='target' ></div> + </body> +</html> diff --git a/dom/animation/test/crashtests/1325193-1.html b/dom/animation/test/crashtests/1325193-1.html new file mode 100644 index 0000000000..bd0666497c --- /dev/null +++ b/dom/animation/test/crashtests/1325193-1.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<meta charset="UTF-8"> +<script> +function boom(){ + o_0.animate([{"perspective": "262ex"}, {}], {duration: 1070, iterationStart: 68, iterationComposite: "accumulate"}); + o_0.animate([{"color": "white", "flex": "inherit"}, {"color": "red", "flex": "66% "}], 3439); + o_0.animate([{"font": "icon"}], 1849); + setTimeout(function() { + document.documentElement.classList.remove("reftest-wait"); + }, 500); +} +document.addEventListener("DOMContentLoaded", boom); +</script> +</head> +<body><details id=o_0><q></q></details></body> +</html> diff --git a/dom/animation/test/crashtests/1330190-1.html b/dom/animation/test/crashtests/1330190-1.html new file mode 100644 index 0000000000..fa14e0f741 --- /dev/null +++ b/dom/animation/test/crashtests/1330190-1.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<span id=a /> +<script> +addEventListener("DOMContentLoaded", function(){ + a.animate([{"left": "38%"}], 100); + a.appendChild(document.createElement("div")); + document.documentElement.classList.remove("reftest-wait"); +}); +</script> +</html> diff --git a/dom/animation/test/crashtests/1330190-2.html b/dom/animation/test/crashtests/1330190-2.html new file mode 100644 index 0000000000..57e5d31b28 --- /dev/null +++ b/dom/animation/test/crashtests/1330190-2.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<style> +@keyframes anim { +} + +#o_0:before { + animation: anim 10s; + content: ""; +} +</style> +<meta charset="UTF-8"> +<script> +function boom(){ + function getPseudoElement() { + var anim = document.getAnimations()[0]; + anim.cancel(); + return anim.effect.target; + } + + var target = getPseudoElement(); + target.animate([{"perspective": "262ex"}, {}], {duration: 1070, iterationStart: 68, iterationComposite: "accumulate"}); + target.animate([{"color": "white", "flex": "inherit"}, {"color": "red", "flex": "66% "}], 3439); + target.animate([{"font": "icon"}], 1849); + setTimeout(function() { + document.documentElement.classList.remove("reftest-wait"); + }, 500); +} +document.addEventListener("DOMContentLoaded", boom); +</script> +</head> +<body> +<div id=o_0></div> +</body> +</html> diff --git a/dom/animation/test/crashtests/1330513-1.html b/dom/animation/test/crashtests/1330513-1.html new file mode 100644 index 0000000000..a497cc9e27 --- /dev/null +++ b/dom/animation/test/crashtests/1330513-1.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> +<html> +<body id=a></body> +<script> +document.getElementById("a") + .animate([{"filter": "grayscale(28%)"}], {fill:"forwards", composite:"add"}); +</script> +</html> diff --git a/dom/animation/test/crashtests/1332588-1.html b/dom/animation/test/crashtests/1332588-1.html new file mode 100644 index 0000000000..11719a86ca --- /dev/null +++ b/dom/animation/test/crashtests/1332588-1.html @@ -0,0 +1,25 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<meta charset="UTF-8"> +<style> +span { + width: 100px; + height: 100px; + background-color: red; +} +</style> +<script> +window.onload = function() { + let body = document.getElementsByTagName("body")[0]; + let o_0 = document.createElement("span"); + body.appendChild(o_0); + o_0.animate([{ "padding": "57pt", "transform": "none" }, + { "padding": "57pt", "transform": "rotate(90deg)" }] , 10000); + body.appendChild(document.createElement("colgroup")); + document.documentElement.classList.remove("reftest-wait"); +}; +</script> +</head> +<body></body> +</html> diff --git a/dom/animation/test/crashtests/1333539-1.html b/dom/animation/test/crashtests/1333539-1.html new file mode 100644 index 0000000000..c9111890b0 --- /dev/null +++ b/dom/animation/test/crashtests/1333539-1.html @@ -0,0 +1,30 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<meta charset="UTF-8"> +<script> +window.onload = function(){ + let body = document.getElementsByTagName("body")[0]; + let target = document.createElement("div"); + let anim1 = new Animation(); + let anim2 = new Animation(); + let effect = new KeyframeEffect(target, { opacity: [ 0, 1 ] }, 1000); + body.appendChild(target); + target.appendChild(document.createElement("meter")); + anim1.startTime = 88; + anim1.timeline = null; + anim1.pause(); + anim1.effect = effect; + anim2.effect = effect; + anim1.effect = effect; + + // anim1, since it doesn't have a timeline, will remain pause-pending, + // so just wait on anim2. + anim2.ready.then(() => { + document.documentElement.classList.remove("reftest-wait"); + }); +}; +</script> +</head> +<body></body> +</html> diff --git a/dom/animation/test/crashtests/1333539-2.html b/dom/animation/test/crashtests/1333539-2.html new file mode 100644 index 0000000000..b00700eccb --- /dev/null +++ b/dom/animation/test/crashtests/1333539-2.html @@ -0,0 +1,38 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<style> +div { + width: 100px; + height: 100px; + background-color: blue; +} +</style> +<script> +window.onload = function(){ + let body = document.getElementsByTagName("body")[0]; + let target = document.createElement("div"); + let anim1 = new Animation(); + let anim2 = new Animation(); + let effect = new KeyframeEffect(target, { opacity: [ 0, 1 ] }, 1000); + body.appendChild(target); + anim1.startTime = 88; + anim1.timeline = null; + anim1.pause(); + anim1.effect = effect; + anim2.effect = effect; + anim1.effect = effect; + // Put another opacity animation on the top of the effect stack so that we + // try to send a lower priority animation that has no timeline to the + // compositor. + let anim3 = target.animate({ opacity : [ 1, 0 ] }, 1000); + + Promise.all([anim1.ready, anim2.ready, anim2.ready]).then(function() { + document.documentElement.classList.remove("reftest-wait"); + }); +}; +</script> +</head> +<body></body> +</html> diff --git a/dom/animation/test/crashtests/1334582-1.html b/dom/animation/test/crashtests/1334582-1.html new file mode 100644 index 0000000000..d67ddc3c52 --- /dev/null +++ b/dom/animation/test/crashtests/1334582-1.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<script> +window.onload = function(){ + let a = document.documentElement.animate([], {"iterations": 1.7976931348623157e+308, "fill": "both"}); +}; +</script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1334582-2.html b/dom/animation/test/crashtests/1334582-2.html new file mode 100644 index 0000000000..d3b223650d --- /dev/null +++ b/dom/animation/test/crashtests/1334582-2.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="UTF-8"> +<script> +window.onload = function(){ + let a = document.documentElement.animate([], {"iterationStart": 1.7976931348623157e+308, "fill": "both"}); +}; +</script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1334583-1.html b/dom/animation/test/crashtests/1334583-1.html new file mode 100644 index 0000000000..b4d4109f0d --- /dev/null +++ b/dom/animation/test/crashtests/1334583-1.html @@ -0,0 +1,9 @@ +<div style="width: 200px; height: 200px; background: purple" id=div> +</div> +<script> +const animation = div.animate( + [ { transform: "scale(1)" }, + { transform: "scale(2)" } ], + { iterations: Infinity, duration: 512 } ); +animation.currentTime = 2147483647; +</script> diff --git a/dom/animation/test/crashtests/1335998-1.html b/dom/animation/test/crashtests/1335998-1.html new file mode 100644 index 0000000000..72353a9692 --- /dev/null +++ b/dom/animation/test/crashtests/1335998-1.html @@ -0,0 +1,28 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <title> + Bug 1335998 - Handle {Interpolate, Accumulate}Matrix of mismatched transform lists + </title> + <style> + #target { + width: 100px; height: 100px; + background: blue; + transform: rotate(45deg); + } + </style> + </head> + <body> + <div id="target"></div> + </body> + <script> + var div = document.getElementById("target"); + var animation = div.animate([ { transform: 'translateX(200px) scale(2.0)', + composite: 'accumulate' }, + { transform: 'rotate(-45deg)' } ], + 2000); + animation.finished.then(function() { + document.documentElement.className = ""; + }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1343589-1.html b/dom/animation/test/crashtests/1343589-1.html new file mode 100644 index 0000000000..a494b83da3 --- /dev/null +++ b/dom/animation/test/crashtests/1343589-1.html @@ -0,0 +1,18 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<head> +<meta charset="UTF-8"> +<script> +window.onload = function(){ + let a = document.documentElement.animate(null, + { duration: 100, iterations: Number.POSITIVE_INFINITY }); + a.startTime = 100000; // Set the start time far in the future + // Try reversing (this should throw because the target effect end is infinity) + try { a.reverse(); } catch(e) {} + // Do something that will trigger a timing update + a.effect.target = document.createElement("span"); + document.documentElement.className = ''; +}; +</script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1359658-1.html b/dom/animation/test/crashtests/1359658-1.html new file mode 100644 index 0000000000..972ec497fa --- /dev/null +++ b/dom/animation/test/crashtests/1359658-1.html @@ -0,0 +1,33 @@ +<!doctype html> +<html class="reftest-wait"> + <head> + <meta charset=utf-8> + <title>Bug 1359658: Animation-only dirty descendants bit should be cleared + for display:none content</title> + </head> + <body> + <div id="ancestor"> + <svg> + <rect id="target" width="100%" height="100%" fill="lime"/> + </svg> + </div> + </body> + <script> +'use strict'; + +const ancestor = document.getElementById('ancestor'); +const target = document.getElementById('target'); + +document.addEventListener('DOMContentLoaded', () => { + const animation = target.animate({ color: [ 'red', 'lime' ] }, + { duration: 1000, iterations: Infinity }); + requestAnimationFrame(() => { + // Tweak animation to cause animation dirty bit to be set + animation.effect.updateTiming({ duration: 2000 }); + ancestor.style.display = "none"; + getComputedStyle(ancestor).display; + document.documentElement.className = ''; + }); +}); + </script> +</html> diff --git a/dom/animation/test/crashtests/1373712-1.html b/dom/animation/test/crashtests/1373712-1.html new file mode 100644 index 0000000000..8b5c121c85 --- /dev/null +++ b/dom/animation/test/crashtests/1373712-1.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> +<head> +<title>Bug 1373712 - Assertions of SpecifiedKeyframeArraysAreEqual(mKeyframes, keyframesCopy) with large color value +</title> +<meta charset="UTF-8"> +<script> +document.documentElement.animate([{ "color": "hsl(63e292,41%,34%)" }]); +</script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1379606-1.html b/dom/animation/test/crashtests/1379606-1.html new file mode 100644 index 0000000000..89f756bf06 --- /dev/null +++ b/dom/animation/test/crashtests/1379606-1.html @@ -0,0 +1,21 @@ +<!doctype html> +<style> +div { + display: none; +} +</style> +<div> +<div> +<div> +<div> +<div> +<div id="target"> +</div> +</div> +</div> +</div> +</div> +</div> +<script> + target.animate({ color: "red" }) +</script> diff --git a/dom/animation/test/crashtests/1393605-1.html b/dom/animation/test/crashtests/1393605-1.html new file mode 100644 index 0000000000..9f282e58ba --- /dev/null +++ b/dom/animation/test/crashtests/1393605-1.html @@ -0,0 +1,15 @@ +<!doctype html> +<html> + <head> + <title> + Bug 1393605 - Still work if determinant of 2d matrix is not 1 or -1 + </title> + </head> + <script> + document.documentElement.animate( + [ { 'transform': 'scale(4)' }, + { 'transform': 'rotate(3grad) scaleX(0) ' + + 'translate(2mm) matrix(2,7,1,.32,7,0)' } ], + { fill: 'both' }); + </script> +</html> diff --git a/dom/animation/test/crashtests/1400022-1.html b/dom/animation/test/crashtests/1400022-1.html new file mode 100644 index 0000000000..8256091e1e --- /dev/null +++ b/dom/animation/test/crashtests/1400022-1.html @@ -0,0 +1,10 @@ +<script> +requestIdleCallback(function(){ location.reload() }) +a = document.createElement("x") +document.documentElement.appendChild(a) +b = document.createElement('link') +b.setAttribute('rel', 'stylesheet') +b.setAttribute('href', 'data:,*{border-block-start:solid}') +document.head.appendChild(b) +a.insertAdjacentHTML("afterBegin", "<d id='id0' style='transition-duration:1s'><svg filter='url(#id0)'>") +</script> diff --git a/dom/animation/test/crashtests/1401809.html b/dom/animation/test/crashtests/1401809.html new file mode 100644 index 0000000000..7a3adcc60c --- /dev/null +++ b/dom/animation/test/crashtests/1401809.html @@ -0,0 +1,14 @@ +<html> + <head> + <style></style> + <script> + o1 = document.createElement('t'); + document.documentElement.appendChild(o1); + document.styleSheets[0].insertRule('* { will-change:an }', 0); + k = new KeyframeEffect(o1, [{'willChange':'s'}], {'':''}); + k = null; + SpecialPowers.forceGC(); + SpecialPowers.forceCC(); + </script> + </head> +</html> diff --git a/dom/animation/test/crashtests/1411318-1.html b/dom/animation/test/crashtests/1411318-1.html new file mode 100644 index 0000000000..5c8e7211c1 --- /dev/null +++ b/dom/animation/test/crashtests/1411318-1.html @@ -0,0 +1,15 @@ +<html> + <head> + <script> + o1 = (new DOMParser).parseFromString('', 'text/html'); + o2 = document.createElement('canvas'); + document.documentElement.appendChild(o2); + o3 = o2.animate([{'transform':'unset'}], {'delay':32}); + o4 = o3.effect; + o5 = o1.createElement('d'); + o6 = new Animation(o4, document.timeline); + o7 = o5.animate([], {}); + o7.effect = o6.effect; + </script> + </head> +</html> diff --git a/dom/animation/test/crashtests/1467277-1.html b/dom/animation/test/crashtests/1467277-1.html new file mode 100644 index 0000000000..c58fc64493 --- /dev/null +++ b/dom/animation/test/crashtests/1467277-1.html @@ -0,0 +1,6 @@ +<script> +addEventListener("DOMContentLoaded", () => { + document.documentElement.animate( + [ { "transform": "rotate3d(1e58, 2, 6, 0turn)" } ], 1000) +}) +</script> diff --git a/dom/animation/test/crashtests/1468294-1.html b/dom/animation/test/crashtests/1468294-1.html new file mode 100644 index 0000000000..e4092046ac --- /dev/null +++ b/dom/animation/test/crashtests/1468294-1.html @@ -0,0 +1,7 @@ +<script> +addEventListener("DOMContentLoaded", () => { + document.documentElement.animate([{ "transform": "matrix(2,1,1,5,2,8)" }], + { duration: 1000, + easing: "cubic-bezier(1,-15,.6,4)" }); +}) +</script> diff --git a/dom/animation/test/crashtests/1524480-1.html b/dom/animation/test/crashtests/1524480-1.html new file mode 100644 index 0000000000..89e5a412d9 --- /dev/null +++ b/dom/animation/test/crashtests/1524480-1.html @@ -0,0 +1,37 @@ +<!doctype html> +<html class="reftest-wait"> +<meta charset=utf-8> +<style> +div { + display: none; + width: 100px; + height: 100px; + background: blue; +} +</style> +<div id=div></div> +<script> +async function test() { + const animation = div.animate({ transform: ['none', 'none'] }, 1000); + animation.cancel(); + + await waitForFrame(); + + div.style.display = 'block'; + + await waitForFrame(); + await waitForFrame(); + + animation.play(); + await animation.finished; + + document.documentElement.className = ""; +} + +function waitForFrame() { + return new Promise(resolve => requestAnimationFrame(resolve)); +} + +test(); +</script> +</html> diff --git a/dom/animation/test/crashtests/1575926.html b/dom/animation/test/crashtests/1575926.html new file mode 100644 index 0000000000..cc37c94235 --- /dev/null +++ b/dom/animation/test/crashtests/1575926.html @@ -0,0 +1,24 @@ +<style> + @keyframes animation_0 { + 88% { } + } + + * { + animation-name: animation_0; + animation-delay: 4s; + } +</style> +<script> + function start () { + const input = document.createElement('input') + document.documentElement.appendChild(input) + const animations = input.getAnimations({}) + const animation = animations[(3782796448 % animations.length)] + const effect = animation.effect + effect.setKeyframes({ 'borderLeft': ['inherit'] }) + effect.target = null + input.contentEditable = 'true' + } + + document.addEventListener('DOMContentLoaded', start) +</script> diff --git a/dom/animation/test/crashtests/1585770.html b/dom/animation/test/crashtests/1585770.html new file mode 100644 index 0000000000..018d688582 --- /dev/null +++ b/dom/animation/test/crashtests/1585770.html @@ -0,0 +1,22 @@ +<html class="reftest-wait"> +<script> +function start () { + const kf_effect = + new KeyframeEffect(document.documentElement, + { opacity: ['', '1'] }, + { easing: 'step-end', + duration: 10000 } ); + const copy = new KeyframeEffect(kf_effect); + const animation = new Animation(copy); + + animation.reverse(); + document.documentElement.getBoundingClientRect(); + + requestAnimationFrame(() => { + document.documentElement.classList.remove("reftest-wait"); + }); +} + +document.addEventListener('DOMContentLoaded', start); +</script> +</html> diff --git a/dom/animation/test/crashtests/1604500-1.html b/dom/animation/test/crashtests/1604500-1.html new file mode 100644 index 0000000000..01a6eafd1f --- /dev/null +++ b/dom/animation/test/crashtests/1604500-1.html @@ -0,0 +1,24 @@ +<!doctype html> +<html> +<head> +<script> +function start () { + const keyframe = new KeyframeEffect(undefined, {}); + const animation = new Animation(keyframe, undefined); + // Make animation run backwards... + animation.playbackRate = -100; + // But then set the current time to the future so it becomes "current"... + animation.currentTime = 2055; + // After updating the playback rate to zero, however, it should no longer + // be "current" (and this takes effect immediately because |animation| is + // paused)... + animation.updatePlaybackRate(0); + // Now update the target and hope nothing goes wrong... + keyframe.target = div; +} + +document.addEventListener('DOMContentLoaded', start) +</script> +</head> +<div id=div></div> +</html> diff --git a/dom/animation/test/crashtests/1611847.html b/dom/animation/test/crashtests/1611847.html new file mode 100644 index 0000000000..720ce1179b --- /dev/null +++ b/dom/animation/test/crashtests/1611847.html @@ -0,0 +1,23 @@ +<html> +<head> + <style> + * { + transition-duration: 2s; + } + </style> + <script> + function start () { + const element = document.createElementNS('', 's'); + const effect = new KeyframeEffect(document.documentElement, {}, 196); + document.documentElement.setAttribute('style', 'padding-left:3'); + effect.updateTiming({ 'delay': 2723 }); + const animations = document.getAnimations(); + animations[0].effect = effect; + animations[0].updatePlaybackRate(-129); + effect.target = element; + } + + document.addEventListener('DOMContentLoaded', start); + </script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1612891-1.html b/dom/animation/test/crashtests/1612891-1.html new file mode 100644 index 0000000000..44cf022e88 --- /dev/null +++ b/dom/animation/test/crashtests/1612891-1.html @@ -0,0 +1,15 @@ +<html> +<head> + <script> + function start() { + const element = document.createElement('img') + element.animate([ + { 'easing': '' }, + { 'offset': 'o' }, + ], {}) + } + + window.addEventListener('load', start) + </script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1612891-2.html b/dom/animation/test/crashtests/1612891-2.html new file mode 100644 index 0000000000..a9779ba70f --- /dev/null +++ b/dom/animation/test/crashtests/1612891-2.html @@ -0,0 +1,15 @@ +<html> +<head> + <script> + function start() { + const element = document.createElement('img') + element.animate([ + { 'easing': '' }, + 123, + ], {}) + } + + window.addEventListener('load', start) + </script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1612891-3.html b/dom/animation/test/crashtests/1612891-3.html new file mode 100644 index 0000000000..89e71b6ca8 --- /dev/null +++ b/dom/animation/test/crashtests/1612891-3.html @@ -0,0 +1,10 @@ +<html> +<head> + <script> + document.addEventListener('DOMContentLoaded', () => { + const keyframe = new KeyframeEffect(document.documentElement, [{}], {}) + keyframe.setKeyframes([{ 'easing': '' }, { 'offset': 'o', }]) + }) + </script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1633442.html b/dom/animation/test/crashtests/1633442.html new file mode 100644 index 0000000000..cb4beedebc --- /dev/null +++ b/dom/animation/test/crashtests/1633442.html @@ -0,0 +1,15 @@ +<!doctype html> +<html class="reftest-wait"> +<head> +<script> + document.addEventListener('DOMContentLoaded', () => { + document.documentElement.style.setProperty('transition-duration', '3s', '') + document.documentElement.style.setProperty('rotate', '2deg', undefined) + document.documentElement.style.setProperty('border-radius', '2%', '') + const [anim_1, anim_0] = document.documentElement.getAnimations({}) + anim_1.effect = anim_0.effect + document.documentElement.classList.remove("reftest-wait"); + }) +</script> +</head> +</html> diff --git a/dom/animation/test/crashtests/1633486.html b/dom/animation/test/crashtests/1633486.html new file mode 100644 index 0000000000..20b88f6327 --- /dev/null +++ b/dom/animation/test/crashtests/1633486.html @@ -0,0 +1,20 @@ +<!doctype html> +<html> +<head> + <script> + window.addEventListener('load', async () => { + const element = document.getElementById('target'); + element.animate({ + 'all': ['initial'] + }, { + 'duration': 500, + 'pseudoElement': '::marker', + }) + element.hidden = false; + }) + </script> +</head> +<ul> + <li id='target' hidden></li> +</ul> +</html> diff --git a/dom/animation/test/crashtests/1656419.html b/dom/animation/test/crashtests/1656419.html new file mode 100644 index 0000000000..4e76cb0a55 --- /dev/null +++ b/dom/animation/test/crashtests/1656419.html @@ -0,0 +1,23 @@ +<!DOCTYPE html> +<html class="reftest-wait"> +<style> +#target { + width: 200vw; + height: 200vh; +} +</style> +<div id="target"></div> +<script> +const animA = target.animate( + { transform: 'translateX(100px)' }, + { duration: 50 } +); +const animB = target.animate( + { transform: 'translateX(100px)', composite: 'add' }, + { duration: 100 } +); +animB.finished.then(() => { + document.documentElement.classList.remove("reftest-wait"); +}); +</script> +</html> diff --git a/dom/animation/test/crashtests/1699890.html b/dom/animation/test/crashtests/1699890.html new file mode 100644 index 0000000000..95aa1e190c --- /dev/null +++ b/dom/animation/test/crashtests/1699890.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<style> +@keyframes anim { + from { background-color: rgba(0, 0, 0, 0); } + to { background-color: rgba(255, 0, 0, 255); } +} +body { + animation: anim 100s; + width: 100vw; + height: 100vh; +} +</style> diff --git a/dom/animation/test/crashtests/1706157.html b/dom/animation/test/crashtests/1706157.html new file mode 100644 index 0000000000..cfa7b70b56 --- /dev/null +++ b/dom/animation/test/crashtests/1706157.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html> +<head> + <style> + @keyframes animation { + to { + left: 100px; + } + } + * { + animation: animation linear 1s; + } + #target { + animation-timing-function: steps(2965566999, jump-both); + } + </style> +</head> +<div id="target"></div> +</html> diff --git a/dom/animation/test/crashtests/1714421.html b/dom/animation/test/crashtests/1714421.html new file mode 100644 index 0000000000..04099b55b7 --- /dev/null +++ b/dom/animation/test/crashtests/1714421.html @@ -0,0 +1,8 @@ +<script> +document.addEventListener('DOMContentLoaded', () => { + document.documentElement.style.setProperty('scale', '89%', undefined) + document.documentElement.style.setProperty('-moz-transition-duration', '2009216159ms', '') + document.getAnimations()[0].timeline = undefined + document.documentElement.animate({'scale': ['none', 'none', 'none']}, 1807) +}) +</script> diff --git a/dom/animation/test/crashtests/1807966.html b/dom/animation/test/crashtests/1807966.html new file mode 100644 index 0000000000..d2759d2b5c --- /dev/null +++ b/dom/animation/test/crashtests/1807966.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<style> + #target { + transition-timing-function: linear(calc(15 / 0), 10 10%); + transition-duration: 10s; + } +</style> +<script> + window.addEventListener('load', () => { + target.style.paddingBlock = "10px 10px"; + }) +</script> +<slot id="target"></slot> diff --git a/dom/animation/test/crashtests/crashtests.list b/dom/animation/test/crashtests/crashtests.list new file mode 100644 index 0000000000..6f5dd140e7 --- /dev/null +++ b/dom/animation/test/crashtests/crashtests.list @@ -0,0 +1,61 @@ +load 1134538.html +pref(dom.animations-api.core.enabled,true) load 1239889-1.html +load 1244595-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1216842-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1216842-2.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1216842-3.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1216842-4.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1216842-5.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1216842-6.html +load 1272475-1.html +load 1272475-2.html +load 1278485-1.html +pref(dom.animations-api.timelines.enabled,true) load 1277272-1.html +load 1282691-1.html +pref(dom.animations-api.core.enabled,true) load 1291413-1.html +pref(dom.animations-api.core.enabled,true) load 1291413-2.html +pref(dom.animations-api.compositing.enabled,true) load 1304886-1.html +pref(dom.animations-api.getAnimations.enabled,true) load 1309198-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1322382-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.implicit-keyframes.enabled,true) load 1322291-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.implicit-keyframes.enabled,true) load 1322291-2.html +pref(dom.animations-api.implicit-keyframes.enabled,true) pref(dom.animations-api.compositing.enabled,true) load 1323114-1.html +pref(dom.animations-api.compositing.enabled,true) load 1323114-2.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1323119-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1324554-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) pref(dom.animations-api.compositing.enabled,true) load 1325193-1.html +load 1332588-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1330190-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.implicit-keyframes.enabled,true) pref(dom.animations-api.compositing.enabled,true) pref(dom.animations-api.getAnimations.enabled,true) load 1330190-2.html +pref(dom.animations-api.implicit-keyframes.enabled,true) pref(dom.animations-api.compositing.enabled,true) load 1330513-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1333539-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1333539-2.html +load 1334582-1.html +load 1334582-2.html +load 1334583-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.compositing.enabled,true) load 1335998-1.html +pref(dom.animations-api.core.enabled,true) load 1343589-1.html +pref(dom.animations-api.core.enabled,true) load 1359658-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1373712-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1379606-1.html +load 1393605-1.html +load 1400022-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.implicit-keyframes.enabled,true) load 1401809.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.timelines.enabled,true) pref(dom.animations-api.implicit-keyframes.enabled,true) load 1411318-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1468294-1.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1467277-1.html +load 1524480-1.html +load 1575926.html +pref(dom.animations-api.implicit-keyframes.enabled,true) load 1585770.html +load 1604500-1.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.getAnimations.enabled,true) load 1611847.html +pref(dom.animations-api.core.enabled,true) load 1612891-1.html +pref(dom.animations-api.core.enabled,true) load 1612891-2.html +pref(dom.animations-api.core.enabled,true) load 1612891-3.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.getAnimations.enabled,true) load 1633442.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.implicit-keyframes.enabled,true) load 1633486.html +pref(layout.animation.prerender.partial,true) load 1656419.html +pref(layout.css.step-position-jump.enabled,true) load 1706157.html +pref(gfx.omta.background-color,true) load 1699890.html +pref(dom.animations-api.core.enabled,true) pref(dom.animations-api.getAnimations.enabled,true) pref(dom.animations-api.timelines.enabled,true) load 1714421.html +pref(layout.css.linear-easing-function.enabled,true) load 1807966.html diff --git a/dom/animation/test/document-timeline/test_document-timeline.html b/dom/animation/test/document-timeline/test_document-timeline.html new file mode 100644 index 0000000000..92a709e2f6 --- /dev/null +++ b/dom/animation/test/document-timeline/test_document-timeline.html @@ -0,0 +1,147 @@ +<!doctype html> +<meta charset=utf-8> +<title>Web Animations API: DocumentTimeline tests</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<iframe srcdoc='<html><meta charset=utf-8></html>' width="10" height="10" id="iframe"></iframe> +<iframe srcdoc='<html style="display:none"><meta charset=utf-8></html>' width="10" height="10" id="hidden-iframe"></iframe> +<div id="log"></div> +<script> +'use strict'; + +test(function() { + assert_equals(document.timeline, document.timeline, + 'document.timeline returns the same object every time'); + var iframe = document.getElementById('iframe'); + assert_not_equals(document.timeline, iframe.contentDocument.timeline, + 'document.timeline returns a different object for each document'); + assert_not_equals(iframe.contentDocument.timeline, null, + 'document.timeline on an iframe is not null'); +}, +'document.timeline identity tests', +{ + help: 'http://dev.w3.org/fxtf/web-animations/#the-document-timeline', + assert: [ 'Each document has a timeline called the document timeline' ], + author: 'Brian Birtles' +}); + +async_test(function(t) { + const { AppConstants } = SpecialPowers.ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" + ); + + if (AppConstants.platform == "android") { + // Skip this test case on Android since it frequently fails on the + // environments. See bug 1761900. + t.done(); + } + + assert_greater_than_equal(document.timeline.currentTime, 0, + 'document.timeline.currentTime is positive or zero'); + // document.timeline.currentTime should be set even before document + // load fires. We expect this code to be run before document load and hence + // the above assertion is sufficient. + // If the following assertion fails, this test needs to be redesigned. + assert_true(document.readyState !== 'complete', + 'Test is running prior to document load'); + + // Test that the document timeline's current time is measured from + // navigationStart. + // + // We can't just compare document.timeline.currentTime to + // window.performance.now() because currentTime is only updated on a sample + // so we use requestAnimationFrame instead. + window.requestAnimationFrame(t.step_func(function(rafTime) { + assert_equals(document.timeline.currentTime, rafTime, + 'document.timeline.currentTime matches' + + ' requestAnimationFrame time'); + t.done(); + })); +}, +'document.timeline.currentTime value tests', +{ + help: [ + 'http://dev.w3.org/fxtf/web-animations/#the-global-clock', + 'http://dev.w3.org/fxtf/web-animations/#the-document-timeline' + ], + assert: [ + 'The global clock is a source of monotonically increasing time values', + 'The time values of the document timeline are calculated as a fixed' + + ' offset from the global clock', + 'the zero time corresponds to the navigationStart moment', + 'the time value of each document timeline must be equal to the time ' + + 'passed to animation frame request callbacks for that browsing context' + ], + author: 'Brian Birtles' +}); + +async_test(function(t) { + var valueAtStart = document.timeline.currentTime; + var timeAtStart = window.performance.now(); + while (window.performance.now() - timeAtStart < 100) { + // Wait 100ms + } + assert_equals(document.timeline.currentTime, valueAtStart, + 'document.timeline.currentTime does not change within a script block'); + window.requestAnimationFrame(t.step_func(function() { + assert_true(document.timeline.currentTime > valueAtStart, + 'document.timeline.currentTime increases between script blocks'); + t.done(); + })); +}, +'document.timeline.currentTime liveness tests', +{ + help: 'http://dev.w3.org/fxtf/web-animations/#script-execution-and-live-updates-to-the-model', + assert: [ 'The value returned by the currentTime attribute of a' + + ' document timeline will not change within a script block' ], + author: 'Brian Birtles' +}); + +test(function() { + var hiddenIFrame = document.getElementById('hidden-iframe'); + assert_equals(typeof hiddenIFrame.contentDocument.timeline.currentTime, + 'number', + 'currentTime of an initially hidden subframe\'s timeline is a number'); + assert_true(hiddenIFrame.contentDocument.timeline.currentTime >= 0, + 'currentTime of an initially hidden subframe\'s timeline is >= 0'); +}, 'document.timeline.currentTime hidden subframe test'); + +async_test(function(t) { + var hiddenIFrame = document.getElementById('hidden-iframe'); + + // Don't run the test until after the iframe has completed loading or else the + // contentDocument may change. + var testToRunOnLoad = t.step_func(function() { + // Remove display:none + hiddenIFrame.style.display = 'block'; + getComputedStyle(hiddenIFrame).display; + + window.requestAnimationFrame(t.step_func(function() { + assert_greater_than(hiddenIFrame.contentDocument.timeline.currentTime, 0, + 'document.timeline.currentTime is positive after removing' + + ' display:none'); + var previousValue = hiddenIFrame.contentDocument.timeline.currentTime; + + // Re-introduce display:none + hiddenIFrame.style.display = 'none'; + getComputedStyle(hiddenIFrame).display; + + window.requestAnimationFrame(t.step_func(function() { + assert_true( + hiddenIFrame.contentDocument.timeline.currentTime >= previousValue, + 'document.timeline.currentTime does not go backwards after' + + ' re-setting display:none'); + t.done(); + })); + })); + }); + + if (hiddenIFrame.contentDocument.readyState === 'complete') { + testToRunOnLoad(); + } else { + hiddenIFrame.addEventListener("load", testToRunOnLoad); + } +}, 'document.timeline.currentTime hidden subframe dynamic test'); + +</script> diff --git a/dom/animation/test/document-timeline/test_request_animation_frame.html b/dom/animation/test/document-timeline/test_request_animation_frame.html new file mode 100644 index 0000000000..3da4e4deb2 --- /dev/null +++ b/dom/animation/test/document-timeline/test_request_animation_frame.html @@ -0,0 +1,27 @@ +<!DOCTYPE html> +<meta charset=utf-8> +<title>Test RequestAnimationFrame Timestamps are monotonically increasing</title> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> + var lastRequestAnimationFrameTimestamp = 0; + var requestAnimationFrameCount = 20; + var currentCount = 0; + + // Test that all timestamps are always increasing + // and do not ever go backwards + function rafCallback(aTimestamp) { + SimpleTest.ok(aTimestamp > lastRequestAnimationFrameTimestamp, + "New RequestAnimationFrame timestamp should be later than the previous RequestAnimationFrame timestamp"); + lastRequestAnimationFrameTimestamp = aTimestamp; + if (currentCount == requestAnimationFrameCount) { + SimpleTest.finish(); + } else { + currentCount++; + window.requestAnimationFrame(rafCallback); + } + } + + window.requestAnimationFrame(rafCallback); + SimpleTest.waitForExplicitFinish(); +</script> diff --git a/dom/animation/test/mochitest.ini b/dom/animation/test/mochitest.ini new file mode 100644 index 0000000000..bd4bfe8a61 --- /dev/null +++ b/dom/animation/test/mochitest.ini @@ -0,0 +1,81 @@ +[DEFAULT] +prefs = + dom.animations-api.compositing.enabled=true + dom.animations-api.core.enabled=true + dom.animations-api.getAnimations.enabled=true + dom.animations-api.implicit-keyframes.enabled=true + dom.animations-api.timelines.enabled=true + gfx.omta.background-color=true + layout.css.motion-path.enabled=true + layout.css.individual-transform.enabled=true + layout.css.scroll-driven-animations.enabled=true + gfx.font_loader.delay=0 +# Support files for chrome tests that we want to load over HTTP need +# to go in here, not chrome.ini. +support-files = + chrome/file_animate_xrays.html + mozilla/xhr_doc.html + mozilla/file_deferred_start.html + mozilla/file_disable_animations_api_autoremove.html + mozilla/file_disable_animations_api_compositing.html + mozilla/file_disable_animations_api_get_animations.html + mozilla/file_disable_animations_api_implicit_keyframes.html + mozilla/file_disable_animations_api_timelines.html + mozilla/file_discrete_animations.html + mozilla/file_transition_finish_on_compositor.html + ../../../layout/style/test/property_database.js + testcommon.js + !/dom/events/test/event_leak_utils.js + +[document-timeline/test_document-timeline.html] +[document-timeline/test_request_animation_frame.html] +[mozilla/test_cascade.html] +[mozilla/test_cubic_bezier_limits.html] +[mozilla/test_deferred_start.html] +skip-if = (os == 'win' && bits == 64) # Bug 1363957 +[mozilla/test_disable_animations_api_autoremove.html] +[mozilla/test_disable_animations_api_compositing.html] +[mozilla/test_disable_animations_api_get_animations.html] +[mozilla/test_disable_animations_api_implicit_keyframes.html] +[mozilla/test_disable_animations_api_timelines.html] +[mozilla/test_disabled_properties.html] +[mozilla/test_discrete_animations.html] +[mozilla/test_distance_of_basic_shape.html] +[mozilla/test_distance_of_filter.html] +[mozilla/test_distance_of_path_function.html] +[mozilla/test_distance_of_transform.html] +[mozilla/test_document_timeline_origin_time_range.html] +[mozilla/test_get_animations_on_scroll_animations.html] +[mozilla/test_hide_and_show.html] +[mozilla/test_mainthread_synchronization_pref.html] +[mozilla/test_moz_prefixed_properties.html] +[mozilla/test_pending_animation_tracker.html] +[mozilla/test_restyles.html] +support-files = + mozilla/file_restyles.html + mozilla/empty.html +skip-if = + (os == 'android' && debug) #Bug 1784931 + (os == 'linux' && tsan) #Bug 1784931 + http3 +[mozilla/test_restyling_xhr_doc.html] +[mozilla/test_set_easing.html] +[mozilla/test_style_after_finished_on_compositor.html] +[mozilla/test_transform_limits.html] +[mozilla/test_transition_finish_on_compositor.html] +skip-if = toolkit == 'android' +[mozilla/test_underlying_discrete_value.html] +[mozilla/test_unstyled.html] +[mozilla/test_event_listener_leaks.html] +skip-if = (os == "win" && processor == "aarch64") #bug 1535784 +[style/test_animation-seeking-with-current-time.html] +[style/test_animation-seeking-with-start-time.html] +[style/test_animation-setting-effect.html] +[style/test_composite.html] +skip-if = xorigin +[style/test_interpolation-from-interpolatematrix-to-none.html] +[style/test_missing-keyframe.html] +[style/test_missing-keyframe-on-compositor.html] +skip-if = + fission && xorigin # Bug 1716403 - New fission platform triage +[style/test_transform-non-normalizable-rotate3d.html] diff --git a/dom/animation/test/mozilla/empty.html b/dom/animation/test/mozilla/empty.html new file mode 100644 index 0000000000..739422cbfa --- /dev/null +++ b/dom/animation/test/mozilla/empty.html @@ -0,0 +1,2 @@ +<!DOCTYPE html> +<script src="../testcommon.js"></script> diff --git a/dom/animation/test/mozilla/file_deferred_start.html b/dom/animation/test/mozilla/file_deferred_start.html new file mode 100644 index 0000000000..863fc80fec --- /dev/null +++ b/dom/animation/test/mozilla/file_deferred_start.html @@ -0,0 +1,179 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<style> +@keyframes empty { } +.target { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +<body> +<script> +'use strict'; + +function waitForDocLoad() { + return new Promise((resolve, reject) => { + if (document.readyState === 'complete') { + resolve(); + } else { + window.addEventListener('load', resolve); + } + }); +} + +function waitForPaints() { + return new Promise((resolve, reject) => { + waitForAllPaintsFlushed(resolve); + }); +} + +promise_test(async t => { + // Test that empty animations actually start. + // + // Normally we tie the start of animations to when their first frame of + // the animation is rendered. However, for animations that don't actually + // trigger a paint (e.g. because they are empty, or are animating something + // that doesn't render or is offscreen) we want to make sure they still + // start. + // + // Before we start, wait for the document to finish loading, then create + // div element, and wait for painting. This is because during loading we will + // have other paint events taking place which might, by luck, happen to + // trigger animations that otherwise would not have been triggered, leading to + // false positives. + // + // As a result, it's better to wait until we have a more stable state before + // continuing. + await waitForDocLoad(); + + const div = addDiv(t); + + await waitForPaints(); + + div.style.animation = 'empty 1000s'; + const animation = div.getAnimations()[0]; + + let promiseCallbackDone = false; + animation.ready.then(() => { + promiseCallbackDone = true; + }).catch(() => { + assert_unreached('ready promise was rejected'); + }); + + // We need to wait for up to three frames. This is because in some + // cases it can take up to two frames for the initial layout + // to take place. Even after that happens we don't actually resolve the + // ready promise until the following tick. + await waitForAnimationFrames(3); + + assert_true(promiseCallbackDone, + 'ready promise for an empty animation was resolved' + + ' within three animation frames'); +}, 'Animation.ready is resolved for an empty animation'); + +// Test that compositor animations with delays get synced correctly +// +// NOTE: It is important that we DON'T use +// SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh here since that takes +// us through a different code path. +promise_test(async t => { + assert_false(SpecialPowers.DOMWindowUtils.isTestControllingRefreshes, + 'Test should run without the refresh driver being under' + + ' test control'); + + // This test only applies to compositor animations + if (!isOMTAEnabled()) { + return; + } + + const div = addDiv(t, { class: 'target' }); + + // As with the above test, any stray paints can cause this test to produce + // a false negative (that is, pass when it should fail). To avoid this we + // wait for paints and only then do we commence the test. + await waitForPaints(); + + const animation = + div.animate({ transform: [ 'translate(0px)', 'translate(100px)' ] }, + { duration: 400 * MS_PER_SEC, + delay: -200 * MS_PER_SEC }); + + await waitForAnimationReadyToRestyle(animation); + + await waitForPaints(); + + const transformStr = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + const translateX = getTranslateXFromTransform(transformStr); + + // If the delay has been applied we should be about half-way through + // the animation. However, if we applied it twice we will be at the + // end of the animation already so check that we are roughly half way + // through. + assert_between_inclusive(translateX, 40, 75, + 'Animation is about half-way through on the compositor'); +}, 'Starting an animation with a delay starts from the correct point'); + +// Test that compositor animations with a playback rate start at the +// appropriate point. +// +// NOTE: As with the previous test, it is important that we DON'T use +// SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh here since that takes +// us through a different code path. +promise_test(async t => { + assert_false(SpecialPowers.DOMWindowUtils.isTestControllingRefreshes, + 'Test should run without the refresh driver being under' + + ' test control'); + + // This test only applies to compositor animations + if (!isOMTAEnabled()) { + return; + } + + const div = addDiv(t, { class: 'target' }); + + // Wait for the document to load and painting (see notes in previous test). + await waitForPaints(); + + const animation = + div.animate({ transform: [ 'translate(0px)', 'translate(100px)' ] }, + 200 * MS_PER_SEC); + animation.currentTime = 100 * MS_PER_SEC; + animation.playbackRate = 0.1; + + await waitForPaints(); + + const transformStr = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + const translateX = getTranslateXFromTransform(transformStr); + + // We pass the playback rate to the compositor independently and we have + // tests to ensure that it is correctly applied there. However, if, when + // we resolve the start time of the pending animation, we fail to + // incorporate the playback rate, we will end up starting from the wrong + // point and the current time calculated on the compositor will be wrong. + assert_between_inclusive(translateX, 25, 75, + 'Animation is about half-way through on the compositor'); +}, 'Starting an animation with a playbackRate starts from the correct point'); + +function getTranslateXFromTransform(transformStr) { + const matrixComponents = + transformStr.startsWith('matrix(') + ? transformStr.substring('matrix('.length, transformStr.length-1) + .split(',') + .map(component => Number(component)) + : []; + assert_equals(matrixComponents.length, 6, + 'Got a valid transform matrix on the compositor' + + ' (got: "' + transformStr + '")'); + + return matrixComponents[4]; +} + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disable_animations_api_autoremove.html b/dom/animation/test/mozilla/file_disable_animations_api_autoremove.html new file mode 100644 index 0000000000..79cb508467 --- /dev/null +++ b/dom/animation/test/mozilla/file_disable_animations_api_autoremove.html @@ -0,0 +1,69 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +promise_test(async t => { + const div = addDiv(t); + + const animA = div.animate({ opacity: 1 }, { duration: 1, fill: 'forwards' }); + const animB = div.animate({ opacity: 1 }, { duration: 1, fill: 'forwards' }); + + // This should be assert_not_own_property but our local copy of testharness.js + // is old. + assert_equals( + animA.replaceState, + undefined, + 'Should not have a replaceState member' + ); + + animA.addEventListener( + 'remove', + t.step_func(() => { + assert_unreached('Should not fire a remove event'); + }) + ); + + // Allow a chance for the remove event to be fired + + await animA.finished; + await waitForNextFrame(); +}, 'Remove events should not be fired if the pref is not set'); + +promise_test(async t => { + const div = addDiv(t); + div.style.opacity = '0.1'; + + const animA = div.animate( + { opacity: 0.2 }, + { duration: 1, fill: 'forwards' } + ); + const animB = div.animate( + { opacity: 0.3, composite: 'add' }, + { duration: 1, fill: 'forwards' } + ); + + await animA.finished; + + assert_approx_equals( + parseFloat(getComputedStyle(div).opacity), + 0.5, + 0.0001, + 'Covered animation should still contribute to effect stack when adding' + ); + + animB.cancel(); + + assert_approx_equals( + parseFloat(getComputedStyle(div).opacity), + 0.2, + 0.0001, + 'Covered animation should still contribute to animated style when replacing' + ); +}, 'Covered animations should still affect style if the pref is not set'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disable_animations_api_compositing.html b/dom/animation/test/mozilla/file_disable_animations_api_compositing.html new file mode 100644 index 0000000000..6d9ba35dc0 --- /dev/null +++ b/dom/animation/test/mozilla/file_disable_animations_api_compositing.html @@ -0,0 +1,137 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(t => { + const anim = addDiv(t).animate( + { marginLeft: ['0px', '10px'] }, + { + duration: 100 * MS_PER_SEC, + iterations: 10, + iterationComposite: 'accumulate', + composite: 'add', + } + ); + assert_false( + 'iterationComposite' in anim.effect, + 'The KeyframeEffect.iterationComposite member is not present' + ); + assert_false( + 'composite' in anim.effect, + 'The KeyframeEffect.composite member is not present' + ); +}, 'The iterationComposite and composite members are not present on Animation' + + ' when the compositing pref is disabled'); + +test(t => { + const div = addDiv(t); + const anim = div.animate( + { marginLeft: ['0px', '10px'] }, + { + duration: 100 * MS_PER_SEC, + iterations: 10, + iterationComposite: 'accumulate', + } + ); + anim.pause(); + anim.currentTime = 200 * MS_PER_SEC; + + assert_equals( + getComputedStyle(div).marginLeft, + '0px', + 'Animated style should NOT accumulate' + ); +}, 'KeyframeEffectOptions.iterationComposite should be ignored if the' + + ' compositing pref is disabled'); + +test(t => { + const div = addDiv(t); + const anim1 = div.animate( + { marginLeft: ['0px', '100px'] }, + { duration: 100 * MS_PER_SEC } + ); + anim1.pause(); + anim1.currentTime = 50 * MS_PER_SEC; + + const anim2 = div.animate( + { marginLeft: ['0px', '100px'] }, + { duration: 100 * MS_PER_SEC, composite: 'add' } + ); + anim2.pause(); + anim2.currentTime = 50 * MS_PER_SEC; + + assert_equals( + getComputedStyle(div).marginLeft, + '50px', + 'Animations should NOT add together' + ); +}, 'KeyframeEffectOptions.composite should be ignored if the' + + ' compositing pref is disabled'); + +test(t => { + const div = addDiv(t); + const anim1 = div.animate({ marginLeft: ['0px', '100px'] }, 100 * MS_PER_SEC); + anim1.pause(); + anim1.currentTime = 50 * MS_PER_SEC; + + const anim2 = div.animate( + [ + { marginLeft: '0px', composite: 'add' }, + { marginLeft: '100px', composite: 'add' }, + ], + 100 * MS_PER_SEC + ); + anim2.pause(); + anim2.currentTime = 50 * MS_PER_SEC; + + assert_equals( + getComputedStyle(div).marginLeft, + '50px', + 'Animations should NOT add together' + ); +}, 'composite member is ignored on keyframes when using array notation'); + +test(t => { + const div = addDiv(t); + const anim1 = div.animate( + { marginLeft: ['0px', '100px'] }, + 100 * MS_PER_SEC + ); + anim1.pause(); + anim1.currentTime = 50 * MS_PER_SEC; + + const anim2 = div.animate( + { marginLeft: ['0px', '100px'], composite: ['add', 'add'] }, + 100 * MS_PER_SEC + ); + anim2.pause(); + anim2.currentTime = 50 * MS_PER_SEC; + + assert_equals( + getComputedStyle(div).marginLeft, + '50px', + 'Animations should NOT add together' + ); +}, 'composite member is ignored on keyframes when using object notation'); + +test(t => { + const anim = addDiv(t).animate( + { marginLeft: ['0px', '10px'] }, + 100 * MS_PER_SEC + ); + + for (let frame of anim.effect.getKeyframes()) { + assert_false( + 'composite' in frame, + 'The BaseComputedKeyframe.composite member is not present' + ); + } +}, 'composite member is hidden from the result of ' + + 'KeyframeEffect::getKeyframes()'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disable_animations_api_get_animations.html b/dom/animation/test/mozilla/file_disable_animations_api_get_animations.html new file mode 100644 index 0000000000..3d484444a7 --- /dev/null +++ b/dom/animation/test/mozilla/file_disable_animations_api_get_animations.html @@ -0,0 +1,20 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(t => { + assert_false('getAnimations' in addDiv(t)); +}, 'Element.getAnimations() is not available when getAnimations pref is' + + ' disabled'); + +test(t => { + assert_false('getAnimations' in document); +}, 'Document.getAnimations() is not available when getAnimations pref is' + + ' disabled'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disable_animations_api_implicit_keyframes.html b/dom/animation/test/mozilla/file_disable_animations_api_implicit_keyframes.html new file mode 100644 index 0000000000..9cd05e7d40 --- /dev/null +++ b/dom/animation/test/mozilla/file_disable_animations_api_implicit_keyframes.html @@ -0,0 +1,48 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +// Tests for cases we should throw an exception for if implicit keyframes are +// disabled. +var gTests = [ + { desc: "single Keyframe value", + keyframes: { left: "100px" } }, + { desc: "single Keyframe with no offset", + keyframes: [{ left: "100px" }] }, + { desc: "single Keyframe with 0% offset", + keyframes: [{ left: "100px", offset: 0 }] }, + { desc: "single Keyframe with 100% offset", + keyframes: [{ left: "100px", offset: 1 }] }, + { desc: "multiple Keyframes with missing 0% Keyframe", + keyframes: [{ left: "100px", offset: 0.25 }, + { left: "200px", offset: 0.50 }, + { left: "300px", offset: 1.00 }] }, + { desc: "multiple Keyframes with missing 100% Keyframe", + keyframes: [{ left: "100px", offset: 0.00 }, + { left: "200px", offset: 0.50 }, + { left: "300px", offset: 0.75 }] }, + { desc: "multiple Keyframes with missing properties on first Keyframe", + keyframes: [{ left: "100px", offset: 0.0 }, + { left: "200px", top: "200px", offset: 0.5 }, + { left: "300px", top: "300px", offset: 1.0 }] }, + { desc: "multiple Keyframes with missing properties on last Keyframe", + keyframes: [{ left: "100px", top: "200px", offset: 0.0 }, + { left: "200px", top: "200px", offset: 0.5 }, + { left: "300px", offset: 1.0 }] }, +]; + +gTests.forEach(function(subtest) { + test(function(t) { + var div = addDiv(t); + assert_throws("NotSupportedError", function() { + div.animate(subtest.keyframes, 100 * MS_PER_SEC); + }); + }, "Element.animate() throws with " + subtest.desc); +}); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_disable_animations_api_timelines.html b/dom/animation/test/mozilla/file_disable_animations_api_timelines.html new file mode 100644 index 0000000000..39fedb299a --- /dev/null +++ b/dom/animation/test/mozilla/file_disable_animations_api_timelines.html @@ -0,0 +1,30 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<body> +<script> +'use strict'; + +test(t => { + assert_false( + window.hasOwnProperty('DocumentTimeline'), + 'DocumentTimeline should not be exposed on the global' + ); + assert_false( + window.hasOwnProperty('AnimationTimeline'), + 'AnimationTimeline should not be exposed on the global' + ); + assert_false( + 'timeline' in document, + 'document should not have a timeline property' + ); + + const anim = addDiv(t).animate(null); + assert_false( + 'timeline' in anim, + 'Animation should not have a timeline property' + ); +}, 'Timeline-related interfaces and members are disabled'); + +done(); +</script> diff --git a/dom/animation/test/mozilla/file_discrete_animations.html b/dom/animation/test/mozilla/file_discrete_animations.html new file mode 100644 index 0000000000..e0de609bc5 --- /dev/null +++ b/dom/animation/test/mozilla/file_discrete_animations.html @@ -0,0 +1,122 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test Mozilla-specific discrete animatable properties</title> +<script type="application/javascript" src="../testcommon.js"></script> +</head> +<body> +<script> +"use strict"; + +const gMozillaSpecificProperties = { + "-moz-box-align": { + // https://developer.mozilla.org/en/docs/Web/CSS/box-align + from: "center", + to: "stretch" + }, + "-moz-box-direction": { + // https://developer.mozilla.org/en/docs/Web/CSS/box-direction + from: "reverse", + to: "normal" + }, + "-moz-box-ordinal-group": { + // https://developer.mozilla.org/en/docs/Web/CSS/box-ordinal-group + from: "1", + to: "5" + }, + "-moz-box-orient": { + // https://www.w3.org/TR/css-flexbox-1/ + from: "horizontal", + to: "vertical" + }, + "-moz-box-pack": { + // https://www.w3.org/TR/2009/WD-css3-flexbox-20090723/#propdef-box-pack + from: "center", + to: "end" + }, + "-moz-float-edge": { + // https://developer.mozilla.org/en/docs/Web/CSS/-moz-float-edge + from: "margin-box", + to: "content-box" + }, + "-moz-force-broken-image-icon": { + // https://developer.mozilla.org/en/docs/Web/CSS/-moz-force-broken-image-icon + from: "1", + to: "0" + }, + "-moz-text-size-adjust": { + // https://drafts.csswg.org/css-size-adjust/#propdef-text-size-adjust + from: "none", + to: "auto" + }, + "-webkit-text-stroke-width": { + // https://compat.spec.whatwg.org/#propdef--webkit-text-stroke-width + from: "10px", + to: "50px" + } +} + +for (let property in gMozillaSpecificProperties) { + const testData = gMozillaSpecificProperties[property]; + const from = testData.from; + const to = testData.to; + const idlName = propertyToIDL(property); + const keyframes = {}; + keyframes[idlName] = [from, to]; + + test(t => { + const div = addDiv(t); + const animation = div.animate(keyframes, + { duration: 1000, fill: "both" }); + testAnimationSamples(animation, idlName, + [{ time: 0, expected: from.toLowerCase() }, + { time: 499, expected: from.toLowerCase() }, + { time: 500, expected: to.toLowerCase() }, + { time: 1000, expected: to.toLowerCase() }]); + }, property + " should animate between '" + + from + "' and '" + to + "' with linear easing"); + + test(function(t) { + // Easing: http://cubic-bezier.com/#.68,0,1,.01 + // With this curve, we don't reach the 50% point until about 95% of + // the time has expired. + const div = addDiv(t); + const animation = div.animate(keyframes, + { duration: 1000, fill: "both", + easing: "cubic-bezier(0.68,0,1,0.01)" }); + testAnimationSamples(animation, idlName, + [{ time: 0, expected: from.toLowerCase() }, + { time: 940, expected: from.toLowerCase() }, + { time: 960, expected: to.toLowerCase() }]); + }, property + " should animate between '" + + from + "' and '" + to + "' with effect easing"); + + test(function(t) { + // Easing: http://cubic-bezier.com/#.68,0,1,.01 + // With this curve, we don't reach the 50% point until about 95% of + // the time has expired. + keyframes.easing = "cubic-bezier(0.68,0,1,0.01)"; + const div = addDiv(t); + const animation = div.animate(keyframes, + { duration: 1000, fill: "both" }); + testAnimationSamples(animation, idlName, + [{ time: 0, expected: from.toLowerCase() }, + { time: 940, expected: from.toLowerCase() }, + { time: 960, expected: to.toLowerCase() }]); + }, property + " should animate between '" + + from + "' and '" + to + "' with keyframe easing"); +} + +function testAnimationSamples(animation, idlName, testSamples) { + const target = animation.effect.target; + testSamples.forEach(testSample => { + animation.currentTime = testSample.time; + assert_equals(getComputedStyle(target)[idlName], testSample.expected, + "The value should be " + testSample.expected + + " at " + testSample.time + "ms"); + }); +} + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/file_restyles.html b/dom/animation/test/mozilla/file_restyles.html new file mode 100644 index 0000000000..8d72cb6c44 --- /dev/null +++ b/dom/animation/test/mozilla/file_restyles.html @@ -0,0 +1,2275 @@ +<!doctype html> +<head> +<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1"> +<meta charset=utf-8> +<title>Tests restyles caused by animations</title> +<script> +const ok = opener.ok.bind(opener); +const is = opener.is.bind(opener); +const todo = opener.todo.bind(opener); +const todo_is = opener.todo_is.bind(opener); +const info = opener.info.bind(opener); +const original_finish = opener.SimpleTest.finish; +const SimpleTest = opener.SimpleTest; +const add_task = opener.add_task; +SimpleTest.finish = function finish() { + self.close(); + original_finish(); +} +</script> +<script src="/tests/SimpleTest/EventUtils.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<script src="../testcommon.js"></script> +<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +<style> +@keyframes background-position { + 0% { + background-position: -25px center; + } + + 40%, + 100% { + background-position: 36px center; + } +} +@keyframes opacity { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes opacity-without-end-value { + from { opacity: 0; } +} +@keyframes on-main-thread { + from { z-index: 0; } + to { z-index: 999; } +} +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes move-in { + from { transform: translate(120%, 120%); } + to { transform: translate(0%, 0%); } +} +@keyframes background-color { + from { background-color: rgb(255, 0, 0,); } + to { background-color: rgb(0, 255, 0,); } +} +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +progress:not(.stop)::-moz-progress-bar { + animation: on-main-thread 100s; +} +body { + /* + * set overflow:hidden to avoid accidentally unthrottling animations to update + * the overflow region. + */ + overflow: hidden; +} +</style> +</head> +<body> +<script> +'use strict'; + +// Returns observed animation restyle markers when |funcToMakeRestyleHappen| +// is called. +// NOTE: This function is synchronous version of the above observeStyling(). +// Unlike the above observeStyling, this function takes a callback function, +// |funcToMakeRestyleHappen|, which may be expected to trigger a synchronous +// restyles, and returns any restyle markers produced by calling that function. +function observeAnimSyncStyling(funcToMakeRestyleHappen) { + const docShell = getDocShellForObservingRestylesForWindow(window); + + funcToMakeRestyleHappen(); + + const markers = docShell.popProfileTimelineMarkers(); + docShell.recordProfileTimelineMarkers = false; + return Array.prototype.filter.call(markers, (marker, index) => { + return marker.name == 'Styles' && marker.isAnimationOnly; + }); +} + +function ensureElementRemoval(aElement) { + return new Promise(resolve => { + aElement.remove(); + waitForAllPaintsFlushed(resolve); + }); +} + +function waitForWheelEvent(aTarget) { + return new Promise(resolve => { + // Get the scrollable target element position in this window coordinate + // system to send a wheel event to the element. + const targetRect = aTarget.getBoundingClientRect(); + const centerX = targetRect.left + targetRect.width / 2; + const centerY = targetRect.top + targetRect.height / 2; + + sendWheelAndPaintNoFlush(aTarget, centerX, centerY, + { deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: targetRect.height }, + resolve); + }); +} + +const omtaEnabled = isOMTAEnabled(); + +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(() => { + add_task(async function () { + // Start vsync rate measurement in after a RAF callback. + await waitForNextFrame(); + + const timeAtStart = document.timeline.currentTime; + await waitForAnimationFrames(5); + const vsyncRate = (document.timeline.currentTime - timeAtStart) / 5; + + // In this test we basically observe restyling counts in 5 frames, if it + // takes over 200ms during the 5 frames, this test will fail. So + // "200ms / 5 = 40ms" is a threshold whether the test works as expected or + // not. We'd take 5ms additional tolerance here. + // Note that the 200ms is a period we unthrottle throttled animations that + // at least one of the animating styles produces change hints causing + // overflow, the value is defined in + // KeyframeEffect::OverflowRegionRefreshInterval. + if (vsyncRate > 40 - 5) { + ok(true, `the vsync rate ${vsyncRate} on this machine is too slow to run this test`); + SimpleTest.finish(); + } + }); + + add_task(async function restyling_for_main_thread_animations() { + const div = addDiv(null, { style: 'animation: on-main-thread 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + is(markers.length, 5, + 'CSS animations running on the main-thread should update style ' + + 'on the main thread'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_for_main_thread_animations_progress_bar_pseudo() { + const progress = document.createElement("progress"); + document.body.appendChild(progress); + + await waitForNextFrame(); + await waitForNextFrame(); + + let markers = await observeStyling(5); + is(markers.length, 5, + 'CSS animations running on the main-thread should update style ' + + 'on the main thread on ::-moz-progress-bar'); + progress.classList.add("stop"); + await waitForNextFrame(); + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 0, 'Animation is correctly removed'); + await ensureElementRemoval(progress); + }); + + add_task_if_omta_enabled(async function no_restyling_for_compositor_animations() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animations running on the compositor should not update style ' + + 'on the main thread'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_for_compositor_transitions() { + const div = addDiv(null, { style: 'transition: opacity 100s; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = 1; + + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS transitions running on the compositor should not update style ' + + 'on the main thread'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_when_animation_duration_is_changed() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + div.animationDuration = '200s'; + + const markers = await observeStyling(5); + is(markers.length, 0, + 'Animations running on the compositor should not update style ' + + 'on the main thread'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function only_one_restyling_after_finish_is_called() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + animation.finish(); + + let markers = await observeStyling(1); + is(markers.length, 1, + 'Animations running on the compositor should only update style once ' + + 'after finish() is called'); + + markers = await observeStyling(1); + todo_is(markers.length, 0, + 'Bug 1415457: Animations running on the compositor should only ' + + 'update style once after finish() is called'); + + markers = await observeStyling(5); + is(markers.length, 0, + 'Finished animations should never update style after one ' + + 'restyle happened for finish()'); + + await ensureElementRemoval(div); + }); + + add_task(async function no_restyling_mouse_movement_on_finished_transition() { + const div = addDiv(null, { style: 'transition: opacity 1ms; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = 1; + + const animation = div.getAnimations()[0]; + const initialRect = div.getBoundingClientRect(); + + await animation.finished; + let markers = await observeStyling(1); + is(markers.length, 1, + 'Finished transitions should restyle once after Animation.finished ' + + 'was fulfilled'); + + let mouseX = initialRect.left + initialRect.width / 2; + let mouseY = initialRect.top + initialRect.height / 2; + markers = await observeStyling(5, () => { + // We can't use synthesizeMouse here since synthesizeMouse causes + // layout flush. + synthesizeMouseAtPoint(mouseX++, mouseY++, + { type: 'mousemove' }, window); + }); + + is(markers.length, 0, + 'Finished transitions should never cause restyles when mouse is moved ' + + 'on the transitions'); + await ensureElementRemoval(div); + }); + + add_task(async function no_restyling_mouse_movement_on_finished_animation() { + const div = addDiv(null, { style: 'animation: opacity 1ms' }); + const animation = div.getAnimations()[0]; + + const initialRect = div.getBoundingClientRect(); + + await animation.finished; + let markers = await observeStyling(1); + is(markers.length, 1, + 'Finished animations should restyle once after Animation.finished ' + + 'was fulfilled'); + + let mouseX = initialRect.left + initialRect.width / 2; + let mouseY = initialRect.top + initialRect.height / 2; + markers = await observeStyling(5, () => { + // We can't use synthesizeMouse here since synthesizeMouse causes + // layout flush. + synthesizeMouseAtPoint(mouseX++, mouseY++, + { type: 'mousemove' }, window); + }); + + is(markers.length, 0, + 'Finished animations should never cause restyles when mouse is moved ' + + 'on the animations'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_out_of_view_element() { + const div = addDiv(null, + { style: 'animation: opacity 100s; transform: translateY(-400px);' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in an out-of-view element ' + + 'should never cause restyles'); + await ensureElementRemoval(div); + }); + + add_task(async function no_restyling_main_thread_animations_out_of_view_element() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; transform: translateY(-400px);' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread in an out-of-view element ' + + 'should never cause restyles'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_in_scrolled_out_element() { + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + const div = addDiv(null, + { style: 'animation: opacity 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor for elements ' + + 'which are scrolled out should never cause restyles'); + + await ensureElementRemoval(parentElement); + }); + + add_task( + async function no_restyling_missing_keyframe_opacity_animations_on_scrolled_out_element() { + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + const div = addDiv(null, + { style: 'animation: opacity-without-end-value 100s; ' + + 'position: relative; top: 100px;' }); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Opacity animations on scrolled out elements should never cause ' + + 'restyles even if the animation has missing keyframes'); + + await ensureElementRemoval(parentElement); + } + ); + + add_task( + async function restyling_transform_animations_in_scrolled_out_element() { + // Make sure we start from the state right after requestAnimationFrame. + await waitForFrame(); + + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + const div = addDiv(null, + { style: 'animation: rotate 100s infinite; position: relative; top: 100px;' }); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + let timeAtStart = document.timeline.currentTime; + + ok(!animation.isRunningOnCompositor, + 'The transform animation is not running on the compositor'); + + let markers; + let now; + let elapsed; + while (true) { + now = document.timeline.currentTime; + elapsed = (now - timeAtStart); + markers = await observeStyling(1); + if (markers.length) { + break; + } + } + // If the current time has elapsed over 200ms since the animation was + // created, it means that the animation should have already + // unthrottled in this tick, let's see what we observe in this tick's + // restyling process. + // We use toPrecision here and below so 199.99999999999977 will turn into 200. + ok(elapsed.toPrecision(10) >= 200, + 'Transform animation running on the element which is scrolled out ' + + 'should be throttled until 200ms is elapsed. now: ' + + now + ' start time: ' + timeAtStart + ' elapsed:' + elapsed); + + timeAtStart = document.timeline.currentTime; + markers = await observeStyling(1); + now = document.timeline.currentTime; + elapsed = (now - timeAtStart); + + let expectedMarkersLengthValid; + // On the fence of 200 ms, we probably have 1 marker; but if we hit a bad rounding + // we might still have 0. But if it's > 200, we should have 1; and less we should have 0. + if (elapsed.toPrecision(10) == 200) + expectedMarkersLengthValid = markers.length < 2; + else if (elapsed.toPrecision(10) > 200) + expectedMarkersLengthValid = markers.length == 1; + else + expectedMarkersLengthValid = !markers.length; + ok(expectedMarkersLengthValid, + 'Transform animation running on the element which is scrolled out ' + + 'should be unthrottled after around 200ms have elapsed. now: ' + + now + ' start time: ' + timeAtStart + ' elapsed: ' + elapsed); + + await ensureElementRemoval(parentElement); + } + ); + + add_task( + async function restyling_out_of_view_transform_animations_in_another_element() { + // Make sure we start from the state right after requestAnimationFrame. + await waitForFrame(); + + const parentElement = addDiv(null, + { style: 'overflow: hidden;' }); + const div = addDiv(null, + { style: 'animation: move-in 100s infinite;' }); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + let timeAtStart = document.timeline.currentTime; + + ok(!animation.isRunningOnCompositor, + 'The transform animation on out of view element ' + + 'is not running on the compositor'); + + // Structure copied from restyling_transform_animations_in_scrolled_out_element + let markers; + let now; + let elapsed; + while (true) { + now = document.timeline.currentTime; + elapsed = (now - timeAtStart); + markers = await observeStyling(1); + if (markers.length) { + break; + } + } + + ok(elapsed.toPrecision(10) >= 200, + 'Transform animation running on out of view element ' + + 'should be throttled until 200ms is elapsed. now: ' + + now + ' start time: ' + timeAtStart + ' elapsed:' + elapsed); + + timeAtStart = document.timeline.currentTime; + markers = await observeStyling(1); + now = document.timeline.currentTime; + elapsed = (now - timeAtStart); + + let expectedMarkersLengthValid; + // On the fence of 200 ms, we probably have 1 marker; but if we hit a bad rounding + // we might still have 0. But if it's > 200, we should have 1; and less we should have 0. + if (elapsed.toPrecision(10) == 200) + expectedMarkersLengthValid = markers.length < 2; + else if (elapsed.toPrecision(10) > 200) + expectedMarkersLengthValid = markers.length == 1; + else + expectedMarkersLengthValid = !markers.length; + ok(expectedMarkersLengthValid, + 'Transform animation running on out of view element ' + + 'should be unthrottled after around 200ms have elapsed. now: ' + + now + ' start time: ' + timeAtStart + ' elapsed: ' + elapsed); + + await ensureElementRemoval(parentElement); + } + ); + + add_task(async function finite_transform_animations_in_out_of_view_element() { + const parentElement = addDiv(null, { style: 'overflow: hidden;' }); + const div = addDiv(null); + const animation = + div.animate({ transform: [ 'translateX(120%)', 'translateX(100%)' ] }, + // This animation will move a bit but + // will remain out-of-view. + 100 * MS_PER_SEC); + parentElement.appendChild(div); + + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor, "Should not be running in compositor"); + + const markers = await observeStyling(20); + is(markers.length, 20, + 'Finite transform animation in out-of-view element should never be ' + + 'throttled'); + + await ensureElementRemoval(parentElement); + }); + + add_task(async function restyling_main_thread_animations_in_scrolled_out_element() { + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; position: relative; top: 20px;' }); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + let markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the main-thread for elements ' + + 'which are scrolled out should never cause restyles'); + + await waitForWheelEvent(parentElement); + + // Make sure we are ready to restyle before counting restyles. + await waitForFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on the main-thread which were in scrolled out ' + + 'elements should update restyling soon after the element moved in ' + + 'view by scrolling'); + + await ensureElementRemoval(parentElement); + }); + + add_task(async function restyling_main_thread_animations_in_nested_scrolled_out_element() { + const grandParent = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 100px;' }); + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; ' + + 'position: relative; ' + + 'top: 20px;' }); // This element is in-view in the parent, but + // out of view in the grandparent. + grandParent.appendChild(parentElement); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + let markers = await 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'); + + await waitForWheelEvent(grandParent); + + await waitForFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + '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'); + + await ensureElementRemoval(grandParent); + }); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_in_visibility_hidden_element() { + const div = addDiv(null, + { style: 'animation: opacity 100s; visibility: hidden' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in visibility hidden element ' + + 'should never cause restyles'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_main_thread_animations_move_out_of_view_by_scrolling() { + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 200px;' }); + const div = addDiv(null, + { style: 'animation: on-main-thread 100s;' }); + const pad = addDiv(null, + { style: 'height: 400px;' }); + parentElement.appendChild(div); + parentElement.appendChild(pad); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + await waitForWheelEvent(parentElement); + + await waitForFrame(); + + const markers = await 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'); + + await ensureElementRemoval(parentElement); + }); + + add_task(async function restyling_main_thread_animations_moved_in_view_by_resizing() { + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 20px;' }); + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; position: relative; top: 100px;' }); + parentElement.appendChild(div); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + let markers = await observeStyling(5); + is(markers.length, 0, + 'Animations running on the main-thread which is in scrolled out ' + + 'elements should not update restyling'); + + parentElement.style.height = '100px'; + markers = await observeStyling(1); + + 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'); + + await ensureElementRemoval(parentElement); + }); + + add_task( + async function restyling_animations_on_visibility_changed_element_having_child() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s;' }); + const childElement = addDiv(null); + div.appendChild(childElement); + + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + // We don't check the animation causes restyles here since we already + // check it in the first test case. + + div.style.visibility = 'hidden'; + await waitForNextFrame(); + + const markers = await observeStyling(5); + todo_is(markers.length, 0, + 'Animations running on visibility hidden element which ' + + 'has a child whose visiblity is inherited from the element and ' + + 'the element was initially visible'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function restyling_animations_on_visibility_hidden_element_which_gets_visible() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; visibility: hidden' }); + const animation = div.getAnimations()[0]; + + + await waitForAnimationReadyToRestyle(animation); + let markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on visibility hidden element should never ' + + 'cause restyles'); + + div.style.visibility = 'visible'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running that was on visibility hidden element which ' + + 'gets visible should not throttle restyling any more'); + + await ensureElementRemoval(div); + } + ); + + add_task(async function restyling_animations_in_visibility_changed_parent() { + const parentDiv = addDiv(null, { style: 'visibility: hidden' }); + const div = addDiv(null, { style: 'animation: on-main-thread 100s;' }); + parentDiv.appendChild(div); + + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + let markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running in visibility hidden parent should never cause ' + + 'restyles'); + + parentDiv.style.visibility = 'visible'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations that was in visibility hidden parent should not ' + + 'throttle restyling any more'); + + parentDiv.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 0, + 'Animations that the parent element became visible should throttle ' + + 'restyling again'); + + await ensureElementRemoval(parentDiv); + }); + + add_task( + async function restyling_animations_on_visibility_hidden_element_with_visibility_changed_children() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; visibility: hidden' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + let markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations on visibility hidden element having no visible children ' + + 'should never cause restyles'); + + const childElement = addDiv(null, { style: 'visibility: visible' }); + div.appendChild(childElement); + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element but the element has ' + + 'a visible child should not throttle restyling'); + + childElement.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + todo_is(markers.length, 0, + 'Animations running on visibility hidden element that a child ' + + 'has become invisible should throttle restyling'); + + childElement.style.visibility = 'visible'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element should not throttle ' + + 'restyling after the invisible element changed to visible'); + + childElement.remove(); + await waitForNextFrame(); + + markers = await observeStyling(5); + todo_is(markers.length, 0, + 'Animations running on visibility hidden element should throttle ' + + 'restyling again after all visible descendants were removed'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function restyling_animations_on_visiblity_hidden_element_having_oof_child() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; position: absolute' }); + const childElement = addDiv(null, + { style: 'float: left; visibility: hidden' }); + div.appendChild(childElement); + + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + // We don't check the animation causes restyles here since we already + // check it in the first test case. + + div.style.visibility = 'hidden'; + await waitForNextFrame(); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'Animations running on visibility hidden element which has an ' + + 'out-of-flow child should throttle restyling'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function restyling_animations_on_visibility_hidden_element_having_grandchild() { + // element tree: + // + // root(visibility:hidden) + // / \ + // childA childB + // / \ / \ + // AA AB BA BB + + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; visibility: hidden' }); + + const childA = addDiv(null); + div.appendChild(childA); + const childB = addDiv(null); + div.appendChild(childB); + + const grandchildAA = addDiv(null); + childA.appendChild(grandchildAA); + const grandchildAB = addDiv(null); + childA.appendChild(grandchildAB); + + const grandchildBA = addDiv(null); + childB.appendChild(grandchildBA); + const grandchildBB = addDiv(null); + childB.appendChild(grandchildBB); + + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + let markers = await observeStyling(5); + is(markers.length, 0, + 'Animations on visibility hidden element having no visible ' + + 'descendants should never cause restyles'); + + childA.style.visibility = 'visible'; + grandchildAA.style.visibility = 'visible'; + grandchildAB.style.visibility = 'visible'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element but the element has ' + + 'visible children should not throttle restyling'); + + // Make childA hidden again but both of grandchildAA and grandchildAB are + // still visible. + childA.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element that a child has ' + + 'become invisible again but there are still visible children should ' + + 'not throttle restyling'); + + // Make grandchildAA hidden but grandchildAB is still visible. + grandchildAA.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element that a grandchild ' + + 'become invisible again but another grandchild is still visible ' + + 'should not throttle restyling'); + + + // Make childB and grandchildBA visible. + childB.style.visibility = 'visible'; + grandchildBA.style.visibility = 'visible'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element but the element has ' + + 'visible descendants should not throttle restyling'); + + // Make childB hidden but grandchildAB and grandchildBA are still visible. + childB.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element but the element has ' + + 'visible grandchildren should not throttle restyling'); + + // Make grandchildAB hidden but grandchildBA is still visible. + grandchildAB.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations running on visibility hidden element but the element has ' + + 'a visible grandchild should not throttle restyling'); + + // Make grandchildBA hidden. Now all descedants are invisible. + grandchildBA.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + todo_is(markers.length, 0, + 'Animations on visibility hidden element that all descendants have ' + + 'become invisible again should never cause restyles'); + + // Make childB visible. + childB.style.visibility = 'visible'; + await waitForNextFrame(); + + markers = await observeStyling(5); + is(markers.length, 5, + 'Animations on visibility hidden element that has a visible child ' + + 'should never cause restyles'); + + // Make childB invisible again + childB.style.visibility = 'hidden'; + await waitForNextFrame(); + + markers = await observeStyling(5); + todo_is(markers.length, 0, + 'Animations on visibility hidden element that the visible child ' + + 'has become invisible again should never cause restyles'); + + await ensureElementRemoval(div); + } + ); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_after_pause_is_called() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + animation.pause(); + + await animation.ready; + + let markers = await observeStyling(1); + is(markers.length, 1, + 'Animations running on the compositor should restyle once after ' + + 'Animation.pause() was called'); + + markers = await observeStyling(5); + is(markers.length, 0, + 'Paused animations running on the compositor should never cause ' + + 'restyles'); + await ensureElementRemoval(div); + }); + + add_task(async function no_restyling_main_thread_animations_after_pause_is_called() { + const div = addDiv(null, { style: 'animation: on-main-thread 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + animation.pause(); + + await animation.ready; + + let markers = await observeStyling(1); + is(markers.length, 1, + 'Animations running on the main-thread should restyle once after ' + + 'Animation.pause() was called'); + + markers = await observeStyling(5); + is(markers.length, 0, + 'Paused animations running on the main-thread should never cause ' + + 'restyles'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function only_one_restyling_when_current_time_is_set_to_middle_of_duration() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + animation.currentTime = 50 * MS_PER_SEC; + + const markers = await 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'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function change_duration_and_currenttime() { + const div = addDiv(null); + const animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(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(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + // Extend the duration. + animation.effect.updateTiming({ duration: 800 * MS_PER_SEC }); + const markers = await observeStyling(5); + is(markers.length, 1, + 'Animations running on the compositor should update style ' + + 'when duration is made longer than the current time'); + + await ensureElementRemoval(div); + }); + + add_task(async function script_animation_on_display_none_element() { + const div = addDiv(null); + const animation = div.animate({ zIndex: [ '0', '999' ] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + div.style.display = 'none'; + + // We need to wait a frame to apply display:none style. + await waitForNextFrame(); + + is(animation.playState, 'running', + 'Script animations keep running even when the target element has ' + + '"display: none" style'); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor, + 'Script animations on "display:none" element should not run on the ' + + 'compositor'); + + let markers = await observeStyling(5); + is(markers.length, 0, + 'Script animations on "display: none" element should not update styles'); + + div.style.display = ''; + + markers = await observeStyling(5); + is(markers.length, 5, + 'Script animations restored from "display: none" state should update ' + + 'styles'); + + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function compositable_script_animation_on_display_none_element() { + const div = addDiv(null); + const animation = div.animate({ opacity: [ 0, 1 ] }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + div.style.display = 'none'; + + // We need to wait a frame to apply display:none style. + await waitForNextFrame(); + + is(animation.playState, 'running', + 'Opacity script animations keep running even when the target element ' + + 'has "display: none" style'); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor, + 'Opacity script animations on "display:none" element should not ' + + 'run on the compositor'); + + let markers = await observeStyling(5); + is(markers.length, 0, + 'Opacity script animations on "display: none" element should not ' + + 'update styles'); + + div.style.display = ''; + + markers = await observeStyling(1); + is(markers.length, 1, + 'Script animations restored from "display: none" state should update ' + + 'styles soon'); + + ok(SpecialPowers.wrap(animation).isRunningOnCompositor, + 'Opacity script animations restored from "display: none" should be ' + + 'run on the compositor in the next frame'); + + await ensureElementRemoval(div); + }); + + add_task(async function restyling_for_empty_keyframes() { + const div = addDiv(null); + const animation = div.animate({ }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + let markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations with no keyframes should not cause restyles'); + + animation.effect.setKeyframes({ zIndex: ['0', '999'] }); + markers = await observeStyling(5); + + is(markers.length, 5, + 'Setting valid keyframes should cause regular animation restyles to ' + + 'occur'); + + animation.effect.setKeyframes({ }); + markers = await observeStyling(5); + + is(markers.length, 1, + 'Setting an empty set of keyframes should trigger a single restyle ' + + 'to remove the previous animated style'); + + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_when_animation_style_when_re_setting_same_animation_property() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + // Apply the same animation style + div.style.animation = 'opacity 100s'; + const markers = await observeStyling(5); + is(markers.length, 0, + 'Applying same animation style ' + + 'should never cause restyles'); + await ensureElementRemoval(div); + }); + + add_task(async function necessary_update_should_be_invoked() { + const div = addDiv(null, { style: 'animation: on-main-thread 100s' }); + const animation = div.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + await waitForAnimationFrames(5); + // Apply another animation style + div.style.animation = 'on-main-thread 110s'; + const markers = await observeStyling(1); + // There should be two restyles. + // 1) Animation-only restyle for before applying the new animation style + // 2) Animation-only restyle for after applying the new animation style + is(markers.length, 2, + 'Applying animation style with different duration ' + + 'should restyle twice'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled( + async function changing_cascading_result_for_main_thread_animation() { + const div = addDiv(null, { style: 'on-main-thread: blue' }); + const animation = div.animate({ opacity: [0, 1], + zIndex: ['0', '999'] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor, + 'The opacity animation is running on the compositor'); + // Make the z-index style as !important to cause an update + // to the cascade. + // Bug 1300982: The z-index animation should be no longer + // running on the main thread. + div.style.setProperty('z-index', '0', 'important'); + const markers = await 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'); + await ensureElementRemoval(div); + } + ); + + add_task_if_omta_enabled( + async function animation_visibility_and_opacity() { + const div = addDiv(null); + const animation1 = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + const animation2 = div.animate({ visibility: ['hidden', 'visible'] }, 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation1); + await waitForAnimationReadyToRestyle(animation2); + const markers = await observeStyling(5); + is(markers.length, 5, 'The animation should not be throttled'); + await ensureElementRemoval(div); + } + ); + + add_task(async function restyling_for_animation_on_orphaned_element() { + const div = addDiv(null); + const animation = div.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + div.remove(); + + let markers = await observeStyling(5); + is(markers.length, 0, + 'Animation on orphaned element should not cause restyles'); + + document.body.appendChild(div); + + await waitForNextFrame(); + markers = await observeStyling(5); + + is(markers.length, 5, + 'Animation on re-attached to the document begins to update style, got ' + markers.length); + + await 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. + async function restyling_for_opacity_animation_on_re_attached_element() { + const div = addDiv(null, { style: 'opacity: 1 ! important' }); + const animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(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(); + + const markers = await 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. + await waitForNextFrame(); + + ok(SpecialPowers.wrap(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'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function no_throttling_additive_animations_out_of_view_element() { + const div = addDiv(null, { style: 'transform: translateY(-400px);' }); + const animation = + div.animate([{ visibility: 'visible' }], + { duration: 100 * MS_PER_SEC, composite: 'add' }); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 5, + 'Additive animation has no keyframe whose offset is 0 or 1 in an ' + + 'out-of-view element should not be throttled'); + await ensureElementRemoval(div); + } + ); + + // Tests that missing keyframes animations don't throttle at all. + add_task(async function no_throttling_animations_out_of_view_element() { + const div = addDiv(null, { style: 'transform: translateY(-400px);' }); + const animation = + div.animate([{ visibility: 'visible' }], 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 5, + 'Discrete animation has has no keyframe whose offset is 0 or 1 in an ' + + 'out-of-view element should not be throttled'); + await ensureElementRemoval(div); + }); + + // Tests that missing keyframes animation on scrolled out element that the + // animation is not able to be throttled. + add_task( + async function no_throttling_missing_keyframe_animations_out_of_view_element() { + const div = + addDiv(null, { style: 'transform: translateY(-400px);' + + 'visibility: collapse;' }); + const animation = + div.animate([{ visibility: 'visible' }], 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 5, + 'visibility animation has no keyframe whose offset is 0 or 1 in an ' + + 'out-of-view element and produces change hint other than paint-only ' + + 'change hint should not be throttled'); + await ensureElementRemoval(div); + } + ); + + // Counter part of the above test. + add_task(async function no_restyling_discrete_animations_out_of_view_element() { + const div = addDiv(null, { style: 'transform: translateY(-400px);' }); + const animation = + div.animate({ visibility: ['visible', 'hidden'] }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Discrete animation running on the main-thread in an out-of-view ' + + 'element should never cause restyles'); + await ensureElementRemoval(div); + }); + + add_task(async function no_restyling_while_computed_timing_is_not_changed() { + const div = addDiv(null); + const animation = div.animate({ zIndex: [ '0', '999' ] }, + { duration: 100 * MS_PER_SEC, + easing: 'step-end' }); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + // We possibly expect one restyle from the initial animation compose, in + // order to update animations, but nothing else. + ok(markers.length <= 1, + 'Animation running on the main-thread while computed timing is not ' + + 'changed should not cause extra restyles, got ' + markers.length); + await ensureElementRemoval(div); + }); + + add_task(async function no_throttling_animations_in_view_svg() { + const div = addDiv(null, { style: 'overflow: scroll;' + + 'height: 100px; width: 100px;' }); + const svg = addSVGElement(div, 'svg', { viewBox: '-10 -10 0.1 0.1', + width: '50px', + height: '50px' }); + const rect = addSVGElement(svg, 'rect', { x: '-10', + y: '-10', + width: '10', + height: '10', + fill: 'red' }); + const animation = rect.animate({ fill: ['blue', 'lime'] }, 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 5, + 'CSS animations on an in-view svg element with post-transform should ' + + 'not be throttled.'); + + await ensureElementRemoval(div); + }); + + add_task(async function no_throttling_animations_in_transformed_parent() { + const div = addDiv(null, { style: 'overflow: scroll;' + + 'transform: translateX(50px);' }); + const svg = addSVGElement(div, 'svg', { viewBox: '0 0 1250 1250', + width: '40px', + height: '40px' }); + const rect = addSVGElement(svg, 'rect', { x: '0', + y: '0', + width: '1250', + height: '1250', + fill: 'red' }); + const animation = rect.animate({ fill: ['blue', 'lime'] }, 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 5, + 'CSS animations on an in-view svg element which is inside transformed ' + + 'parent should not be throttled.'); + + await ensureElementRemoval(div); + }); + + add_task(async function throttling_animations_out_of_view_svg() { + const div = addDiv(null, { style: 'overflow: scroll;' + + 'height: 100px; width: 100px;' }); + const svg = addSVGElement(div, 'svg', { viewBox: '-10 -10 0.1 0.1', + width: '50px', + height: '50px' }); + const rect = addSVGElement(svg, 'rect', { width: '10', + height: '10', + fill: 'red' }); + + const animation = rect.animate({ fill: ['blue', 'lime'] }, 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animations on an out-of-view svg element with post-transform ' + + 'should be throttled.'); + + await ensureElementRemoval(div); + }); + + add_task(async function no_throttling_animations_in_view_css_transform() { + const scrollDiv = addDiv(null, { style: 'overflow: scroll; ' + + 'height: 100px; width: 100px;' }); + const targetDiv = addDiv(null, + { style: 'animation: on-main-thread 100s;' + + 'transform: translate(-50px, -50px);' }); + scrollDiv.appendChild(targetDiv); + + const animation = targetDiv.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 5, + 'CSS animation on an in-view element with pre-transform should not ' + + 'be throttled.'); + + await ensureElementRemoval(scrollDiv); + }); + + add_task(async function throttling_animations_out_of_view_css_transform() { + const scrollDiv = addDiv(null, { style: 'overflow: scroll;' + + 'height: 100px; width: 100px;' }); + const targetDiv = addDiv(null, + { style: 'animation: on-main-thread 100s;' + + 'transform: translate(100px, 100px);' }); + scrollDiv.appendChild(targetDiv); + + const animation = targetDiv.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animation on an out-of-view element with pre-transform should be ' + + 'throttled.'); + + await ensureElementRemoval(scrollDiv); + }); + + add_task( + async function throttling_animations_in_out_of_view_position_absolute_element() { + const parentDiv = addDiv(null, + { style: 'position: absolute; top: -1000px;' }); + const targetDiv = addDiv(null, + { style: 'animation: on-main-thread 100s;' }); + parentDiv.appendChild(targetDiv); + + const animation = targetDiv.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animation in an out-of-view position absolute element should ' + + 'be throttled'); + + await ensureElementRemoval(parentDiv); + } + ); + + add_task( + async function throttling_animations_on_out_of_view_position_absolute_element() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; ' + + 'position: absolute; top: -1000px;' }); + + const animation = div.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animation on an out-of-view position absolute element should ' + + 'be throttled'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function throttling_animations_in_out_of_view_position_fixed_element() { + const parentDiv = addDiv(null, + { style: 'position: fixed; top: -1000px;' }); + const targetDiv = addDiv(null, + { style: 'animation: on-main-thread 100s;' }); + parentDiv.appendChild(targetDiv); + + const animation = targetDiv.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animation on an out-of-view position:fixed element should be ' + + 'throttled'); + + await ensureElementRemoval(parentDiv); + } + ); + + add_task( + async function throttling_animations_on_out_of_view_position_fixed_element() { + const div = addDiv(null, + { style: 'animation: on-main-thread 100s; ' + + 'position: fixed; top: -1000px;' }); + + const animation = div.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'CSS animation on an out-of-view position:fixed element should be ' + + 'throttled'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function no_throttling_animations_in_view_position_fixed_element() { + const iframe = document.createElement('iframe'); + iframe.setAttribute('srcdoc', '<div id="target"></div>'); + document.documentElement.appendChild(iframe); + + await new Promise(resolve => { + iframe.addEventListener('load', () => { + resolve(); + }); + }); + + const target = iframe.contentDocument.getElementById('target'); + target.style= 'position: fixed; top: 20px; width: 100px; height: 100px;'; + + const animation = target.animate({ zIndex: [ '0', '999' ] }, + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStylingInTargetWindow(iframe.contentWindow, 5); + is(markers.length, 5, + 'CSS animation on an in-view position:fixed element should NOT be ' + + 'throttled'); + + await ensureElementRemoval(iframe); + } + ); + + add_task( + async function throttling_position_absolute_animations_in_collapsed_iframe() { + const iframe = document.createElement('iframe'); + iframe.setAttribute('srcdoc', '<div id="target"></div>'); + iframe.style.height = '0px'; + document.documentElement.appendChild(iframe); + + await new Promise(resolve => { + iframe.addEventListener('load', () => { + resolve(); + }); + }); + + const target = iframe.contentDocument.getElementById("target"); + target.style= 'position: absolute; top: 50%; width: 100px; height: 100px'; + + const animation = target.animate({ opacity: [0, 1] }, + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStylingInTargetWindow(iframe.contentWindow, 5); + is(markers.length, 0, + 'Animation on position:absolute element in collapsed iframe should ' + + 'be throttled'); + + await ensureElementRemoval(iframe); + } + ); + + add_task( + async function position_absolute_animations_in_collapsed_element() { + const parent = addDiv(null, { style: 'overflow: scroll; height: 0px;' }); + const target = addDiv(null, + { style: 'animation: on-main-thread 100s infinite;' + + 'position: absolute; top: 50%;' + + 'width: 100px; height: 100px;' }); + parent.appendChild(target); + + const animation = target.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 5, + 'Animation on position:absolute element in collapsed element ' + + 'should not be throttled'); + + await ensureElementRemoval(parent); + } + ); + + add_task( + async function throttling_position_absolute_animations_in_collapsed_element() { + const parent = addDiv(null, { style: 'overflow: scroll; height: 0px;' }); + const target = addDiv(null, + { style: 'animation: on-main-thread 100s infinite;' + + 'position: absolute; top: 50%;' }); + parent.appendChild(target); + + const animation = target.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + todo_is(markers.length, 0, + 'Animation on collapsed position:absolute element in collapsed ' + + 'element should be throttled'); + + await ensureElementRemoval(parent); + } + ); + + add_task_if_omta_enabled( + async function no_restyling_for_compositor_animation_on_unrelated_style_change() { + const div = addDiv(null); + const animation = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor, + 'The opacity animation is running on the compositor'); + + div.style.setProperty('color', 'blue', ''); + const markers = await observeStyling(5); + is(markers.length, 0, + 'The opacity animation keeps running on the compositor when ' + + 'color style is changed'); + await ensureElementRemoval(div); + } + ); + + add_task( + async function no_overflow_transform_animations_in_scrollable_element() { + const parentElement = addDiv(null, + { style: 'overflow-y: scroll; height: 100px;' }); + const div = addDiv(null); + const animation = + div.animate({ transform: [ 'translateY(10px)', 'translateY(10px)' ] }, + 100 * MS_PER_SEC); + parentElement.appendChild(div); + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(20); + is(markers.length, 0, + 'No-overflow transform animations running on the compositor should ' + + 'never update style on the main thread'); + + await ensureElementRemoval(parentElement); + } + ); + + add_task(async function no_flush_on_getAnimations() { + const div = addDiv(null); + const animation = + div.animate({ opacity: [ '0', '1' ] }, 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = observeAnimSyncStyling(() => { + is(div.getAnimations().length, 1, 'There should be one animation'); + }); + is(markers.length, 0, + 'Element.getAnimations() should not flush throttled animation style'); + + await ensureElementRemoval(div); + }); + + add_task(async function restyling_for_throttled_animation_on_getAnimations() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = observeAnimSyncStyling(() => { + div.style.animationDuration = '0s'; + is(div.getAnimations().length, 0, 'There should be no animation'); + }); + + is(markers.length, 1, // For discarding the throttled animation. + 'Element.getAnimations() should flush throttled animation style so ' + + 'that the throttled animation is discarded'); + + await ensureElementRemoval(div); + }); + + add_task( + async function no_restyling_for_throttled_animation_on_querying_play_state() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + const sibling = addDiv(null); + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = observeAnimSyncStyling(() => { + sibling.style.opacity = '0.5'; + is(animation.playState, 'running', + 'Animation.playState should be running'); + }); + is(markers.length, 0, + 'Animation.playState should not flush throttled animation in the ' + + 'case where there are only style changes that don\'t affect the ' + + 'throttled animation'); + + await ensureElementRemoval(div); + await ensureElementRemoval(sibling); + } + ); + + add_task( + async function restyling_for_throttled_animation_on_querying_play_state() { + const div = addDiv(null, { style: 'animation: opacity 100s' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = observeAnimSyncStyling(() => { + div.style.animationPlayState = 'paused'; + is(animation.playState, 'paused', + 'Animation.playState should be reflected by pending style'); + }); + + is(markers.length, 1, + 'Animation.playState should flush throttled animation style that ' + + 'affects the throttled animation'); + + await ensureElementRemoval(div); + } + ); + + add_task( + async function no_restyling_for_throttled_transition_on_querying_play_state() { + const div = addDiv(null, { style: 'transition: opacity 100s; opacity: 0' }); + const sibling = addDiv(null); + + getComputedStyle(div).opacity; + div.style.opacity = 1; + + const transition = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(transition); + ok(SpecialPowers.wrap(transition).isRunningOnCompositor); + + const markers = observeAnimSyncStyling(() => { + sibling.style.opacity = '0.5'; + is(transition.playState, 'running', + 'Animation.playState should be running'); + }); + + is(markers.length, 0, + 'Animation.playState should not flush throttled transition in the ' + + 'case where there are only style changes that don\'t affect the ' + + 'throttled animation'); + + await ensureElementRemoval(div); + await ensureElementRemoval(sibling); + } + ); + + add_task( + async function restyling_for_throttled_transition_on_querying_play_state() { + const div = addDiv(null, { style: 'transition: opacity 100s; opacity: 0' }); + getComputedStyle(div).opacity; + div.style.opacity = '1'; + + const transition = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(transition); + ok(SpecialPowers.wrap(transition).isRunningOnCompositor); + + const markers = observeAnimSyncStyling(() => { + div.style.transitionProperty = 'none'; + is(transition.playState, 'idle', + 'Animation.playState should be reflected by pending style change ' + + 'which cancel the transition'); + }); + + is(markers.length, 1, + 'Animation.playState should flush throttled transition style that ' + + 'affects the throttled animation'); + + await ensureElementRemoval(div); + } + ); + + add_task(async function restyling_visibility_animations_on_in_view_element() { + const div = addDiv(null); + const animation = + div.animate({ visibility: ['hidden', 'visible'] }, 100 * MS_PER_SEC); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 5, + 'Visibility animation running on the main-thread on in-view element ' + + 'should not be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_outline_offset_animations_on_invisible_element() { + const div = addDiv(null, + { style: 'visibility: hidden; ' + + 'outline-style: solid; ' + + 'outline-width: 1px;' }); + const animation = + div.animate({ outlineOffset: [ '0px', '10px' ] }, + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Outline offset animation running on the main-thread on invisible ' + + 'element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_transform_animations_on_invisible_element() { + const div = addDiv(null, { style: 'visibility: hidden;' }); + + const animation = + div.animate({ transform: [ 'none', 'rotate(360deg)' ] }, + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Transform animations on visibility hidden element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_transform_animations_on_invisible_element() { + const div = addDiv(null, { style: 'visibility: hidden;' }); + + const animation = + div.animate([ { transform: 'rotate(360deg)' } ], + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Transform animations without 100% keyframe on visibility hidden ' + + 'element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_translate_animations_on_invisible_element() { + const div = addDiv(null, { style: 'visibility: hidden;' }); + + const animation = + div.animate([ { translate: '100px' } ], + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Translate animations without 100% keyframe on visibility hidden ' + + 'element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_rotate_animations_on_invisible_element() { + const div = addDiv(null, { style: 'visibility: hidden;' }); + + const animation = + div.animate([ { rotate: '45deg' } ], + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Rotate animations without 100% keyframe on visibility hidden ' + + 'element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function restyling_scale_animations_on_invisible_element() { + const div = addDiv(null, { style: 'visibility: hidden;' }); + + const animation = + div.animate([ { scale: '2 2' } ], + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Scale animations without 100% keyframe on visibility hidden ' + + 'element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task( + async function restyling_transform_animations_having_abs_pos_child_on_invisible_element() { + const div = addDiv(null, { style: 'visibility: hidden;' }); + const child = addDiv(null, { style: 'position: absolute; top: 100px;' }); + div.appendChild(child); + + const animation = + div.animate({ transform: [ 'none', 'rotate(360deg)' ] }, + { duration: 100 * MS_PER_SEC, + iterations: Infinity }); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Transform animation having an absolutely positioned child on ' + + 'visibility hidden element should be throttled'); + await ensureElementRemoval(div); + }); + + add_task(async function no_restyling_animations_in_out_of_view_iframe() { + const div = addDiv(null, { style: 'overflow-y: scroll; height: 100px;' }); + + const iframe = document.createElement('iframe'); + iframe.setAttribute( + 'srcdoc', + '<div style="height: 100px;"></div><div id="target"></div>'); + div.appendChild(iframe); + + await new Promise(resolve => { + iframe.addEventListener('load', () => { + resolve(); + }); + }); + + const target = iframe.contentDocument.getElementById("target"); + target.style= 'width: 100px; height: 100px;'; + + const animation = target.animate({ zIndex: [ '0', '999' ] }, + 100 * MS_PER_SEC); + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStylingInTargetWindow(iframe.contentWindow, 5); + is(markers.length, 0, + 'Animation in out-of-view iframe should be throttled'); + + await ensureElementRemoval(div); + }); + + // Tests that transform animations are not able to run on the compositor due + // to layout restrictions (e.g. animations on a large size frame) doesn't + // flush layout at all. + add_task(async function flush_layout_for_transform_animations() { + // Set layout.animation.prerender.partial to disallow transform animations + // on large frames to be sent to the compositor. + await SpecialPowers.pushPrefEnv({ + set: [['layout.animation.prerender.partial', false]] }); + const div = addDiv(null, { style: 'width: 10000px; height: 10000px;' }); + + const animation = div.animate([ { transform: 'rotate(360deg)', } ], + { duration: 100 * MS_PER_SEC, + // Set step-end to skip further restyles. + easing: 'step-end' }); + + const FLUSH_LAYOUT = SpecialPowers.DOMWindowUtils.FLUSH_LAYOUT; + ok(SpecialPowers.DOMWindowUtils.needsFlush(FLUSH_LAYOUT), + 'Flush is needed for the appended div'); + + await waitForAnimationReadyToRestyle(animation); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor, "Shouldn't be running in the compositor"); + + // We expect one restyle from the initial animation compose. + await waitForNextFrame(); + + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor, "Still shouldn't be running in the compositor"); + ok(!SpecialPowers.DOMWindowUtils.needsFlush(FLUSH_LAYOUT), + 'No further layout flush needed'); + + await ensureElementRemoval(div); + }); + + add_task(async function partial_prerendered_transform_animations() { + await SpecialPowers.pushPrefEnv({ + set: [['layout.animation.prerender.partial', true]] }); + const div = addDiv(null, { style: 'width: 10000px; height: 10000px;' }); + + const animation = div.animate( + // Use the same value both for `from` and `to` to avoid jank on the + // compositor. + { transform: ['rotate(0deg)', 'rotate(0deg)'] }, + 100 * MS_PER_SEC + ); + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'Transform animation with partial pre-rendered should never cause ' + + 'restyles'); + + await ensureElementRemoval(div); + }); + + add_task(async function restyling_on_create_animation() { + const div = addDiv(); + const docShell = getDocShellForObservingRestylesForWindow(window); + + const animationA = div.animate( + { transform: ['none', 'rotate(360deg)'] }, + 100 * MS_PER_SEC + ); + const animationB = div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + const animationC = div.animate( + { color: ['blue', 'green'] }, + 100 * MS_PER_SEC + ); + const animationD = div.animate( + { width: ['100px', '200px'] }, + 100 * MS_PER_SEC + ); + const animationE = div.animate( + { height: ['100px', '200px'] }, + 100 * MS_PER_SEC + ); + + const markers = docShell + .popProfileTimelineMarkers() + .filter(marker => marker.name === 'Styles' && !marker.isAnimationOnly); + docShell.recordProfileTimelineMarkers = false; + + is(markers.length, 0, 'Creating animations should not flush styles'); + + await ensureElementRemoval(div); + }); + + add_task(async function out_of_view_background_position() { + const div = addDiv(null, { + style: ` + background-image: linear-gradient(90deg, rgb(224, 224, 224), rgb(241, 241, 241) 30%, rgb(224, 224, 224) 60%); + background-size: 80px; + animation: background-position 100s infinite; + transform: translateY(-400px); + `, + }) + + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 0, 'background-position animations can be throttled'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_animations_in_opacity_zero_element() { + const div = addDiv(null, { style: 'animation: on-main-thread 100s infinite; opacity: 0' }); + const animation = div.getAnimations()[0]; + + await waitForAnimationReadyToRestyle(animation); + const markers = await observeStyling(5); + is(markers.length, 0, + 'Animations running on the main thread in opacity: 0 element ' + + 'should never cause restyles'); + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_in_opacity_zero_descendant() { + const container = addDiv(null, { style: 'opacity: 0' }); + const child = addDiv(null, { style: 'animation: background-color 100s infinite;' }); + container.appendChild(child); + + const animation = child.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in opacity zero descendant element ' + + 'should never cause restyles'); + await ensureElementRemoval(container); + }); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_in_opacity_zero_descendant_abspos() { + const container = addDiv(null, { style: 'opacity: 0' }); + const child = addDiv(null, { style: 'position: absolute; animation: background-color 100s infinite;' }); + container.appendChild(child); + + const animation = child.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in opacity zero abspos descendant element ' + + 'should never cause restyles'); + await ensureElementRemoval(container); + }); + + add_task_if_omta_enabled(async function no_restyling_compositor_animations_in_opacity_zero_element() { + const child = addDiv(null, { style: 'animation: background-color 100s infinite; opacity: 0' }); + + const animation = child.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + ok(!SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in opacity zero element ' + + 'should never cause restyles'); + await ensureElementRemoval(child); + }); + + add_task_if_omta_enabled(async function restyling_main_thread_animations_in_opacity_zero_descendant_after_root_opacity_animation() { + const container = addDiv(null, { style: 'opacity: 0' }); + + const child = addDiv(null, { style: 'animation: on-main-thread 100s infinite;' }); + container.appendChild(child); + + // Animate the container from 1 to zero opacity and ensure the child animation is throttled then. + const containerAnimation = container.animate({ opacity: [ '1', '0' ] }, 100); + await containerAnimation.finished; + + const animation = child.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 0, + 'Animations running on the compositor in opacity zero descendant element ' + + 'should never cause restyles after root animation has finished'); + await ensureElementRemoval(container); + }); + + add_task_if_omta_enabled(async function restyling_main_thread_animations_in_opacity_zero_descendant_during_root_opacity_animation() { + const container = addDiv(null, { style: 'opacity: 0; animation: opacity 100s infinite' }); + + const child = addDiv(null, { style: 'animation: on-main-thread 100s infinite;' }); + container.appendChild(child); + + const animation = child.getAnimations()[0]; + await waitForAnimationReadyToRestyle(animation); + + const markers = await observeStyling(5); + + is(markers.length, 5, + 'Animations in opacity zero descendant element ' + + 'should not be throttled if root is animating opacity'); + await ensureElementRemoval(container); + }); + + add_task_if_omta_enabled(async function transparent_background_color_animations() { + const div = addDiv(null); + const animation = + div.animate({ backgroundColor: [ 'rgb(0, 200, 0, 0)', + 'rgb(200, 0, 0, 0.1)' ] }, + { duration: 100 * MS_PER_SEC, + // An easing function producing zero in the first half of + // the duration. + easing: 'cubic-bezier(1, 0, 1, 0)' }); + await waitForAnimationReadyToRestyle(animation); + + ok(SpecialPowers.wrap(animation).isRunningOnCompositor); + + const markers = await observeStyling(5); + is(markers.length, 0, + 'transparent background-color animation should not update styles on ' + + 'the main thread'); + + await ensureElementRemoval(div); + }); + + add_task_if_omta_enabled(async function transform_animation_on_collapsed_element() { + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + + // Load a cross origin iframe. + const targetURL = SimpleTest.getTestFileURL("empty.html") + .replace(window.location.origin, "http://example.com/"); + iframe.src = targetURL; + await new Promise(resolve => { + iframe.onload = resolve; + }); + + await SpecialPowers.spawn(iframe, [MS_PER_SEC], async (MS_PER_SEC) => { + // Create a flex item with "preserve-3d" having an abs-pos child inside + // a grid container. + // These styles make the the flex item size (0x0). + const gridContainer = content.document.createElement("div"); + gridContainer.style.display = "grid"; + gridContainer.style.placeItems = "center"; + + const target = content.document.createElement("div"); + target.style.display = "flex"; + target.style.transformStyle = "preserve-3d"; + gridContainer.appendChild(target); + + const child = content.document.createElement("div"); + child.style.position = "absolute"; + child.style.transform = "rotateY(0deg)"; + child.style.width = "100px"; + child.style.height = "100px"; + child.style.backgroundColor = "green"; + target.appendChild(child); + + content.document.body.appendChild(gridContainer); + + const animation = + target.animate({ transform: [ "rotateY(0deg)", "rotateY(360deg)" ] }, + { duration: 100 * MS_PER_SEC, + id: "test", + easing: 'step-end' }); + await content.wrappedJSObject.waitForAnimationReadyToRestyle(animation); + ok(SpecialPowers.wrap(animation).isRunningOnCompositor, + 'transform animation on a collapsed element should run on the ' + + 'compositor'); + + const markers = await content.wrappedJSObject.observeStyling(5); + is(markers.length, 0, + 'transform animation on a collapsed element animation should not ' + + 'update styles on the main thread'); + }); + + await ensureElementRemoval(iframe); + }); +}); + +</script> +</body> diff --git a/dom/animation/test/mozilla/file_transition_finish_on_compositor.html b/dom/animation/test/mozilla/file_transition_finish_on_compositor.html new file mode 100644 index 0000000000..4912d05dd1 --- /dev/null +++ b/dom/animation/test/mozilla/file_transition_finish_on_compositor.html @@ -0,0 +1,67 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<style> +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +<body> +<script> +'use strict'; + +function waitForPaints() { + return new Promise(function(resolve, reject) { + waitForAllPaintsFlushed(resolve); + }); +} + +promise_test(t => { + // This test only applies to compositor animations + if (!isOMTAEnabled()) { + return; + } + + var div = addDiv(t, { style: 'transition: transform 50ms; ' + + 'transform: translateX(0px)' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(100px)'; + + var timeBeforeStart = window.performance.now(); + return waitForPaints().then(() => { + // If it took over 50ms to paint the transition, we have no luck + // to test it. This situation will happen if GC runs while waiting for the + // paint. + if (window.performance.now() - timeBeforeStart >= 50) { + return; + } + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_not_equals(transform, '', + 'The transition style is applied on the compositor'); + + // Generate artificial busyness on the main thread for 100ms. + var timeAtStart = window.performance.now(); + while (window.performance.now() - timeAtStart < 100) {} + + // Now the transition on the compositor should finish but stay at the final + // position because there was no chance to pull the transition back from + // the compositor. + transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_equals(transform, 'matrix(1, 0, 0, 1, 100, 0)', + 'The final transition style is still applied on the ' + + 'compositor'); + }); +}, 'Transition on the compositor keeps the final style while the main thread ' + + 'is busy even if the transition finished on the compositor'); + +done(); +</script> +</body> diff --git a/dom/animation/test/mozilla/test_cascade.html b/dom/animation/test/mozilla/test_cascade.html new file mode 100644 index 0000000000..4bdb07530e --- /dev/null +++ b/dom/animation/test/mozilla/test_cascade.html @@ -0,0 +1,37 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<style> +@keyframes margin-left { + from { margin-left: 20px; } + to { margin-left: 80px; } +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t, { style: 'transition: margin-left 100s; ' + + 'margin-left: 80px' }); + var cs = getComputedStyle(div); + + assert_equals(cs.marginLeft, '80px', 'initial margin-left'); + + div.style.marginLeft = "20px"; + assert_equals(cs.marginLeft, '80px', 'margin-left transition at 0s'); + + div.style.animation = "margin-left 2s"; + assert_equals(cs.marginLeft, '20px', + 'margin-left animation overrides transition at 0s'); + + div.style.animation = "none"; + assert_equals(cs.marginLeft, '80px', + 'margin-left animation stops overriding transition at 0s'); +}, 'Animation overrides/stops overriding transition immediately'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_cubic_bezier_limits.html b/dom/animation/test/mozilla/test_cubic_bezier_limits.html new file mode 100644 index 0000000000..bdbc78654f --- /dev/null +++ b/dom/animation/test/mozilla/test_cubic_bezier_limits.html @@ -0,0 +1,168 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<body> +<style> +@keyframes anim { + to { margin-left: 100px; } +} + +.transition-div { + margin-left: 100px; +} +</style> +<div id="log"></div> +<script> +'use strict'; + +// We clamp +infinity or -inifinity value in floating point to +// maximum floating point value or -maxinum floating point value. +const max_float = '3.40282e38'; + +test(function(t) { + var div = addDiv(t); + var anim = div.animate({ }, 100 * MS_PER_SEC); + + anim.effect.updateTiming({ easing: 'cubic-bezier(0, 1e+39, 0, 0)' }); + assert_equals(anim.effect.getComputedTiming().easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for effect easing is out of upper boundary'); + + anim.effect.updateTiming({ easing: 'cubic-bezier(0, 0, 0, 1e+39)' }); + assert_equals(anim.effect.getComputedTiming().easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for effect easing is out of upper boundary'); + + anim.effect.updateTiming({ easing: 'cubic-bezier(0, -1e+39, 0, 0)' }); + assert_equals(anim.effect.getComputedTiming().easing, + 'cubic-bezier(0, ' + '-' + max_float + ', 0, 0)', + 'y1 control point for effect easing is out of lower boundary'); + + anim.effect.updateTiming({ easing: 'cubic-bezier(0, 0, 0, -1e+39)' }); + assert_equals(anim.effect.getComputedTiming().easing, + 'cubic-bezier(0, 0, 0, ' + '-' + max_float + ')', + 'y2 control point for effect easing is out of lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for effect easing' ); + +test(function(t) { + var div = addDiv(t); + var anim = div.animate({ }, 100 * MS_PER_SEC); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, 1e+39, 0, 0)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for keyframe easing is out of upper boundary'); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, 0, 0, 1e+39)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for keyframe easing is out of upper boundary'); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, -1e+39, 0, 0)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + '-' + max_float + ', 0, 0)', + 'y1 control point for keyframe easing is out of lower boundary'); + + anim.effect.setKeyframes([ { easing: 'cubic-bezier(0, 0, 0, -1e+39)' }]); + assert_equals(anim.effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + '-' + max_float + ')', + 'y2 control point for keyframe easing is out of lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for keyframe easing' ); + +test(function(t) { + var div = addDiv(t); + + div.style.animation = 'anim 100s cubic-bezier(0, 1e+39, 0, 0)'; + + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for CSS animation is out of upper boundary'); + + div.style.animation = 'anim 100s cubic-bezier(0, 0, 0, 1e+39)'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for CSS animation is out of upper boundary'); + + div.style.animation = 'anim 100s cubic-bezier(0, -1e+39, 0, 0)'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, ' + '-' + max_float + ', 0, 0)', + 'y1 control point for CSS animation is out of lower boundary'); + + div.style.animation = 'anim 100s cubic-bezier(0, 0, 0, -1e+39)'; + assert_equals(div.getAnimations()[0].effect.getKeyframes()[0].easing, + 'cubic-bezier(0, 0, 0, ' + '-' + max_float + ')', + 'y2 control point for CSS animation is out of lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for CSS animation' ); + +test(function(t) { + var div = addDiv(t, {'class': 'transition-div'}); + + div.style.transition = 'margin-left 100s cubic-bezier(0, 1e+39, 0, 0)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getTiming().easing, + 'cubic-bezier(0, ' + max_float + ', 0, 0)', + 'y1 control point for CSS transition on upper boundary'); + div.style.transition = ''; + div.style.marginLeft = ''; + + div.style.transition = 'margin-left 100s cubic-bezier(0, 0, 0, 1e+39)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getTiming().easing, + 'cubic-bezier(0, 0, 0, ' + max_float + ')', + 'y2 control point for CSS transition on upper boundary'); + div.style.transition = ''; + div.style.marginLeft = ''; + + div.style.transition = 'margin-left 100s cubic-bezier(0, -1e+39, 0, 0)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getTiming().easing, + 'cubic-bezier(0, ' + '-' + max_float + ', 0, 0)', + 'y1 control point for CSS transition on lower boundary'); + div.style.transition = ''; + div.style.marginLeft = ''; + + div.style.transition = 'margin-left 100s cubic-bezier(0, 0, 0, -1e+39)'; + flushComputedStyle(div); + div.style.marginLeft = '0px'; + assert_equals(div.getAnimations()[0].effect.getTiming().easing, + 'cubic-bezier(0, 0, 0, ' + '-' + max_float + ')', + 'y2 control point for CSS transition on lower boundary'); + +}, 'Clamp y1 and y2 control point out of boundaries for CSS transition' ); + +test(function(t) { + var div = addDiv(t); + var anim = div.animate({ }, { duration: 100 * MS_PER_SEC, fill: 'forwards' }); + + anim.pause(); + // The positive steepest function on both edges. + anim.effect.updateTiming({ easing: 'cubic-bezier(0, 1e+39, 0, 1e+39)' }); + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'progress on lower edge for the highest value of y1 and y2 control points'); + + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'progress on upper edge for the highest value of y1 and y2 control points'); + + // The negative steepest function on both edges. + anim.effect.updateTiming({ easing: 'cubic-bezier(0, -1e+39, 0, -1e+39)' }); + anim.currentTime = 0; + assert_equals(anim.effect.getComputedTiming().progress, 0.0, + 'progress on lower edge for the lowest value of y1 and y2 control points'); + + anim.finish(); + assert_equals(anim.effect.getComputedTiming().progress, 1.0, + 'progress on lower edge for the lowest value of y1 and y2 control points'); + +}, 'Calculated values on both edges'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_deferred_start.html b/dom/animation/test/mozilla/test_deferred_start.html new file mode 100644 index 0000000000..7d0a15b1f7 --- /dev/null +++ b/dom/animation/test/mozilla/test_deferred_start.html @@ -0,0 +1,21 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { + set: [ + ["dom.animations-api.core.enabled", true], + ["dom.animations-api.getAnimations.enabled", true], + ["dom.animations-api.timelines.enabled", true], + ], + }, + function() { + window.open("file_deferred_start.html"); + } +); +</script> diff --git a/dom/animation/test/mozilla/test_disable_animations_api_autoremove.html b/dom/animation/test/mozilla/test_disable_animations_api_autoremove.html new file mode 100644 index 0000000000..56e6362273 --- /dev/null +++ b/dom/animation/test/mozilla/test_disable_animations_api_autoremove.html @@ -0,0 +1,15 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({ explicit_done: true }); +SpecialPowers.pushPrefEnv( + { set: [['dom.animations-api.autoremove.enabled', false]] }, + function() { + window.open('file_disable_animations_api_autoremove.html'); + } +); +</script> diff --git a/dom/animation/test/mozilla/test_disable_animations_api_compositing.html b/dom/animation/test/mozilla/test_disable_animations_api_compositing.html new file mode 100644 index 0000000000..94216ea62d --- /dev/null +++ b/dom/animation/test/mozilla/test_disable_animations_api_compositing.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.compositing.enabled", false]]}, + function() { + window.open("file_disable_animations_api_compositing.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_disable_animations_api_get_animations.html b/dom/animation/test/mozilla/test_disable_animations_api_get_animations.html new file mode 100644 index 0000000000..a7253439b7 --- /dev/null +++ b/dom/animation/test/mozilla/test_disable_animations_api_get_animations.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.getAnimations.enabled", false]]}, + function() { + window.open("file_disable_animations_api_get_animations.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_disable_animations_api_implicit_keyframes.html b/dom/animation/test/mozilla/test_disable_animations_api_implicit_keyframes.html new file mode 100644 index 0000000000..aaebf1f00a --- /dev/null +++ b/dom/animation/test/mozilla/test_disable_animations_api_implicit_keyframes.html @@ -0,0 +1,14 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [["dom.animations-api.implicit-keyframes.enabled", false]]}, + function() { + window.open("file_disable_animations_api_implicit_keyframes.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_disable_animations_api_timelines.html b/dom/animation/test/mozilla/test_disable_animations_api_timelines.html new file mode 100644 index 0000000000..a20adf4ea2 --- /dev/null +++ b/dom/animation/test/mozilla/test_disable_animations_api_timelines.html @@ -0,0 +1,16 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; + +setup({ explicit_done: true }); +SpecialPowers.pushPrefEnv( + { set: [['dom.animations-api.timelines.enabled', false]] }, + function() { + window.open('file_disable_animations_api_timelines.html'); + } +); +</script> diff --git a/dom/animation/test/mozilla/test_disabled_properties.html b/dom/animation/test/mozilla/test_disabled_properties.html new file mode 100644 index 0000000000..2244143ceb --- /dev/null +++ b/dom/animation/test/mozilla/test_disabled_properties.html @@ -0,0 +1,73 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<body> +<div id="log"></div> +<script> +'use strict'; + +function waitForSetPref(pref, value) { + return SpecialPowers.pushPrefEnv({ 'set': [[pref, value]] }); +} + +/* + * These tests rely on the fact that the overflow-clip-box property is + * disabled by the layout.css.overflow-clip-box.enabled pref. If we ever remove + * that pref we will need to substitute some other pref:property combination. + */ + +promise_test(function(t) { + return waitForSetPref('layout.css.overflow-clip-box.enabled', true).then(() => { + var anim = addDiv(t).animate({ overflowClipBoxBlock: [ 'padding-box', 'content-box' ]}); + assert_equals(anim.effect.getKeyframes().length, 2, + 'A property-indexed keyframe specifying only enabled' + + ' properties produces keyframes'); + return waitForSetPref('layout.css.overflow-clip-box.enabled', false); + }).then(() => { + var anim = addDiv(t).animate({ overflowClipBoxBlock: [ 'padding-box', 'content-box' ]}); + assert_equals(anim.effect.getKeyframes().length, 0, + 'A property-indexed keyframe specifying only disabled' + + ' properties produces no keyframes'); + }); +}, 'Specifying a disabled property using a property-indexed keyframe'); + +promise_test(function(t) { + var createAnim = () => { + var anim = addDiv(t).animate([ { overflowClipBoxBlock: 'padding-box' }, + { overflowClipBoxBlock: 'content-box' } ]); + assert_equals(anim.effect.getKeyframes().length, 2, + 'Animation specified using a keyframe sequence should' + + ' return the same number of keyframes regardless of' + + ' whether or not the specified properties are disabled'); + return anim; + }; + + var assert_has_property = (anim, index, descr, property) => { + assert_true( + anim.effect.getKeyframes()[index].hasOwnProperty(property), + `${descr} should have the '${property}' property`); + }; + var assert_does_not_have_property = (anim, index, descr, property) => { + assert_false( + anim.effect.getKeyframes()[index].hasOwnProperty(property), + `${descr} should NOT have the '${property}' property`); + }; + + return waitForSetPref('layout.css.overflow-clip-box.enabled', true).then(() => { + var anim = createAnim(); + assert_has_property(anim, 0, 'Initial keyframe', 'overflowClipBoxBlock'); + assert_has_property(anim, 1, 'Final keyframe', 'overflowClipBoxBlock'); + return waitForSetPref('layout.css.overflow-clip-box.enabled', false); + }).then(() => { + var anim = createAnim(); + assert_does_not_have_property(anim, 0, 'Initial keyframe', + 'overflowClipBoxBlock'); + assert_does_not_have_property(anim, 1, 'Final keyframe', + 'overflowClipBoxBlock'); + }); +}, 'Specifying a disabled property using a keyframe sequence'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_discrete_animations.html b/dom/animation/test/mozilla/test_discrete_animations.html new file mode 100644 index 0000000000..d4826a74bd --- /dev/null +++ b/dom/animation/test/mozilla/test_discrete_animations.html @@ -0,0 +1,16 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +SpecialPowers.pushPrefEnv( + { "set": [ + ["layout.css.osx-font-smoothing.enabled", true], + ] }, + function() { + window.open("file_discrete_animations.html"); + }); +</script> diff --git a/dom/animation/test/mozilla/test_distance_of_basic_shape.html b/dom/animation/test/mozilla/test_distance_of_basic_shape.html new file mode 100644 index 0000000000..65e403bf06 --- /dev/null +++ b/dom/animation/test/mozilla/test_distance_of_basic_shape.html @@ -0,0 +1,91 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../testcommon.js'></script> +<div id='log'></div> +<script type='text/javascript'> +'use strict'; + +// We don't have an official spec to define the distance between two basic +// shapes, but we still need this for DevTools, so Gecko and Servo backends use +// the similar rules to define the distance. If there is a spec for it, we have +// to update this test file. +// See https://github.com/w3c/csswg-drafts/issues/662. + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'clip-path', 'none', 'none'); + assert_equals(dist, 0, 'none and none'); +}, 'none and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'clip-path', 'circle(10px)', 'circle(20px)'); + assert_equals(dist, 10, 'circle(10px) and circle(20px)'); +}, 'circles'); + +test(function(t) { + var target = addDiv(t); + var circle1 = 'circle(calc(10px + 10px) at 20px 10px)'; + var circle2 = 'circle(30px at 10px 10px)'; + var dist = getDistance(target, 'clip-path', circle1, circle2); + assert_equals(dist, + Math.sqrt(10 * 10 + 10 * 10), + circle1 + ' and ' + circle2); +}, 'circles with positions'); + +test(function(t) { + var target = addDiv(t); + var ellipse1 = 'ellipse(20px calc(10px + 10px))'; + var ellipse2 = 'ellipse(30px 30px)'; + var dist = getDistance(target, 'clip-path', ellipse1, ellipse2); + assert_equals(dist, + Math.sqrt(10 * 10 + 10 * 10), + ellipse1 + ' and ' + ellipse2); +}, 'ellipses'); + +test(function(t) { + var target = addDiv(t); + var polygon1 = 'polygon(50px 0px, 100px 50px, 50px 100px, 0px 50px)'; + var polygon2 = 'polygon(40px 0px, 100px 70px, 10px 100px, 0px 70px)'; + var dist = getDistance(target, 'clip-path', polygon1, polygon2); + assert_equals(dist, + Math.sqrt(10 * 10 + 20 * 20 + 40 * 40 + 20 * 20), + polygon1 + ' and ' + polygon2); +}, 'polygons'); + +test(function(t) { + var target = addDiv(t); + var inset1 = 'inset(5px 5px 5px 5px round 40px 30px 20px 5px)'; + var inset2 = 'inset(10px 5px round 50px 60px)'; + var dist = getDistance(target, 'clip-path', inset1, inset2); + + // if we have only two parameter in inset(), the first one means + // top and bottom edges, and the second one means left and right edges. + // and the definitions of inset is inset(top, right, bottom, left). Besides, + // the "round" part uses the shorthand of border radius for each corner, so + // each corner is a pair of (x, y). We are computing the distance between: + // 1. inset(5px 5px 5px 5px + // round (40px 40px) (30px 30px) (20px 20px) (5px 5px)) + // 2. inset(10px 5px 10px 5px + // round (50px 50px) (60px 60px) (50px 50px) (60px 60px)) + // That is why we need to multiply 2 for each border-radius corner. + assert_equals(dist, + Math.sqrt(5 * 5 + 5 * 5 + + (50 - 40) * (50 - 40) * 2 + + (60 - 30) * (60 - 30) * 2 + + (50 - 20) * (50 - 20) * 2 + + (60 - 5) * (60 - 5) * 2), + inset1 + ' and ' + inset2); +}, 'insets'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'clip-path', + 'circle(20px)', 'ellipse(10px 20px)'); + assert_equals(dist, 0, 'circle(20px) and ellipse(10px 20px)'); +}, 'Mismatched basic shapes'); + +</script> +</html> diff --git a/dom/animation/test/mozilla/test_distance_of_filter.html b/dom/animation/test/mozilla/test_distance_of_filter.html new file mode 100644 index 0000000000..33f772d983 --- /dev/null +++ b/dom/animation/test/mozilla/test_distance_of_filter.html @@ -0,0 +1,248 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../testcommon.js'></script> +<div id='log'></div> +<script type='text/javascript'> +'use strict'; + +const EPSILON = 1e-6; + +// We don't have an official spec to define the distance between two filter +// lists, but we still need this for DevTools, so Gecko and Servo backends use +// the similar rules to define the distance. If there is a spec for it, we have +// to update this test file. +// See https://github.com/w3c/fxtf-drafts/issues/91. + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'none', 'none'); + assert_equals(dist, 0, 'none and none'); +}, 'none and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'blur(10px)', 'none'); + // The default value of blur is 0px. + assert_equals(dist, 10, 'blur(10px) and none'); +}, 'blur and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'blur(10px)', 'blur(1px)'); + assert_equals(dist, 9, 'blur(10px) and blur(1px)'); +}, 'blurs'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'brightness(75%)', 'none'); + // The default value of brightness is 100%. + assert_equals(dist, (1 - 0.75), 'brightness(75%) and none'); +}, 'brightness and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', + 'brightness(50%)', 'brightness(175%)'); + assert_equals(dist, (1.75 - 0.5), 'brightness(50%) and brightness(175%)'); +}, 'brightnesses'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'contrast(75%)', 'none'); + // The default value of contrast is 100%. + assert_equals(dist, (1 - 0.75), 'contrast(75%) and none'); +}, 'contrast and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'contrast(50%)', 'contrast(175%)'); + assert_equals(dist, (1.75 - 0.5), 'contrast(50%) and contrast(175%)'); +}, 'contrasts'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'drop-shadow(10px 10px 10px blue)'; + var filter2 = 'none'; + var dist = getDistance(target, 'filter', filter1, filter2); + // The rgba of Blue is rgba(0, 0, 255, 1.0) = rgba(0%, 0%, 100%, 100%). + // So we are try to compute the distance of + // 1. drop-shadow(10, 10, 10, rgba(0, 0, 1.0, 1.0)). + // 2. drop-shadow( 0, 0, 0, rgba(0, 0, 0, 0)). + assert_equals(dist, + Math.sqrt(10 * 10 * 3 + (1 * 1 + 1 * 1)), + filter1 + ' and ' + filter2); +}, 'drop-shadow and none'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'drop-shadow(10px 10px 10px blue)'; + var filter2 = 'drop-shadow(5px 5px 1px yellow)'; + var dist = getDistance(target, 'filter', filter1, filter2); + // Blue: rgba(0, 0, 255, 1.0) = rgba( 0%, 0%, 100%, 100%). + // Yellow: rgba(255, 255, 0, 1.0) = rgba(100%, 100%, 0%, 100%). + assert_equals(dist, + Math.sqrt(5 * 5 * 2 + 9 * 9 + (1 * 1 * 3)), + filter1 + ' and ' + filter2); +}, 'drop-shadows'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'drop-shadow(10px 10px 10px)'; + var filter2 = 'drop-shadow(5px 5px 1px yellow)'; + var dist = getDistance(target, 'filter', filter1, filter2); + // Yellow: rgba(255, 255, 0, 1.0) = rgba(100%, 100%, 0%, 100%) + // Transparent: rgba(0, 0, 0, 0) = rgba( 0%, 0%, 0%, 0%) + // Distance involving `currentcolor` is calculated as distance + // from `transparent` + assert_equals(dist, + Math.sqrt(5 * 5 * 2 + 9 * 9 + (1 * 1 * 3)), + filter1 + ' and ' + filter2); +}, 'drop-shadows with color and non-color'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'grayscale(25%)', 'none'); + // The default value of grayscale is 0%. + assert_equals(dist, 0.25, 'grayscale(25%) and none'); +}, 'grayscale and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'grayscale(50%)', 'grayscale(75%)'); + assert_equals(dist, 0.25, 'grayscale(50%) and grayscale(75%)'); +}, 'grayscales'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'grayscale(75%)', 'grayscale(175%)'); + assert_equals(dist, 0.25, 'distance of grayscale(75%) and grayscale(175%)'); +}, 'grayscales where one has a value larger than 1.0'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'hue-rotate(180deg)', 'none'); + // The default value of hue-rotate is 0deg. + assert_approx_equals(dist, Math.PI, EPSILON, 'hue-rotate(180deg) and none'); +}, 'hue-rotate and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', + 'hue-rotate(720deg)', 'hue-rotate(-180deg)'); + assert_approx_equals(dist, 5 * Math.PI, EPSILON, + 'hue-rotate(720deg) and hue-rotate(-180deg)'); +}, 'hue-rotates'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'invert(25%)', 'none'); + // The default value of invert is 0%. + assert_equals(dist, 0.25, 'invert(25%) and none'); +}, 'invert and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'invert(50%)', 'invert(75%)'); + assert_equals(dist, 0.25, 'invert(50%) and invert(75%)'); +}, 'inverts'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'invert(75%)', 'invert(175%)'); + assert_equals(dist, 0.25, 'invert(75%) and invert(175%)'); +}, 'inverts where one has a value larger than 1.0'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'opacity(75%)', 'none'); + // The default value of opacity is 100%. + assert_equals(dist, (1 - 0.75), 'opacity(75%) and none'); +}, 'opacity and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'opacity(50%)', 'opacity(75%)'); + assert_equals(dist, 0.25, 'opacity(50%) and opacity(75%)'); +}, 'opacities'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'opacity(75%)', 'opacity(175%)'); + assert_equals(dist, 0.25, 'opacity(75%) and opacity(175%)'); +}, 'opacities where one has a value larger than 1.0'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'saturate(75%)', 'none'); + // The default value of saturate is 100%. + assert_equals(dist, (1 - 0.75), 'saturate(75%) and none'); +}, 'saturate and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'saturate(50%)', 'saturate(175%)'); + assert_equals(dist, (1.75 - 0.5), 'saturate(50%) and saturate(175%)'); +}, 'saturates'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'sepia(25%)', 'none'); + // The default value of sepia is 0%. + assert_equals(dist, 0.25, 'sepia(25%) and none'); +}, 'sepia and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'sepia(50%)', 'sepia(75%)'); + assert_equals(dist, 0.25, 'sepia(50%) and sepia(75%)'); +}, 'sepias'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'filter', 'sepia(75%)', 'sepia(175%)'); + assert_equals(dist, 0.25, 'sepia(75%) and sepia(175%)'); +}, 'sepias where one has a value larger than 1.0'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'grayscale(50%) opacity(100%) blur(5px)'; + // none filter: 'grayscale(0) opacity(1) blur(0px)' + var filter2 = 'none'; + var dist = getDistance(target, 'filter', filter1, filter2); + assert_equals(dist, + Math.sqrt(0.5 * 0.5 + 5 * 5), + filter1 + ' and ' + filter2); +}, 'Filter list and none'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'grayscale(50%) opacity(100%) blur(5px)'; + var filter2 = 'grayscale(100%) opacity(50%) blur(1px)'; + var dist = getDistance(target, 'filter', filter1, filter2); + assert_equals(dist, + Math.sqrt(0.5 * 0.5 + 0.5 * 0.5 + 4 * 4), + filter1 + ' and ' + filter2); +}, 'Filter lists'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'grayscale(50%) opacity(100%) blur(5px)'; + var filter2 = 'grayscale(100%) opacity(50%) blur(1px) sepia(50%)'; + var dist = getDistance(target, 'filter', filter1, filter2); + assert_equals(dist, + Math.sqrt(0.5 * 0.5 + 0.5 * 0.5 + 4 * 4 + 0.5 * 0.5), + filter1 + ' and ' + filter2); +}, 'Filter lists where one has extra functions'); + +test(function(t) { + var target = addDiv(t); + var filter1 = 'grayscale(50%) opacity(100%)'; + var filter2 = 'opacity(100%) grayscale(50%)'; + var dist = getDistance(target, 'filter', filter1, filter2); + assert_equals(dist, 0, filter1 + ' and ' + filter2); +}, 'Mismatched filter lists'); + +</script> +</html> diff --git a/dom/animation/test/mozilla/test_distance_of_path_function.html b/dom/animation/test/mozilla/test_distance_of_path_function.html new file mode 100644 index 0000000000..af6592c892 --- /dev/null +++ b/dom/animation/test/mozilla/test_distance_of_path_function.html @@ -0,0 +1,140 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../testcommon.js'></script> +<div id="log"></div> +<script type='text/javascript'> +'use strict'; + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', 'none', 'none'); + assert_equals(dist, 0, 'none and none'); +}, 'none and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', 'path("M 10 10")', 'none'); + assert_equals(dist, 0, 'path("M 10 10") and none'); +}, 'Path and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 10 10 H 10")', + 'path("M 10 10 H 10 H 10")'); + assert_equals(dist, 0, 'path("M 10 10 H 10") and ' + + 'path("M 10 10 H 10 H 10")'); +}, 'Mismatched path functions'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 10 10")', + 'path("M 20 20")'); + assert_equals(dist, + Math.sqrt(10 * 10 * 2), + 'path("M 10 10") and path("M 30 30")'); +}, 'The moveto commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 L 10 10")', + 'path("M 0 0 L 20 20")'); + assert_equals(dist, + Math.sqrt(10 * 10 * 2), + 'path("M 0 0 L 10 10") and path("M 0 0 L 20 20")'); +}, 'The lineto commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 H 10")', + 'path("M 0 0 H 20")'); + assert_equals(dist, 10, 'path("M 0 0 H 10") and path("M 0 0 H 20")'); +}, 'The horizontal lineto commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 V 10")', + 'path("M 0 0 V 20")'); + assert_equals(dist, 10, 'path("M 0 0 V 10") and path("M 0 0 V 20")'); +}, 'The vertical lineto commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 C 10 10 20 20 30 30")', + 'path("M 0 0 C 20 20 40 40 0 0")'); + assert_equals(dist, + Math.sqrt(10 * 10 * 2 + 20 * 20 * 2 + 30 * 30 * 2), + 'path("M 0 0 C 10 10 20 20 30 30") and ' + + 'path("M 0 0 C 20 20 40 40 0 0")'); +}, 'The cubic Bézier curve commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 S 20 20 30 30")', + 'path("M 0 0 S 40 40 0 0")'); + assert_equals(dist, + Math.sqrt(20 * 20 * 2 + 30 * 30 * 2), + 'path("M 0 0 S 20 20 30 30") and ' + + 'path("M 0 0 S 40 40 0 0")'); +}, 'The smooth cubic Bézier curve commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 Q 10 10 30 30")', + 'path("M 0 0 Q 20 20 0 0")'); + assert_equals(dist, + Math.sqrt(10 * 10 * 2 + 30 * 30 * 2), + 'path("M 0 0 Q 10 10 30 30") and ' + + 'path("M 0 0 Q 20 20 0 0")'); +}, 'The quadratic cubic Bézier curve commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 T 30 30")', + 'path("M 0 0 T 0 0")'); + assert_equals(dist, + Math.sqrt(30 * 30 * 2), + 'path("M 0 0 T 30 30") and ' + + 'path("M 0 0 T 0 0")'); +}, 'The smooth quadratic cubic Bézier curve commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("M 0 0 A 5 5 10 0 1 30 30")', + 'path("M 0 0 A 4 4 5 0 0 20 20")'); + assert_equals(dist, + Math.sqrt(1 * 1 * 2 + // radii + 5 * 5 + // angle + 1 * 1 + // flag + 10 * 10 * 2), + 'path("M 0 0 A 5 5 10 0 1 30 30") and ' + + 'path("M 0 0 A 4 4 5 0 0 20 20")'); +}, 'The elliptical arc curve commands'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'offset-path', + 'path("m 10 20 h 30 v 60 h 10 v -10 l 110 60")', + // == 'path("M 10 20 H 40 V 80 H 50 V 70 L 160 130")' + 'path("M 130 140 H 120 V 160 H 130 V 150 L 200 170")'); + assert_equals(dist, + Math.sqrt(120 * 120 * 2 + + 80 * 80 * 4 + + 40 * 40 * 2), + 'path("m 10 20 h 30 v 60 h 10 v -10 l 110 60") and ' + + 'path("M 130 140 H 120 V 160 H 130 V 150 L 200 170")'); +}, 'The distance of paths with absolute and relative coordinates'); + +</script> +</html> diff --git a/dom/animation/test/mozilla/test_distance_of_transform.html b/dom/animation/test/mozilla/test_distance_of_transform.html new file mode 100644 index 0000000000..96ff1eb66d --- /dev/null +++ b/dom/animation/test/mozilla/test_distance_of_transform.html @@ -0,0 +1,404 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../testcommon.js'></script> +<div id='log'></div> +<script type='text/javascript'> +'use strict'; + +// We don't have an official spec to define the distance between two transform +// lists, but we still need this for DevTools, so Gecko and Servo backend use +// the similar rules to define the distance. If there is a spec for it, we have +// to update this test file. + +const EPSILON = 0.00001; + +// |v| should be a unit vector (i.e. having length 1) +function getQuaternion(v, angle) { + return [ + v[0] * Math.sin(angle / 2.0), + v[1] * Math.sin(angle / 2.0), + v[2] * Math.sin(angle / 2.0), + Math.cos(angle / 2.0) + ]; +} + +function computeRotateDistance(q1, q2) { + const dot = q1.reduce((sum, e, i) => sum + e * q2[i], 0); + return Math.acos(Math.min(Math.max(dot, -1.0), 1.0)) * 2.0; +} + +function createMatrixFromArray(array) { + return (array.length === 16 ? 'matrix3d' : 'matrix') + `(${array.join()})`; +} + +function rotate3dToMatrix(x, y, z, radian) { + var sc = Math.sin(radian / 2) * Math.cos(radian / 2); + var sq = Math.sin(radian / 2) * Math.sin(radian / 2); + + // Normalize the vector. + var length = Math.sqrt(x*x + y*y + z*z); + x /= length; + y /= length; + z /= length; + + return [ + 1 - 2 * (y*y + z*z) * sq, + 2 * (x * y * sq + z * sc), + 2 * (x * z * sq - y * sc), + 0, + 2 * (x * y * sq - z * sc), + 1 - 2 * (x*x + z*z) * sq, + 2 * (y * z * sq + x * sc), + 0, + 2 * (x * z * sq + y * sc), + 2 * (y * z * sq - x * sc), + 1 - 2 * (x*x + y*y) * sq, + 0, + 0, + 0, + 0, + 1 + ]; +} + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', 'none', 'none'); + assert_equals(dist, 0, 'distance of translate'); +}, 'Test distance of none and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', 'translate(100px)', 'none'); + assert_equals(dist, 100, 'distance of translate'); +}, 'Test distance of translate function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = + getDistance(target, 'transform', 'translate(100px)', 'translate(200px)'); + assert_equals(dist, 200 - 100, 'distance of translate'); +}, 'Test distance of translate functions'); + +test(function(t) { + var target = addDiv(t); + var dist = + getDistance(target, 'transform', 'translate3d(100px, 0, 50px)', 'none'); + assert_equals(dist, Math.sqrt(100 * 100 + 50 * 50), + 'distance of translate3d'); +}, 'Test distance of translate3d function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = + getDistance(target, 'transform', + 'translate3d(100px, 0, 50px)', + 'translate3d(200px, 80px, 0)'); + assert_equals(dist, Math.sqrt(100 * 100 + 80 * 80 + 50 * 50), + 'distance of translate'); +}, 'Test distance of translate3d functions'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', 'scale(1.5)', 'none'); + assert_equals(dist, Math.sqrt(0.5 * 0.5 + 0.5 * 0.5), 'distance of scale'); +}, 'Test distance of scale function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', 'scale(1.5)', 'scale(2.0)'); + assert_equals(dist, Math.sqrt(0.5 * 0.5 + 0.5 * 0.5), 'distance of scale'); +}, 'Test distance of scale functions'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'scale3d(1.5, 1.5, 1.5)', + 'none'); + assert_equals(dist, + Math.sqrt(0.5 * 0.5 + 0.5 * 0.5 + 0.5 * 0.5), + 'distance of scale3d'); +}, 'Test distance of scale3d function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'scale3d(1.5, 1.5, 1.5)', + 'scale3d(2.0, 2.0, 1.0)'); + assert_equals(dist, + Math.sqrt(0.5 * 0.5 + 0.5 * 0.5 + 0.5 * 0.5), + 'distance of scale3d'); +}, 'Test distance of scale3d functions'); + +test(function(t) { + var target = addDiv(t); + var dist = + getDistance(target, 'transform', 'rotate(45deg)', 'rotate(90deg)'); + assert_approx_equals(dist, Math.PI / 2.0 - Math.PI / 4.0, EPSILON, 'distance of rotate'); +}, 'Test distance of rotate functions'); + +test(function(t) { + var target = addDiv(t); + var dist = + getDistance(target, 'transform', 'rotate(45deg)', 'none'); + assert_approx_equals(dist, Math.PI / 4.0, EPSILON, 'distance of rotate'); +}, 'Test distance of rotate function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'rotate3d(0, 1, 0, 90deg)', + 'none'); + assert_approx_equals(dist, Math.PI / 2, EPSILON, 'distance of rotate3d'); +}, 'Test distance of rotate3d function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'rotate3d(0, 0, 1, 90deg)', + 'rotate3d(1, 0, 0, 90deg)'); + let q1 = getQuaternion([0, 0, 1], Math.PI / 2.0); + let q2 = getQuaternion([1, 0, 0], Math.PI / 2.0); + assert_approx_equals(dist, computeRotateDistance(q1, q2), EPSILON, 'distance of rotate3d'); +}, 'Test distance of rotate3d functions'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'rotate3d(0, 0, 1, 90deg)', + 'rotate3d(0, 0, 0, 90deg)'); + assert_approx_equals(dist, Math.PI / 2, EPSILON, 'distance of rotate3d'); +}, 'Test distance of rotate3d functions whose direction vector cannot be ' + + 'normalized'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', 'skew(1rad, 0.5rad)', 'none'); + assert_approx_equals(dist, Math.sqrt(1 * 1 + 0.5 * 0.5), EPSILON, 'distance of skew'); +}, 'Test distance of skew function and none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'skew(1rad, 0.5rad)', + 'skew(-1rad, 0)'); + assert_approx_equals(dist, Math.sqrt(2 * 2 + 0.5 * 0.5), EPSILON, 'distance of skew'); +}, 'Test distance of skew functions'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'perspective(128px)', + 'none'); + assert_equals(dist, Infinity, 'distance of perspective'); +}, 'Test distance of perspective function and none'); + +test(function(t) { + var target = addDiv(t); + // perspective(0) is treated as perspective(inf) because perspective length + // should be greater than or equal to zero. + var dist = getDistance(target, 'transform', + 'perspective(128px)', + 'perspective(0)'); + assert_equals(dist, 128, 'distance of perspective'); +}, 'Test distance of perspective function and an invalid perspective'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'perspective(128px)', + 'perspective(1024px)'); + assert_equals(dist, 1024 - 128, 'distance of perspective'); +}, 'Test distance of perspective functions'); + +test(function(t) { + var target = addDiv(t); + var sin_30 = Math.sin(Math.PI / 6); + var cos_30 = Math.cos(Math.PI / 6); + // matrix => translate(100, 0) rotate(30deg). + var matrix = createMatrixFromArray([ cos_30, sin_30, + -sin_30, cos_30, + 100, 0 ]); + var dist = getDistance(target, 'transform', matrix, 'none'); + assert_approx_equals(dist, + Math.sqrt(100 * 100 + (Math.PI / 6) * (Math.PI / 6)), + EPSILON, + 'distance of matrix'); +}, 'Test distance of matrix function and none'); + +test(function(t) { + var target = addDiv(t); + var sin_30 = Math.sin(Math.PI / 6); + var cos_30 = Math.cos(Math.PI / 6); + // matrix1 => translate(100, 0) rotate(30deg). + var matrix1 = createMatrixFromArray([ cos_30, sin_30, + -sin_30, cos_30, + 100, 0 ]); + // matrix2 => translate(0, 100) scale(0.5). + var matrix2 = createMatrixFromArray([ 0.5, 0, 0, 0.5, 0, 100 ]); + var dist = getDistance(target, 'transform', matrix1, matrix2); + assert_approx_equals(dist, + Math.sqrt(100 * 100 + 100 * 100 + // translate + (Math.PI / 6) * (Math.PI / 6) + // rotate + 0.5 * 0.5 + 0.5 * 0.5), // scale + EPSILON, + 'distance of matrix'); +}, 'Test distance of matrix functions'); + +test(function(t) { + var target = addDiv(t); + var matrix = createMatrixFromArray(rotate3dToMatrix(0, 1, 0, Math.PI / 6)); + var dist = getDistance(target, 'transform', matrix, 'none'); + assert_approx_equals(dist, Math.PI / 6, EPSILON, 'distance of matrix3d'); +}, 'Test distance of matrix3d function and none'); + +test(function(t) { + var target = addDiv(t); + // matrix1 => rotate3d(0, 1, 0, 30deg). + var matrix1 = createMatrixFromArray(rotate3dToMatrix(0, 1, 0, Math.PI / 6)); + // matrix1 => translate3d(100, 0, 0) scale3d(0.5, 0.5, 0.5). + var matrix2 = createMatrixFromArray([ 0.5, 0, 0, 0, + 0, 0.5, 0, 0, + 0, 0, 0.5, 0, + 100, 0, 0, 1 ]); + var dist = getDistance(target, 'transform', matrix1, matrix2); + assert_approx_equals(dist, + Math.sqrt(100 * 100 + // translate + 0.5 * 0.5 * 3 + // scale + (Math.PI / 6) * (Math.PI / 6)), // rotate + EPSILON, + 'distance of matrix'); +}, 'Test distance of matrix3d functions'); + +test(function(t) { + var target = addDiv(t); + var cos_180 = Math.cos(Math.PI); + var sin_180 = Math.sin(Math.PI); + // matrix1 => translate3d(100px, 50px, -10px) skew(45deg). + var matrix1 = createMatrixFromArray([ 1, 0, 0, 0, + Math.tan(Math.PI/4.0), 1, 0, 0, + 0, 0, 1, 0, + 100, 50, -10, 1]); + // matrix2 => translate3d(1000px, 0, 0) rotate3d(1, 0, 0, 180deg). + var matrix2 = createMatrixFromArray([ 1, 0, 0, 0, + 0, cos_180, sin_180, 0, + 0, -sin_180, cos_180, 0, + 1000, 0, 0, 1 ]); + var dist = getDistance(target, 'transform', matrix1, matrix2); + assert_approx_equals(dist, + Math.sqrt(900 * 900 + 50 * 50 + 10 * 10 + // translate + Math.PI * Math.PI + // rotate + (Math.PI / 4) * (Math.PI / 4)), // skew angle + EPSILON, + 'distance of matrix'); +}, 'Test distance of matrix3d functions with skew factors'); + +test(function(t) { + var target = addDiv(t); + var dist = + getDistance(target, 'transform', + 'rotate(180deg) translate(1000px)', + 'rotate(360deg) translate(0px)'); + assert_approx_equals(dist, Math.sqrt(1000 * 1000 + Math.PI * Math.PI), EPSILON, + 'distance of transform lists'); +}, 'Test distance of transform lists'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'translate(100px) rotate(180deg)', + 'translate(50px) rotate(90deg) scale(5) skew(1rad)'); + assert_approx_equals(dist, + Math.sqrt(50 * 50 + + Math.PI / 2 * Math.PI / 2 + + 4 * 4 * 2 + + 1 * 1), + EPSILON, + 'distance of transform lists'); +}, 'Test distance of transform lists where one has extra items'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'translate(1000px) rotate3d(1, 0, 0, 180deg)', + 'translate(1000px) scale3d(2.5, 0.5, 1)'); + assert_equals(dist, Math.sqrt(Math.PI * Math.PI + 1.5 * 1.5 + 0.5 * 0.5), + 'distance of transform lists'); +}, 'Test distance of mismatched transform lists'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'transform', + 'translate(100px) skew(1rad)', + 'translate(1000px) rotate3d(0, 1, 0, -2rad)'); + assert_approx_equals(dist, + Math.sqrt(900 * 900 + 1 * 1 + 2 * 2), + EPSILON, + 'distance of transform lists'); +}, 'Test distance of mismatched transform lists with skew function'); + + +// Individual transforms +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'translate', '50px', 'none'); + assert_equals(dist, Math.sqrt(50 * 50), 'distance of 2D translate and none'); +}, 'Test distance of 2D translate property with none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'translate', '10px 30px', '50px'); + assert_equals(dist, Math.sqrt(40 * 40 + 30 * 30), 'distance of 2D translate'); +}, 'Test distance of 2D translate property'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'translate', '10px 30px 50px', '50px'); + assert_equals(dist, Math.sqrt(40 * 40 + 30 * 30 + 50 * 50), + 'distance of 3D translate'); +}, 'Test distance of 3D translate property'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'scale', '2', 'none'); + assert_equals(dist, Math.sqrt(1 + 1), 'distance of 2D scale and none'); +}, 'Test distance of 2D scale property with none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'scale', '3', '1 1'); + assert_equals(dist, Math.sqrt(2 * 2 + 2 * 2), 'distance of 2D scale'); +}, 'Test distance of 2D scale property'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'scale', '3 2 2', '1 1'); + assert_equals(dist, Math.sqrt(2 * 2 + 1 * 1 + 1 * 1), + 'distance of 3D scale'); +}, 'Test distance of 3D scale property'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'rotate', '180deg', 'none'); + assert_equals(dist, Math.PI, 'distance of 2D rotate and none'); +}, 'Test distance of 2D rotate property with none'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'rotate', '180deg', '90deg'); + assert_equals(dist, Math.PI / 2.0, 'distance of 2D rotate'); +}, 'Test distance of 2D rotate property'); + +test(function(t) { + var target = addDiv(t); + var dist = getDistance(target, 'rotate', 'z 90deg', 'x 90deg'); + let q1 = getQuaternion([0, 0, 1], Math.PI / 2.0); + let q2 = getQuaternion([1, 0, 0], Math.PI / 2.0); + assert_approx_equals(dist, computeRotateDistance(q1, q2), EPSILON, + 'distance of 3D rotate'); +}, 'Test distance of 3D rotate property'); + +</script> +</html> diff --git a/dom/animation/test/mozilla/test_document_timeline_origin_time_range.html b/dom/animation/test/mozilla/test_document_timeline_origin_time_range.html new file mode 100644 index 0000000000..b2aeef8a77 --- /dev/null +++ b/dom/animation/test/mozilla/test_document_timeline_origin_time_range.html @@ -0,0 +1,32 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<body> +<div id="log"></div> +<script> +'use strict'; + +// If the originTime parameter passed to the DocumentTimeline exceeds +// the range of the internal storage type (a signed 64-bit integer number +// of ticks--a platform-dependent unit) then we should throw. +// Infinity isn't allowed as an origin time value and clamping to just +// inside the allowed range will just mean we overflow elsewhere. + +test(function(t) { + assert_throws({ name: 'TypeError'}, + function() { + new DocumentTimeline({ originTime: Number.MAX_SAFE_INTEGER }); + }); +}, 'Calculated current time is positive infinity'); + +test(function(t) { + assert_throws({ name: 'TypeError'}, + function() { + new DocumentTimeline({ originTime: -1 * Number.MAX_SAFE_INTEGER }); + }); +}, 'Calculated current time is negative infinity'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_event_listener_leaks.html b/dom/animation/test/mozilla/test_event_listener_leaks.html new file mode 100644 index 0000000000..bcfadaf9e9 --- /dev/null +++ b/dom/animation/test/mozilla/test_event_listener_leaks.html @@ -0,0 +1,43 @@ +<!-- + Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ +--> +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1450271 - Test Animation event listener leak conditions</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/dom/events/test/event_leak_utils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<script class="testbody" type="text/javascript"> +// Manipulate Animation. Its important here that we create a +// listener callback from the DOM objects back to the frame's global +// in order to exercise the leak condition. +async function useAnimation(contentWindow) { + let div = contentWindow.document.createElement("div"); + contentWindow.document.body.appendChild(div); + let animation = div.animate({}, 100 * 1000); + is(animation.playState, "running", "animation should be running"); + animation.onfinish = _ => { + contentWindow.finishCount += 1; + }; +} + +async function runTest() { + try { + await checkForEventListenerLeaks("Animation", useAnimation); + } catch (e) { + ok(false, e); + } finally { + SimpleTest.finish(); + } +} + +SimpleTest.waitForExplicitFinish(); +addEventListener("load", runTest, { once: true }); +</script> +</pre> +</body> +</html> diff --git a/dom/animation/test/mozilla/test_get_animations_on_scroll_animations.html b/dom/animation/test/mozilla/test_get_animations_on_scroll_animations.html new file mode 100644 index 0000000000..7d20e5b70b --- /dev/null +++ b/dom/animation/test/mozilla/test_get_animations_on_scroll_animations.html @@ -0,0 +1,74 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test getAnimations() which doesn't return scroll animations</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<style> + @keyframes animWidth { + from { width: 100px; } + to { width: 200px } + } + @keyframes animTop { + to { top: 100px } + } + .fill-vh { + width: 100px; + height: 100vh; + } +</style> +</head> +<body> +<div id="log"></div> +<script> +"use strict"; + +test(function(t) { + const div = addDiv(t, + { style: "width: 10px; height: 100px; " + + "animation: animWidth 100s scroll(), animTop 200s;" }); + + // Sanity check to make sure the scroll animation is there. + addDiv(t, { class: "fill-vh" }); + const scroller = document.scrollingElement; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = maxScroll; + assert_equals(getComputedStyle(div).width, "200px", + "The scroll animation is there"); + + const animations = div.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations() should include scroll animations'); + assert_equals(animations[0].animationName, "animWidth", + 'getAmimations() should return scroll animations'); + // FIXME: Bug 1676794. Support ScrollTimeline interface. + assert_equals(animations[0].timeline, null, + 'scroll animation should not return scroll timeline'); +}, 'Element.getAnimation() should include scroll animations'); + +test(function(t) { + const div = addDiv(t, + { style: "width: 10px; height: 100px; " + + "animation: animWidth 100s scroll(), animTop 100s;" }); + + // Sanity check to make sure the scroll animation is there. + addDiv(t, { class: "fill-vh" }); + const scroller = document.scrollingElement; + const maxScroll = scroller.scrollHeight - scroller.clientHeight; + scroller.scrollTop = maxScroll; + assert_equals(getComputedStyle(div).width, "200px", + "The scroll animation is there"); + + const animations = document.getAnimations(); + assert_equals(animations.length, 2, + 'getAnimations() should include scroll animations'); + assert_equals(animations[0].animationName, "animWidth", + 'getAmimations() should return scroll animations'); + // FIXME: Bug 1676794. Support ScrollTimeline interface. + assert_equals(animations[0].timeline, null, + 'scroll animation should not return scroll timeline'); +}, 'Document.getAnimation() should include scroll animations'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_hide_and_show.html b/dom/animation/test/mozilla/test_hide_and_show.html new file mode 100644 index 0000000000..f36543bb1e --- /dev/null +++ b/dom/animation/test/mozilla/test_hide_and_show.html @@ -0,0 +1,198 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<style> +@keyframes move { + 100% { + transform: translateX(100px); + } +} + +div.pseudo::before { + animation: move 0.01s; + content: 'content'; +} + +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +test(function(t) { + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + div.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'display:none element has no animations'); +}, 'Animation stops playing when the element style display is set to "none"'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + parentElement.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); +}, 'Animation stops playing when its parent element style display is set ' + + 'to "none"'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + div.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'display:none element has no animations'); + + div.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer display:none has animations ' + + 'again'); +}, 'Animation starts playing when the element gets shown from ' + + '"display:none" state'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s infinite' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + parentElement.style.display = 'none'; + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); + + parentElement.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer in display:none subtree has ' + + 'animations again'); +}, 'Animation starts playing when its parent element is shown from ' + + '"display:none" state'); + +test(function(t) { + var div = addDiv(t, { style: 'animation: move 100s forwards' }); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + var animation = div.getAnimations()[0]; + animation.finish(); + assert_equals(div.getAnimations().length, 1, + 'Element has finished animation if the animation ' + + 'fill-mode is forwards'); + + div.style.display = 'none'; + assert_equals(animation.playState, 'idle', + 'The animation.playState should be idle'); + + assert_equals(div.getAnimations().length, 0, + 'display:none element has no animations'); + + div.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer display:none has animations ' + + 'again'); + assert_not_equals(div.getAnimations()[0], animation, + 'Restarted animation is a newly-generated animation'); + +}, 'Animation which has already finished starts playing when the element ' + + 'gets shown from "display:none" state'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s forwards' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + var animation = div.getAnimations()[0]; + animation.finish(); + assert_equals(div.getAnimations().length, 1, + 'Element has finished animation if the animation ' + + 'fill-mode is forwards'); + + parentElement.style.display = 'none'; + assert_equals(animation.playState, 'idle', + 'The animation.playState should be idle'); + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); + + parentElement.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer in display:none subtree has ' + + 'animations again'); + + assert_not_equals(div.getAnimations()[0], animation, + 'Restarted animation is a newly-generated animation'); + +}, 'Animation with fill:forwards which has already finished starts playing ' + + 'when its parent element is shown from "display:none" state'); + +test(function(t) { + var parentElement = addDiv(t); + var div = addDiv(t, { style: 'animation: move 100s' }); + parentElement.appendChild(div); + assert_equals(div.getAnimations().length, 1, + 'display:initial element has animations'); + + var animation = div.getAnimations()[0]; + animation.finish(); + assert_equals(div.getAnimations().length, 0, + 'Element does not have finished animations'); + + parentElement.style.display = 'none'; + assert_equals(animation.playState, 'idle', + 'The animation.playState should be idle'); + assert_equals(div.getAnimations().length, 0, + 'Element in display:none subtree has no animations'); + + parentElement.style.display = ''; + assert_equals(div.getAnimations().length, 1, + 'Element which is no longer in display:none subtree has ' + + 'animations again'); + + assert_not_equals(div.getAnimations()[0], animation, + 'Restarted animation is a newly-generated animation'); + +}, 'CSS Animation which has already finished starts playing when its parent ' + + 'element is shown from "display:none" state'); + +promise_test(function(t) { + var div = addDiv(t, { 'class': 'pseudo' }); + var eventWatcher = new EventWatcher(t, div, 'animationend'); + + assert_equals(document.getAnimations().length, 1, + 'CSS animation on pseudo element'); + + return eventWatcher.wait_for('animationend').then(function() { + assert_equals(document.getAnimations().length, 0, + 'No CSS animation on pseudo element after the animation ' + + 'finished'); + + // Remove the class which generated this pseudo element. + div.classList.remove('pseudo'); + + // We need to wait for two frames to process re-framing. + // The callback of 'animationend' is processed just before rAF callbacks, + // and rAF callbacks are processed before re-framing process, so waiting for + // one rAF callback is not sufficient. + return waitForAnimationFrames(2); + }).then(function() { + // Add the class again to re-generate pseudo element. + div.classList.add('pseudo'); + assert_equals(document.getAnimations().length, 1, + 'A new CSS animation on pseudo element'); + }); +}, 'CSS animation on pseudo element restarts after the pseudo element that ' + + 'had a finished CSS animation is re-generated'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_mainthread_synchronization_pref.html b/dom/animation/test/mozilla/test_mainthread_synchronization_pref.html new file mode 100644 index 0000000000..3653fd9536 --- /dev/null +++ b/dom/animation/test/mozilla/test_mainthread_synchronization_pref.html @@ -0,0 +1,42 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<style> +.compositable { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +promise_test(async t => { + await SpecialPowers.pushPrefEnv({ + set: [[ 'dom.animations.mainthread-synchronization-with-geometric-animations', false ]] + }); + + const elemA = addDiv(t, { class: 'compositable' }); + const elemB = addDiv(t, { class: 'compositable' }); + + const animA = elemA.animate({ transform: [ 'translate(0px)', + 'translate(100px)' ] }, + 100 * MS_PER_SEC); + const animB = elemB.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + + await waitForPaints(); + + assert_true(SpecialPowers.wrap(animA).isRunningOnCompositor, + 'Transform animation should not synchronize with margin-left animation ' + + 'created within the same tick with disabling the corresponding pref'); +}, 'Transform animation should not synchronize with margin-left animation ' + + 'created within the same tick with disabling the corresponding pref'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_moz_prefixed_properties.html b/dom/animation/test/mozilla/test_moz_prefixed_properties.html new file mode 100644 index 0000000000..f65d05134d --- /dev/null +++ b/dom/animation/test/mozilla/test_moz_prefixed_properties.html @@ -0,0 +1,93 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test animations of all properties that have -moz prefix</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../testcommon.js"></script> + <script src="../property_database.js"></script> +</head> +<body> +<div id="log"></div> +<script> +"use strict"; + +const testcases = [ + { + property: "-moz-box-align" + }, + { + property: "-moz-box-direction" + }, + { + property: "-moz-box-ordinal-group" + }, + { + property: "-moz-box-orient", + }, + { + property: "-moz-box-pack" + }, + { + property: "-moz-float-edge" + }, + { + property: "-moz-force-broken-image-icon" + }, + { + property: "-moz-orient" + }, + { + property: "-moz-osx-font-smoothing", + pref: "layout.css.osx-font-smoothing.enabled" + }, + { + property: "-moz-text-size-adjust" + }, + { + property: "-moz-user-focus" + }, + { + property: "-moz-user-input" + }, + { + property: "-moz-user-modify" + }, + { + property: "user-select" + }, + { + property: "-moz-window-dragging" + }, +]; + +testcases.forEach(testcase => { + if (testcase.pref && !IsCSSPropertyPrefEnabled(testcase.pref)) { + return; + } + + const property = gCSSProperties[testcase.property]; + const values = property.initial_values.concat(property.other_values); + values.forEach(value => { + test(function(t) { + const container = addDiv(t); + const target = document.createElement("div"); + container.appendChild(target); + + container.style[property.domProp] = value; + + const animation = + target.animate({ [property.domProp]: [value, "inherit"] }, + { duration: 1000, delay: -500 } ); + + const expectedValue = getComputedStyle(container)[property.domProp]; + assert_equals(getComputedStyle(target)[property.domProp], expectedValue, + `Computed style shoud be "${ expectedValue }"`); + }, `Test inherit value for "${ testcase.property }" ` + + `(Parent element style is "${ value }")`); + }); +}); +</script> +</pre> +</body> +</html> diff --git a/dom/animation/test/mozilla/test_pending_animation_tracker.html b/dom/animation/test/mozilla/test_pending_animation_tracker.html new file mode 100644 index 0000000000..022efa7bcf --- /dev/null +++ b/dom/animation/test/mozilla/test_pending_animation_tracker.html @@ -0,0 +1,134 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test animations in PendingAnimationTracker</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +</head> +<body> +<div id="log"></div> +<script> +"use strict"; + +promise_test(function waitForLoad() { + return new Promise(resolve => { + window.addEventListener("load", resolve, { once: true }); + }); +}); + +promise_test(async t => { + // See below, but we should ensure we are in a rAF callback before proceeding + // or else we will get inconsistent results. + await waitForNextFrame(); + + const target = addDiv(t); + const anim = target.animate(null, 100 * MS_PER_SEC); + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be tracked by tracker'); + + anim.effect = null; + await waitForNextFrame(); + + assert_false(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should NOT be tracked by the tracker'); +}, 'An animation whose effect is made null while pending is subsequently' + + ' removed from the tracker'); + +test(t => { + const target = addDiv(t); + const anim = target.animate(null, 100 * MS_PER_SEC); + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be tracked by tracker'); + + const newEffect = new KeyframeEffect(target, null); + anim.effect = newEffect; + + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be still tracked by tracker'); +}, 'Setting another effect keeps the pending animation in the tracker'); + +test(t => { + const effect = new KeyframeEffect(null, null); + const anim = new Animation(effect); + anim.play(); + assert_false(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The orphaned animation should NOT be tracked by tracker'); + + const target = addDiv(t); + const newEffect = new KeyframeEffect(target, null); + anim.effect = newEffect; + + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be now tracked by tracker'); +}, 'Setting effect having target element starts being tracked by the ' + + 'tracker'); + +test(t => { + const target = addDiv(t); + const anim = target.animate(null, 100 * MS_PER_SEC); + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be tracked by tracker'); + + anim.cancel(); + + assert_false(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should NOT be tracked by the tracker'); +}, 'Calling cancel() removes the animation from the tracker'); + +promise_test(async t => { + // Before proceeding this test, make sure following code is _NOT_ processed + // between paint and refresh driver's tick. Otherwise, waitForNextFrame below + // doesn't ensure that a paint process happens which means that there is + // no chance to call TriggerPendingAnimationsOnNextTick to discard the + // animation from the pending animation tracker. + await waitForNextFrame(); + + const target = addDiv(t); + const anim = target.animate(null, 100 * MS_PER_SEC); + + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be tracked by tracker'); + + target.remove(); + + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation is still being tracked by the tracker'); + + await waitForNextFrame(); + assert_false(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should NOT be tracked by the tracker in the ' + + 'next frame'); +}, 'Removing target element from the document removes the animation from ' + + 'the tracker in the next tick'); + +test(t => { + const target = addDiv(t); + const anotherTarget = addDiv(t); + const anim = target.animate(null, 100 * MS_PER_SEC); + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be tracked by tracker'); + + anim.effect.target = anotherTarget; + + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be still tracked by tracker'); +}, 'Setting another target keeps the pending animation in the tracker'); + +test(t => { + const effect = new KeyframeEffect(null, null); + const anim = new Animation(effect); + anim.play(); + assert_false(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The orphaned animation should NOT be tracked by tracker'); + + const target = addDiv(t); + anim.effect.target = target; + + assert_true(SpecialPowers.DOMWindowUtils.isAnimationInPendingTracker(anim), + 'The animation should be now tracked by tracker'); +}, 'Setting target element to the orphaned animation starts being tracked ' + + 'by the tracker'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_restyles.html b/dom/animation/test/mozilla/test_restyles.html new file mode 100644 index 0000000000..bc1ab70c74 --- /dev/null +++ b/dom/animation/test/mozilla/test_restyles.html @@ -0,0 +1,22 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<div id='log'></div> +<script> +'use strict'; +SimpleTest.waitForExplicitFinish(); +SimpleTest.expectAssertions(0, 1); // bug 1332970 +SpecialPowers.pushPrefEnv( + { + set: [ + ['layout.reflow.synthMouseMove', false], + ['privacy.reduceTimerPrecision', false], + ], + }, + function() { + window.open('file_restyles.html'); + } +); +</script> +</html> diff --git a/dom/animation/test/mozilla/test_restyling_xhr_doc.html b/dom/animation/test/mozilla/test_restyling_xhr_doc.html new file mode 100644 index 0000000000..67b6ac8845 --- /dev/null +++ b/dom/animation/test/mozilla/test_restyling_xhr_doc.html @@ -0,0 +1,106 @@ +<!doctype html> +<meta charset=utf-8> +<script src="../testcommon.js"></script> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; + +// This test supplements the web-platform-tests in: +// +// web-animations/interfaces/Animatable/animate-no-browsing-context.html +// +// Specifically, it covers the case where we have a running animation +// targetting an element in a document without a browsing context. +// +// Currently the behavior in this case is not well-defined. For example, +// if we were to simply take an element from such a document, and do: +// +// const xdoc = xhr.responseXML; +// const div = xdoc.getElementById('test'); +// div.style.opacity = '0'; +// alert(getComputedStyle(div).opacity); +// +// We'd get '0' in Firefox and Edge, but an empty string in Chrome. +// +// However, if instead of using the style attribute, we set style in a <style> +// element in *either* the document we're calling from *or* the XHR doc and +// do the same we get '1' in Firefox and Edge, but an empty string in Chrome. +// +// That is, no browser appears to apply styles to elements in a document without +// a browsing context unless the styles are defined using the style attribute, +// and even then Chrome does not. +// +// There is some prose in CSSOM which says, +// +// Note: This means that even if obj is in a different document (e.g. one +// fetched via XMLHttpRequest) it will still use the style rules associated +// with the document that is associated with the global object on which +// getComputedStyle() was invoked to compute the CSS declaration block.[1] +// +// However, this text has been around since at least 2013 and does not appear +// to be implemented. +// +// As a result, it's not really possible to write a cross-browser test for the +// behavior for animations in this context since it's not clear what the result +// should be. That said, we still want to exercise this particular code path so +// we make this case a Mozilla-specific test. The other similar tests cases for +// which the behavior is well-defined are covered by web-platform-tests. +// +// [1] https://drafts.csswg.org/cssom/#extensions-to-the-window-interface + +function getXHRDoc(t) { + return new Promise(resolve => { + const xhr = new XMLHttpRequest(); + xhr.open('GET', 'xhr_doc.html'); + xhr.responseType = 'document'; + xhr.onload = t.step_func(() => { + assert_equals(xhr.readyState, xhr.DONE, + 'Request should complete successfully'); + assert_equals(xhr.status, 200, + 'Response should be OK'); + resolve(xhr.responseXML); + }); + xhr.send(); + }); +} + +promise_test(t => { + let anim; + return getXHRDoc(t).then(xhrdoc => { + const div = xhrdoc.getElementById('test'); + anim = div.animate({ opacity: [ 0, 1 ] }, 1000); + // Give the animation an active timeline and kick-start it. + anim.timeline = document.timeline; + anim.startTime = document.timeline.currentTime; + assert_equals(anim.playState, 'running', + 'The animation should be running'); + // Gecko currently skips applying animation styles to elements in documents + // without browsing contexts. + assert_not_equals(getComputedStyle(div).opacity, '0', + 'Style should NOT be updated'); + }); +}, 'Forcing an animation targetting an element in a document without a' + + ' browsing context to play does not cause style to update'); + +promise_test(t => { + let anim; + return getXHRDoc(t).then(xhrdoc => { + const div = addDiv(t); + anim = div.animate({ opacity: [ 0, 1 ] }, 1000); + assert_equals(getComputedStyle(div).opacity, '0', + 'Style should be updated'); + // Trigger an animation restyle to be queued + anim.currentTime = 0.1; + // Adopt node into XHR doc + xhrdoc.body.appendChild(div); + // We should skip applying animation styles to elements in documents + // without a pres shell. + assert_equals(getComputedStyle(div).opacity, '1', + 'Style should NOT be updated'); + }); +}, 'Moving an element with a pending animation restyle to a document without' + + ' a browsing context resets animation style'); + +</script> diff --git a/dom/animation/test/mozilla/test_set_easing.html b/dom/animation/test/mozilla/test_set_easing.html new file mode 100644 index 0000000000..55c77f0e8f --- /dev/null +++ b/dom/animation/test/mozilla/test_set_easing.html @@ -0,0 +1,36 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test setting easing in sandbox</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +</head> +<body> +<div id="log"></div> +<script> +"use strict"; + +test(function(t) { + const div = document.createElement("div"); + document.body.appendChild(div); + div.animate({ opacity: [0, 1] }, 100000 ); + + const contentScript = function() { + try { + document.getAnimations()[0].effect.updateTiming({ easing: 'linear' }); + assert_true(true, 'Setting easing should not throw in sandbox'); + } catch (e) { + assert_unreached('Setting easing threw ' + e); + } + }; + + const sandbox = new SpecialPowers.Cu.Sandbox(window); + sandbox.importFunction(document, "document"); + sandbox.importFunction(assert_true, "assert_true"); + sandbox.importFunction(assert_unreached, "assert_unreached"); + SpecialPowers.Cu.evalInSandbox(`(${contentScript.toString()})()`, sandbox); +}, 'Setting easing should not throw any exceptions in sandbox'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_style_after_finished_on_compositor.html b/dom/animation/test/mozilla/test_style_after_finished_on_compositor.html new file mode 100644 index 0000000000..bccae9e0d5 --- /dev/null +++ b/dom/animation/test/mozilla/test_style_after_finished_on_compositor.html @@ -0,0 +1,138 @@ +<!doctype html> +<head> +<meta charset=utf-8> +<title>Test for styles after finished on the compositor</title> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<style> +.compositor { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: green; +} +</style> +</head> +<body> +<div id="log"></div> +<script> +"use strict"; + +promise_test(async t => { + const div = addDiv(t, { 'class': 'compositor' }); + const anim = div.animate([ { offset: 0, opacity: 1 }, + { offset: 1, opacity: 0 } ], + { delay: 10, + duration: 100 }); + + await anim.finished; + + await waitForNextFrame(); + + const opacity = SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + + assert_equals(opacity, '', 'No opacity animation runs on the compositor'); +}, 'Opacity animation with positive delay is removed from compositor when ' + + 'finished'); + +promise_test(async t => { + const div = addDiv(t, { 'class': 'compositor' }); + const anim = div.animate([ { offset: 0, opacity: 1 }, + { offset: 0.9, opacity: 1 }, + { offset: 1, opacity: 0 } ], + { duration: 100 }); + + await anim.finished; + + await waitForNextFrame(); + + const opacity = SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + + assert_equals(opacity, '', 'No opacity animation runs on the compositor'); +}, 'Opacity animation initially opacity: 1 is removed from compositor when ' + + 'finished'); + +promise_test(async t => { + const div = addDiv(t, { 'class': 'compositor' }); + const anim = div.animate([ { offset: 0, opacity: 0 }, + { offset: 0.5, opacity: 1 }, + { offset: 0.51, opacity: 1 }, + { offset: 1, opacity: 0 } ], + { delay: 10, duration: 100 }); + + await waitForAnimationFrames(2); + + // Setting the current time at the offset generating opacity: 1. + anim.currentTime = 60; + + await anim.finished; + + await waitForNextFrame(); + + const opacity = SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + + assert_equals(opacity, '', 'No opacity animation runs on the compositor'); +}, 'Opacity animation is removed from compositor even when it only visits ' + + 'exactly the point where the opacity: 1 value was set'); + +promise_test(async t => { + const div = addDiv(t, { 'class': 'compositor' }); + const anim = div.animate([ { offset: 0, transform: 'none' }, + { offset: 1, transform: 'translateX(100px)' } ], + { delay: 10, + duration: 100 }); + + await anim.finished; + + await waitForNextFrame(); + + const transform = SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + + assert_equals(transform, '', 'No transform animation runs on the compositor'); +}, 'Transform animation with positive delay is removed from compositor when ' + + 'finished'); + +promise_test(async t => { + const div = addDiv(t, { 'class': 'compositor' }); + const anim = div.animate([ { offset: 0, transform: 'none' }, + { offset: 0.9, transform: 'none' }, + { offset: 1, transform: 'translateX(100px)' } ], + { duration: 100 }); + + await anim.finished; + + await waitForNextFrame(); + + const transform = SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + + assert_equals(transform, '', 'No transform animation runs on the compositor'); +}, 'Transform animation initially transform: none is removed from compositor ' + + 'when finished'); + + +promise_test(async t => { + const div = addDiv(t, { 'class': 'compositor' }); + const anim = div.animate([ { offset: 0, transform: 'translateX(100px)' }, + { offset: 0.5, transform: 'none' }, + { offset: 0.9, transform: 'none' }, + { offset: 1, transform: 'translateX(100px)' } ], + { delay: 10, duration: 100 }); + + await waitForAnimationFrames(2); + + // Setting the current time at the offset generating transform: none. + anim.currentTime = 60; + + await anim.finished; + + await waitForNextFrame(); + + const transform = SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + + assert_equals(transform, '', 'No transform animation runs on the compositor'); +}, 'Transform animation is removed from compositor even when it only visits ' + + 'exactly the point where the transform: none value was set'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_transform_limits.html b/dom/animation/test/mozilla/test_transform_limits.html new file mode 100644 index 0000000000..92d1b7e1ec --- /dev/null +++ b/dom/animation/test/mozilla/test_transform_limits.html @@ -0,0 +1,56 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<body> +<div id="log"></div> +<script> +'use strict'; + +// We clamp +infinity or -inifinity value in floating point to +// maximum floating point value or -maximum floating point value. +const MAX_FLOAT = 3.40282e+38; + +test(function(t) { + var div = addDiv(t); + div.style = "width: 1px; height: 1px;"; + var anim = div.animate([ { transform: 'scale(1)' }, + { transform: 'scale(3.5e+38)'}, + { transform: 'scale(3)' } ], 100 * MS_PER_SEC); + + anim.pause(); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).transform, + 'matrix(' + MAX_FLOAT + ', 0, 0, ' + MAX_FLOAT + ', 0, 0)'); +}, 'Test that the parameter of transform scale is clamped' ); + +test(function(t) { + var div = addDiv(t); + div.style = "width: 1px; height: 1px;"; + var anim = div.animate([ { transform: 'translate(1px)' }, + { transform: 'translate(3.5e+38px)'}, + { transform: 'translate(3px)' } ], 100 * MS_PER_SEC); + + anim.pause(); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).transform, + 'matrix(1, 0, 0, 1, ' + MAX_FLOAT + ', 0)'); +}, 'Test that the parameter of transform translate is clamped' ); + +test(function(t) { + var div = addDiv(t); + div.style = "width: 1px; height: 1px;"; + var anim = div.animate([ { transform: 'matrix(0.5, 0, 0, 0.5, 0, 0)' }, + { transform: 'matrix(2, 0, 0, 2, 3.5e+38, 0)'}, + { transform: 'matrix(0, 2, 0, -2, 0, 0)' } ], + 100 * MS_PER_SEC); + + anim.pause(); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).transform, + 'matrix(2, 0, 0, 2, ' + MAX_FLOAT + ', 0)'); +}, 'Test that the parameter of transform matrix is clamped' ); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_transition_finish_on_compositor.html b/dom/animation/test/mozilla/test_transition_finish_on_compositor.html new file mode 100644 index 0000000000..46a154b9af --- /dev/null +++ b/dom/animation/test/mozilla/test_transition_finish_on_compositor.html @@ -0,0 +1,22 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<div id="log"></div> +<script> +'use strict'; +setup({explicit_done: true}); +// This test appears like it might get racey and cause a timeout with too low of a +// precision, so we hardcode it to something reasonable. +SpecialPowers.pushPrefEnv( + { + set: [ + ['privacy.reduceTimerPrecision', true], + ['privacy.resistFingerprinting.reduceTimerPrecision.microseconds', 2000], + ], + }, + function() { + window.open('file_transition_finish_on_compositor.html'); + } +); +</script> diff --git a/dom/animation/test/mozilla/test_underlying_discrete_value.html b/dom/animation/test/mozilla/test_underlying_discrete_value.html new file mode 100644 index 0000000000..3961305df3 --- /dev/null +++ b/dom/animation/test/mozilla/test_underlying_discrete_value.html @@ -0,0 +1,188 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<body> +<div id="log"></div> +<script> +"use strict"; + +// Tests that we correctly extract the underlying value when the animation +// type is 'discrete'. +const discreteTests = [ + { + stylesheet: { + "@keyframes keyframes": + "from { align-content: flex-start; } to { align-content: flex-end; } " + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "flex-start" }, + { computedOffset: 1, alignContent: "flex-end" } + ], + explanation: "Test for fully-specified keyframes" + }, + { + stylesheet: { + "@keyframes keyframes": "from { align-content: flex-start; }" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "flex-start" }, + { computedOffset: 1, alignContent: "normal" } + ], + explanation: "Test for 0% keyframe only", + }, + { + stylesheet: { + "@keyframes keyframes": "to { align-content: flex-end; }" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "normal" }, + { computedOffset: 1, alignContent: "flex-end" } + ], + explanation: "Test for 100% keyframe only", + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "#target": "align-content: space-between;" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }" + }, + attributes: { + style: "align-content: space-between" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element using style attribute" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "#target": "align-content: inherit;" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "normal" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "normal" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and 'inherit' specified on target element", + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + ".target": "align-content: space-between;" + }, + attributes: { + class: "target" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element using class selector" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "div": "align-content: space-between;" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element using type selector" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "div": "align-content: space-between;", + ".target": "align-content: flex-start;", + "#target": "align-content: flex-end;" + }, + attributes: { + class: "target" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "flex-end" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "flex-end" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element " + + "using ID selector that overrides class selector" + }, + { + stylesheet: { + "@keyframes keyframes": "50% { align-content: center; }", + "div": "align-content: space-between !important;", + ".target": "align-content: flex-start;", + "#target": "align-content: flex-end;" + }, + attributes: { + class: "target" + }, + expectedKeyframes: [ + { computedOffset: 0, alignContent: "space-between" }, + { computedOffset: 0.5, alignContent: "center" }, + { computedOffset: 1, alignContent: "space-between" } + ], + explanation: "Test for no 0%/100% keyframes " + + "and specified style on target element " + + "using important type selector that overrides other rules" + }, +]; + +discreteTests.forEach(testcase => { + test(t => { + if (testcase.skip) { + return; + } + addStyle(t, testcase.stylesheet); + + const div = addDiv(t, { "id": "target" }); + if (testcase.attributes) { + for (let attributeName in testcase.attributes) { + div.setAttribute(attributeName, testcase.attributes[attributeName]); + } + } + div.style.animation = "keyframes 100s"; + + const keyframes = div.getAnimations()[0].effect.getKeyframes(); + const expectedKeyframes = testcase.expectedKeyframes; + assert_equals(keyframes.length, expectedKeyframes.length, + `keyframes.length should be ${ expectedKeyframes.length }`); + + keyframes.forEach((keyframe, index) => { + const expectedKeyframe = expectedKeyframes[index]; + assert_equals(keyframe.computedOffset, expectedKeyframe.computedOffset, + `computedOffset of keyframes[${ index }] should be ` + + `${ expectedKeyframe.computedOffset }`); + assert_equals(keyframe.alignContent, expectedKeyframe.alignContent, + `alignContent of keyframes[${ index }] should be ` + + `${ expectedKeyframe.alignContent }`); + }); + }, testcase.explanation); +}); + +</script> +</body> diff --git a/dom/animation/test/mozilla/test_unstyled.html b/dom/animation/test/mozilla/test_unstyled.html new file mode 100644 index 0000000000..4724979c11 --- /dev/null +++ b/dom/animation/test/mozilla/test_unstyled.html @@ -0,0 +1,54 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<style> +div.pseudo::before { + animation: animation 1s; + content: 'content'; +} +@keyframes animation { + to { opacity: 0 } +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +// Tests for cases where we may not have style data for an element + +promise_test(async t => { + // Get a CSSPseudoElement + const div = addDiv(t, { class: 'pseudo' }); + const cssAnim = document.getAnimations()[0]; + const pseudoElem = cssAnim.effect.target; + + // Drop pseudo from styles and flush styles + div.classList.remove('pseudo'); + getComputedStyle(div, '::before').content; + + // Try animating the pseudo's content attribute + const contentAnim = pseudoElem.animate( + { content: ['none', '"content"'] }, + { duration: 100 * MS_PER_SEC, fill: 'both' } + ); + + // Check that the initial value is as expected + await contentAnim.ready; + assert_equals(getComputedStyle(div, '::before').content, 'none'); + + contentAnim.finish(); + + // Animating an obsolete pseudo element should NOT cause the pseudo element + // to be re-generated. That behavior might change in which case this test + // will need to be updated. The most important part of this test, however, + // is simply checking that nothing explodes if we try to animate such a + // pseudo element. + + assert_equals(getComputedStyle(div, '::before').content, 'none'); +}, 'Animation on an obsolete pseudo element produces expected results'); + +</script> +</body> diff --git a/dom/animation/test/mozilla/xhr_doc.html b/dom/animation/test/mozilla/xhr_doc.html new file mode 100644 index 0000000000..b9fa57e3f5 --- /dev/null +++ b/dom/animation/test/mozilla/xhr_doc.html @@ -0,0 +1,2 @@ +<!doctype html> +<div id=test></div> diff --git a/dom/animation/test/style/test_animation-seeking-with-current-time.html b/dom/animation/test/style/test_animation-seeking-with-current-time.html new file mode 100644 index 0000000000..265de8f0f5 --- /dev/null +++ b/dom/animation/test/style/test_animation-seeking-with-current-time.html @@ -0,0 +1,123 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for seeking using Animation.currentTime</title> + <style> +.animated-div { + margin-left: -10px; + animation-timing-function: linear ! important; +} + +@keyframes anim { + from { margin-left: 0px; } + to { margin-left: 100px; } +} + </style> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../testcommon.js"></script> + </head> + <body> + <div id="log"></div> + <script type="text/javascript"> +'use strict'; + +function assert_marginLeft_equals(target, expect, description) { + var marginLeft = parseFloat(getComputedStyle(target).marginLeft); + assert_equals(marginLeft, expect, description); +} + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.currentTime = 90 * MS_PER_SEC; + assert_marginLeft_equals(div, 90, + 'Computed style is updated when seeking forwards in active interval'); + + animation.currentTime = 10 * MS_PER_SEC; + assert_marginLeft_equals(div, 10, + 'Computed style is updated when seeking backwards in active interval'); + }); +}, 'Seeking forwards and backward in active interval'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in before phase with no backwards fill'); + + // before -> active (non-active -> active) + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking forwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (before -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // move to after phase + animation.currentTime = 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in after phase with no forwards fill'); + + // after -> active (non-active -> active) + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking backwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (after -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // move to active phase + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> before + animation.currentTime = 50 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not effected after seeking backwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> before)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // move to active phase + animation.currentTime = 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> after + animation.currentTime = 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not affected after seeking forwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> after)'); + + </script> + </body> +</html> diff --git a/dom/animation/test/style/test_animation-seeking-with-start-time.html b/dom/animation/test/style/test_animation-seeking-with-start-time.html new file mode 100644 index 0000000000..e56db5f23d --- /dev/null +++ b/dom/animation/test/style/test_animation-seeking-with-start-time.html @@ -0,0 +1,123 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for seeking using Animation.startTime</title> + <style> +.animated-div { + margin-left: -10px; + animation-timing-function: linear ! important; +} + +@keyframes anim { + from { margin-left: 0px; } + to { margin-left: 100px; } +} + </style> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src="../testcommon.js"></script> + </head> + <body> + <div id="log"></div> + <script type="text/javascript"> +'use strict'; + +function assert_marginLeft_equals(target, expect, description) { + var marginLeft = parseFloat(getComputedStyle(target).marginLeft); + assert_equals(marginLeft, expect, description); +} + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + animation.startTime = animation.timeline.currentTime - 90 * MS_PER_SEC + assert_marginLeft_equals(div, 90, + 'Computed style is updated when seeking forwards in active interval'); + + animation.startTime = animation.timeline.currentTime - 10 * MS_PER_SEC; + assert_marginLeft_equals(div, 10, + 'Computed style is updated when seeking backwards in active interval'); + }); +}, 'Seeking forwards and backward in active interval'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in before phase with no backwards fill'); + + // before -> active (non-active -> active) + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking forwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (before -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // move to after phase + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed style is unaffected in after phase with no forwards fill'); + + // after -> active (non-active -> active) + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed style is updated when seeking backwards from ' + + 'not \'in effect\' to \'in effect\' state'); + }); +}, 'Seeking to non-\'in effect\' from \'in effect\' (after -> active)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // move to active phase + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> before + animation.startTime = animation.timeline.currentTime - 50 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not affected after seeking backwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> before)'); + +promise_test(function(t) { + var div = addDiv(t, {'class': 'animated-div'}); + div.style.animation = "anim 100s 100s"; + var animation = div.getAnimations()[0]; + + return animation.ready.then(function() { + // move to active phase + animation.startTime = animation.timeline.currentTime - 150 * MS_PER_SEC; + assert_marginLeft_equals(div, 50, + 'Computed value is set during active phase'); + + // active -> after + animation.startTime = animation.timeline.currentTime - 250 * MS_PER_SEC; + assert_marginLeft_equals(div, -10, + 'Computed value is not affected after seeking forwards from ' + + '\'in effect\' to not \'in effect\' state'); + }); +}, 'Seeking to \'in effect\' from non-\'in effect\' (active -> after)'); + + </script> + </body> +</html> diff --git a/dom/animation/test/style/test_animation-setting-effect.html b/dom/animation/test/style/test_animation-setting-effect.html new file mode 100644 index 0000000000..8712072a51 --- /dev/null +++ b/dom/animation/test/style/test_animation-setting-effect.html @@ -0,0 +1,127 @@ +<!doctype html> +<html> + <head> + <meta charset=utf-8> + <title>Tests for setting effects by using Animation.effect</title> + <script src="/resources/testharness.js"></script> + <script src="/resources/testharnessreport.js"></script> + <script src='../testcommon.js'></script> + </head> + <body> + <div id="log"></div> + <script type='text/javascript'> + +'use strict'; + +test(function(t) { + var target = addDiv(t); + var anim = new Animation(); + anim.effect = new KeyframeEffect(target, + { marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px'); +}, 'After setting target effect on an animation with null effect, the ' + + 'animation still works'); + +test(function(t) { + var target = addDiv(t); + target.style.marginLeft = '10px'; + var anim = target.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + anim.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px'); + + anim.effect = null; + assert_equals(getComputedStyle(target).marginLeft, '10px'); +}, 'After setting null target effect, the computed style of the target ' + + 'element becomes the initial value'); + +test(function(t) { + var target = addDiv(t); + var animA = target.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + var animB = new Animation(); + animA.currentTime = 50 * MS_PER_SEC; + animB.currentTime = 20 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px', + 'original computed style of the target element'); + + animB.effect = animA.effect; + assert_equals(getComputedStyle(target).marginLeft, '20px', + 'new computed style of the target element'); +}, 'After setting the target effect from an existing animation, the computed ' + + 'style of the target effect should reflect the time of the updated ' + + 'animation.'); + +test(function(t) { + var target = addDiv(t); + target.style.marginTop = '-10px'; + var animA = target.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + var animB = target.animate({ marginTop: [ '0px', '100px' ] }, + 50 * MS_PER_SEC); + animA.currentTime = 50 * MS_PER_SEC; + animB.currentTime = 10 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '50px', + 'original margin-left of the target element'); + assert_equals(getComputedStyle(target).marginTop, '20px', + 'original margin-top of the target element'); + + animB.effect = animA.effect; + assert_equals(getComputedStyle(target).marginLeft, '10px', + 'new margin-left of the target element'); + assert_equals(getComputedStyle(target).marginTop, '-10px', + 'new margin-top of the target element'); +}, 'After setting target effect with an animation to another animation which ' + + 'also has an target effect and both animation effects target to the same ' + + 'element, the computed style of this element should reflect the time and ' + + 'effect of the animation that was set'); + +test(function(t) { + var targetA = addDiv(t); + var targetB = addDiv(t); + targetB.style.marginLeft = '-10px'; + var animA = targetA.animate({ marginLeft: [ '0px', '100px' ] }, + 100 * MS_PER_SEC); + var animB = targetB.animate({ marginLeft: [ '0px', '100px' ] }, + 50 * MS_PER_SEC); + animA.currentTime = 50 * MS_PER_SEC; + animB.currentTime = 10 * MS_PER_SEC; + assert_equals(getComputedStyle(targetA).marginLeft, '50px', + 'original margin-left of the first element'); + assert_equals(getComputedStyle(targetB).marginLeft, '20px', + 'original margin-left of the second element'); + + animB.effect = animA.effect; + assert_equals(getComputedStyle(targetA).marginLeft, '10px', + 'new margin-left of the first element'); + assert_equals(getComputedStyle(targetB).marginLeft, '-10px', + 'new margin-left of the second element'); +}, 'After setting target effect with an animation to another animation which ' + + 'also has an target effect and these animation effects target to ' + + 'different elements, the computed styles of the two elements should ' + + 'reflect the time and effect of the animation that was set'); + +test(function(t) { + var target = addDiv(t); + var animA = target.animate({ marginLeft: [ '0px', '100px' ] }, + 50 * MS_PER_SEC); + var animB = target.animate({ marginTop: [ '0px', '50px' ] }, + 100 * MS_PER_SEC); + animA.currentTime = 20 * MS_PER_SEC; + animB.currentTime = 30 * MS_PER_SEC; + assert_equals(getComputedStyle(target).marginLeft, '40px'); + assert_equals(getComputedStyle(target).marginTop, '15px'); + + var effectA = animA.effect; + animA.effect = animB.effect; + animB.effect = effectA; + assert_equals(getComputedStyle(target).marginLeft, '60px'); + assert_equals(getComputedStyle(target).marginTop, '10px'); +}, 'After swapping effects of two playing animations, both animations are ' + + 'still running with the same current time'); + + </script> + </body> +</html> diff --git a/dom/animation/test/style/test_composite.html b/dom/animation/test/style/test_composite.html new file mode 100644 index 0000000000..1383b1b1e6 --- /dev/null +++ b/dom/animation/test/style/test_composite.html @@ -0,0 +1,142 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<style> +div { + /* Element needs geometry to be eligible for layerization */ + width: 20px; + height: 20px; + background-color: white; +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +if (!SpecialPowers.DOMWindowUtils.layerManagerRemote || + !SpecialPowers.getBoolPref( + 'layers.offmainthreadcomposition.async-animations')) { + // If OMTA is disabled, nothing to run. + done(); +} + +function waitForPaintsFlushed() { + return new Promise(function(resolve, reject) { + waitForAllPaintsFlushed(resolve); + }); +} + +promise_test(t => { + // Without this, the first test case fails on Android. + return waitForDocumentLoad(); +}, 'Ensure document has been loaded'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate({ transform: ['translateX(0px)', 'translateX(200px)'], + composite: 'accumulate' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 200, 0)', + 'Transform value at 50%'); + }); +}, 'Accumulate onto the base value'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + div.animate({ transform: ['translateX(100px)', 'translateX(200px)'], + composite: 'replace' }, + 100 * MS_PER_SEC); + div.animate({ transform: ['translateX(0px)', 'translateX(100px)'], + composite: 'accumulate' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 200, 0)', + 'Transform value at 50%'); + }); +}, 'Accumulate onto an underlying animation value'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate([{ transform: 'translateX(100px)', composite: 'accumulate' }, + { transform: 'translateX(300px)', composite: 'replace' }], + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 250, 0)', + 'Transform value at 50s'); + }); +}, 'Composite when mixing accumulate and replace'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate([{ transform: 'translateX(100px)', composite: 'replace' }, + { transform: 'translateX(300px)' }], + { duration: 100 * MS_PER_SEC, composite: 'accumulate' }); + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 250, 0)', + 'Transform value at 50%'); + }); +}, 'Composite specified on a keyframe overrides the composite mode of the ' + + 'effect'); + +promise_test(t => { + var div; + var anim; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + div.animate({ transform: [ 'scale(2)', 'scale(2)' ] }, 100 * MS_PER_SEC); + anim = div.animate({ transform: [ 'scale(4)', 'scale(4)' ] }, + { duration: 100 * MS_PER_SEC, composite: 'add' }); + + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(8, 0, 0, 8, 0, 0)', + 'The additive scale value should be scale(8)'); // scale(2) scale(4) + + anim.effect.composite = 'accumulate'; + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(1); + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(5, 0, 0, 5, 0, 0)', + // (scale(2 - 1) + scale(4 - 1) + scale(1)) + 'The accumulate scale value should be scale(5)'); + }); +}, 'Composite operation change'); + +</script> +</body> diff --git a/dom/animation/test/style/test_interpolation-from-interpolatematrix-to-none.html b/dom/animation/test/style/test_interpolation-from-interpolatematrix-to-none.html new file mode 100644 index 0000000000..1da95392eb --- /dev/null +++ b/dom/animation/test/style/test_interpolation-from-interpolatematrix-to-none.html @@ -0,0 +1,43 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../testcommon.js'></script> +<div id='log'></div> +<script type='text/javascript'> +'use strict'; + +test(function(t) { + var target = addDiv(t); + target.style.transform = 'translateX(100px)'; + target.style.transition = 'all 10s linear -5s'; + getComputedStyle(target).transform; + + target.style.transform = 'rotate(90deg)'; + var interpolated_matrix = 'matrix(' + Math.cos(Math.PI / 4) + ',' + + Math.sin(Math.PI / 4) + ',' + + -Math.sin(Math.PI / 4) + ',' + + Math.cos(Math.PI / 4) + ',' + + '50, 0)'; + assert_matrix_equals(getComputedStyle(target).transform, + interpolated_matrix, + 'the equivalent matrix of ' + 'interpolatematrix(' + + 'translateX(100px), rotate(90deg), 0.5)'); + + // Trigger a new transition from + // interpolatematrix(translateX(100px), rotate(90deg), 0.5) to none + // with 'all 10s linear -5s'. + target.style.transform = 'none'; + interpolated_matrix = 'matrix(' + Math.cos(Math.PI / 8) + ',' + + Math.sin(Math.PI / 8) + ',' + + -Math.sin(Math.PI / 8) + ',' + + Math.cos(Math.PI / 8) + ',' + + '25, 0)'; + assert_matrix_equals(getComputedStyle(target).transform, + interpolated_matrix, + 'the expected matrix from interpolatematrix(' + + 'translateX(100px), rotate(90deg), 0.5) to none at 50%'); +}, 'Test interpolation from interpolatematrix to none at 50%'); + +</script> +</html> diff --git a/dom/animation/test/style/test_missing-keyframe-on-compositor.html b/dom/animation/test/style/test_missing-keyframe-on-compositor.html new file mode 100644 index 0000000000..8b92a89168 --- /dev/null +++ b/dom/animation/test/style/test_missing-keyframe-on-compositor.html @@ -0,0 +1,577 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<script src="/tests/SimpleTest/paint_listener.js"></script> +<style> +div { + /* Element needs geometry to be eligible for layerization */ + width: 100px; + height: 100px; + background-color: white; +} +</style> +<body> +<div id="log"></div> +<script> +'use strict'; + +if (!SpecialPowers.DOMWindowUtils.layerManagerRemote || + !SpecialPowers.getBoolPref( + 'layers.offmainthreadcomposition.async-animations')) { + // If OMTA is disabled, nothing to run. + done(); +} + +function waitForPaintsFlushed() { + return new Promise(function(resolve, reject) { + waitForAllPaintsFlushed(resolve); + }); +} + +// Note that promise tests run in sequence so this ensures the document is +// loaded before any of the other tests run. +promise_test(t => { + // Without this, the first test case fails on Android. + return waitForDocumentLoad(); +}, 'Ensure document has been loaded'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'opacity: 0.1' }); + div.animate({ opacity: 1 }, 100 * MS_PER_SEC); + return waitForPaintsFlushed(); + }).then(() => { + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + assert_equals(opacity, '0.1', + 'The initial opacity value should be the base value'); + }); +}, 'Initial opacity value for animation with no no keyframe at offset 0'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'opacity: 0.1' }); + div.animate({ opacity: [ 0.5, 1 ] }, 100 * MS_PER_SEC); + div.animate({ opacity: 1 }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + assert_equals(opacity, '0.5', + 'The initial opacity value should be the value of ' + + 'lower-priority animation value'); + }); +}, 'Initial opacity value for animation with no keyframe at offset 0 when ' + + 'there is a lower-priority animation'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'opacity: 0.1; transition: opacity 100s linear' }); + getComputedStyle(div).opacity; + + div.style.opacity = '0.5'; + getComputedStyle(div).opacity; + + div.animate({ opacity: 1 }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + assert_equals(opacity, '0.1', + 'The initial opacity value should be the initial value of ' + + 'the transition'); + }); +}, 'Initial opacity value for animation with no keyframe at offset 0 when ' + + 'there is a transition on the same property'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'opacity: 0' }); + div.animate([{ offset: 0, opacity: 1 }], 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + assert_equals(opacity, '0.5', + 'Opacity value at 50% should be composed onto the base ' + + 'value'); + }); +}, 'Opacity value for animation with no keyframe at offset 1 at 50% '); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'opacity: 0' }); + div.animate({ opacity: [ 0.5, 0.5 ] }, 100 * MS_PER_SEC); + div.animate([{ offset: 0, opacity: 1 }], 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + assert_equals(opacity, '0.75', // (0.5 + 1) * 0.5 + 'Opacity value at 50% should be composed onto the value ' + + 'of middle of lower-priority animation'); + }); +}, 'Opacity value for animation with no keyframe at offset 1 at 50% when ' + + 'there is a lower-priority animation'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'opacity: 0; transition: opacity 100s linear' }); + getComputedStyle(div).opacity; + + div.style.opacity = '0.5'; + getComputedStyle(div).opacity; + + div.animate([{ offset: 0, opacity: 1 }], 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + assert_equals(opacity, '0.625', // ((0 + 0.5) * 0.5 + 1) * 0.5 + 'Opacity value at 50% should be composed onto the value ' + + 'of middle of transition'); + }); +}, 'Opacity value for animation with no keyframe at offset 1 at 50% when ' + + 'there is a transition on the same property'); + +promise_test(t => { + var div; + var lowerAnimation; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + lowerAnimation = div.animate({ opacity: [ 0.5, 1 ] }, 100 * MS_PER_SEC); + var higherAnimation = div.animate({ opacity: 1 }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + lowerAnimation.pause(); + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + // The underlying value is the value that is staying at 0ms of the + // lowerAnimation, that is 0.5. + // (0.5 + 1.0) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 0.75. + assert_equals(opacity, '0.75', + 'Composed opacity value should be composed onto the value ' + + 'of lower-priority paused animation'); + }); +}, 'Opacity value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a paused underlying animation'); + +promise_test(t => { + var div; + var lowerAnimation; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + lowerAnimation = div.animate({ opacity: [ 0.5, 1 ] }, 100 * MS_PER_SEC); + var higherAnimation = div.animate({ opacity: 1 }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + lowerAnimation.playbackRate = 0; + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + // The underlying value is the value that is staying at 0ms of the + // lowerAnimation, that is 0.5. + // (0.5 + 1.0) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 0.75. + assert_equals(opacity, '0.75', + 'Composed opacity value should be composed onto the value ' + + 'of lower-priority zero playback rate animation'); + }); +}, 'Opacity value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a zero playback rate underlying animation'); + +promise_test(t => { + var div; + var lowerAnimation; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + lowerAnimation = div.animate({ opacity: [ 1, 0.5 ] }, 100 * MS_PER_SEC); + var higherAnimation = div.animate({ opacity: 1 }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + lowerAnimation.effect.updateTiming({ + duration: 0, + fill: 'forwards', + }); + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var opacity = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'opacity'); + // The underlying value is the value that is filling forwards state of the + // lowerAnimation, that is 0.5. + // (0.5 + 1.0) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 0.75. + assert_equals(opacity, '0.75', + 'Composed opacity value should be composed onto the value ' + + 'of lower-priority zero active duration animation'); + }); +}, 'Opacity value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a zero active duration underlying animation'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate({ transform: 'translateX(200px)' }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 100, 0)', + 'The initial transform value should be the base value'); + }); +}, 'Initial transform value for animation with no keyframe at offset 0'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate({ transform: [ 'translateX(200px)', 'translateX(300px)' ] }, + 100 * MS_PER_SEC); + div.animate({ transform: 'translateX(400px)' }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 200, 0)', + 'The initial transform value should be lower-priority animation value'); + }); +}, 'Initial transform value for animation with no keyframe at offset 0 when ' + + 'there is a lower-priority animation'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px);' + + 'transition: transform 100s linear' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(200px)'; + getComputedStyle(div).transform; + + div.animate({ transform: 'translateX(400px)' }, 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 100, 0)', + 'The initial transform value should be the initial value of the ' + + 'transition'); + }); +}, 'Initial transform value for animation with no keyframe at offset 0 when ' + + 'there is a transition'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate([{ offset: 0, transform: 'translateX(200pX)' }], + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 150, 0)', + 'Transform value at 50% should be the base value'); + }); +}, 'Transform value for animation with no keyframe at offset 1 at 50%'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate({ transform: [ 'translateX(200px)', 'translateX(200px)' ] }, + 100 * MS_PER_SEC); + div.animate([{ offset: 0, transform: 'translateX(300px)' }], + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 250, 0)', + 'The final transform value should be the base value'); + }); +}, 'Transform value for animation with no keyframe at offset 1 at 50% when ' + + 'there is a lower-priority animation'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px);' + + 'transition: transform 100s linear' }); + getComputedStyle(div).transform; + + div.style.transform = 'translateX(200px)'; + getComputedStyle(div).transform; + + div.animate([{ offset: 0, transform: 'translateX(300px)' }], + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // (150px + 300px) * 0.5 + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 225, 0)', + 'The final transform value should be the final value of the transition'); + }); +}, 'Transform value for animation with no keyframe at offset 1 at 50% when ' + + 'there is a transition'); + +promise_test(t => { + var div; + var lowerAnimation; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + lowerAnimation = + div.animate({ transform: [ 'translateX(100px)', 'translateX(200px)' ] }, + 100 * MS_PER_SEC); + var higherAnimation = div.animate({ transform: 'translateX(300px)' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + lowerAnimation.pause(); + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // The underlying value is the value that is staying at 0ms of the + // lowerAnimation, that is 100px. + // (100px + 300px) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 200px. + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 200, 0)', + 'Composed transform value should be composed onto the value of ' + + 'lower-priority paused animation'); + }); +}, 'Transform value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a paused underlying animation'); + +promise_test(t => { + var div; + var lowerAnimation; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + lowerAnimation = + div.animate({ transform: [ 'translateX(100px)', 'translateX(200px)' ] }, + 100 * MS_PER_SEC); + var higherAnimation = div.animate({ transform: 'translateX(300px)' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + lowerAnimation.playbackRate = 0; + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // The underlying value is the value that is staying at 0ms of the + // lowerAnimation, that is 100px. + // (100px + 300px) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 200px. + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 200, 0)', + 'Composed transform value should be composed onto the value of ' + + 'lower-priority zero playback rate animation'); + }); +}, 'Transform value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a zero playback rate underlying animation'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + var lowerAnimation = + div.animate({ transform: [ 'translateX(100px)', 'translateX(200px)' ] }, + { duration: 10 * MS_PER_SEC, + fill: 'forwards' }); + var higherAnimation = div.animate({ transform: 'translateX(300px)' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + // We need to wait for a paint so that we can send the state of the lower + // animation that is actually finished at this point. + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // (200px + 300px) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 250px. + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 250, 0)', + 'Composed transform value should be composed onto the value of ' + + 'lower-priority animation with fill:forwards'); + }); +}, 'Transform value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a underlying animation with fill:forwards'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + var lowerAnimation = + div.animate({ transform: [ 'translateX(100px)', 'translateX(200px)' ] }, + { duration: 10 * MS_PER_SEC, + endDelay: -5 * MS_PER_SEC, + fill: 'forwards' }); + var higherAnimation = div.animate({ transform: 'translateX(300px)' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + // We need to wait for a paint just like the above test. + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // (150px + 300px) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 225px. + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 225, 0)', + 'Composed transform value should be composed onto the value of ' + + 'lower-priority animation with fill:forwards and negative endDelay'); + }); +}, 'Transform value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a underlying animation with fill:forwards and negative ' + + 'endDelay'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + var lowerAnimation = + div.animate({ transform: [ 'translateX(100px)', 'translateX(200px)' ] }, + { duration: 10 * MS_PER_SEC, + endDelay: 100 * MS_PER_SEC, + fill: 'forwards' }); + var higherAnimation = div.animate({ transform: 'translateX(300px)' }, + 100 * MS_PER_SEC); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // (200px + 300px) * 0.5 + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 250, 0)', + 'Composed transform value should be composed onto the value of ' + + 'lower-priority animation with fill:forwards during positive endDelay'); + }); +}, 'Transform value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto a underlying animation with fill:forwards during positive ' + + 'endDelay'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + div.animate({ transform: 'translateX(200px)' }, + { duration: 100 * MS_PER_SEC, delay: 50 * MS_PER_SEC }); + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(100 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 150, 0)', + 'Transform value for animation with positive delay should be composed ' + + 'onto the base style'); + }); +}, 'Transform value for animation with no keyframe at offset 0 and with ' + + 'positive delay'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t, { style: 'transform: translateX(100px)' }); + + div.animate([{ offset: 0, transform: 'translateX(200px)'}], + { duration: 100 * MS_PER_SEC, + iterationStart: 1, + iterationComposite: 'accumulate' }); + + return waitForPaintsFlushed(); + }).then(() => { + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 300, 0)', + 'Transform value for animation with no keyframe at offset 1 and its ' + + 'iterationComposite is accumulate'); + }); +}, 'Transform value for animation with no keyframe at offset 1 and its ' + + 'iterationComposite is accumulate'); + +promise_test(t => { + var div; + return useTestRefreshMode(t).then(() => { + div = addDiv(t); + var lowerAnimation = + div.animate({ transform: [ 'translateX(100px)', 'translateX(200px)' ] }, + 100 * MS_PER_SEC); + var higherAnimation = div.animate({ transform: 'translateX(300px)' }, + 100 * MS_PER_SEC); + + lowerAnimation.timeline = null; + // Set current time at 50% duration. + lowerAnimation.currentTime = 50 * MS_PER_SEC; + + return waitForPaintsFlushed(); + }).then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(50 * MS_PER_SEC); + + var transform = + SpecialPowers.DOMWindowUtils.getOMTAStyle(div, 'transform'); + // (150px + 300px) * (50 * MS_PER_SEC / 100 * MS_PER_SEC) = 225px. + assert_matrix_equals(transform, 'matrix(1, 0, 0, 1, 225, 0)', + 'Composed transform value should be composed onto the value of ' + + 'lower-priority animation without timeline'); + }); +}, 'Transform value for animation with no keyframe at offset 0 at 50% when ' + + 'composed onto an animation without timeline'); + +</script> +</body> diff --git a/dom/animation/test/style/test_missing-keyframe.html b/dom/animation/test/style/test_missing-keyframe.html new file mode 100644 index 0000000000..4047e62408 --- /dev/null +++ b/dom/animation/test/style/test_missing-keyframe.html @@ -0,0 +1,110 @@ +<!doctype html> +<meta charset=utf-8> +<script src="/resources/testharness.js"></script> +<script src="/resources/testharnessreport.js"></script> +<script src="../testcommon.js"></script> +<body> +<div id="log"></div> +<script> +'use strict'; + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px' }); + div.animate([{ marginLeft: '200px' }], 100 * MS_PER_SEC); + + assert_equals(getComputedStyle(div).marginLeft, '100px', + 'The initial margin-left value should be the base value'); +}, 'Initial margin-left value for an animation with no keyframe at offset 0'); + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px' }); + div.animate([{ offset: 0, marginLeft: '200px' }, + { offset: 1, marginLeft: '300px' }], + 100 * MS_PER_SEC); + div.animate([{ marginLeft: '200px' }], 100 * MS_PER_SEC); + + assert_equals(getComputedStyle(div).marginLeft, '200px', + 'The initial margin-left value should be the initial value ' + + 'of lower-priority animation'); +}, 'Initial margin-left value for an animation with no keyframe at offset 0 ' + + 'is that of lower-priority animations'); + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px;' + + 'transition: margin-left 100s -50s linear'}); + flushComputedStyle(div); + + div.style.marginLeft = '200px'; + flushComputedStyle(div); + + div.animate([{ marginLeft: '300px' }], 100 * MS_PER_SEC); + + assert_equals(getComputedStyle(div).marginLeft, '150px', + 'The initial margin-left value should be the initial value ' + + 'of the transition'); +}, 'Initial margin-left value for an animation with no keyframe at offset 0 ' + + 'is that of transition'); + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px' }); + var animation = div.animate([{ offset: 0, marginLeft: '200px' }], + 100 * MS_PER_SEC); + + animation.currentTime = 50 * MS_PER_SEC; + assert_equals(getComputedStyle(div).marginLeft, '150px', + 'The margin-left value at 50% should be the base value'); +}, 'margin-left value at 50% for an animation with no keyframe at offset 1'); + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px' }); + var lowerAnimation = div.animate([{ offset: 0, marginLeft: '200px' }, + { offset: 1, marginLeft: '300px' }], + 100 * MS_PER_SEC); + var higherAnimation = div.animate([{ offset: 0, marginLeft: '400px' }], + 100 * MS_PER_SEC); + + lowerAnimation.currentTime = 50 * MS_PER_SEC; + higherAnimation.currentTime = 50 * MS_PER_SEC; + // (250px + 400px) * 0.5 + assert_equals(getComputedStyle(div).marginLeft, '325px', + 'The margin-left value at 50% should be additive value of ' + + 'lower-priority animation and higher-priority animation'); +}, 'margin-left value at 50% for an animation with no keyframe at offset 1 ' + + 'is that of lower-priority animations'); + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px;' + + 'transition: margin-left 100s linear' }); + flushComputedStyle(div); + + div.style.marginLeft = '300px'; + flushComputedStyle(div); + + div.animate([{ offset: 0, marginLeft: '200px' }], 100 * MS_PER_SEC); + + div.getAnimations().forEach(animation => { + animation.currentTime = 50 * MS_PER_SEC; + }); + // (200px + 200px) * 0.5 + assert_equals(getComputedStyle(div).marginLeft, '200px', + 'The margin-left value at 50% should be additive value of ' + + 'the transition and animation'); +}, 'margin-left value at 50% for an animation with no keyframe at offset 1 ' + + 'is that of transition'); + +test(t => { + var div = addDiv(t, { style: 'margin-left: 100px' }); + + var animation = div.animate([{ offset: 0, marginLeft: '200px' }], + { duration: 100 * MS_PER_SEC, + iterationStart: 1, + iterationComposite: 'accumulate' }); + + assert_equals(getComputedStyle(div).marginLeft, '300px', + 'The margin-left value should be additive value of the ' + + 'accumulation of the initial value onto the base value '); +}, 'margin-left value for an animation with no keyframe at offset 1 and its ' + + 'iterationComposite is accumulate'); + +</script> +</body> diff --git a/dom/animation/test/style/test_transform-non-normalizable-rotate3d.html b/dom/animation/test/style/test_transform-non-normalizable-rotate3d.html new file mode 100644 index 0000000000..ad2584ac40 --- /dev/null +++ b/dom/animation/test/style/test_transform-non-normalizable-rotate3d.html @@ -0,0 +1,28 @@ +<!doctype html> +<meta charset=utf-8> +<script src='/resources/testharness.js'></script> +<script src='/resources/testharnessreport.js'></script> +<script src='../testcommon.js'></script> +<div id='log'></div> +<script type='text/javascript'> +'use strict'; + +test(function(t) { + var target = addDiv(t); + target.style.transform = 'rotate3d(0, 0, 1, 90deg)'; + target.style.transition = 'all 10s linear -5s'; + getComputedStyle(target).transform; + + target.style.transform = 'rotate3d(0, 0, 0, 270deg)'; + var interpolated_matrix = 'matrix(' + Math.cos(Math.PI / 4) + ',' + + Math.sin(Math.PI / 4) + ',' + + -Math.sin(Math.PI / 4) + ',' + + Math.cos(Math.PI / 4) + ',' + + '0, 0)'; + assert_matrix_equals(getComputedStyle(target).transform, interpolated_matrix, + 'transition from a normal rotate3d to a ' + + 'non-normalizable rotate3d'); +}, 'Test interpolation on non-normalizable rotate3d function'); + +</script> +</html> diff --git a/dom/animation/test/testcommon.js b/dom/animation/test/testcommon.js new file mode 100644 index 0000000000..e1645d4a19 --- /dev/null +++ b/dom/animation/test/testcommon.js @@ -0,0 +1,527 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Use this variable if you specify duration or some other properties + * for script animation. + * E.g., div.animate({ opacity: [0, 1] }, 100 * MS_PER_SEC); + * + * NOTE: Creating animations with short duration may cause intermittent + * failures in asynchronous test. For example, the short duration animation + * might be finished when animation.ready has been fulfilled because of slow + * platforms or busyness of the main thread. + * Setting short duration to cancel its animation does not matter but + * if you don't want to cancel the animation, consider using longer duration. + */ +const MS_PER_SEC = 1000; + +/* The recommended minimum precision to use for time values[1]. + * + * [1] https://drafts.csswg.org/web-animations/#precision-of-time-values + */ +var TIME_PRECISION = 0.0005; // ms + +/* + * Allow implementations to substitute an alternative method for comparing + * times based on their precision requirements. + */ +function assert_times_equal(actual, expected, description) { + assert_approx_equals(actual, expected, TIME_PRECISION * 2, description); +} + +/* + * Compare a time value based on its precision requirements with a fixed value. + */ +function assert_time_equals_literal(actual, expected, description) { + assert_approx_equals(actual, expected, TIME_PRECISION, description); +} + +/* + * Compare matrix string like 'matrix(1, 0, 0, 1, 100, 0)'. + * This function allows error, 0.01, because on Android when we are scaling down + * the document, it results in some errors. + */ +function assert_matrix_equals(actual, expected, description) { + var matrixRegExp = /^matrix\((.+),(.+),(.+),(.+),(.+),(.+)\)/; + assert_regexp_match(actual, matrixRegExp, "Actual value should be a matrix"); + assert_regexp_match( + expected, + matrixRegExp, + "Expected value should be a matrix" + ); + + var actualMatrixArray = actual.match(matrixRegExp).slice(1).map(Number); + var expectedMatrixArray = expected.match(matrixRegExp).slice(1).map(Number); + + assert_equals( + actualMatrixArray.length, + expectedMatrixArray.length, + "Array lengths should be equal (got '" + + expected + + "' and '" + + actual + + "'): " + + description + ); + for (var i = 0; i < actualMatrixArray.length; i++) { + assert_approx_equals( + actualMatrixArray[i], + expectedMatrixArray[i], + 0.01, + "Matrix array should be equal (got '" + + expected + + "' and '" + + actual + + "'): " + + description + ); + } +} + +/** + * Compare given values which are same format of + * KeyframeEffectReadonly::GetProperties. + */ +function assert_properties_equal(actual, expected) { + assert_equals(actual.length, expected.length); + + const compareProperties = (a, b) => + a.property == b.property ? 0 : a.property < b.property ? -1 : 1; + + const sortedActual = actual.sort(compareProperties); + const sortedExpected = expected.sort(compareProperties); + + const serializeValues = values => + values + .map( + value => + "{ " + + ["offset", "value", "easing", "composite"] + .map(member => `${member}: ${value[member]}`) + .join(", ") + + " }" + ) + .join(", "); + + for (let i = 0; i < sortedActual.length; i++) { + assert_equals( + sortedActual[i].property, + sortedExpected[i].property, + "CSS property name should match" + ); + assert_equals( + serializeValues(sortedActual[i].values), + serializeValues(sortedExpected[i].values), + `Values arrays do not match for ` + `${sortedActual[i].property} property` + ); + } +} + +/** + * Construct a object which is same to a value of + * KeyframeEffectReadonly::GetProperties(). + * The method returns undefined as a value in case of missing keyframe. + * Therefor, we can use undefined for |value| and |easing| parameter. + * @param offset - keyframe offset. e.g. 0.1 + * @param value - any keyframe value. e.g. undefined '1px', 'center', 0.5 + * @param composite - 'replace', 'add', 'accumulate' + * @param easing - e.g. undefined, 'linear', 'ease' and so on + * @return Object - + * e.g. { offset: 0.1, value: '1px', composite: 'replace', easing: 'ease'} + */ +function valueFormat(offset, value, composite, easing) { + return { offset, value, easing, composite }; +} + +/** + * Appends a div to the document body and creates an animation on the div. + * NOTE: This function asserts when trying to create animations with durations + * shorter than 100s because the shorter duration may cause intermittent + * failures. If you are not sure how long it is suitable, use 100s; it's + * long enough but shorter than our test framework timeout (330s). + * If you really need to use shorter durations, use animate() function directly. + * + * @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. + * @param frames The keyframes passed to Element.animate(). + * @param options The options passed to Element.animate(). + */ +function addDivAndAnimate(t, attrs, frames, options) { + let animDur = typeof options === "object" ? options.duration : options; + assert_greater_than_equal( + animDur, + 100 * MS_PER_SEC, + "Clients of this addDivAndAnimate API must request a duration " + + "of at least 100s, to avoid intermittent failures from e.g." + + "the main thread being busy for an extended period" + ); + + return addDiv(t, attrs).animate(frames, options); +} + +/** + * 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. + */ +function addDiv(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. + */ +function addStyle(t, rules) { + var extraStyle = document.createElement("style"); + document.head.appendChild(extraStyle); + if (rules) { + var sheet = extraStyle.sheet; + for (var selector in rules) { + sheet.insertRule( + selector + "{" + rules[selector] + "}", + sheet.cssRules.length + ); + } + } + + if (t && typeof t.add_cleanup === "function") { + t.add_cleanup(function () { + extraStyle.remove(); + }); + } +} + +/** + * Takes a CSS property (e.g. margin-left) and returns the equivalent IDL + * name (e.g. marginLeft). + */ +function propertyToIDL(property) { + var prefixMatch = property.match(/^-(\w+)-/); + if (prefixMatch) { + var prefix = prefixMatch[1] === "moz" ? "Moz" : prefixMatch[1]; + property = prefix + property.substring(prefixMatch[0].length - 1); + } + // https://drafts.csswg.org/cssom/#css-property-to-idl-attribute + return property.replace(/-([a-z])/gi, function (str, group) { + return group.toUpperCase(); + }); +} + +/** + * Promise wrapper for requestAnimationFrame. + */ +function waitForFrame() { + return new Promise(function (resolve, reject) { + window.requestAnimationFrame(resolve); + }); +} + +/** + * Waits for a requestAnimationFrame callback in the next refresh driver tick. + * Note that the 'dom.animations-api.core.enabled' and + * 'dom.animations-api.timelines.enabled' prefs should be true to use this + * function. + */ +function waitForNextFrame(aWindow = window) { + const timeAtStart = aWindow.document.timeline.currentTime; + return new Promise(resolve => { + aWindow.requestAnimationFrame(() => { + if (timeAtStart === aWindow.document.timeline.currentTime) { + aWindow.requestAnimationFrame(resolve); + } else { + resolve(); + } + }); + }); +} + +/** + * Returns a Promise that is resolved after the given number of consecutive + * animation frames have occured (using requestAnimationFrame callbacks). + * + * @param aFrameCount The number of animation frames. + * @param aOnFrame An optional function to be processed in each animation frame. + * @param aWindow An optional window object to be used for requestAnimationFrame. + */ +function waitForAnimationFrames(aFrameCount, aOnFrame, aWindow = window) { + const timeAtStart = aWindow.document.timeline.currentTime; + return new Promise(function (resolve, reject) { + function handleFrame() { + if (aOnFrame && typeof aOnFrame === "function") { + aOnFrame(); + } + if ( + timeAtStart != aWindow.document.timeline.currentTime && + --aFrameCount <= 0 + ) { + resolve(); + } else { + aWindow.requestAnimationFrame(handleFrame); // wait another frame + } + } + aWindow.requestAnimationFrame(handleFrame); + }); +} + +/** + * Promise wrapper for requestIdleCallback. + */ +function waitForIdle() { + return new Promise(resolve => { + requestIdleCallback(resolve); + }); +} + +/** + * Wrapper that takes a sequence of N animations and returns: + * + * Promise.all([animations[0].ready, animations[1].ready, ... animations[N-1].ready]); + */ +function waitForAllAnimations(animations) { + return Promise.all( + animations.map(function (animation) { + return animation.ready; + }) + ); +} + +/** + * Flush the computed style for the given element. This is useful, for example, + * when we are testing a transition and need the initial value of a property + * to be computed so that when we synchronouslyet set it to a different value + * we actually get a transition instead of that being the initial value. + */ +function flushComputedStyle(elem) { + var cs = getComputedStyle(elem); + cs.marginLeft; +} + +if (opener) { + for (var funcName of [ + "async_test", + "assert_not_equals", + "assert_equals", + "assert_approx_equals", + "assert_less_than", + "assert_less_than_equal", + "assert_greater_than", + "assert_between_inclusive", + "assert_true", + "assert_false", + "assert_class_string", + "assert_throws", + "assert_unreached", + "assert_regexp_match", + "promise_test", + "test", + ]) { + if (opener[funcName]) { + window[funcName] = opener[funcName].bind(opener); + } + } + + window.EventWatcher = opener.EventWatcher; + + function done() { + opener.add_completion_callback(function () { + self.close(); + }); + opener.done(); + } +} + +/* + * Returns a promise that is resolved when the document has finished loading. + */ +function waitForDocumentLoad() { + return new Promise(function (resolve, reject) { + if (document.readyState === "complete") { + resolve(); + } else { + window.addEventListener("load", resolve); + } + }); +} + +/* + * Enters test refresh mode, and restores the mode when |t| finishes. + */ +function useTestRefreshMode(t) { + function ensureNoSuppressedPaints() { + return new Promise(resolve => { + function checkSuppressedPaints() { + if (!SpecialPowers.DOMWindowUtils.paintingSuppressed) { + resolve(); + } else { + window.requestAnimationFrame(checkSuppressedPaints); + } + } + checkSuppressedPaints(); + }); + } + + return ensureNoSuppressedPaints().then(() => { + SpecialPowers.DOMWindowUtils.advanceTimeAndRefresh(0); + t.add_cleanup(() => { + SpecialPowers.DOMWindowUtils.restoreNormalRefresh(); + }); + }); +} + +/** + * Returns true if off-main-thread animations. + */ +function isOMTAEnabled() { + const OMTAPrefKey = "layers.offmainthreadcomposition.async-animations"; + return ( + SpecialPowers.DOMWindowUtils.layerManagerRemote && + SpecialPowers.getBoolPref(OMTAPrefKey) + ); +} + +/** + * Append an SVG element to the target element. + * + * @param target The element which want to append. + * @param attrs A array object with attribute name and values to set on + * the SVG element. + * @return An SVG outer element. + */ +function addSVGElement(target, tag, attrs) { + if (!target) { + return null; + } + var element = document.createElementNS("http://www.w3.org/2000/svg", tag); + if (attrs) { + for (var attrName in attrs) { + element.setAttributeNS(null, attrName, attrs[attrName]); + } + } + target.appendChild(element); + return element; +} + +/* + * Get Animation distance between two specified values for a specific property. + * + * @param target The target element. + * @param prop The CSS property. + * @param v1 The first property value. + * @param v2 The Second property value. + * + * @return The distance between |v1| and |v2| for |prop| on |target|. + */ +function getDistance(target, prop, v1, v2) { + if (!target) { + return 0.0; + } + return SpecialPowers.DOMWindowUtils.computeAnimationDistance( + target, + prop, + v1, + v2 + ); +} + +/* + * A promise wrapper for waiting MozAfterPaint. + */ +function waitForPaints() { + // FIXME: Bug 1415065. Instead waiting for two requestAnimationFrames, we + // should wait for MozAfterPaint once after MozAfterPaint is fired properly + // (bug 1341294). + return waitForAnimationFrames(2); +} + +// Returns true if |aAnimation| begins at the current timeline time. We +// sometimes need to detect this case because if we started an animation +// asynchronously (e.g. using play()) and then ended up running the next frame +// at precisely the time the animation started (due to aligning with vsync +// refresh rate) then we won't end up restyling in that frame. +function animationStartsRightNow(aAnimation) { + return ( + aAnimation.startTime === aAnimation.timeline.currentTime && + aAnimation.currentTime === 0 + ); +} + +// Waits for a given animation being ready to restyle. +async function waitForAnimationReadyToRestyle(aAnimation) { + await aAnimation.ready; + // If |aAnimation| begins at the current timeline time, we will not process + // restyling in the initial frame because of aligning with the refresh driver, + // the animation frame in which the ready promise is resolved happens to + // coincide perfectly with the start time of the animation. In this case no + // restyling is needed in the frame so we have to wait one more frame. + if (animationStartsRightNow(aAnimation)) { + await waitForNextFrame(aAnimation.ownerGlobal); + } +} + +function getDocShellForObservingRestylesForWindow(aWindow) { + const docShell = SpecialPowers.wrap(aWindow).docShell; + + docShell.recordProfileTimelineMarkers = true; + docShell.popProfileTimelineMarkers(); + + return docShell; +} + +// Returns the animation restyle markers observed during |frameCount| refresh +// driver ticks in this `window`. This function is typically used to count the +// number of restyles that take place as part of the style update that happens +// on each refresh driver tick, as opposed to synchronous restyles triggered by +// script. +// +// For the latter observeAnimSyncStyling (below) should be used. +function observeStyling(frameCount, onFrame) { + return observeStylingInTargetWindow(window, frameCount, onFrame); +} + +// As with observeStyling but applied to target window |aWindow|. +function observeStylingInTargetWindow(aWindow, aFrameCount, aOnFrame) { + const docShell = getDocShellForObservingRestylesForWindow(aWindow); + + return new Promise(resolve => { + return waitForAnimationFrames(aFrameCount, aOnFrame, aWindow).then(() => { + const markers = docShell.popProfileTimelineMarkers(); + docShell.recordProfileTimelineMarkers = false; + const stylingMarkers = Array.prototype.filter.call( + markers, + (marker, index) => { + return marker.name == "Styles" && marker.isAnimationOnly; + } + ); + resolve(stylingMarkers); + }); + }); +} |