Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
31 changes: 28 additions & 3 deletions packages/@stylexjs/devtools-extension/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<link href>` 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(
/(<link\b[^>]*\bhref=")[^"]*\.css(")/g,
`$1${cssHref}$2`,
);
await fs.writeFile(dest, rewritten);
return;
}
await fs.copyFile(src, dest);
}),
);
Expand All @@ -77,8 +102,8 @@ function copyStatic({ outDir, targets }) {
this.addWatchFile(src);
}
},
async generateBundle() {
await copyAll();
async generateBundle(_opts, bundle) {
await copyAll(bundle);
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment on lines +36 to +38
function safeString(value: mixed): string {
if (typeof value === 'string') return value;
if (value == null) return '';
Expand Down Expand Up @@ -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: '—' },
Expand Down Expand Up @@ -681,6 +703,11 @@ export function collectStylexDebugData(): StylexDebugData {
}

const classToDecls = new Map<string, Array<$FlowFixMe>>();
// 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<string, Set<string>>();
Comment on lines +706 to +710
for (const rule of rules) {
const decls = parseDeclarationsFromRuleCssText(rule.cssText);
if (decls.length === 0) continue;
Expand Down Expand Up @@ -722,11 +749,25 @@ 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<string>();
if (existingSeen == null) seenDeclKeys.set(cls, seen);
for (const decl of declsWithCondition) {
// 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 ?? null,
decl.pseudoElement ?? null,
]);
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
declList.push(decl);
}
for (const decl of decls) {
if (computed[decl.property] == null) {
Expand Down
2 changes: 2 additions & 0 deletions packages/@stylexjs/devtools-extension/src/panel/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -131,6 +132,7 @@ function Panel({
</div>
<div {...stylex.props(styles.title)}>
<span {...stylex.props(styles.pill, styles.mono)}>{tagName}</span>
{!showEmptyState && <CopyMetadataButton data={data} />}
<Button onClick={refresh}>Refresh</Button>
</div>
</header>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* 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, useEffect, 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<boolean> {
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<?TimeoutID>(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);
}
const ok = await copyText(exportMetadata(data));
setLabel(ok ? 'Copied!' : 'Copy failed');
timeoutRef.current = setTimeout(() => {
setLabel('Copy metadata');
timeoutRef.current = null;
}, 1500);
}, [data]);

return (
<Button
onClick={handleClick}
title="Copy this element's StyleX metadata as markdown"
>
{label}
</Button>
);
}
Loading
Loading