tor-browser

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

commit f138b617b8c25f48403acfecac00f700acc02455
parent d0e113a6419211369bb592557e29c4b311ad0c81
Author: Nicolas Chevobbe <nchevobbe@mozilla.com>
Date:   Thu, 13 Nov 2025 09:46:37 +0000

Bug 1996608 - [devtools] Display ::view-transition* pseudo elements in markup view. r=devtools-reviewers,ochameau.

This handles all the anonymous content pseudo elements for view transition.
Those elements are a bit different from the pseudo elements we had to handle
until now.
First, they don't have a special nodeName (e.g. for `::before`, we have a `_moz_generated_content_before`
nodeName).
Instead, they have a type attribute, and a name attribute (except `::view-transition`).
So we need to check the type attribute to see if those elements are view transition
pseudo elements.
Those elements also have a common root, `<div type=":-moz-snapshot-containing-block">`
that we don't want to display in the markup view, as its' not exposed in CSS, it's
just an implementation detail.
To handle that, we add a new walker filter result, `FILTER_ACCEPT_CHILDREN`, that
will not be returned, but will have its children evaluated.
This impact the `WalkerActor#rawParentNode` function as we don't want to return
nodes that would have been ignored.

A few test cases are added to make sure the pseudo elements are properly displayed
in the markup view, in the breadcrumb and in the highlighter status bar.

Differential Revision: https://phabricator.services.mozilla.com/D270294

Diffstat:
Mdevtools/client/inspector/markup/test/browser.toml | 2++
Adevtools/client/inspector/markup/test/browser_markup_pseudo_view-transition.js | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/inspector/markup/test/browser_markup_search_01.js | 43+++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/inspector/markup/test/doc_markup_search.css | 6++++++
Mdevtools/client/inspector/test/browser_inspector_breadcrumbs.js | 136++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mdevtools/client/inspector/test/doc_inspector_breadcrumbs.html | 5+++++
Mdevtools/client/inspector/test/head.js | 7+++----
Mdevtools/client/inspector/test/highlighter/browser_inspector_highlighter-02.js | 77+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdevtools/client/inspector/test/highlighter/doc_inspector_highlighter.html | 5+++++
Mdevtools/server/actors/inspector/utils.js | 30++++++++++++++++++++++++++----
Mdevtools/server/actors/inspector/walker.js | 30++++++++++++++++++++++++++++--
Mdevtools/shared/dom-node-filter-constants.js | 1+
Mdevtools/shared/inspector/css-logic.js | 16+++++++++++++---
13 files changed, 424 insertions(+), 25 deletions(-)

