Bug 1354501 - Dispatch web animation events at the same time when CSS animations/transitions events are dispatched. r?birtles draft
authorHiroyuki Ikezoe <hikezoe@mozilla.com>
Tue, 03 Jul 2018 11:05:23 +0900
changeset 813417 069a372d1bddb2cca4ccb7e8b2e67f4d15cc7f54
parent 813416 5dad33c012f0598280a0ac63afdf7adeb6f37b22
child 813418 b76580eebaab4a23fbde4d8d738d2fad13da3bb1
push id114891
push userhikezoe@mozilla.com
push dateTue, 03 Jul 2018 04:49:25 +0000
reviewersbirtles
bugs1354501
milestone63.0a1
Bug 1354501 - Dispatch web animation events at the same time when CSS animations/transitions events are dispatched. r?birtles MozReview-Commit-ID: u7lWtAF8Ml
dom/animation/Animation.cpp
dom/animation/Animation.h
dom/animation/AnimationEventDispatcher.h
dom/animation/AnimationTimeline.h
dom/animation/DocumentTimeline.h
testing/web-platform/meta/MANIFEST.json
testing/web-platform/mozilla/meta/MANIFEST.json
testing/web-platform/tests/web-animations/timing-model/timelines/update-and-send-events.html
--- a/dom/animation/Animation.cpp
+++ b/dom/animation/Animation.cpp
@@ -4,19 +4,19 @@
  * 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 "mozilla/dom/AnimationBinding.h"
 #include "mozilla/dom/AnimationPlaybackEvent.h"
 #include "mozilla/dom/DocumentTimeline.h"
+#include "mozilla/AnimationEventDispatcher.h"
 #include "mozilla/AnimationTarget.h"
 #include "mozilla/AutoRestore.h"
-#include "mozilla/AsyncEventDispatcher.h" // For AsyncEventDispatcher
 #include "mozilla/Maybe.h" // For Maybe
 #include "mozilla/TypeTraits.h" // For std::forward<>
 #include "nsAnimationManager.h" // For CSSAnimation
 #include "nsDOMMutationObserver.h" // For nsAutoAnimationMutationBatch
 #include "nsIDocument.h" // For nsIDocument
 #include "nsIPresShell.h" // For nsIPresShell
 #include "nsThreadUtils.h" // For nsRunnableMethod and nsRevocableEventPtr
 #include "nsTransitionManager.h" // For CSSTransition
@@ -869,17 +869,18 @@ Animation::CancelNoUpdate()
   if (PlayState() != AnimationPlayState::Idle) {
     ResetPendingTasks();
 
     if (mFinished) {
       mFinished->MaybeReject(NS_ERROR_DOM_ABORT_ERR);
     }
     ResetFinishedPromise();
 
-    DispatchPlaybackEvent(NS_LITERAL_STRING("cancel"));
+    // FIXME: Bug 1472900 - Use the timestamp associated with the timeline.
+    QueuePlaybackEvent(NS_LITERAL_STRING("cancel"), TimeStamp());
   }
 
   StickyTimeDuration activeTime = mEffect
                                   ? mEffect->GetComputedTiming().mActiveTime
                                   : StickyTimeDuration();
 
   mHoldTime.SetNull();
   mStartTime.SetNull();
@@ -1550,16 +1551,22 @@ Animation::GetRenderedDocument() const
 {
   if (!mEffect || !mEffect->AsKeyframeEffect()) {
     return nullptr;
   }
 
   return mEffect->AsKeyframeEffect()->GetRenderedDocument();
 }
 
+nsIDocument*
+Animation::GetTimelineDocument() const
+{
+  return mTimeline ? mTimeline->GetDocument() : nullptr;
+}
+
 class AsyncFinishNotification : public MicroTaskRunnable
 {
 public:
   explicit AsyncFinishNotification(Animation* aAnimation)
   : MicroTaskRunnable()
   , mAnimation(aAnimation)
   {}
 
@@ -1619,38 +1626,54 @@ Animation::DoFinishNotificationImmediate
   mFinishNotificationTask = nullptr;
 
   if (PlayState() != AnimationPlayState::Finished) {
     return;
   }
 
   MaybeResolveFinishedPromise();
 
-  DispatchPlaybackEvent(NS_LITERAL_STRING("finish"));
+  QueuePlaybackEvent(NS_LITERAL_STRING("finish"),
+                     AnimationTimeToTimeStamp(EffectEnd()));
 }
 
 void
