tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mdevtools/client/inspector/animation/test/browser_animation_pseudo-element.js | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mdevtools/client/inspector/animation/test/doc_pseudo.html | 31++++++++++++++++++++++++++++---
Mdevtools/client/inspector/animation/test/head.js | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/server/actors/animation.js | 96+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
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; });