commit 7dcfda0c9f7f38cc4d445a084ce32b2db998d560
parent 9c351986e95f8990c5ab09fcf74b01240ce442ab
Author: Nicolas Chevobbe <nchevobbe@mozilla.com>
Date: Thu, 20 Nov 2025 12:57:11 +0000
Bug 1995296 - [devtools] Display View Transition in the Animations panel. r=devtools-reviewers,ochameau.
This patch allows the animations panel to display view transition animations.
The panel usually display all the animations that are running for the selected
node and its subtree; but for view transition, even if a "deep" view transition
pseudo element node is selected (e.g. `::view-transition-new(root)`), we'll
always display all the animation running in the ::view-transition node and its
subtree. This way when the user scrubs through the animation, it replays the
transition as a whole, and you can't control only part of it.
This required us to properly look for the view transition node in the `AnimationPlayerActor`,
as well as fix the filtering done in `AnimationActor#onAnimationMutation` to
avoid considering animations on different pseudo elements but with the same
binding element, and with same animation name, as being equal.
A client test is added to ensure everything works as expected.
Differential Revision: https://phabricator.services.mozilla.com/D270296
Diffstat:
4 files changed, 294 insertions(+), 40 deletions(-)
diff --git a/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js b/devtools/client/inspector/animation/test/browser_animation_pseudo-element.js
@@ -30,26 +30,95 @@ const TEST_DATA = [
expectedTargetLabel: "::marker",
expectedAnimationNameLabel: "div-marker",
},
+ {
+ expectedTargetLabel: "::view-transition-group(root)",
+ expectedAnimationNameLabel: "-ua-view-transition-group-anim-root",
+ },
+ {
+ expectedTargetLabel: "::view-transition-group(my-vt)",
+ expectedAnimationNameLabel: "my-vt-animation",
+ },
+ {
+ expectedTargetLabel: "::view-transition-old(root)",
+ expectedAnimationNameLabel: "-ua-mix-blend-mode-plus-lighter",
+ },
+ {
+ expectedTargetLabel: "::view-transition-old(root)",
+ expectedAnimationNameLabel: "-ua-view-transition-fade-out",
+ },
+ {
+ expectedTargetLabel: "::view-transition-new(root)",
+ expectedAnimationNameLabel: "-ua-mix-blend-mode-plus-lighter",
+ },
+ {
+ expectedTargetLabel: "::view-transition-new(root)",
+ expectedAnimationNameLabel: "-ua-view-transition-fade-in",
+ },
+ {
+ expectedTargetLabel: "::view-transition-old(my-vt)",
+ expectedAnimationNameLabel: "-ua-mix-blend-mode-plus-lighter",
+ },
+ {
+ expectedTargetLabel: "::view-transition-old(my-vt)",
+ expectedAnimationNameLabel: "-ua-view-transition-fade-out",
+ },
+ {
+ expectedTargetLabel: "::view-transition-new(my-vt)",
+ expectedAnimationNameLabel: "-ua-mix-blend-mode-plus-lighter",
+ },
+ {
+ expectedTargetLabel: "::view-transition-new(my-vt)",
+ expectedAnimationNameLabel: "-ua-view-transition-fade-in",
+ },
];
add_task(async function () {
await addTab(URL_ROOT + "doc_pseudo.html");
+
const { animationInspector, inspector, panel } =
await openAnimationInspector();
- info("Checking count of animation item for pseudo elements");
- is(
- panel.querySelectorAll(".animation-list .animation-item").length,
- TEST_DATA.length,
- `Count of animation item should be ${TEST_DATA.length}`
- );
+ // Select the html node so we can see ::view-transition animations
+ await selectNode("html", inspector);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ const document = content.document;
+ const transition = document.startViewTransition(() => {
+ document.querySelector(".div-view-transition").append("world");
+ });
+ await transition.ready;
+ await transition.updateCallbackDone;
+ });
+
+ info("Waiting for expected animations to be displayed");
+ try {
+ await waitFor(
+ () =>
+ panel.querySelectorAll(".animation-list .animation-item").length ===
+ TEST_DATA.length
+ );
+ ok(
+ true,
+ `Got expectedCount of animation item should be ${TEST_DATA.length}`
+ );
+ } catch (e) {
+ ok(
+ false,
+ `Didn't get expected number of animations. Got ${panel.querySelectorAll(".animation-list .animation-item").length} expected ${TEST_DATA.length}`
+ );
+ }
info("Checking content of each animation item");
for (let i = 0; i < TEST_DATA.length; i++) {
const testData = TEST_DATA[i];
- info(`Checking pseudo element for ${testData.expectedTargetLabel}`);
+ info(`Checking pseudo element for animation at index #${i}`);
const animationItemEl = await findAnimationItemByIndex(panel, i);
+ if (!animationItemEl) {
+ ok(false, `Didn't find an animation at index #${i}`);
+ continue;
+ }
+
info("Checking text content of animation target");
const animationTargetEl = animationItemEl.querySelector(
".animation-list .animation-item .animation-target"
@@ -57,7 +126,7 @@ add_task(async function () {
is(
animationTargetEl.textContent,
testData.expectedTargetLabel,
- `Text content of animation target[${i}] should be ${testData.expectedTarget}`
+ `Got expected target for animation at index #${i}`
);
info("Checking text content of animation name");
@@ -65,7 +134,7 @@ add_task(async function () {
is(
animationNameEl.textContent,
testData.expectedAnimationNameLabel,
- `The animation name should be ${testData.expectedAnimationNameLabel}`
+ `Got expected animation name for animation at index #${i}`
);
}
@@ -85,7 +154,8 @@ add_task(async function () {
TEST_DATA[0].expectedKeyframsGraphPathSegments
);
- info("Select <body> again to reset the animation list");
+ // Select `body` only so we avoid having all the view transition items
+ info("Select <body> to reset the animation list");
await selectNode("body", inspector);
info(
@@ -101,6 +171,55 @@ add_task(async function () {
panel,
TEST_DATA[1].expectedKeyframsGraphPathSegments
);
+
+ info(
+ "Check that view-transition node can be selected from the animation panel"
+ );
+ info("Select <html> to reset the animation list");
+ await selectNode("html", inspector);
+ // wait for all the animations to be displayed again
+ await waitFor(
+ () =>
+ panel.querySelectorAll(".animation-list .animation-item").length ===
+ TEST_DATA.length
+ );
+
+ onDetailRendered = animationInspector.once("animation-keyframes-rendered");
+ await clickOnTargetNodeByTargetText(
+ animationInspector,
+ panel,
+ "::view-transition-group(my-vt)"
+ );
+
+ // we get all the view transition animations, even those on elements who are not children
+ // of the currently selected node
+ const expectedTargets = [
+ "::view-transition-group(my-vt)",
+ "::view-transition-group(root)",
+ "::view-transition-old(my-vt)",
+ "::view-transition-old(my-vt)",
+ "::view-transition-old(root)",
+ "::view-transition-old(root)",
+ "::view-transition-new(my-vt)",
+ "::view-transition-new(my-vt)",
+ "::view-transition-new(root)",
+ "::view-transition-new(root)",
+ ];
+ await waitFor(() =>
+ [
+ ...panel.querySelectorAll(
+ ".animation-list .animation-item .animation-target .attrName"
+ ),
+ ]
+ .map(attrNameEl => attrNameEl.textContent)
+ .every((targetElText, index) => targetElText === expectedTargets[index])
+ );
+
+ is(
+ inspector.selection.nodeFront.displayName,
+ "::view-transition-group(my-vt)",
+ "Expected view transition pseudo element node was selected"
+ );
});
function assertAnimationCount(panel, expectedCount) {
diff --git a/devtools/client/inspector/animation/test/doc_pseudo.html b/devtools/client/inspector/animation/test/doc_pseudo.html
@@ -4,21 +4,21 @@
<meta charset="UTF-8">
<style>
body::before {
- animation: body 10s infinite;
+ animation: body 100s infinite;
background-color: lime;
content: "body-before";
width: 100px;
}
.div-before::before {
- animation: div-before 10s infinite;
+ animation: div-before 100s infinite;
background-color: lime;
content: "div-before";
width: 100px;
}
.div-after::after {
- animation: div-after 10s infinite;
+ animation: div-after 100s infinite;
background-color: lime;
content: "div-after";
width: 100px;
@@ -33,6 +33,21 @@
content: "div-marker";
}
+ ::view-transition-group(*),
+ ::view-transition-old(*),
+ ::view-transition-new(*) {
+ /* large number so the view-transition pseudo elements are available during the whole test */
+ animation-duration: 3600s;
+ }
+
+ ::view-transition-group(my-vt) {
+ animation-name: my-vt-animation;
+ }
+
+ .div-view-transition {
+ view-transition-name: my-vt;
+ }
+
@keyframes body {
from {
opacity: 0;
@@ -62,12 +77,22 @@
opacity: 0;
}
}
+
+ @keyframes my-vt-animation {
+ from {
+ rotate: 360deg;
+ }
+ to {
+ opacity: 0deg;
+ }
+ }
</style>
</head>
<body>
<div class="div-before"></div>
<div class="div-after"></div>
<div class="div-marker"></div>
+ <div class="div-view-transition">Hello</div>
<script>
"use strict";
diff --git a/devtools/client/inspector/animation/test/head.js b/devtools/client/inspector/animation/test/head.js
@@ -316,6 +316,41 @@ const clickOnTargetNode = async function (animationInspector, panel, index) {
};
/**
+ * Click on the target node for the given AnimationTargetComponent target element text
+ *
+ * @param {AnimationInspector} animationInspector.
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {string} targetText
+ * text displayed to represent the animation target in the panel.
+ */
+const clickOnTargetNodeByTargetText = async function (
+ animationInspector,
+ panel,
+ targetText
+) {
+ const { inspector } = animationInspector;
+ const { waitForHighlighterTypeShown } = getHighlighterTestHelpers(inspector);
+ info(`Click on a target node in animation target "${targetText}"`);
+
+ const animationItemEl = await findAnimationItemByTargetText(
+ panel,
+ targetText
+ );
+ if (!animationItemEl) {
+ throw new Error(`Couln't find target "${targetText}"`);
+ }
+ const targetEl = animationItemEl.querySelector(
+ ".animation-target .objectBox"
+ );
+ const onHighlight = waitForHighlighterTypeShown(
+ inspector.highlighters.TYPES.BOXMODEL
+ );
+ EventUtils.synthesizeMouseAtCenter(targetEl, {}, targetEl.ownerGlobal);
+ await onHighlight;
+};
+
+/**
* Drag on the scrubber to update the animation current time.
*
* @param {DOMElement} panel
@@ -847,6 +882,10 @@ function isPassingThrough(pathData, x, y) {
async function findAnimationItemByIndex(panel, index) {
const itemEls = [...panel.querySelectorAll(".animation-item")];
const itemEl = itemEls[index];
+ if (!itemEl) {
+ return null;
+ }
+
itemEl.scrollIntoView(false);
await waitUntil(
@@ -891,6 +930,35 @@ async function findAnimationItemByTargetSelector(panel, selector) {
}
/**
+ * Return animation item element by given target element text of animation.
+ *
+ * @param {DOMElement} panel
+ * #animation-container element.
+ * @param {string} targetText
+ * text displayed to represent the animation target in the panel.
+ * @return {DOMElement|null}
+ * Animation item element.
+ */
+async function findAnimationItemByTargetText(panel, targetText) {
+ for (const itemEl of panel.querySelectorAll(".animation-item")) {
+ itemEl.scrollIntoView(false);
+
+ await waitUntil(
+ () =>
+ itemEl.querySelector(".animation-target .attrName") &&
+ itemEl.querySelector(".animation-computed-timing-path")
+ );
+
+ const attrNameEl = itemEl.querySelector(".animation-target .attrName");
+ if (attrNameEl.textContent.trim() === targetText) {
+ return itemEl;
+ }
+ }
+
+ return null;
+}
+
+/**
* Find the <stop> element which has the given offset in the given linearGradientEl.
*
* @param {Element} linearGradientEl
diff --git a/devtools/server/actors/animation.js b/devtools/server/actors/animation.js
@@ -35,6 +35,13 @@ const {
ANIMATION_TYPE_FOR_LONGHANDS,
} = require("resource://devtools/server/actors/animation-type-longhand.js");
+loader.lazyRequireGetter(
+ this,
+ "getNodeDisplayName",
+ "resource://devtools/server/actors/inspector/utils.js",
+ true
+);
+
// Types of animations.
const ANIMATION_TYPES = {
CSS_ANIMATION: "cssanimation",
@@ -83,16 +90,19 @@ class AnimationPlayerActor extends Actor {
this.walker = animationsActor.walker;
this.player = player;
+ // getting the node might need to traverse the DOM, let's only do this once, when
+ // the Actor gets created
+ this.node = this.getNode();
// Listen to animation mutations on the node to alert the front when the
// current animation changes.
- // If the node is a pseudo-element, then we listen on its parent with
- // subtree:true (there's no risk of getting too many notifications in
- // onAnimationMutation since we filter out events that aren't for the
- // current animation).
this.observer = new this.window.MutationObserver(this.onAnimationMutation);
if (this.isPseudoElement) {
- this.observer.observe(this.node.parentElement, {
+ // If the node is a pseudo-element, then we listen on its binding element (which is
+ // this.player.effect.target here), with `subtree:true` (there's no risk of getting
+ // too many notifications in onAnimationTargetMutation since we filter out events
+ // that aren't for the current animation).
+ this.observer.observe(this.player.effect.target, {
animations: true,
subtree: true,
});
@@ -119,23 +129,11 @@ class AnimationPlayerActor extends Actor {
return !!this.player.effect.pseudoElement;
}
- get pseudoElemenName() {
- if (!this.isPseudoElement) {
- return null;
- }
-
- return `_moz_generated_content_${this.player.effect.pseudoElement.replace(
- /^::/,
- ""
- )}`;
- }
-
- get node() {
+ getNode() {
if (!this.isPseudoElement) {
return this.player.effect.target;
}
- const pseudoElementName = this.pseudoElemenName;
const originatingElem = this.player.effect.target;
const treeWalker = this.walker.getDocumentWalker(originatingElem);
@@ -144,9 +142,15 @@ class AnimationPlayerActor extends Actor {
for (
let next = treeWalker.firstChild();
next;
- next = treeWalker.nextSibling()
+ // Use `nextNode` (and not `nextSibling`) as we might need to traverse the whole
+ // children tree to find nested elements (e.g. `::view-transition-group(root)`).
+ next = treeWalker.nextNode()
) {
- if (next.nodeName === pseudoElementName) {
+ if (!next.implementedPseudoElement) {
+ continue;
+ }
+
+ if (this.player.effect.pseudoElement === getNodeDisplayName(next)) {
return next;
}
}
@@ -154,11 +158,12 @@ class AnimationPlayerActor extends Actor {
console.warn(
`Pseudo element ${this.player.effect.pseudoElement} is not found`
);
- return originatingElem;
+
+ return null;
}
get document() {
- return this.node.ownerDocument;
+ return this.player.effect.target.ownerDocument;
}
get window() {
@@ -376,7 +381,7 @@ class AnimationPlayerActor extends Actor {
// The document timeline's currentTime is being sent along too. This is
// not strictly related to the node's animationPlayer, but is useful to
// know the current time of the animation with respect to the document's.
- documentCurrentTime: this.node.ownerDocument.timeline.currentTime,
+ documentCurrentTime: this.document.timeline.currentTime,
// The time which this animation created.
createdTime: this.createdTime,
// The time which an animation's current time when this animation has created.
@@ -660,7 +665,16 @@ exports.AnimationsActor = class AnimationsActor extends Actor {
* /devtools/server/actors/inspector
*/
getAnimationPlayersForNode(nodeActor) {
- const animations = nodeActor.rawNode.getAnimations({ subtree: true });
+ let { rawNode } = nodeActor;
+
+ // If the selected node is a ::view-transition child, we want to show all the view-transition
+ // animations so the user can't play only "parts" of the transition.
+ const viewTransitionNode = this.#closestViewTransitionNode(rawNode);
+ if (viewTransitionNode) {
+ rawNode = viewTransitionNode;
+ }
+
+ const animations = rawNode.getAnimations({ subtree: true });
// Destroy previously stored actors
if (this.actors) {
@@ -684,9 +698,9 @@ exports.AnimationsActor = class AnimationsActor extends Actor {
this.stopAnimationPlayerUpdates();
// ownerGlobal doesn't exist in content privileged windows.
// eslint-disable-next-line mozilla/use-ownerGlobal
- const win = nodeActor.rawNode.ownerDocument.defaultView;
+ const win = rawNode.ownerDocument.defaultView;
this.observer = new win.MutationObserver(this.onAnimationMutation);
- this.observer.observe(nodeActor.rawNode, {
+ this.observer.observe(rawNode, {
animations: true,
subtree: true,
});
@@ -694,6 +708,32 @@ exports.AnimationsActor = class AnimationsActor extends Actor {
return this.actors;
}
+ /**
+ * Returns the passed node closest ::view-transition node if it exists, null otherwise
+ *
+ * @param {Element} rawNode
+ * @returns {Element|null}
+ */
+ #closestViewTransitionNode(rawNode) {
+ const { implementedPseudoElement } = rawNode;
+ if (
+ !implementedPseudoElement ||
+ !implementedPseudoElement?.startsWith("::view-transition")
+ ) {
+ return null;
+ }
+ // Look up for the root ::view-transition node
+ while (
+ rawNode &&
+ rawNode.implementedPseudoElement &&
+ rawNode.implementedPseudoElement !== "::view-transition"
+ ) {
+ rawNode = rawNode.parentElement;
+ }
+
+ return rawNode;
+ }
+
onAnimationMutation(mutations) {
const eventData = [];
const readyPromises = [];
@@ -737,7 +777,9 @@ exports.AnimationsActor = class AnimationsActor extends Actor {
a.player.animationName === player.animationName) ||
(a.isCssTransition() &&
a.player.transitionProperty === player.transitionProperty);
- const isSameNode = a.player.effect.target === player.effect.target;
+ const isSameNode =
+ a.player.effect.target === player.effect.target &&
+ a.player.effect.pseudoElement === player.effect.pseudoElement;
return isSameType && isSameNode && isSameName;
});