-Animation::DispatchPlaybackEvent(const nsAString& aName)
+Animation::QueuePlaybackEvent(const nsAString& aName,
+                              TimeStamp&& aScheduledEventTime)
 {
+  // Use document for timing.
+  // https://drafts.csswg.org/web-animations-1/#document-for-timing
+  nsIDocument* doc = GetTimelineDocument();
+  if (!doc) {
+    return;
+  }
+
+  nsPresContext* presContext = doc->GetPresContext();
+  if (!presContext) {
+    return;
+  }
+
   AnimationPlaybackEventInit init;
 
   if (aName.EqualsLiteral("finish")) {
     init.mCurrentTime = GetCurrentTimeAsDouble();
   }
   if (mTimeline) {
     init.mTimelineTime = mTimeline->GetCurrentTimeAsDouble();
   }
 
   RefPtr<AnimationPlaybackEvent> event =
     AnimationPlaybackEvent::Constructor(this, aName, init);
   event->SetTrusted(true);
 
-  RefPtr<AsyncEventDispatcher> asyncDispatcher =
-    new AsyncEventDispatcher(this, event);
-  asyncDispatcher->PostDOMEvent();
+  presContext->AnimationEventDispatcher()->
+    QueueEvent(AnimationEventInfo(aName,
+                                  std::move(event),
+                                  std::move(aScheduledEventTime),
+                                  this));
 }
 
 bool
 Animation::IsRunningOnCompositor() const
 {
   return mEffect &&
          mEffect->AsKeyframeEffect() &&
          mEffect->AsKeyframeEffect()->IsRunningOnCompositor();
--- a/dom/animation/Animation.h
+++ b/dom/animation/Animation.h
@@ -439,17 +439,18 @@ protected:
    */
   void FlushUnanimatedStyle() const;
   void PostUpdate();
   void ResetFinishedPromise();
   void MaybeResolveFinishedPromise();
   void DoFinishNotification(SyncNotifyFlag aSyncNotifyFlag);
   friend class AsyncFinishNotification;
   void DoFinishNotificationImmediately(MicroTaskRunnable* aAsync = nullptr);
-  void DispatchPlaybackEvent(const nsAString& aName);
+  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();
 
@@ -517,16 +518,17 @@ protected:
     static constexpr StickyTimeDuration zeroDuration = StickyTimeDuration();
     return std::max(
       std::min((EffectEnd() - mEffect->SpecifiedTiming().Delay()),
                aActiveDuration),
       zeroDuration);
   }
 
   nsIDocument* GetRenderedDocument() const;
+  nsIDocument* GetTimelineDocument() const;
 
   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
--- a/dom/animation/AnimationEventDispatcher.h
+++ b/dom/animation/AnimationEventDispatcher.h
@@ -8,31 +8,34 @@
 #define mozilla_AnimationEventDispatcher_h
 
 #include <algorithm> // For <std::stable_sort>
 #include "mozilla/AnimationComparator.h"
 #include "mozilla/Assertions.h"
 #include "mozilla/ContentEvents.h"
 #include "mozilla/EventDispatcher.h"
 #include "mozilla/Variant.h"
+#include "mozilla/dom/AnimationPlaybackEvent.h"
 #include "nsCSSProps.h"
 #include "nsCycleCollectionParticipant.h"
 
 class nsPresContext;
 class nsRefreshDriver;
 
 namespace mozilla {
 
 struct AnimationEventInfo
 {
   RefPtr<dom::EventTarget> mTarget;
   RefPtr<dom::Animation> mAnimation;
   TimeStamp mTimeStamp;
 
-  typedef Variant<InternalTransitionEvent, InternalAnimationEvent> EventVariant;
+  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& aTimeStamp,
@@ -68,33 +71,86 @@ struct AnimationEventInfo
     event.mPropertyName =
       NS_ConvertUTF8toUTF16(nsCSSProps::GetStringValue(aProperty));
     // XXX Looks like nobody initialize WidgetEvent::time
     event.mElapsedTime = aElapsedTime;
     event.mPseudoElement =
       nsCSSPseudoElements::PseudoTypeAsString(aTarget.mPseudoType);
   }
 
+  // For web animation events
+  AnimationEventInfo(const nsAString& aName,
+                     RefPtr<dom::AnimationPlaybackEvent>&& aEvent,
+                     TimeStamp&& aTimeStamp,
+                     dom::Animation* aAnimation)
+    : mTarget(aAnimation)
+    , mAnimation(aAnimation)
+    , mTimeStamp(std::move(aTimeStamp))
+    , 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;
   }