diff --git a/devtools/client/inspector/markup/test/browser.toml b/devtools/client/inspector/markup/test/browser.toml @@ -190,6 +190,8 @@ skip-if = [ ["browser_markup_pseudo.js"] +["browser_markup_pseudo_view-transition.js"] + ["browser_markup_remove_xul_attributes.js"] ["browser_markup_screenshot_node.js"] diff --git a/devtools/client/inspector/markup/test/browser_markup_pseudo_view-transition.js b/devtools/client/inspector/markup/test/browser_markup_pseudo_view-transition.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const TEST_URL = `data:text/html,<!DOCTYPE html><meta charset=utf8>${encodeURIComponent(` + <style> + ::view-transition-group(root) { + /* large number so the view-transition pseudo elements are available during the whole test */ + animation-duration: 3600s; + } + header { + view-transition-name: main-header; + } + + header h1 { + view-transition-name: main-header-text; + } + </style> + <header> + <h1>::view-transition</h1> + </header> +`)}`; + +add_task(async function () { + const { inspector } = await openInspectorForURL(TEST_URL); + + await selectNode("html", inspector); + + const onMarkupMutation = inspector.once("markupmutation"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const document = content.document; + content.testTransition = document.startViewTransition(() => { + document.querySelector("h1").replaceChildren("updated"); + }); + await content.testTransition.ready; + await content.testTransition.updateCallbackDone; + }); + await onMarkupMutation; + + const htmlNodeFront = await getNodeFront("html", inspector); + const htmlContainer = await getContainerForNodeFront( + htmlNodeFront, + inspector + ); + + const viewTransitionMarkupNodeEl = htmlContainer.children.childNodes[2]; + is( + viewTransitionMarkupNodeEl.textContent, + "::view-transition", + "::view-transition node is displayed" + ); + + const viewTransitionContainer = viewTransitionMarkupNodeEl.container; + is( + viewTransitionContainer.type, + "readonlycontainer", + "The ::view-transition container is read-only" + ); + + info("Expand the whole ::view-transition subtree"); + await toggleContainerByClick(inspector, viewTransitionContainer, { + altKey: true, + }); + + const tree = ` + html + head!ignore-children + body!ignore-children + ::view-transition + ::view-transition-group(root) + ::view-transition-image-pair(root) + ::view-transition-old(root) + ::view-transition-new(root) + ::view-transition-group(main-header) + ::view-transition-image-pair(main-header) + ::view-transition-old(main-header) + ::view-transition-new(main-header) + ::view-transition-group(main-header-text) + ::view-transition-image-pair(main-header-text) + ::view-transition-old(main-header-text) + ::view-transition-new(main-header-text) + `.trim(); + await assertMarkupViewAsTree(tree, "html", inspector); + + // Cancel transition + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.testTransition.skipTransition(); + delete content.testTransition; + }); +}); diff --git a/devtools/client/inspector/markup/test/browser_markup_search_01.js b/devtools/client/inspector/markup/test/browser_markup_search_01.js @@ -259,6 +259,49 @@ add_task(async function () { ); // no highlighting as the `content` text isn't displayed in the markup view checkHighlightedSearchResults(inspector, []); + + info("Search for view-transition pseudo elements"); + // Trigger the view transition + const onMarkupMutation = inspector.once("markupmutation"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const document = content.document; + content.testTransition = document.startViewTransition(() => { + document.querySelector(".pseudos").replaceChildren("updated"); + }); + await content.testTransition.ready; + await content.testTransition.updateCallbackDone; + }); + await onMarkupMutation; + + await searchInMarkupView(inspector, "::view-transition"); + is( + inspector.selection.nodeFront.displayName, + "::view-transition", + "The ::view-transition element is selected" + ); + checkHighlightedSearchResults(inspector, ["::view-transition"]); + + await searchInMarkupView(inspector, "::view-transition-old(root)"); + is( + inspector.selection.nodeFront.displayName, + "::view-transition-old(root)", + "The ::view-transition-old(root) element is selected" + ); + checkHighlightedSearchResults(inspector, ["::view-transition-old(root)"]); + + await searchInMarkupView(inspector, "::view-transition-new(custom)"); + is( + inspector.selection.nodeFront.displayName, + "::view-transition-new(custom)", + "The ::view-transition-new(custom) element is selected" + ); + checkHighlightedSearchResults(inspector, ["::view-transition-new(custom)"]); + + // Cancel transition + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.testTransition.skipTransition(); + delete content.testTransition; + }); }); function checkHighlightedSearchResults(inspector, expectedHighlights) { diff --git a/devtools/client/inspector/markup/test/doc_markup_search.css b/devtools/client/inspector/markup/test/doc_markup_search.css @@ -1,4 +1,5 @@ .pseudos { + view-transition-name: custom; &::before { content: "my_before_text"; } @@ -6,3 +7,8 @@ content: "my_after_text"; } } + +::view-transition-group(root) { + /* large number so the view-transition pseudo elements are available during the whole test */ + animation-duration: 3600s; +} diff --git a/devtools/client/inspector/test/browser_inspector_breadcrumbs.js b/devtools/client/inspector/test/browser_inspector_breadcrumbs.js @@ -116,29 +116,141 @@ add_task(async function () { async function testPseudoElements(inspector, container) { info("Checking for pseudo elements"); + const checkBreadcrumbContent = async (nodeFront, expected, desc) => { + const onBreadcrumbsUpdated = inspector.once("breadcrumbs-updated"); + await selectNode(nodeFront, inspector); + await onBreadcrumbsUpdated; + Assert.deepEqual( + [...container.childNodes].map(el => el.textContent), + expected, + desc + ); + }; + const pseudoParent = await getNodeFront("#pseudo-container", inspector); const children = await inspector.walker.children(pseudoParent); is(children.nodes.length, 2, "Pseudo children returned from walker"); const beforeElement = children.nodes[0]; - let breadcrumbsUpdated = inspector.once("breadcrumbs-updated"); - await selectNode(beforeElement, inspector); - await breadcrumbsUpdated; - is( - container.childNodes[3].textContent, - "::before", + await checkBreadcrumbContent( + beforeElement, + ["html", "body", "div#pseudo-container", "::before"], "::before shows up in breadcrumb" ); const afterElement = children.nodes[1]; - breadcrumbsUpdated = inspector.once("breadcrumbs-updated"); - await selectNode(afterElement, inspector); - await breadcrumbsUpdated; + await checkBreadcrumbContent( + afterElement, + ["html", "body", "div#pseudo-container", "::after"], + "::after shows up in breadcrumb" + ); + + info("Check rules on ::view-transition"); + const htmlNodeFront = await getNodeFront("html", inspector); + const onMarkupMutation = inspector.once("markupmutation"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const document = content.document; + content.testTransition = document.startViewTransition(); + await content.testTransition; + }); + await onMarkupMutation; + + const htmlChildren = await inspector.markup.walker.children(htmlNodeFront); + const viewTransitionNodeFront = htmlChildren.nodes[2]; + is( - container.childNodes[3].textContent, - "::after", - "::before shows up in breadcrumb" + viewTransitionNodeFront.getAttribute("type"), + ":view-transition", + "Got expected ::view-transition node front" + ); + + await checkBreadcrumbContent( + viewTransitionNodeFront, + ["html", "::view-transition"], + "::view-transition shows up in breadcrumb" + ); + + const viewTransitionChildren = await inspector.markup.walker.children( + viewTransitionNodeFront + ); + const viewTransitionGroupNodeFront = viewTransitionChildren.nodes[0]; + is( + viewTransitionGroupNodeFront.getAttribute("type"), + ":view-transition-group", + "Got expected ::view-transition-group node front" + ); + + await checkBreadcrumbContent( + viewTransitionGroupNodeFront, + ["html", "::view-transition", "::view-transition-group(root)"], + "::view-transition-group(root) shows up in breadcrumb" ); + + const viewTransitionGroupChildren = await inspector.markup.walker.children( + viewTransitionGroupNodeFront + ); + const viewTransitionImagePairNodeFront = viewTransitionGroupChildren.nodes[0]; + is( + viewTransitionImagePairNodeFront.getAttribute("type"), + ":view-transition-image-pair", + "Got expected ::view-transition-image-pair node front" + ); + + await checkBreadcrumbContent( + viewTransitionImagePairNodeFront, + [ + "html", + "::view-transition", + "::view-transition-group(root)", + "::view-transition-image-pair(root)", + ], + "::view-transition-image-pair(root) shows up in breadcrumb" + ); + + const viewTransitionImagePairChildren = + await inspector.markup.walker.children(viewTransitionImagePairNodeFront); + const [viewTransitionOldNodeFront, viewTransitionNewNodeFront] = + viewTransitionImagePairChildren.nodes; + is( + viewTransitionOldNodeFront.getAttribute("type"), + ":view-transition-old", + "Got expected ::view-transition-old node front" + ); + is( + viewTransitionNewNodeFront.getAttribute("type"), + ":view-transition-new", + "Got expected ::view-transition-new node front" + ); + + await checkBreadcrumbContent( + viewTransitionOldNodeFront, + [ + "html", + "::view-transition", + "::view-transition-group(root)", + "::view-transition-image-pair(root)", + "::view-transition-old(root)", + ], + "::view-transition-old(root) shows up in breadcrumb" + ); + + await checkBreadcrumbContent( + viewTransitionNewNodeFront, + [ + "html", + "::view-transition", + "::view-transition-group(root)", + "::view-transition-image-pair(root)", + "::view-transition-new(root)", + ], + "::view-transition-new(root) shows up in breadcrumb" + ); + + // Cancel transition + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.testTransition.skipTransition(); + delete content.testTransition; + }); } async function testComments(inspector, container) { diff --git a/devtools/client/inspector/test/doc_inspector_breadcrumbs.html b/devtools/client/inspector/test/doc_inspector_breadcrumbs.html @@ -13,6 +13,11 @@ #pseudo-container::after { content: 'after'; } + + ::view-transition-group(root) { + /* large number so the view-transition pseudo elements are available during the whole test */ + animation-duration: 3600s; + } </style> </head> <body> diff --git a/devtools/client/inspector/test/head.js b/devtools/client/inspector/test/head.js @@ -1327,8 +1327,7 @@ async function assertMarkupViewAsTree(tree, selector, inspector) { async function _checkMarkupViewNode(treeNode, container, inspector) { const { node, children, path } = treeNode; - info("Checking [" + path + "]"); - info("Checking node: " + node); + info(`Checking [${path}]`); const ignoreChildren = node.includes("!ignore-children"); const slotted = node.includes("!slotted"); @@ -1409,7 +1408,7 @@ function _parseMarkupViewTree(inputString) { children: [], parent, level, - path: parent.path + " " + nodeString, + path: (parent.path ? parent.path + " > " : "") + nodeString, }; parent.children.push(node); @@ -1438,7 +1437,7 @@ function assertContainerHasText(container, expectedText) { const textContent = container.elt.textContent; ok( textContent.includes(expectedText), - "Container has expected text: " + expectedText + `Container has expected text "${expectedText}"${!textContent.includes(expectedText) ? ` - got "${textContent}"` : ""}` ); } diff --git a/devtools/client/inspector/test/highlighter/browser_inspector_highlighter-02.js b/devtools/client/inspector/test/highlighter/browser_inspector_highlighter-02.js @@ -92,6 +92,83 @@ add_task(async function () { (await getHighlighterInfobarText()).startsWith("ul#pseudo::before::marker"), `::before::marker is properly displayed (${await getHighlighterInfobarText()})` ); + + info("Check highlighting for ::view-transition pseudo elements"); + const onMarkupMutation = inspector.once("markupmutation"); + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + const document = content.document; + content.testTransition = document.startViewTransition(() => { + document.querySelector("#simple-div").replaceChildren("updated"); + }); + await content.testTransition.ready; + await content.testTransition.updateCallbackDone; + }); + await onMarkupMutation; + + const htmlNodeFront = await getNodeFront("html", inspector); + const htmlContainer = await getContainerForNodeFront( + htmlNodeFront, + inspector + ); + + const viewTransitionMarkupNodeEl = htmlContainer.children.childNodes[2]; + is( + viewTransitionMarkupNodeEl.textContent, + "::view-transition", + "Got ::view-transition node" + ); + + info("Highlighting the ::view-transition node"); + onHighlighterShown = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + await selectNode( + viewTransitionMarkupNodeEl.container.node, + inspector, + "test-highlight" + ); + await onHighlighterShown; + + ok( + // Make sure the infobar starts with the expected text + (await getHighlighterInfobarText()).startsWith("html::view-transition"), + `::view-transition is properly displayed (${await getHighlighterInfobarText()})` + ); + + info("Expand ::view-transition node"); + await expandContainer(inspector, viewTransitionMarkupNodeEl.container); + const viewTransitionGroupRootEl = + viewTransitionMarkupNodeEl.container.children.childNodes[0]; + is( + viewTransitionGroupRootEl.textContent, + "::view-transition-group(root)", + "Got ::view-transition-group(root) node" + ); + + info("Highlighting the ::view-transition-group(root) node"); + onHighlighterShown = waitForHighlighterTypeShown( + inspector.highlighters.TYPES.BOXMODEL + ); + await selectNode( + viewTransitionGroupRootEl.container.node, + inspector, + "test-highlight" + ); + await onHighlighterShown; + + ok( + // Make sure the infobar starts with the expected text + (await getHighlighterInfobarText()).startsWith( + "html::view-transition-group(root)" + ), + `::view-transition-group(root) is properly displayed (${await getHighlighterInfobarText()})` + ); + + // Cancel transition + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => { + content.testTransition.skipTransition(); + delete content.testTransition; + }); }); async function getHighlighterInfobarText() { diff --git a/devtools/client/inspector/test/highlighter/doc_inspector_highlighter.html b/devtools/client/inspector/test/highlighter/doc_inspector_highlighter.html @@ -47,6 +47,11 @@ content: "-"; color: gold; } + + ::view-transition-group(root) { + /* large number so the view-transition pseudo elements are available during the whole test */ + animation-duration: 3600s; + } </style> </head> <body> diff --git a/devtools/server/actors/inspector/utils.js b/devtools/server/actors/inspector/utils.js @@ -73,6 +73,13 @@ const IMAGE_FETCHING_TIMEOUT = 500; const getNodeDisplayName = function (rawNode) { const { implementedPseudoElement } = rawNode; if (implementedPseudoElement) { + if ( + implementedPseudoElement.startsWith("::view-transition") && + rawNode.hasAttribute("name") + ) { + return `${implementedPseudoElement}(${rawNode.getAttribute("name")})`; + } + return implementedPseudoElement; } @@ -155,10 +162,25 @@ function standardTreeWalkerFilter(node) { : nodeFilterConstants.FILTER_SKIP; } - // Ignore all native anonymous roots inside a non-XUL document. - // We need to do this to skip things like form controls, scrollbars, - // video controls, etc (see bug 1187482). - if (isNativeAnonymous(node) && !isInXULDocument(node)) { + if (node.isNativeAnonymous && !isInXULDocument(node)) { + const nodeTypeAttribute = node.getAttribute && node.getAttribute("type"); + // The ::view-transition pseudo element node has a <div type=":-moz-snapshot-containing-block"> + // parent element that we don't want to display in the markup view. + // Instead, we want to directly display the ::view-transition pseudo-element. + if (nodeTypeAttribute === ":-moz-snapshot-containing-block") { + // FILTER_ACCEPT_CHILDREN means that the node won't be returned, but its children + // will be instead + return nodeFilterConstants.FILTER_ACCEPT_CHILDREN; + } + + // Display all the ::view-transition* nodes + if (nodeTypeAttribute && nodeTypeAttribute.startsWith(":view-transition")) { + return nodeFilterConstants.FILTER_ACCEPT; + } + + // Ignore all other native anonymous roots inside a non-XUL document. + // We need to do this to skip things like form controls, scrollbars, + // video controls, etc (see bug 1187482). return nodeFilterConstants.FILTER_SKIP; } diff --git a/devtools/server/actors/inspector/walker.js b/devtools/server/actors/inspector/walker.js @@ -722,7 +722,26 @@ class WalkerActor extends Actor { if (rawNode == this.rootDoc) { return null; } - return InspectorUtils.getParentForNode(rawNode, /* anonymous = */ true); + const parentNode = InspectorUtils.getParentForNode( + rawNode, + /* anonymous = */ true + ); + + if (!parentNode) { + return null; + } + + const filter = this.showAllAnonymousContent + ? allAnonymousContentTreeWalkerFilter + : standardTreeWalkerFilter; + // If the parent node is one we should ignore (e.g. :-moz-snapshot-containing-block, + // which is the root node for ::view-transition pseudo elements), we want to return + // the closest non-ignored parent. + if (filter(parentNode) === nodeFilterConstants.FILTER_ACCEPT_CHILDREN) { + return this.rawParentNode(parentNode); + } + + return parentNode; } /** @@ -924,8 +943,15 @@ class WalkerActor extends Actor { includeAssigned ); for (const child of children) { - if (filter(child) == nodeFilterConstants.FILTER_ACCEPT) { + const filterResult = filter(child); + if (filterResult == nodeFilterConstants.FILTER_ACCEPT) { ret.push(child); + } else if (filterResult == nodeFilterConstants.FILTER_ACCEPT_CHILDREN) { + // In some cases, we want to completly ignore a node, and display its children + // instead (e.g. for `<div type="::-moz-snapshot-containing-block">`, + // we don't want it displayed in the markup view, + // but we do want to have its `::view-transition` child) + ret.push(...this._rawChildren(child, includeAssigned)); } } return ret; diff --git a/devtools/shared/dom-node-filter-constants.js b/devtools/shared/dom-node-filter-constants.js @@ -8,6 +8,7 @@ module.exports = { FILTER_ACCEPT: 1, FILTER_REJECT: 2, FILTER_SKIP: 3, + FILTER_ACCEPT_CHILDREN: 4, SHOW_ALL: 0xffffffff, SHOW_ELEMENT: 0x00000001, diff --git a/devtools/shared/inspector/css-logic.js b/devtools/shared/inspector/css-logic.js @@ -555,13 +555,17 @@ exports.prettifyCSS = prettifyCSS; * it was. Otherwise, return the node itself. * * @returns {Object} - * - {DOMNode} node The non-anonymous node - * - {string} pseudo One of '::marker', '::before', '::after', or null. + * - {DOMNode} node: The non-anonymous node + * - {string|null} pseudo: The label representing the anonymous node + * (e.g. '::marker', '::before', '::after', '::view-transition', …). + * null if node isn't an anonymous node or isn't handled + * yet. */ function getBindingElementAndPseudo(node) { let bindingElement = node; let pseudo = null; - if (node.implementedPseudoElement) { + const { implementedPseudoElement } = node; + if (implementedPseudoElement) { // we can't return `node.implementedPseudoElement` directly as the property only holds // the pseudo element type (e.g. "::view-transition-group"), while the callsites of this // function need the full pseudo element name (e.g. "::view-transition-group(root)") @@ -572,8 +576,14 @@ function getBindingElementAndPseudo(node) { pseudo === "::after" ) { bindingElement = node.parentNode; + } else if (implementedPseudoElement.startsWith("::view-transition")) { + // The binding for all view transition pseudo element is the <html> element, i.e. we + // can't use `node.parentNode` as for`::view-transition-old` element, we'd get the + // `::view-transition-group`, which is not the binding element. + bindingElement = node.getRootNode().documentElement; } } + return { bindingElement, pseudo,