From f08f8ae669b39e20e75ea6578e96121a45bcf395 Mon Sep 17 00:00:00 2001 From: Melissa Liu Date: Tue, 16 Jun 2026 16:38:42 -0400 Subject: [PATCH 1/2] [devtools-extension] fix panel CSS, dedupe rules, add metadata export - Fix unstyled panel: the StyleX unplugin merges/renames the CSS asset (e.g. style.css -> style2.css) and only rewrites JS references, leaving the static panel.html pointing at a missing file. The copy-static plugin now rewrites the stylesheet href to the actual emitted CSS name. - Dedupe declarations in collectStylexDebugData so an identical atomic rule injected into multiple stylesheets (HMR/StrictMode/multi-bundle) no longer renders as duplicate rows. - Parameterize collectStylexDebugData with an optional `target` (element or CSS selector), defaulting to `$0`, so the scraper can run outside DevTools. - Add a "Copy metadata" button + exportMetadata serializer that copies the selected element's StyleX context (resolved declarations, sources, overrides) as markdown. --- .../devtools-extension/rollup.config.mjs | 31 ++++- .../src/inspected/collectStylexDebugData.js | 55 ++++++-- .../devtools-extension/src/panel/App.jsx | 2 + .../panel/components/CopyMetadataButton.js | 80 +++++++++++ .../src/utils/exportMetadata.js | 129 ++++++++++++++++++ 5 files changed, 286 insertions(+), 11 deletions(-) create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js create mode 100644 packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js diff --git a/packages/@stylexjs/devtools-extension/rollup.config.mjs b/packages/@stylexjs/devtools-extension/rollup.config.mjs index 4ac4c4c0a..fd4781035 100644 --- a/packages/@stylexjs/devtools-extension/rollup.config.mjs +++ b/packages/@stylexjs/devtools-extension/rollup.config.mjs @@ -61,11 +61,36 @@ function copyStatic({ outDir, targets }) { src: path.resolve(rootDir, src), dest: path.resolve(outDir, dest), })); - async function copyAll() { + // The StyleX unplugin merges its CSS into the `.css` asset and may rename it + // (e.g. `style.css` -> `style2.css`) to avoid collisions. It rewrites + // references inside bundled JS, but these HTML files are copied verbatim and + // are not part of the bundle, so their `` must be rewritten to the + // actual emitted CSS filename — otherwise the panel loads with no styles. + async function copyAll(bundle) { + const cssAsset = bundle + ? Object.values(bundle).find( + (item) => + item != null && + item.type === 'asset' && + typeof item.fileName === 'string' && + item.fileName.endsWith('.css'), + ) + : null; + const cssHref = cssAsset ? `./${cssAsset.fileName}` : null; + await fs.mkdir(outDir, { recursive: true }); await Promise.all( resolved.map(async ({ src, dest }) => { await fs.mkdir(path.dirname(dest), { recursive: true }); + if (cssHref != null && dest.endsWith('.html')) { + const html = await fs.readFile(src, 'utf8'); + const rewritten = html.replace( + /(]*\bhref=")[^"]*\.css(")/g, + `$1${cssHref}$2`, + ); + await fs.writeFile(dest, rewritten); + return; + } await fs.copyFile(src, dest); }), ); @@ -77,8 +102,8 @@ function copyStatic({ outDir, targets }) { this.addWatchFile(src); } }, - async generateBundle() { - await copyAll(); + async generateBundle(_opts, bundle) { + await copyAll(bundle); }, }; } diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index ed4de0922..1c17972d9 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -24,7 +24,18 @@ declare const window: any; // NOTE: // This function is stringified and used using `evalInInspectedWindow` in the panel. // So it must be a completely self-contained function that doesn't rely on any external variables or functions. -export function collectStylexDebugData(): StylexDebugData { +// +// `target` selects the element to inspect: +// - omitted -> the DevTools-selected element (`$0`), used by the panel +// - a CSS selector -> `document.querySelector(target)`, used when running this +// function outside DevTools (e.g. injected/evaluated by an +// agent's browser automation, where there is no `$0`) +// - an element -> inspected directly +// This keeps a single canonical scraper that works both in the panel and when +// vendored into other tooling. +export function collectStylexDebugData( + target?: HTMLElement | string | null, +): StylexDebugData { function safeString(value: mixed): string { if (typeof value === 'string') return value; if (value == null) return ''; @@ -635,8 +646,19 @@ export function collectStylexDebugData(): StylexDebugData { return decls; } - // $FlowExpectedError[cannot-resolve-name] - $0 helps get the currently selected item - const element = typeof $0 !== 'undefined' ? $0 : null; + function resolveElement(): HTMLElement | null { + if (typeof target === 'string') { + try { + return document.querySelector(target) as $FlowFixMe; + } catch { + return null; + } + } + if (target != null) return target; + // $FlowExpectedError[cannot-resolve-name] - $0 helps get the currently selected item + return typeof $0 !== 'undefined' ? $0 : null; + } + const element = resolveElement(); if (!element) { return { element: { tagName: '—' }, @@ -681,6 +703,11 @@ export function collectStylexDebugData(): StylexDebugData { } const classToDecls = new Map>(); + // Tracks which (property/value/important/condition/pseudoElement) tuples we have + // already recorded per class, so an identical atomic rule appearing in multiple + // stylesheets (common in dev: HMR re-injection, StrictMode, multiple bundles) + // does not render as duplicate rows. + const seenDeclKeys = new Map>(); for (const rule of rules) { const decls = parseDeclarationsFromRuleCssText(rule.cssText); if (decls.length === 0) continue; @@ -722,11 +749,23 @@ export function collectStylexDebugData(): StylexDebugData { ? { pseudoElement: pseudoElementValue } : {}) as $FlowFixMe), })); - const declList = classToDecls.get(cls); - if (declList == null) { - classToDecls.set(cls, [...declsWithCondition]); - } else { - declList.push(...declsWithCondition); + const existingList = classToDecls.get(cls); + const declList = existingList != null ? existingList : []; + if (existingList == null) classToDecls.set(cls, declList); + const existingSeen = seenDeclKeys.get(cls); + const seen = existingSeen != null ? existingSeen : new Set(); + if (existingSeen == null) seenDeclKeys.set(cls, seen); + for (const decl of declsWithCondition) { + const dedupKey = [ + decl.property, + decl.value, + decl.important ? '!' : '', + decl.condition ?? '', + decl.pseudoElement ?? '', + ].join('::'); + if (seen.has(dedupKey)) continue; + seen.add(dedupKey); + declList.push(decl); } for (const decl of decls) { if (computed[decl.property] == null) { diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index 8f5286808..744f8ad55 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -25,6 +25,7 @@ import { subscribeToSelectionAndNavigation } from '../devtools/events.js'; import { evalInInspectedWindow } from '../devtools/api.js'; import { collectStylexDebugData } from '../inspected/collectStylexDebugData.js'; import { Button } from './components/Button'; +import { CopyMetadataButton } from './components/CopyMetadataButton'; import { DeclarationsList } from './components/DeclarationsList'; import { SourcesList } from './components/SourcesList'; import { Section } from './components/Section'; @@ -131,6 +132,7 @@ function Panel({
{tagName} + {!showEmptyState && }
diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js b/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js new file mode 100644 index 000000000..814eeeccf --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js @@ -0,0 +1,80 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import * as React from 'react'; +import { useCallback, useRef, useState } from 'react'; +import type { StylexDebugData } from '../../types.js'; +import { exportMetadata } from '../../utils/exportMetadata.js'; +import { Button } from './Button'; + +declare const navigator: any; +declare const document: any; + +// Clipboard access can be flaky inside a DevTools panel context, so fall back +// to a hidden-textarea + execCommand copy when the async clipboard API is +// unavailable or rejects (e.g. the panel is not focused). +async function copyText(text: string): Promise { + try { + if ( + navigator.clipboard && + typeof navigator.clipboard.writeText === 'function' + ) { + await navigator.clipboard.writeText(text); + return true; + } + } catch { + // fall through to the execCommand path + } + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + +export function CopyMetadataButton({ + data, +}: { + data: StylexDebugData, +}): React.Node { + const [label, setLabel] = useState('Copy metadata'); + const timeoutRef = useRef(null); + + const handleClick = useCallback(async () => { + if (timeoutRef.current != null) { + clearTimeout(timeoutRef.current); + } + const ok = await copyText(exportMetadata(data)); + setLabel(ok ? 'Copied!' : 'Copy failed'); + timeoutRef.current = setTimeout(() => { + setLabel('Copy metadata'); + timeoutRef.current = null; + }, 1500); + }, [data]); + + return ( + + ); +} diff --git a/packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js b/packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js new file mode 100644 index 000000000..e4a7fe45e --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/exportMetadata.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict + */ + +'use strict'; + +import type { + StylexDebugData, + StylexDeclaration, + StylexOverride, +} from '../types.js'; + +// Serializes the StyleX inspection of the selected element into compact +// markdown. The value here is the StyleX-specific metadata that is hard to +// reconstruct from a screenshot: the resolved declarations, the condition +// under which each applies, the atomic class that produced it, and the +// source file:line that authored it. + +function conditionLabel(decl: StylexDeclaration): string { + if (decl.conditions != null && decl.conditions.length > 0) { + return decl.conditions.join(', '); + } + if (decl.condition != null && decl.condition !== 'default') { + return decl.condition; + } + return 'default'; +} + +function formatValue(value: string, important: boolean): string { + return important ? `${value} !important` : value; +} + +function renderSources(data: StylexDebugData): Array { + if (data.sources.length === 0) return []; + const lines = ['## Sources']; + for (const source of data.sources) { + lines.push( + `- ${source.line != null ? `${source.file}:${source.line}` : source.file}`, + ); + } + return lines; +} + +function renderAppliedStyles(data: StylexDebugData): Array { + // section (pseudo-element, '' for the base element) -> property -> entries + const sections: Map< + string, + Map>, + > = new Map(); + for (const cls of data.applied.classes) { + for (const decl of cls.declarations) { + const sectionKey = decl.pseudoElement ?? ''; + let properties = sections.get(sectionKey); + if (properties == null) { + properties = new Map(); + sections.set(sectionKey, properties); + } + const bucket = properties.get(decl.property); + if (bucket == null) { + properties.set(decl.property, [decl]); + } else { + bucket.push(decl); + } + } + } + if (sections.size === 0) return []; + + // Render the base element first, then any pseudo-element sections. + const orderedKeys = [ + ...(sections.has('') ? [''] : []), + ...[...sections.keys()].filter((key) => key !== ''), + ]; + + const lines = ['## Applied styles']; + for (const sectionKey of orderedKeys) { + const properties = sections.get(sectionKey); + if (properties == null) continue; + if (sectionKey !== '') { + lines.push(`### ${sectionKey}`); + } + for (const [property, entries] of properties) { + if (entries.length === 1) { + const entry = entries[0]; + const cond = conditionLabel(entry); + const condText = cond === 'default' ? '' : ` [${cond}]`; + lines.push( + `- ${property}: ${formatValue(entry.value, entry.important)}${condText} (.${entry.className ?? '?'})`, + ); + continue; + } + lines.push(`- ${property}:`); + for (const entry of entries) { + lines.push( + ` - ${conditionLabel(entry)}: ${formatValue(entry.value, entry.important)} (.${entry.className ?? '?'})`, + ); + } + } + } + return lines; +} + +function renderOverrides(overrides: Array): Array { + if (overrides.length === 0) return []; + const lines = ['## Overrides (live, not yet in source)']; + for (const override of overrides) { + lines.push( + `- ${override.property}: ${formatValue(override.value, override.important)} (${override.kind})`, + ); + } + return lines; +} + +export function exportMetadata(data: StylexDebugData): string { + const blocks: Array> = [ + [`# StyleX metadata — <${data.element.tagName}>`], + renderSources(data), + renderAppliedStyles(data), + renderOverrides(data.overrides), + ]; + return blocks + .filter((block) => block.length > 0) + .map((block) => block.join('\n')) + .join('\n\n'); +} From 8657f150a7690affd11fba68baaa85e3b5ab2835 Mon Sep 17 00:00:00 2001 From: Melissa Liu Date: Tue, 16 Jun 2026 16:49:21 -0400 Subject: [PATCH 2/2] [devtools-extension] address review: dedup key encoding, timeout cleanup, tests - Use JSON.stringify for the per-class dedup key so values containing the delimiter (e.g. pseudoElement '::before') can't collide. - Clear the pending label-reset timeout on CopyMetadataButton unmount. - Add tests: cross-stylesheet dedup, target by selector, target by element, and invalid-selector handling. --- .../__tests__/collectStylexDebugData-test.js | 114 ++++++++++++++++++ .../src/inspected/collectStylexDebugData.js | 12 +- .../panel/components/CopyMetadataButton.js | 13 +- 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/packages/@stylexjs/devtools-extension/__tests__/collectStylexDebugData-test.js b/packages/@stylexjs/devtools-extension/__tests__/collectStylexDebugData-test.js index 3b7a9d045..7cf11d524 100644 --- a/packages/@stylexjs/devtools-extension/__tests__/collectStylexDebugData-test.js +++ b/packages/@stylexjs/devtools-extension/__tests__/collectStylexDebugData-test.js @@ -155,4 +155,118 @@ describe('collectStylexDebugData', () => { }, ]); }); + + test('dedupes identical declarations across multiple stylesheets', () => { + const element = document.createElement('div'); + element.setAttribute('class', 'xfoo'); + document.body.appendChild(element); + global.$0 = element; + + Object.defineProperty(element, 'matches', { + configurable: true, + value: (selector) => selector === '.xfoo', + }); + + const makeSheet = () => ({ + cssRules: [ + { type: 1, selectorText: '.xfoo', cssText: '.xfoo { color: red; }' }, + ], + }); + + // Same atomic rule injected into three stylesheets (as HMR / StrictMode / + // multiple bundles do in dev). + Object.defineProperty(document, 'styleSheets', { + configurable: true, + value: [makeSheet(), makeSheet(), makeSheet()], + }); + + const data = collectStylexDebugData(); + + const xfoo = data.applied.classes.find((entry) => entry.name === 'xfoo'); + expect(xfoo.declarations).toEqual([ + { + property: 'color', + value: 'red', + important: false, + condition: 'default', + className: 'xfoo', + }, + ]); + }); + + test('inspects an element by CSS selector via the target argument', () => { + const element = document.createElement('div'); + element.setAttribute('class', 'xfoo'); + element.setAttribute('id', 'target'); + document.body.appendChild(element); + // Intentionally do NOT set global.$0 — the selector path must work without it. + + Object.defineProperty(element, 'matches', { + configurable: true, + value: (selector) => selector === '.xfoo', + }); + + Object.defineProperty(document, 'styleSheets', { + configurable: true, + value: [ + { + cssRules: [ + { + type: 1, + selectorText: '.xfoo', + cssText: '.xfoo { color: red; }', + }, + ], + }, + ], + }); + + const data = collectStylexDebugData('#target'); + + expect(data.element.tagName).toBe('div'); + expect(data.applied.classes.map((entry) => entry.name)).toEqual(['xfoo']); + }); + + test('inspects an explicitly passed element via the target argument', () => { + const element = document.createElement('span'); + element.setAttribute('class', 'xfoo'); + document.body.appendChild(element); + + Object.defineProperty(element, 'matches', { + configurable: true, + value: (selector) => selector === '.xfoo', + }); + + Object.defineProperty(document, 'styleSheets', { + configurable: true, + value: [ + { + cssRules: [ + { + type: 1, + selectorText: '.xfoo', + cssText: '.xfoo { color: red; }', + }, + ], + }, + ], + }); + + const data = collectStylexDebugData(element); + + expect(data.element.tagName).toBe('span'); + expect(data.applied.classes.map((entry) => entry.name)).toEqual(['xfoo']); + }); + + test('returns empty data for an invalid selector', () => { + Object.defineProperty(document, 'styleSheets', { + configurable: true, + value: [], + }); + + const data = collectStylexDebugData(':::not a valid selector'); + + expect(data.element.tagName).toBe('—'); + expect(data.applied.classes).toEqual([]); + }); }); diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index 1c17972d9..098bc56ca 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -756,13 +756,15 @@ export function collectStylexDebugData( const seen = existingSeen != null ? existingSeen : new Set(); if (existingSeen == null) seenDeclKeys.set(cls, seen); for (const decl of declsWithCondition) { - const dedupKey = [ + // JSON encoding (rather than a delimiter join) so values that contain + // the delimiter — e.g. `pseudoElement: '::before'` — cannot collide. + const dedupKey = JSON.stringify([ decl.property, decl.value, - decl.important ? '!' : '', - decl.condition ?? '', - decl.pseudoElement ?? '', - ].join('::'); + decl.important, + decl.condition ?? null, + decl.pseudoElement ?? null, + ]); if (seen.has(dedupKey)) continue; seen.add(dedupKey); declList.push(decl); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js b/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js index 814eeeccf..7f16249b8 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/CopyMetadataButton.js @@ -10,7 +10,7 @@ 'use strict'; import * as React from 'react'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import type { StylexDebugData } from '../../types.js'; import { exportMetadata } from '../../utils/exportMetadata.js'; import { Button } from './Button'; @@ -57,6 +57,17 @@ export function CopyMetadataButton({ const [label, setLabel] = useState('Copy metadata'); const timeoutRef = useRef(null); + // The panel re-mounts on refresh (its `key` changes), so clear any pending + // label-reset timeout on unmount to avoid a state update after unmount. + useEffect( + () => () => { + if (timeoutRef.current != null) { + clearTimeout(timeoutRef.current); + } + }, + [], + ); + const handleClick = useCallback(async () => { if (timeoutRef.current != null) { clearTimeout(timeoutRef.current);