+
+  void Dispatch(nsPresContext* aPresContext)
+  {
+    if (mEvent.is<RefPtr<dom::AnimationPlaybackEvent>>()) {
+      EventDispatcher::DispatchDOMEvent(
+        mTarget,
+        nullptr /* WidgetEvent */,
+        mEvent.as<RefPtr<dom::AnimationPlaybackEvent>>(),
+        aPresContext,
+        nullptr /* nsEventStatus */);
+      return;
+    }
+
+    MOZ_ASSERT(mEvent.is<InternalTransitionEvent>() ||
+               mEvent.is<InternalAnimationEvent>());
+
+    EventDispatcher::Dispatch(mTarget, aPresContext, AsWidgetEvent());
+  }
 };
 
 class AnimationEventDispatcher final
 {
 public:
   explicit AnimationEventDispatcher(nsPresContext* aPresContext)
     : mPresContext(aPresContext)
     , mIsSorted(true)
@@ -121,22 +177,18 @@ public:
 
     SortEvents();
 
     EventArray events;
     mPendingEvents.SwapElements(events);
     // 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.AsWidgetEvent()->mFlags.mIsBeingDispatched &&
-                 !info.AsWidgetEvent()->mFlags.mDispatchedAtLeastOnce,
-                 "The WidgetEvent should be fresh");
-      EventDispatcher::Dispatch(info.mTarget,
-                                mPresContext,
-                                info.AsWidgetEvent());
+      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;
       }
     }
   }
@@ -169,16 +221,21 @@ private:
         // Null timestamps sort first
         if (a.mTimeStamp.IsNull() || b.mTimeStamp.IsNull()) {
           return a.mTimeStamp.IsNull();
         } else {
           return a.mTimeStamp < b.mTimeStamp;
         }
       }
 
+      // 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
--- a/dom/animation/AnimationTimeline.h
+++ b/dom/animation/AnimationTimeline.h
@@ -18,16 +18,18 @@
 #include "nsTHashtable.h"
 
 // GetCurrentTime is defined in winbase.h as zero argument macro forwarding to
 // GetTickCount().
 #ifdef GetCurrentTime
 #undef GetCurrentTime
 #endif
 
+class nsIDocument;
+
 namespace mozilla {
 namespace dom {
 
 class Animation;
 
 class AnimationTimeline
   : public nsISupports
   , public nsWrapperCache
@@ -99,16 +101,18 @@ public:
    * time.
    */
   bool HasAnimations() const {
     return !mAnimations.IsEmpty();
   }
 
   virtual void RemoveAnimation(Animation* aAnimation);
 
+  virtual nsIDocument* GetDocument() const = 0;
+
 protected:
   nsCOMPtr<nsIGlobalObject> mWindow;
 
   // Animations observing this timeline
   //
   // We store them in (a) a hashset for quick lookup, and (b) an array
   // to maintain a fixed sampling order.
   //
--- a/dom/animation/DocumentTimeline.h
+++ b/dom/animation/DocumentTimeline.h
@@ -84,16 +84,18 @@ public:
   void RemoveAnimation(Animation* aAnimation) override;
 
   // nsARefreshObserver methods
   void WillRefresh(TimeStamp aTime) override;
 
   void NotifyRefreshDriverCreated(nsRefreshDriver* aDriver);
   void NotifyRefreshDriverDestroying(nsRefreshDriver* aDriver);
 
+  nsIDocument* GetDocument() const override { return mDocument; }
+
 protected:
   TimeStamp GetCurrentTimeStamp() const;
   nsRefreshDriver* GetRefreshDriver() const;
   void UnregisterFromRefreshDriver();
 
   nsCOMPtr<nsIDocument> mDocument;
 
   // The most recently used refresh driver time. This is used in cases where
--- a/testing/web-platform/meta/MANIFEST.json
+++ b/testing/web-platform/meta/MANIFEST.json
@@ -377893,16 +377893,22 @@
     ]
    ],
    "web-animations/timing-model/timelines/timelines.html": [
     [
      "/web-animations/timing-model/timelines/timelines.html",
      {}
     ]
    ],
+   "web-animations/timing-model/timelines/update-and-send-events.html": [
+    [
+     "/web-animations/timing-model/timelines/update-and-send-events.html",
+     {}
+    ]
+   ],
    "web-nfc/idlharness.https.html": [
     [
      "/web-nfc/idlharness.https.html",
      {}
     ]
    ],
    "web-nfc/nfc_insecure_context.html": [
     [
@@ -618764,16 +618770,20 @@
   "web-animations/timing-model/timelines/document-timelines.html": [
    "d0fcb390c19c9ede7288278dc11ea5b3d33671cb",
    "testharness"
   ],
   "web-animations/timing-model/timelines/timelines.html": [
    "29d7fe91c355fc22f563ca17315d2ab493dc0566",
    "testharness"
   ],
+  "web-animations/timing-model/timelines/update-and-send-events.html": [
+   "1f60aba2afa57960f17f19aa5ea1ff0370ec0b74",
+   "testharness"
+  ],
   "web-nfc/OWNERS": [
    "d42f3f15d00686bf5a5c7c69169ef5cf2554bd7b",
    "support"
   ],
   "web-nfc/idlharness.https.html": [
    "4e939e8328c0fa1ffde6a0e5a259fc790db84551",
    "testharness"
   ],
--- a/testing/web-platform/mozilla/meta/MANIFEST.json
+++ b/testing/web-platform/mozilla/meta/MANIFEST.json
@@ -1039,17 +1039,17 @@
   }
  },
  "paths": {
   "./placeholder": [
    "74e16eb87ecdfeb2dfc28f36e0c73a584abdf9c2",
    "support"
   ],
   "baselinecoverage/wpt_baselinecoverage.html": [
-   "7bb4029a6718dbdb06b33fb2d86aef3e7344e20e",
+   "86c9040e69d1b7eab0106fa49bd4582549be99a6",
    "testharness"
   ],
   "binast/insecure.html": [
    "04b9d7807d0adf6a471d0621f18ddf20010fdac1",
    "testharness"
   ],
   "binast/large.binjs": [
    "b83da2863c47d8cedec22bf136fbea077ba8a86b",
new file mode 100644
--- /dev/null
+++ b/testing/web-platform/tests/web-animations/timing-model/timelines/update-and-send-events.html
@@ -0,0 +1,191 @@
+<!doctype html>
+<meta charset=utf-8>
+<title>Update animations and send events</title>
+<link rel="help" href="https://drafts.csswg.org/web-animations/#update-animations-and-send-events">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="../../testcommon.js"></script>
+<div id="log"></div>
+<script>
+'use strict';
+
+promise_test(async t => {
+  const div = createDiv(t);
+  const animation = div.animate(null, 100 * MS_PER_SEC);
+
+  // The ready promise should be resolved as part of micro-task checkpoint
+  // after updating the current time of all timeslines in the procedure to
+  // "update animations and send events".
+  await animation.ready;
+
+  let rAFReceived = false;
+  requestAnimationFrame(() => {
+    rAFReceived = true;
+  });
+
+  const eventWatcher = new EventWatcher(t, animation, 'cancel');
+  animation.cancel();
+
+  await eventWatcher.wait_for('cancel');
+
+  assert_false(rAFReceived,
+    'cancel event should be fired before requestAnimationFrame');
+}, 'Fires cancel event before requestAnimationFrame');
+
+promise_test(async t => {
+  const div = createDiv(t);
+  const animation = div.animate(null, 100 * MS_PER_SEC);
+
+  // Like the above test, the ready promise should be resolved micro-task
+  // checkpoint after updating the current time of all timeslines in the
+  // procedure to "update animations and send events".
+  await animation.ready;
+
+  let rAFReceived = false;
+  requestAnimationFrame(() => {
+    rAFReceived = true;
+  });
+
+  const eventWatcher = new EventWatcher(t, animation, 'finish');
+  animation.finish();
+
+  await eventWatcher.wait_for('finish');
+
+  assert_false(rAFReceived,
+    'finish event should be fired before requestAnimationFrame');
+}, 'Fires finish event before requestAnimationFrame');
+
+function animationType(anim) {
+  if (anim instanceof CSSAnimation) {
+    return 'CSSAnimation';
+  } else if (anim instanceof CSSTransition) {
+    return 'CSSTransition';
+  } else {
+    return 'ScriptAnimation';
+  }
+}
+
+promise_test(async t => {
+  createStyle(t, { '@keyframes anim': '' });
+  const div = createDiv(t);
+
+  getComputedStyle(div).marginLeft;
+  div.style = 'animation: anim 100s; ' +
+              'transition: margin-left 100s; ' +
+              'margin-left: 100px;';
+  div.animate(null, 100 * MS_PER_SEC);
+  const animations = div.getAnimations();
+
+  let receivedEvents = [];
+  animations.forEach(anim => {
+    anim.onfinish = event => {
+      receivedEvents.push({
+        type: animationType(anim) + ':' + event.type,
+        timeStamp: event.timeStamp
+      });
+    };
+  });
+
+  await Promise.all(animations.map(anim => anim.ready));
+
+  // Setting current time to the time just before the effect end.
+  animations.forEach(anim => anim.currentTime = 100 * MS_PER_SEC - 1);
+
+  await waitForNextFrame();
+
+  assert_array_equals(receivedEvents.map(event => event.type),
+    [ 'CSSTransition:finish', 'CSSAnimation:finish',
+      'ScriptAnimation:finish' ],
+    'finish events for various animation type should be sorted by composite ' +
+    'order');
+}, 'Sorts finish events by composite order');
+
+promise_test(async t => {
+  createStyle(t, { '@keyframes anim': '' });
+  const div = createDiv(t);
+
+  let receivedEvents = [];
+  function receiveEvent(type, timeStamp) {
+    receivedEvents.push({ type, timeStamp });
+  }
+
+  div.onanimationcancel = event => {
+    receiveEvent(event.type, event.timeStamp);
+  };
+  div.ontransitioncancel = event => {
+    receiveEvent(event.type, event.timeStamp);
+  };
+
+  getComputedStyle(div).marginLeft;
+  div.style = 'animation: anim 100s; ' +
+              'transition: margin-left 100s; ' +
+              'margin-left: 100px;';
+  div.animate(null, 100 * MS_PER_SEC);
+  const animations = div.getAnimations();
+
+  animations.forEach(anim => {
+    anim.oncancel = event => {
+      receiveEvent(animationType(anim) + ':' + event.type, event.timeStamp);
+    };
+  });
+
+  await Promise.all(animations.map(anim => anim.ready));
+
+  const timeInAnimationReady = document.timeline.currentTime;
+
+  // Call cancel() in reverse composite order.  I.e. canceling for script
+  // animation happen first, then for CSS animation and CSS transition.
+  // 'cancel' events for these animations should be sorted by composite
+  // order.
+  animations.reverse().forEach(anim => anim.cancel());
+
+  // requestAnimationFrame callback which is actually the _same_ frame since we
+  // are currently operating in the `ready` callbac of the animations which
+  // happens as part of the "Update animations and send events" procedure
+  // _before_ we run animation frame callbacks.
+  await waitForAnimationFrames(1);
+
+  assert_times_equal(timeInAnimationReady, document.timeline.currentTime,
+    'A rAF callback should happen in the same frame');
+
+  assert_array_equals(receivedEvents.map(event => event.type),
+    // This ordering needs more clarification in the spec, but the intention is
+    // that the cancel playback event fires before the equivalent CSS cancel
+    // event in each case.
+    [ 'CSSTransition:cancel', 'CSSAnimation:cancel', 'ScriptAnimation:cancel',
+      'transitioncancel', 'animationcancel' ],
+    'cancel events should be sorted by composite order');
+}, 'Sorts cancel events by composite order');
+
+promise_test(async t => {
+  const div = createDiv(t);
+  getComputedStyle(div).marginLeft;
+  div.style = 'transition: margin-left 100s; margin-left: 100px;';
+  const anim = div.getAnimations()[0];
+
+  let receivedEvents = [];
+  anim.oncancel = event => {
+    receivedEvents.push(event);
+  };
+
+  const eventWatcher = new EventWatcher(t, div, 'transitionstart');
+  await eventWatcher.wait_for('transitionstart');
+
+  const timeInEventCallback = document.timeline.currentTime;
+
+  // Calling cancel() queues a cancel event
+  anim.cancel();
+
+  await waitForAnimationFrames(1);
+  assert_times_equal(timeInEventCallback, document.timeline.currentTime,
+    'A rAF callback should happen in the same frame');
+
+  assert_array_equals(receivedEvents, [],
+    'The queued cancel event shouldn\'t be dispatched in the same frame');
+
+  await waitForAnimationFrames(1);
+  assert_array_equals(receivedEvents.map(event => event.type), ['cancel'],
+    'The cancel event should be dispatched in a later frame');
+}, 'Queues a cancel event in transitionstart event callback');
+
+</script>