From 3c1353eaf8c13fec8f686c29fee99668ea643478 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Fri, 19 Dec 2025 19:10:13 -0800 Subject: [PATCH 01/13] Implement a read-only stylex devtools extension --- .eslintrc.js | 1 + .prettierignore | 3 +- .../@stylexjs/devtools-extension/.babelrc.js | 28 ++ .../@stylexjs/devtools-extension/.gitignore | 3 + .../@stylexjs/devtools-extension/README.md | 35 ++ .../devtools-extension/devtools.html | 12 + .../extension/manifest.json | 8 + .../devtools-extension/flow-types/chrome.js | 81 +++ .../@stylexjs/devtools-extension/package.json | 30 ++ .../@stylexjs/devtools-extension/panel.html | 13 + .../devtools-extension/public/manifest.json | 8 + .../devtools-extension/src/devtools/api.js | 72 +++ .../src/devtools/createSidebarPane.js | 19 + .../devtools-extension/src/devtools/events.js | 24 + .../devtools-extension/src/devtools/main.js | 14 + .../src/devtools/resources.js | 66 +++ .../src/inspected/collectStylexDebugData.js | 359 +++++++++++++ .../devtools-extension/src/panel/App.jsx | 476 ++++++++++++++++++ .../devtools-extension/src/panel/index.css | 10 + .../devtools-extension/src/panel/main.jsx | 21 + .../@stylexjs/devtools-extension/src/types.js | 50 ++ .../src/utils/cssRuleType.js | 40 ++ .../src/utils/resourceMatching.js | 117 +++++ .../src/utils/sourceSnippet.js | 213 ++++++++ .../devtools-extension/src/utils/vscode.js | 99 ++++ .../devtools-extension/vite.config.mjs | 43 ++ 26 files changed, 1844 insertions(+), 1 deletion(-) create mode 100644 packages/@stylexjs/devtools-extension/.babelrc.js create mode 100644 packages/@stylexjs/devtools-extension/.gitignore create mode 100644 packages/@stylexjs/devtools-extension/README.md create mode 100644 packages/@stylexjs/devtools-extension/devtools.html create mode 100644 packages/@stylexjs/devtools-extension/extension/manifest.json create mode 100644 packages/@stylexjs/devtools-extension/flow-types/chrome.js create mode 100644 packages/@stylexjs/devtools-extension/package.json create mode 100644 packages/@stylexjs/devtools-extension/panel.html create mode 100644 packages/@stylexjs/devtools-extension/public/manifest.json create mode 100644 packages/@stylexjs/devtools-extension/src/devtools/api.js create mode 100644 packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js create mode 100644 packages/@stylexjs/devtools-extension/src/devtools/events.js create mode 100644 packages/@stylexjs/devtools-extension/src/devtools/main.js create mode 100644 packages/@stylexjs/devtools-extension/src/devtools/resources.js create mode 100644 packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js create mode 100644 packages/@stylexjs/devtools-extension/src/panel/App.jsx create mode 100644 packages/@stylexjs/devtools-extension/src/panel/index.css create mode 100644 packages/@stylexjs/devtools-extension/src/panel/main.jsx create mode 100644 packages/@stylexjs/devtools-extension/src/types.js create mode 100644 packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js create mode 100644 packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js create mode 100644 packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js create mode 100644 packages/@stylexjs/devtools-extension/src/utils/vscode.js create mode 100644 packages/@stylexjs/devtools-extension/vite.config.mjs diff --git a/.eslintrc.js b/.eslintrc.js index 62b27799a..829991b32 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -43,6 +43,7 @@ module.exports = { '**/__mocks__/snapshot*', '**/storybook-static/**', '**/examples/example-cli/src/**', + '**/devtools-extension/extension/**', ], globals: { $Call: 'readonly', diff --git a/.prettierignore b/.prettierignore index 2cfbe37fa..3a2fee7da 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,4 +12,5 @@ node_modules packages/benchmarks/size/fixtures/lotsOfStyles.js flow-typed examples/example-storybook/storybook-static -examples/example-cli/src \ No newline at end of file +examples/example-cli/src +devtools-extension/extension \ No newline at end of file diff --git a/packages/@stylexjs/devtools-extension/.babelrc.js b/packages/@stylexjs/devtools-extension/.babelrc.js new file mode 100644 index 000000000..58429fc00 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/.babelrc.js @@ -0,0 +1,28 @@ +/** + * 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. + */ + +const BABEL_ENV = process.env['BABEL_ENV']; + +module.exports = { + assumptions: { + iterableIsArray: true, + }, + presets: [ + // [ + // '@babel/preset-env', + // { + // exclude: ['@babel/plugin-transform-typeof-symbol'], + // targets: 'defaults', + // // Convert files to cjs for jest testing + // modules: BABEL_ENV === 'test' ? 'cjs' : false, + // }, + // ], + '@babel/preset-flow', + '@babel/preset-react', + ], + plugins: [['babel-plugin-syntax-hermes-parser', { flow: 'detect' }]], +}; diff --git a/packages/@stylexjs/devtools-extension/.gitignore b/packages/@stylexjs/devtools-extension/.gitignore new file mode 100644 index 000000000..6f722e973 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/.gitignore @@ -0,0 +1,3 @@ + +extension/assets +extension/*.html diff --git a/packages/@stylexjs/devtools-extension/README.md b/packages/@stylexjs/devtools-extension/README.md new file mode 100644 index 000000000..734577798 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/README.md @@ -0,0 +1,35 @@ +# @stylexjs/devtools-extension + +Chrome DevTools extension for debugging StyleX in development. + +## Develop / Build + +Source lives in `src/` (React + StyleX). The loadable Chrome extension is the +build output in `extension/`. + +```sh +npm run build -w @stylexjs/devtools-extension +``` + +## Load in Chrome + +1. Open `chrome://extensions` +2. Enable **Developer mode** +3. Click **Load unpacked** +4. Select `packages/@stylexjs/devtools-extension/extension` + +## Use + +1. Open DevTools → **Elements** +2. Select an element that has `data-style-src` +3. Open the **StyleX** tab in the Elements sidebar + +For best results, enable StyleX dev mode so the DOM includes `data-style-src` +and dev marker classNames: + +```js +// @stylexjs/babel-plugin +{ + dev: true, +} +``` diff --git a/packages/@stylexjs/devtools-extension/devtools.html b/packages/@stylexjs/devtools-extension/devtools.html new file mode 100644 index 000000000..0c3f178b1 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/devtools.html @@ -0,0 +1,12 @@ + + + + + + StyleX DevTools + + + + + + diff --git a/packages/@stylexjs/devtools-extension/extension/manifest.json b/packages/@stylexjs/devtools-extension/extension/manifest.json new file mode 100644 index 000000000..515e97937 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/extension/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "StyleX DevTools", + "version": "0.1.0", + "description": "Inspect StyleX-applied styles and their sources in the Elements panel.", + "devtools_page": "devtools.html" +} + diff --git a/packages/@stylexjs/devtools-extension/flow-types/chrome.js b/packages/@stylexjs/devtools-extension/flow-types/chrome.js new file mode 100644 index 000000000..e57ef677e --- /dev/null +++ b/packages/@stylexjs/devtools-extension/flow-types/chrome.js @@ -0,0 +1,81 @@ +/** + * 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 + */ + +type Devtools = { + inspectedWindow: InspectedWindow, + panels: Panels, + network: Network, + ... +}; + +export type InspectedWindowEvalOptions = { + includeCommandLineAPI?: boolean, + ... +}; + +export type ExceptionInfo = { + isException: boolean, + value?: mixed, + ... +}; + +export type Resource = { + url: string, + getContent: ( + callback: (content: ?string, encoding: ?string) => mixed, + ) => void, + ... +}; + +export type InspectedWindow = { + eval: ( + expression: string, + options: InspectedWindowEvalOptions, + callback: (result: mixed, exceptionInfo?: ExceptionInfo) => mixed, + ) => void, + getResources: (callback: (resources: Array) => mixed) => void, + ... +}; + +export type DevtoolsEvent = { + addListener: (callback: (...args: any[]) => mixed) => void, + removeListener: (callback: (...args: any[]) => mixed) => void, + ... +}; + +export type SidebarPane = { + setPage: (page: string) => void, + setHeight: (height: number) => void, + ... +}; + +export type Panels = { + elements: { + createSidebarPane: ( + title: string, + callback: (pane: SidebarPane) => mixed, + ) => void, + onSelectionChanged: DevtoolsEvent, + ... + }, + openResource: (url: string, lineNumber?: number) => void, + ... +}; + +export type Network = { + onNavigated: DevtoolsEvent, + ... +}; + +declare var chrome: { + devtools: Devtools, + ... +}; + +export const devtools: Devtools = chrome.devtools; diff --git a/packages/@stylexjs/devtools-extension/package.json b/packages/@stylexjs/devtools-extension/package.json new file mode 100644 index 000000000..e13b1bbd1 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "@stylexjs/devtools-extension", + "version": "0.17.3", + "private": true, + "description": "Chrome DevTools extension for debugging StyleX.", + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/stylex.git" + }, + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "files": [ + "extension/**" + ], + "dependencies": { + "@stylexjs/stylex": "0.17.3", + "react": "^17.0.2", + "react-dom": "^17.0.2" + }, + "devDependencies": { + "@babel/plugin-transform-flow-strip-types": "^7.27.1", + "@stylexjs/unplugin": "0.17.3", + "@vitejs/plugin-react": "^5.1.0", + "vite": "^5.4.21" + } +} diff --git a/packages/@stylexjs/devtools-extension/panel.html b/packages/@stylexjs/devtools-extension/panel.html new file mode 100644 index 000000000..4ac82ac20 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/panel.html @@ -0,0 +1,13 @@ + + + + + + StyleX + + +
+ + + + diff --git a/packages/@stylexjs/devtools-extension/public/manifest.json b/packages/@stylexjs/devtools-extension/public/manifest.json new file mode 100644 index 000000000..515e97937 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/public/manifest.json @@ -0,0 +1,8 @@ +{ + "manifest_version": 3, + "name": "StyleX DevTools", + "version": "0.1.0", + "description": "Inspect StyleX-applied styles and their sources in the Elements panel.", + "devtools_page": "devtools.html" +} + diff --git a/packages/@stylexjs/devtools-extension/src/devtools/api.js b/packages/@stylexjs/devtools-extension/src/devtools/api.js new file mode 100644 index 000000000..0026eb2d9 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/api.js @@ -0,0 +1,72 @@ +/** + * 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 { devtools } from '../../flow-types/chrome.js'; +import type { + ExceptionInfo, + InspectedWindowEvalOptions, + Resource, +} from '../../flow-types/chrome.js'; + +export { devtools }; +export type { ExceptionInfo, InspectedWindowEvalOptions, Resource }; + +export function evalInInspectedWindow( + fn: () => T, + options?: InspectedWindowEvalOptions, +): Promise { + const expression = `(${fn.toString()})()`; + const mergedOptions = { includeCommandLineAPI: true, ...options }; + + return new Promise((resolve, reject) => { + devtools.inspectedWindow.eval( + expression, + mergedOptions as any, + (result, exceptionInfo) => { + if (exceptionInfo && exceptionInfo.isException) { + const msg = + exceptionInfo.value != null + ? `Error: ${String(exceptionInfo.value)}` + : 'Error evaluating in inspected window.'; + reject(new Error(msg)); + return; + } + resolve(result as any as T); + }, + ); + }); +} + +export function getResources(): Promise> { + return new Promise((resolve) => { + devtools.inspectedWindow.getResources((resources) => resolve(resources)); + }); +} + +export function getResourceText(resource: Resource): Promise { + return new Promise((resolve) => { + resource.getContent((content, encoding) => { + if (encoding === 'base64' && typeof content === 'string') { + try { + resolve(atob(content)); + } catch { + resolve(null); + } + return; + } + resolve(content); + }); + }); +} + +export function openResource(url: string, lineZeroBased?: number): void { + devtools.panels.openResource(url, lineZeroBased); +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js b/packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js new file mode 100644 index 000000000..7bf958dd8 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/createSidebarPane.js @@ -0,0 +1,19 @@ +/** + * 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 { devtools } from '../../flow-types/chrome.js'; + +export function createStylexSidebarPane(): void { + devtools.panels.elements.createSidebarPane('StyleX', (pane) => { + pane.setPage('panel.html'); + pane.setHeight(400); + }); +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/events.js b/packages/@stylexjs/devtools-extension/src/devtools/events.js new file mode 100644 index 000000000..8fbcc95ae --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/events.js @@ -0,0 +1,24 @@ +/** + * 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 { devtools } from './api.js'; + +export function subscribeToSelectionAndNavigation( + callback: () => mixed, +): () => void { + devtools.panels.elements.onSelectionChanged.addListener(callback); + devtools.network.onNavigated.addListener(callback); + + return () => { + devtools.panels.elements.onSelectionChanged.removeListener(callback); + devtools.network.onNavigated.removeListener(callback); + }; +} diff --git a/packages/@stylexjs/devtools-extension/src/devtools/main.js b/packages/@stylexjs/devtools-extension/src/devtools/main.js new file mode 100644 index 000000000..35a6be9af --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/main.js @@ -0,0 +1,14 @@ +/** + * 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 { createStylexSidebarPane } from './createSidebarPane.js'; + +createStylexSidebarPane(); diff --git a/packages/@stylexjs/devtools-extension/src/devtools/resources.js b/packages/@stylexjs/devtools-extension/src/devtools/resources.js new file mode 100644 index 000000000..efea22f69 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/resources.js @@ -0,0 +1,66 @@ +/** + * 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 { getResourceText, getResources, openResource } from './api.js'; +import type { Resource } from './api.js'; +import { findBestMatchingResource } from '../utils/resourceMatching.js'; +import { formatSourceSnippet } from '../utils/sourceSnippet.js'; +import type { SourcePreview } from '../types.js'; + +function normalizeLineToZeroBased(line: ?number | null): number { + if (typeof line !== 'number') return 0; + return Math.max(line - 1, 0); +} + +export async function findBestResourceForFile( + file: string, +): Promise { + const resources = await getResources(); + return findBestMatchingResource(resources, file); +} + +export async function openSourceBestEffort( + file: string, + line: number | null, +): Promise { + const best = await findBestResourceForFile(file); + if (!best) { + throw new Error(`Could not find a loaded resource matching: ${file}`); + } + openResource(best.url, normalizeLineToZeroBased(line)); +} + +export async function getSourcePreview( + file: string, + line: number | null, +): Promise { + const best = await findBestResourceForFile(file); + if (!best) { + return { + url: '', + snippet: `Could not find a DevTools resource matching:\n${file}`, + }; + } + + const content = await getResourceText(best); + if (typeof content !== 'string' || content.length === 0) { + return { + url: best.url, + snippet: + 'DevTools did not provide file contents for this resource.\nTry opening it in the Sources panel once, then retry.', + }; + } + + return { + url: best.url, + snippet: formatSourceSnippet(content, line), + }; +} diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js new file mode 100644 index 000000000..a2eba9d38 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -0,0 +1,359 @@ +/** + * 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 } from '../types.js'; + +import { + isCSSMediaRule, + isCSSStyleRule, + isCSSSupportsRule, +} from '../utils/cssRuleType'; + +type RuleData = $ReadOnly<{ + selectorText: string, + classNames: Array, + cssText: string, + order: number, +}>; + +export function collectStylexDebugData(): StylexDebugData { + function safeString(value: mixed): string { + if (typeof value === 'string') return value; + if (value == null) return ''; + return String(value); + } + + function parseDataStyleSrc(raw: string): Array { + if (typeof raw !== 'string' || raw.trim() === '') return []; + return raw + .split(';') + .map((s) => s.trim()) + .filter(Boolean); + } + + function parseSourceEntry(raw: mixed): { + raw: string, + file: string, + line: number | null, + } { + const text = safeString(raw).trim(); + const match = text.match(/:(\d+)\s*$/); + if (!match || match.index == null) { + return { raw: text, file: text, line: null }; + } + const line = Number(match[1]); + const file = text.slice(0, match.index); + return { raw: text, file, line: Number.isFinite(line) ? line : null }; + } + + function splitSelectors(selectorText: string): Array { + return selectorText + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } + + function extractClassNames(selectorText: string): Array { + const out = []; + const re = /\.([_a-zA-Z0-9-]+)/g; + let m = re.exec(selectorText); + while (m != null) { + out.push(m[1]); + m = re.exec(selectorText); + } + return out; + } + + function collectStyleRulesFromSheet( + sheet: CSSStyleSheet, + elementClassSet: $ReadOnlySet, + out: Array, + state: { ruleOrder: number, skippedSheets: number }, + ) { + let rules: ?CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + state.skippedSheets += 1; + return; + } + if (!rules) return; + + function walkRules(ruleList: CSSRuleList) { + for (let i = 0; i < ruleList.length; i += 1) { + const rule = ruleList[i]; + if (!rule) continue; + + // CSSRule.STYLE_RULE === 1 + if (isCSSStyleRule(rule) && rule.selectorText && rule.cssText) { + const selectorText = rule.selectorText; + const classNames = extractClassNames(selectorText); + if (classNames.length === 0) continue; + + let hasIntersection = false; + for (const cls of classNames) { + if (elementClassSet.has(cls)) { + hasIntersection = true; + break; + } + } + if (!hasIntersection) continue; + + out.push({ + selectorText, + classNames, + cssText: rule.cssText, + order: state.ruleOrder++, + }); + continue; + } + + // CSSRule.MEDIA_RULE === 4 + if (isCSSMediaRule(rule) && rule.conditionText && rule.cssRules) { + let matches = false; + try { + matches = window.matchMedia(rule.conditionText).matches; + } catch { + matches = false; + } + if (matches) { + walkRules(rule.cssRules); + } + continue; + } + + // CSSRule.SUPPORTS_RULE === 12 + if (isCSSSupportsRule(rule) && rule.conditionText && rule.cssRules) { + let matches = false; + try { + // $FlowFixMe[cannot-resolve-name] + matches = CSS.supports(rule.conditionText); + } catch { + matches = false; + } + if (matches) { + walkRules(rule.cssRules); + } + continue; + } + + if ('cssRules' in rule) { + // $FlowFixMe[prop-missing] + walkRules(rule.cssRules); + } + } + } + + walkRules(rules); + } + + function tryMatchSelector(element: HTMLElement, selectorText: string) { + const selectors = splitSelectors(selectorText); + for (const selector of selectors) { + try { + if (element.matches(selector)) return selector; + } catch { + // ignore invalid selectors (e.g. some pseudo-elements) + } + } + return null; + } + + function stripCssComments(cssText: string) { + let out = ''; + let i = 0; + let inString = false; + let quote = ''; + while (i < cssText.length) { + const ch = cssText[i]; + + if (!inString && ch === '/' && cssText[i + 1] === '*') { + const end = cssText.indexOf('*/', i + 2); + if (end === -1) { + break; + } + i = end + 2; + continue; + } + + out += ch; + if (inString) { + if (ch === quote) { + inString = false; + quote = ''; + } else if (ch === '\\\\') { + // skip escaped char + i += 1; + if (i < cssText.length) out += cssText[i]; + } + } else if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + } + + i += 1; + } + return out; + } + + function splitTopLevel(text: string, delimiterChar: string) { + const parts = []; + let current = ''; + let depth = 0; + let inString = false; + let quote = ''; + + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + + if (inString) { + current += ch; + if (ch === quote) { + inString = false; + quote = ''; + } else if (ch === '\\\\') { + i += 1; + if (i < text.length) current += text[i]; + } + continue; + } + + if (ch === '"' || ch === "'") { + inString = true; + quote = ch; + current += ch; + continue; + } + + if (ch === '(') depth += 1; + if (ch === ')') depth = Math.max(depth - 1, 0); + + if (ch === delimiterChar && depth === 0) { + parts.push(current); + current = ''; + continue; + } + + current += ch; + } + + if (current) parts.push(current); + return parts; + } + + function parseDeclarationsFromRuleCssText(ruleCssText: unknown) { + if (typeof ruleCssText !== 'string') return []; + const cleaned = stripCssComments(ruleCssText); + const start = cleaned.indexOf('{'); + const end = cleaned.lastIndexOf('}'); + if (start === -1 || end === -1 || end <= start) return []; + const body = cleaned.slice(start + 1, end).trim(); + if (!body) return []; + + const decls = []; + const statements = splitTopLevel(body, ';') + .map((s) => s.trim()) + .filter(Boolean); + + for (const stmt of statements) { + const [propPart, ...rest] = splitTopLevel(stmt, ':'); + if (!propPart || rest.length === 0) continue; + const property = propPart.trim(); + const valueRaw = rest.join(':').trim(); + if (!property || !valueRaw) continue; + + let value = valueRaw; + let important = false; + if (/\s!important\s*$/i.test(value)) { + important = true; + value = value.replace(/\s!important\s*$/i, '').trim(); + } + decls.push({ property, value, important }); + } + + return decls; + } + + // $FlowExpectedError[cannot-resolve-name] - $0 helps get the currently selected item + const element = typeof $0 !== 'undefined' ? $0 : null; + if (!element) { + return { + element: { tagName: '—' }, + sources: [], + applied: { classes: [] }, + }; + } + + const tagName = safeString(element.tagName).toLowerCase(); + const classAttr: string = safeString(element.getAttribute('class')); + const classesOrdered = classAttr.trim() ? classAttr.trim().split(/\s+/) : []; + const elementClassSet = new Set(classesOrdered); + + const dataStyleSrcRaw = safeString(element.getAttribute('data-style-src')); + const sourcesRaw = parseDataStyleSrc(dataStyleSrcRaw); + const sources = sourcesRaw.map(parseSourceEntry); + + const state = { ruleOrder: 0, skippedSheets: 0 }; + const rules: Array = []; + + const stylexStyleEls: Array = Array.from( + document.querySelectorAll('style[data-stylex]'), + ) as $FlowFixMe; + const preferredSheets = stylexStyleEls + .map((el: HTMLStyleElement) => el.sheet) + .filter(Boolean); + + const sheets: Array = + preferredSheets.length > 0 + ? preferredSheets + : (Array.from(document.styleSheets) as $FlowFixMe); + + for (const sheet of sheets) { + collectStyleRulesFromSheet(sheet, elementClassSet, rules, state); + } + + const classToDecls = new Map>(); + for (const rule of rules) { + const matchedSelector = tryMatchSelector(element, rule.selectorText); + if (!matchedSelector) continue; + + const matchedClasses = rule.classNames.filter((cls: string) => + elementClassSet.has(cls), + ); + const uniqueMatchedClasses = Array.from(new Set(matchedClasses)); + if (uniqueMatchedClasses.length === 0) continue; + + const decls = parseDeclarationsFromRuleCssText(rule.cssText); + if (decls.length === 0) continue; + + for (const cls of uniqueMatchedClasses) { + const declList = classToDecls.get(cls); + if (declList == null) { + classToDecls.set(cls, [...decls]); + } else { + declList.push(...decls); + } + } + } + + const classes = []; + for (const cls of classesOrdered) { + const decls = classToDecls.get(cls); + if (!decls) continue; + classes.push({ name: cls, declarations: decls }); + } + + return { + element: { tagName }, + sources, + applied: { classes }, + }; +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx new file mode 100644 index 000000000..53a9a475b --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -0,0 +1,476 @@ +/** + * 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 { useState, useCallback, useEffect, useRef } from 'react'; +import * as stylex from '@stylexjs/stylex'; + +import type { SourcePreview, StatusState, StylexDebugData } from '../types.js'; +import { subscribeToSelectionAndNavigation } from '../devtools/events.js'; +import { evalInInspectedWindow } from '../devtools/api.js'; +import { + openSourceBestEffort, + getSourcePreview, +} from '../devtools/resources.js'; +import { openInVsCodeFromStylexSource } from '../utils/vscode.js'; +import { collectStylexDebugData } from '../inspected/collectStylexDebugData.js'; + +export function App(): React.Node { + const [data, setData] = useState(null); + const [status, setStatus] = useState({ + message: 'Loading…', + kind: 'info', + }); + + const requestIdRef = useRef(0); + + const refresh = useCallback(() => { + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; + + setStatus({ message: 'Loading…', kind: 'info' }); + evalInInspectedWindow(collectStylexDebugData) + .then((result) => { + if (requestId !== requestIdRef.current) return; + setData(result); + setStatus({ message: 'Ready', kind: 'info' }); + }) + .catch((e) => { + if (requestId !== requestIdRef.current) return; + setStatus({ + message: e instanceof Error ? e.message : 'Unknown error.', + kind: 'error', + }); + }); + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + useEffect(() => subscribeToSelectionAndNavigation(refresh), [refresh]); + + const tagName = data?.element?.tagName ?? '—'; + + const classes = data?.applied?.classes ?? []; + + return ( +
+
+
+ StyleX + {tagName} +
+
+ +
+
+ +
+ {status.message} +
+ +
+ setStatus({ message: msg, kind: 'error' })} + sources={data?.sources ?? []} + /> +
+ +
+ +
+
+ ); +} + +function Section({ + title, + children, +}: { + title: string, + children: React.Node, +}): React.Node { + return ( +
+

{title}

+ {children} +
+ ); +} + +function SourcesList({ + sources, + onError, +}: { + sources: $ReadOnlyArray<{ + raw: string, + file: string, + line: number | null, + ... + }>, + onError: (message: string) => void, +}): React.Node { + const previewCacheRef = useRef>(new Map()); + + if (sources.length === 0) { + return
No data-style-src found.
; + } + + return ( +
+ {sources.map((src, index) => ( + + ))} +
+ ); +} + +function SourceRow({ + src, + index, + previewCache, + onError, +}: { + src: { raw: string, file: string, line: number | null, ... }, + index: number, + previewCache: Map, + onError: (message: string) => void, +}): React.Node { + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [preview, setPreview] = useState(null); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + + const key = src.raw ?? `${src.file}:${String(src.line ?? '')}`; + + const openPreview = useCallback(() => { + setIsPreviewOpen(true); + const cached = previewCache.get(key); + if (cached) { + setPreview(cached); + return; + } + setIsLoadingPreview(true); + getSourcePreview(src.file, src.line) + .then((value) => { + previewCache.set(key, value); + setPreview(value); + }) + .catch((e) => { + onError(e instanceof Error ? e.message : 'Failed to load preview.'); + }) + .finally(() => setIsLoadingPreview(false)); + }, [key, onError, previewCache, src.file, src.line]); + + const togglePreview = useCallback(() => { + if (isPreviewOpen) { + setIsPreviewOpen(false); + return; + } + openPreview(); + }, [isPreviewOpen, openPreview]); + + return ( +
+
+ + {index + 1} + + + {src.raw} + + + + +
+ + {isPreviewOpen ? ( +
+
+
+ {preview?.url ? preview.url : src.file} +
+ +
+
+            {isLoadingPreview ? 'Loading…' : (preview?.snippet ?? '')}
+          
+
+ ) : null} +
+ ); +} + +function DeclarationsList({ + classes, +}: { + classes: $ReadOnlyArray< + $ReadOnly<{ + name: string, + declarations: $ReadOnlyArray<{ + property: string, + value: string, + important: boolean, + ... + }>, + ... + }>, + >, +}): React.Node { + if (classes.length === 0) { + return ( +
+ No matching StyleX CSS rules found for the selected element. +
+ ); + } + + return ( +
+ {classes.map((entry) => ( +
+
{entry.name}
+
+ {entry.declarations.map((decl, i) => { + const value = decl.value + (decl.important ? ' !important' : ''); + return ( +
+ {decl.property}: {value}; +
+ ); + })} +
+
+ ))} +
+ ); +} + +const styles = stylex.create({ + root: { + padding: 10, + backgroundColor: '#ffffff', + color: '#1f2328', + fontFamily: + 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif', + fontSize: 12, + height: '100%', + boxSizing: 'border-box', + }, + mono: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: 8, + }, + title: { + display: 'flex', + alignItems: 'center', + gap: 8, + fontWeight: 600, + fontSize: 13, + }, + button: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + backgroundColor: '#f6f8fa', + padding: '4px 8px', + borderRadius: 6, + cursor: 'pointer', + }, + buttonHover: { + borderColor: { ':hover': '#0969da' }, + }, + status: { + color: '#5e636a', + marginTop: 6, + marginBottom: 10, + }, + statusError: { + color: '#cf222e', + }, + section: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + borderRadius: 8, + padding: 8, + marginTop: 10, + marginBottom: 10, + backgroundColor: '#ffffff', + }, + sectionTitle: { + marginTop: 0, + marginBottom: 8, + fontSize: 12, + fontWeight: 600, + }, + muted: { + color: '#5e636a', + }, + pill: { + display: 'inline-block', + paddingTop: 1, + paddingRight: 6, + paddingBottom: 1, + paddingLeft: 6, + borderRadius: 999, + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + backgroundColor: '#f6f8fa', + color: '#5e636a', + fontSize: 11, + }, + sourcesList: { + display: 'grid', + gap: 6, + }, + sourceEntry: { + display: 'grid', + gap: 6, + }, + sourceRow: { + display: 'flex', + alignItems: 'center', + gap: 8, + }, + sourcePath: { + flex: 1, + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: '#1f2328', + wordBreak: 'break-word', + }, + sourcePreview: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + borderRadius: 8, + backgroundColor: '#ffffff', + padding: 8, + marginLeft: 28, + }, + sourcePreviewHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginBottom: 6, + }, + sourcePreviewUrl: { + flex: 1, + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: '#5e636a', + wordBreak: 'break-word', + }, + sourcePreviewCode: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre', + overflow: 'auto', + maxHeight: 220, + backgroundColor: '#f6f8fa', + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + borderRadius: 6, + padding: 8, + }, + classList: { + display: 'grid', + gap: 8, + }, + classBlock: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + borderRadius: 8, + padding: 8, + backgroundColor: '#f6f8fa', + }, + className: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + fontWeight: 600, + marginBottom: 6, + wordBreak: 'break-word', + }, + declList: { + display: 'grid', + gap: 4, + }, + declLine: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/index.css b/packages/@stylexjs/devtools-extension/src/panel/index.css new file mode 100644 index 000000000..c40c289d6 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/index.css @@ -0,0 +1,10 @@ +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; +} + diff --git a/packages/@stylexjs/devtools-extension/src/panel/main.jsx b/packages/@stylexjs/devtools-extension/src/panel/main.jsx new file mode 100644 index 000000000..1e70d9d2c --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/main.jsx @@ -0,0 +1,21 @@ +/** + * 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 { createRoot } from 'react-dom'; +import { App } from './App.jsx'; + +import './index.css'; + +const rootEl = document.getElementById('root'); +if (rootEl) { + createRoot(rootEl).render(); +} diff --git a/packages/@stylexjs/devtools-extension/src/types.js b/packages/@stylexjs/devtools-extension/src/types.js new file mode 100644 index 000000000..706033b97 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/types.js @@ -0,0 +1,50 @@ +/** + * 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'; + +export type StatusKind = 'info' | 'error'; + +export type StatusState = { + message: string, + kind: StatusKind, +}; + +export type StylexSource = { + raw: string, + file: string, + line: number | null, +}; + +export type StylexDeclaration = { + property: string, + value: string, + important: boolean, + ... +}; + +export type AppliedStylexClass = { + name: string, + declarations: Array, +}; + +export type StylexDebugData = $ReadOnly<{ + element: { + tagName: string, + }, + sources: Array, + applied: { + classes: Array, + }, +}>; + +export type SourcePreview = { + url: string, + snippet: string, +}; diff --git a/packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js b/packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js new file mode 100644 index 000000000..f6222d0e0 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js @@ -0,0 +1,40 @@ +/** + * 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 + */ + +export function isCSSStyleRule(rule: CSSRule): implies rule is CSSStyleRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 1; +} + +export function isCSSMediaRule(rule: CSSRule): implies rule is CSSMediaRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 4; +} + +export function isCSSSupportsRule( + rule: CSSRule, +): implies rule is CSSSupportsRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 12; +} + +// CSSRule.STYLE_RULE 1 +// CSSRule.CHARSET_RULE 2 +// CSSRule.IMPORT_RULE 3 +// CSSRule.MEDIA_RULE 4 +// CSSRule.FONT_FACE_RULE 5 +// CSSRule.PAGE_RULE 6 +// CSSRule.KEYFRAMES_RULE 7 +// CSSRule.KEYFRAME_RULE 8 +// CSSRule.NAMESPACE_RULE 10 +// CSSRule.COUNTER_STYLE_RULE 11 +// CSSRule.SUPPORTS_RULE 12 +// CSSRule.FONT_FEATURE_VALUES_RULE 14 +// CSSRule.VIEWPORT_RULE 15 +// CSSRule.MARGIN_RULE 16 diff --git a/packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js b/packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js new file mode 100644 index 000000000..3fe8ce27c --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/resourceMatching.js @@ -0,0 +1,117 @@ +/** + * 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'; + +function isProbablySourceMappedUrl(url: string): boolean { + return ( + url.startsWith('webpack://') || + url.startsWith('vite://') || + url.startsWith('rollup://') || + url.startsWith('parcel://') || + url.startsWith('ng://') + ); +} + +function normalizeForMatching(text: string): string { + const noHash = text.split('#')[0]; + const noQuery = noHash.split('?')[0]; + let decoded = noQuery; + try { + decoded = decodeURIComponent(noQuery); + } catch { + // ignore + } + return decoded.replace(/\\\\/g, '/'); +} + +function buildFileMatchCandidates(file: string): Array { + const candidates = new Set(); + const raw = normalizeForMatching(file).trim(); + if (!raw) return []; + candidates.add(raw); + + const colonIndex = raw.indexOf(':'); + const possiblePrefix = colonIndex !== -1 ? raw.slice(0, colonIndex) : ''; + const possiblePath = colonIndex !== -1 ? raw.slice(colonIndex + 1) : ''; + const looksLikePackagePrefix = + colonIndex !== -1 && + possiblePrefix && + !possiblePrefix.includes('/') && + !/^[a-zA-Z]$/.test(possiblePrefix); + + if (looksLikePackagePrefix) { + const withoutPrefix = possiblePath.replace(/^\.?\//, ''); + if (withoutPrefix) candidates.add(withoutPrefix); + candidates.add(`${possiblePrefix}/${withoutPrefix}`); + candidates.add( + `packages/${possiblePrefix.replace(/\/+$/, '')}/${withoutPrefix}`, + ); + candidates.add( + `node_modules/${possiblePrefix.replace(/\/+$/, '')}/${withoutPrefix}`, + ); + } + + const parts = raw.split('/').filter(Boolean); + if (parts.length >= 3) { + candidates.add(parts.slice(-3).join('/')); + } + if (parts.length >= 2) { + candidates.add(parts.slice(-2).join('/')); + } + if (parts.length >= 1) { + candidates.add(parts.slice(-1)[0]); + } + + return Array.from(candidates) + .map((s) => normalizeForMatching(s).replace(/^\/+/, '')) + .filter(Boolean); +} + +export function findBestMatchingResource( + resources: $ReadOnlyArray, + file: string, +): T | null { + const suffixes = buildFileMatchCandidates(file); + if (suffixes.length === 0) return null; + + function scoreResourceUrl(url: string): number | null { + const u = normalizeForMatching(url); + if (!u) return null; + + let matchScore: number | null = null; + for (const s of suffixes) { + if (u === s) matchScore = Math.max(matchScore ?? -1, 10_000); + else if (u.endsWith(`/${s}`)) + matchScore = Math.max(matchScore ?? -1, 9_000); + else if (u.endsWith(s)) matchScore = Math.max(matchScore ?? -1, 8_000); + else if (u.includes(`/${s}`)) + matchScore = Math.max(matchScore ?? -1, 7_000); + else if (u.includes(s)) matchScore = Math.max(matchScore ?? -1, 6_000); + } + if (matchScore == null) return null; + + const schemeBonus = isProbablySourceMappedUrl(url) ? 500 : 0; + const hasQuery = url.split('#')[0].includes('?'); + const queryPenalty = hasQuery ? -250 : 0; + return matchScore + schemeBonus + queryPenalty; + } + + let best: T | null = null; + let bestScore: number | null = null; + for (const r of resources) { + const score = scoreResourceUrl(r.url); + if (score == null) continue; + if (bestScore == null || score > bestScore) { + best = r; + bestScore = score; + } + } + return best; +} diff --git a/packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js b/packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js new file mode 100644 index 000000000..19b3c95cb --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/sourceSnippet.js @@ -0,0 +1,213 @@ +/** + * 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'; + +function findMatchingClosingCurlyBraceLine( + lines: Array, + startLine: number, +): number | null { + const startIndex = Math.max(0, startLine - 1); + const firstLine = lines[startIndex] ?? ''; + const startColumn = Math.max(0, firstLine.lastIndexOf('{')); + + let depth = 0; + let started = false; + let inSingle = false; + let inDouble = false; + let inLineComment = false; + let inBlockComment = false; + let escapeNext = false; + const templateStack = []; + + for (let li = startIndex; li < lines.length; li += 1) { + const text = lines[li] ?? ''; + inLineComment = false; + const colStart = li === startIndex ? startColumn : 0; + + for (let ci = colStart; ci < text.length; ci += 1) { + const ch = text[ci]; + const next = text[ci + 1]; + + if (inLineComment) break; + + if (inBlockComment) { + if (ch === '*' && next === '/') { + inBlockComment = false; + ci += 1; + } + continue; + } + + if (inSingle) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === "'") { + inSingle = false; + } + continue; + } + + if (inDouble) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '"') { + inDouble = false; + } + continue; + } + + const templateTop = + templateStack.length > 0 + ? templateStack[templateStack.length - 1] + : null; + const inTemplateText = templateTop && templateTop.inExpression === false; + + if (inTemplateText) { + if (escapeNext) { + escapeNext = false; + continue; + } + if (ch === '\\') { + escapeNext = true; + continue; + } + if (ch === '`') { + templateStack.pop(); + continue; + } + if (ch === '$' && next === '{' && templateTop != null) { + templateTop.inExpression = true; + templateTop.exprDepth = 1; + + if (!started) { + started = true; + depth = 1; + } else { + depth += 1; + } + + ci += 1; + continue; + } + continue; + } + + if (ch === '/' && next === '/') { + inLineComment = true; + ci += 1; + continue; + } + if (ch === '/' && next === '*') { + inBlockComment = true; + ci += 1; + continue; + } + if (ch === "'") { + inSingle = true; + escapeNext = false; + continue; + } + if (ch === '"') { + inDouble = true; + escapeNext = false; + continue; + } + if (ch === '`') { + templateStack.push({ inExpression: false, exprDepth: 0 }); + escapeNext = false; + continue; + } + + if (ch === '{') { + if (!started) { + started = true; + depth = 1; + } else { + depth += 1; + } + if (templateTop && templateTop.inExpression) { + templateTop.exprDepth += 1; + } + continue; + } + + if (ch === '}') { + if (!started) continue; + depth -= 1; + if (templateTop && templateTop.inExpression) { + templateTop.exprDepth -= 1; + if (templateTop.exprDepth === 0) { + templateTop.inExpression = false; + templateTop.exprDepth = 0; + } + } + if (depth === 0) { + return li + 1; + } + } + } + } + + return null; +} + +export function formatSourceSnippet( + content: string, + line: number | null, +): string { + const normalized = content.replace(/\r\n/g, '\n'); + const lines = normalized.split('\n'); + if (lines.length === 0) return ''; + + const targetLine = + typeof line === 'number' && Number.isFinite(line) ? line : null; + if (targetLine == null) { + return lines.slice(0, Math.min(40, lines.length)).join('\n'); + } + + const start = Math.max(targetLine, 1); + const startLineText = lines[targetLine - 1] ?? ''; + const hasOpeningBrace = startLineText.includes('{'); + const braceEnd = hasOpeningBrace + ? findMatchingClosingCurlyBraceLine(lines, targetLine) + : null; + + let end = + braceEnd != null ? braceEnd : Math.min(targetLine + 6, lines.length); + const maxPreviewLines = 200; + const truncated = end - start + 1 > maxPreviewLines; + if (truncated) { + end = Math.min(start + maxPreviewLines - 1, lines.length); + } + + const width = String(end).length; + const out = []; + for (let i = start; i <= end; i += 1) { + const prefix = targetLine === i ? '>' : ' '; + const num = String(i).padStart(width, ' '); + out.push(`${prefix} ${num} | ${lines[i - 1] ?? ''}`); + } + if (truncated) { + out.push('… (preview truncated)'); + } + return out.join('\n'); +} diff --git a/packages/@stylexjs/devtools-extension/src/utils/vscode.js b/packages/@stylexjs/devtools-extension/src/utils/vscode.js new file mode 100644 index 000000000..7416bc5a6 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/utils/vscode.js @@ -0,0 +1,99 @@ +/** + * 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'; + +function openExternalUrl(url: string): void { + const a = document.createElement('a'); + a.href = url; + a.target = '_blank'; + a.rel = 'noreferrer'; + a.click(); +} + +function ensureLeadingSlash(path: string): string { + if (!path) return '/'; + if (path.startsWith('/')) return path; + return `/${path}`; +} + +function joinPaths(root: string, rel: string): string { + const left = root.replace(/\\\\/g, '/').replace(/\/+$/, ''); + const right = rel.replace(/\\\\/g, '/').replace(/^\/+/, ''); + return `${left}/${right}`; +} + +function stylexFileToEditorRelativePath(file: string): string { + const normalized = file.replace(/\\\\/g, '/').replace(/^\.?\//, ''); + + const nmIndex = normalized.indexOf('node_modules/'); + if (nmIndex !== -1) { + return normalized.slice(nmIndex); + } + + const colonIndex = normalized.indexOf(':'); + if (colonIndex !== -1 && colonIndex !== 1) { + const prefix = normalized.slice(0, colonIndex); + const rest = normalized.slice(colonIndex + 1).replace(/^\/+/, ''); + if (prefix && rest) { + return `packages/${prefix.replace(/\/+$/, '')}/${rest}`; + } + } + + return normalized; +} + +function getOrPromptForVsCodeRoot(): string | null { + const key = 'stylex_vscode_root'; + const existing = (localStorage.getItem(key) ?? '').trim(); + if (existing) return existing; + + // eslint-disable-next-line no-alert + const next = window.prompt( + 'VS Code root path (absolute). Example: /Users/you/project', + '', + ); + if (!next) return null; + const value = next.trim(); + if (!value) return null; + localStorage.setItem(key, value); + return value; +} + +function resolveStylexSourceToAbsolutePath(file: string): string | null { + const normalized = file.replace(/\\\\/g, '/').trim(); + if (!normalized) return null; + + if (normalized.startsWith('/') || /^[a-zA-Z]:\//.test(normalized)) { + return normalized; + } + + const root = getOrPromptForVsCodeRoot(); + if (!root) return null; + + const rel = stylexFileToEditorRelativePath(normalized); + return joinPaths(root, rel); +} + +export function openInVsCodeFromStylexSource( + file: string, + line: number | null, +): void { + const absPath = resolveStylexSourceToAbsolutePath(file); + if (!absPath) { + return; + } + + const normalizedPath = absPath.replace(/\\\\/g, '/'); + const lineSuffix = typeof line === 'number' ? `:${line}:1` : ''; + const url = encodeURI( + `vscode://file${ensureLeadingSlash(normalizedPath)}${lineSuffix}`, + ); + openExternalUrl(url); +} diff --git a/packages/@stylexjs/devtools-extension/vite.config.mjs b/packages/@stylexjs/devtools-extension/vite.config.mjs new file mode 100644 index 000000000..6f8c107e0 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/vite.config.mjs @@ -0,0 +1,43 @@ +/** + * 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. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import stylex from '@stylexjs/unplugin'; + +const rootDir = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + base: './', + publicDir: path.resolve(rootDir, 'public'), + build: { + outDir: path.resolve(rootDir, 'extension'), + emptyOutDir: true, + rollupOptions: { + input: { + devtools: path.resolve(rootDir, 'devtools.html'), + panel: path.resolve(rootDir, 'panel.html'), + }, + output: { + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, + }, + plugins: [ + stylex.vite({ devMode: 'off' }), + react({ + babel: { + babelrc: true, + plugins: ['@babel/plugin-transform-flow-strip-types'], + }, + }), + ], +}); From 9b33d20619b9bcd03609a6c2da2c8349dc146305 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Fri, 19 Dec 2025 20:38:19 -0800 Subject: [PATCH 02/13] Use just Rollup and not Vite --- package-lock.json | 267 ++++++++++-------- .../devtools-extension/devtools.html | 3 +- .../@stylexjs/devtools-extension/package.json | 17 +- .../@stylexjs/devtools-extension/panel.html | 4 +- .../devtools-extension/rollup.config.mjs | 134 +++++++++ .../src/inspected/collectStylexDebugData.js | 21 +- .../devtools-extension/src/panel/App.jsx | 1 + .../src/panel/components/SourceRow.js | 210 ++++++++++++++ .../devtools-extension/src/panel/index.css | 17 +- .../src/panel/{main.jsx => main.js} | 2 +- .../src/utils/cssRuleType.js | 40 --- .../devtools-extension/vite.config.mjs | 43 --- 12 files changed, 533 insertions(+), 226 deletions(-) create mode 100644 packages/@stylexjs/devtools-extension/rollup.config.mjs create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js rename packages/@stylexjs/devtools-extension/src/panel/{main.jsx => main.js} (89%) delete mode 100644 packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js delete mode 100644 packages/@stylexjs/devtools-extension/vite.config.mjs diff --git a/package-lock.json b/package-lock.json index 27399de79..e90996f16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,6 @@ "integrity": "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -80,7 +79,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -588,7 +586,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -903,7 +900,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1158,7 +1154,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1218,7 +1213,6 @@ "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -1286,7 +1280,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1509,7 +1502,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1553,7 +1545,6 @@ "examples/example-react-router/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1561,7 +1552,6 @@ "examples/example-react-router/node_modules/react-dom": { "version": "19.2.0", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -1649,7 +1639,6 @@ "version": "7.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -1746,7 +1735,6 @@ "examples/example-redwoodsdk/node_modules/@cloudflare/vite-plugin": { "version": "1.15.2", "license": "MIT", - "peer": true, "dependencies": { "@cloudflare/unenv-preset": "2.7.11", "@remix-run/node-fetch-server": "^0.8.0", @@ -1824,7 +1812,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2142,7 +2129,6 @@ "examples/example-redwoodsdk/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2153,7 +2139,6 @@ "examples/example-redwoodsdk/node_modules/react": { "version": "19.3.0-canary-561ee24d-20251101", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2161,7 +2146,6 @@ "examples/example-redwoodsdk/node_modules/react-dom": { "version": "19.3.0-canary-561ee24d-20251101", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "0.28.0-canary-561ee24d-20251101" }, @@ -2274,7 +2258,6 @@ "examples/example-redwoodsdk/node_modules/rwsdk/node_modules/@types/react": { "version": "19.1.17", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -2416,7 +2399,6 @@ "examples/example-redwoodsdk/node_modules/vite": { "version": "7.2.4", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -2539,7 +2521,6 @@ "examples/example-rollup/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2609,7 +2590,6 @@ "examples/example-rspack/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2826,7 +2806,6 @@ "version": "8.44.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.44.0", "@typescript-eslint/types": "8.44.0", @@ -3048,7 +3027,6 @@ "version": "9.36.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3150,7 +3128,6 @@ "version": "3.0.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -3259,7 +3236,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3368,7 +3344,6 @@ "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3675,7 +3650,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3700,7 +3674,6 @@ "version": "9.39.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3862,7 +3835,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3873,7 +3845,6 @@ "examples/example-vite-react/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3996,7 +3967,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4050,7 +4020,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4061,7 +4030,6 @@ "examples/example-vite-rsc/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4094,7 +4062,6 @@ "version": "7.1.12", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4212,7 +4179,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4223,7 +4189,6 @@ "examples/example-vite/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4256,7 +4221,6 @@ "version": "7.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4353,7 +4317,6 @@ "version": "19.2.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4424,7 +4387,6 @@ "examples/example-waku/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4435,7 +4397,6 @@ "examples/example-waku/node_modules/react": { "version": "19.2.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -4443,7 +4404,6 @@ "examples/example-waku/node_modules/react-dom": { "version": "19.2.0", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4465,7 +4425,6 @@ "examples/example-waku/node_modules/vite": { "version": "7.2.2", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -4627,7 +4586,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -4790,7 +4748,6 @@ "examples/example-webpack/node_modules/react": { "version": "19.1.1", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5109,7 +5066,6 @@ "node_modules/@algolia/client-search": { "version": "5.34.0", "license": "MIT", - "peer": true, "dependencies": { "@algolia/client-common": "5.34.0", "@algolia/requester-browser-xhr": "5.34.0", @@ -5418,7 +5374,6 @@ "node_modules/@babel/core": { "version": "7.28.5", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -7423,8 +7378,7 @@ "node_modules/@cloudflare/workers-types": { "version": "4.20251121.0", "devOptional": true, - "license": "MIT OR Apache-2.0", - "peer": true + "license": "MIT OR Apache-2.0" }, "node_modules/@codemirror/autocomplete": { "version": "6.20.0", @@ -7732,7 +7686,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -7753,7 +7706,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -9037,7 +8989,6 @@ "node_modules/@fortawesome/fontawesome-svg-core": { "version": "6.7.2", "license": "MIT", - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.7.2" }, @@ -10590,7 +10541,6 @@ "node_modules/@mdx-js/mdx/node_modules/@babel/core": { "version": "7.12.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -11173,7 +11123,6 @@ "version": "3.6.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^2.4.4", "@octokit/graphql": "^4.5.8", @@ -11376,7 +11325,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -11734,7 +11682,6 @@ "version": "5.2.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", @@ -12047,7 +11994,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rspack/cli": { "version": "1.6.5", @@ -12072,7 +12020,6 @@ "version": "1.6.4", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.21.4", "@rspack/binding": "1.6.4", @@ -12128,7 +12075,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -12379,7 +12325,6 @@ "version": "8.13.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "json-schema-traverse": "^1.0.0", @@ -12852,6 +12797,10 @@ "resolved": "packages/@stylexjs/cli", "link": true }, + "node_modules/@stylexjs/devtools-extension": { + "resolved": "packages/@stylexjs/devtools-extension", + "link": true + }, "node_modules/@stylexjs/eslint-plugin": { "resolved": "packages/@stylexjs/eslint-plugin", "link": true @@ -13019,7 +12968,6 @@ "node_modules/@svgr/core": { "version": "6.5.1", "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.19.6", "@svgr/babel-preset": "^6.5.1", @@ -13841,7 +13789,6 @@ "node_modules/@types/react": { "version": "18.3.23", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -13960,7 +13907,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/@types/unist": { "version": "2.0.11", @@ -14671,7 +14619,6 @@ "version": "3.2.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -14867,6 +14814,7 @@ "version": "3.2.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -15202,7 +15150,6 @@ "node_modules/acorn": { "version": "8.15.0", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -15275,7 +15222,6 @@ "node_modules/ajv": { "version": "6.12.6", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -15330,7 +15276,6 @@ "node_modules/algoliasearch": { "version": "4.25.2", "license": "MIT", - "peer": true, "dependencies": { "@algolia/cache-browser-local-storage": "4.25.2", "@algolia/cache-common": "4.25.2", @@ -15787,7 +15732,6 @@ "node_modules/astring": { "version": "1.9.0", "license": "MIT", - "peer": true, "bin": { "astring": "bin/astring" } @@ -16460,7 +16404,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -16555,6 +16498,7 @@ "version": "6.7.14", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -17215,8 +17159,7 @@ }, "node_modules/codemirror": { "version": "5.65.19", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/collapse-white-space": { "version": "1.0.6", @@ -17496,7 +17439,6 @@ "node_modules/copy-webpack-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17792,7 +17734,6 @@ "node_modules/css-minimizer-webpack-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18669,8 +18610,7 @@ }, "node_modules/devtools-protocol": { "version": "0.0.1495869", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dir-glob": { "version": "3.0.1", @@ -18768,6 +18708,7 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -19343,7 +19284,6 @@ "node_modules/eslint": { "version": "8.57.1", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -19565,7 +19505,6 @@ "version": "2.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -19617,7 +19556,6 @@ "version": "6.10.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", @@ -19646,7 +19584,6 @@ "version": "7.37.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -19678,7 +19615,6 @@ "version": "4.6.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -20858,7 +20794,6 @@ "node_modules/flow-api-translator/node_modules/typescript": { "version": "5.3.2", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22416,7 +22351,6 @@ "node_modules/hermes-eslint": { "version": "0.32.1", "license": "MIT", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "hermes-estree": "0.32.1", @@ -22531,7 +22465,6 @@ "node_modules/hono": { "version": "4.10.6", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -25068,7 +25001,6 @@ "version": "26.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -25201,7 +25133,6 @@ "node_modules/kysely": { "version": "0.28.8", "license": "MIT", - "peer": true, "engines": { "node": ">=20.0.0" } @@ -25268,7 +25199,6 @@ "node_modules/lightningcss": { "version": "1.30.2", "license": "MPL-2.0", - "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -26017,6 +25947,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -27326,7 +27257,6 @@ "node_modules/mini-css-extract-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -28741,7 +28671,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -28804,7 +28733,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -29212,7 +29140,6 @@ "version": "7.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29414,7 +29341,6 @@ "node_modules/postcss-selector-parser": { "version": "6.1.2", "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -29494,7 +29420,6 @@ "node_modules/prettier": { "version": "3.5.3", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -29509,7 +29434,6 @@ "version": "0.32.0", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "prettier": "^3.0.0" } @@ -29967,7 +29891,6 @@ "node_modules/react": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1" @@ -30104,7 +30027,6 @@ "node_modules/react-dom": { "version": "17.0.2", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -30164,7 +30086,6 @@ "name": "@docusaurus/react-loadable", "version": "5.5.2", "license": "MIT", - "peer": true, "dependencies": { "@types/react": "*", "prop-types": "^15.6.2" @@ -30190,7 +30111,6 @@ "node_modules/react-refresh": { "version": "0.17.0", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -30198,7 +30118,6 @@ "node_modules/react-router": { "version": "5.3.4", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.12.13", "history": "^4.9.0", @@ -30250,7 +30169,6 @@ "resolved": "https://registry.npmjs.org/react-server-dom-webpack/-/react-server-dom-webpack-19.2.1.tgz", "integrity": "sha512-6z3FuEtZ7wVWyPYRhKKRo4TF7IyQ7XrQZRW1fTjzK2mLVmcv7xJtoANSixaUJ+EFjCh2ekNOaBSoI9spiMSnNA==", "license": "MIT", - "peer": true, "dependencies": { "acorn-loose": "^8.3.0", "neo-async": "^2.6.1", @@ -31071,7 +30989,6 @@ "node_modules/remark-mdx/node_modules/@babel/core": { "version": "7.12.9", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/generator": "^7.12.5", @@ -31702,7 +31619,6 @@ "node_modules/rollup": { "version": "4.45.1", "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -33218,7 +33134,6 @@ "integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@testing-library/jest-dom": "^6.6.3", @@ -34181,7 +34096,6 @@ "node_modules/terser-webpack-plugin/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -34394,7 +34308,6 @@ "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -34406,6 +34319,7 @@ "version": "1.1.1", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^18.0.0 || >=20.0.0" } @@ -34644,8 +34558,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/turbo-stream": { "version": "3.1.0", @@ -34781,7 +34694,6 @@ "node_modules/typescript": { "version": "5.9.3", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -34849,7 +34761,6 @@ "version": "8.46.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -35155,7 +35066,6 @@ "node_modules/unenv": { "version": "2.0.0-rc.24", "license": "MIT", - "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -35382,6 +35292,7 @@ "node_modules/unplugin": { "version": "2.3.11", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", @@ -35395,6 +35306,7 @@ "node_modules/unplugin/node_modules/picomatch": { "version": "4.0.3", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -35947,6 +35859,7 @@ "version": "3.2.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", @@ -35968,6 +35881,7 @@ "version": "6.5.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -35996,6 +35910,7 @@ "version": "7.1.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -36200,6 +36115,7 @@ "version": "3.2.4", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", @@ -36225,6 +36141,7 @@ "version": "3.0.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -36233,6 +36150,7 @@ "version": "6.5.0", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.0.0" }, @@ -36421,7 +36339,6 @@ "node_modules/webpack": { "version": "5.101.3", "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -36647,7 +36564,6 @@ "node_modules/webpack-dev-middleware/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -36757,7 +36673,6 @@ "node_modules/webpack-dev-server/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -36857,7 +36772,6 @@ "node_modules/webpack/node_modules/ajv": { "version": "8.17.1", "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -37123,7 +37037,6 @@ "version": "1.20251118.0", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -37141,7 +37054,6 @@ "node_modules/wrangler": { "version": "4.50.0", "license": "MIT OR Apache-2.0", - "peer": true, "dependencies": { "@cloudflare/kv-asset-handler": "0.4.0", "@cloudflare/unenv-preset": "2.7.11", @@ -37815,7 +37727,6 @@ "version": "2.3.1", "devOptional": true, "license": "ISC", - "peer": true, "engines": { "node": ">= 14" } @@ -38089,7 +38000,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -38119,6 +38029,131 @@ "scripts": "0.17.4" } }, + "packages/@stylexjs/devtools-extension": { + "version": "0.17.3", + "license": "MIT", + "dependencies": { + "@stylexjs/stylex": "0.17.3", + "react": "^19.2.3", + "react-dom": "^19.2.3" + }, + "devDependencies": { + "@babel/plugin-transform-flow-strip-types": "^7.27.1", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-replace": "^6.0.1", + "@stylexjs/unplugin": "0.17.3", + "rollup": "^4.24.0" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@rollup/plugin-commonjs": { + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "packages/@stylexjs/devtools-extension/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "packages/@stylexjs/devtools-extension/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "packages/@stylexjs/eslint-plugin": { "version": "0.17.4", "license": "MIT", @@ -38201,7 +38236,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -38310,7 +38344,6 @@ "version": "4.0.3", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/@stylexjs/devtools-extension/devtools.html b/packages/@stylexjs/devtools-extension/devtools.html index 0c3f178b1..b6a6982ba 100644 --- a/packages/@stylexjs/devtools-extension/devtools.html +++ b/packages/@stylexjs/devtools-extension/devtools.html @@ -6,7 +6,6 @@ StyleX DevTools - + - diff --git a/packages/@stylexjs/devtools-extension/package.json b/packages/@stylexjs/devtools-extension/package.json index e13b1bbd1..29d983fd7 100644 --- a/packages/@stylexjs/devtools-extension/package.json +++ b/packages/@stylexjs/devtools-extension/package.json @@ -9,22 +9,25 @@ }, "license": "MIT", "scripts": { - "dev": "vite", - "build": "vite build", - "preview": "vite preview" + "dev": "rollup -c --watch", + "build": "rollup -c" }, "files": [ "extension/**" ], "dependencies": { "@stylexjs/stylex": "0.17.3", - "react": "^17.0.2", - "react-dom": "^17.0.2" + "react": "^19.2.3", + "react-dom": "^19.2.3" }, "devDependencies": { "@babel/plugin-transform-flow-strip-types": "^7.27.1", "@stylexjs/unplugin": "0.17.3", - "@vitejs/plugin-react": "^5.1.0", - "vite": "^5.4.21" + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.3.0", + "@rollup/plugin-replace": "^6.0.1", + "rollup": "^4.24.0" } } diff --git a/packages/@stylexjs/devtools-extension/panel.html b/packages/@stylexjs/devtools-extension/panel.html index 4ac82ac20..3a5f86030 100644 --- a/packages/@stylexjs/devtools-extension/panel.html +++ b/packages/@stylexjs/devtools-extension/panel.html @@ -4,10 +4,10 @@ StyleX +
- + - diff --git a/packages/@stylexjs/devtools-extension/rollup.config.mjs b/packages/@stylexjs/devtools-extension/rollup.config.mjs new file mode 100644 index 000000000..f62d8f00e --- /dev/null +++ b/packages/@stylexjs/devtools-extension/rollup.config.mjs @@ -0,0 +1,134 @@ +/** + * 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. + */ + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import fs from 'node:fs/promises'; +import { babel } from '@rollup/plugin-babel'; +import commonjs from '@rollup/plugin-commonjs'; +import json from '@rollup/plugin-json'; +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import replace from '@rollup/plugin-replace'; +import stylex from '@stylexjs/unplugin'; + +const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const outDir = path.resolve(rootDir, 'extension'); +const extensions = ['.js', '.jsx']; +const isWatch = Boolean(process.env.ROLLUP_WATCH); + +function cssBundle({ fileName = 'assets/style.css' } = {}) { + let styles = new Map(); + return { + name: 'css-bundle', + buildStart() { + styles = new Map(); + }, + resolveId(source, importer) { + if (!source.endsWith('.css')) return null; + const resolved = importer + ? path.resolve(path.dirname(importer), source) + : path.resolve(source); + return { id: resolved, moduleSideEffects: true }; + }, + async load(id) { + if (!id.endsWith('.css')) return null; + const css = await fs.readFile(id, 'utf8'); + styles.set(id, css); + this.addWatchFile(id); + return 'export default ""'; + }, + generateBundle() { + if (styles.size === 0) return; + const combined = Array.from(styles.values()).join('\n'); + this.emitFile({ + type: 'asset', + fileName, + source: combined, + }); + }, + }; +} + +function copyStatic({ outDir, targets }) { + const resolved = targets.map(({ src, dest }) => ({ + src: path.resolve(rootDir, src), + dest: path.resolve(outDir, dest), + })); + async function copyAll() { + await fs.mkdir(outDir, { recursive: true }); + await Promise.all( + resolved.map(async ({ src, dest }) => { + await fs.mkdir(path.dirname(dest), { recursive: true }); + await fs.copyFile(src, dest); + }), + ); + } + return { + name: 'copy-static', + buildStart() { + for (const { src } of resolved) { + this.addWatchFile(src); + } + }, + async generateBundle() { + await copyAll(); + }, + }; +} + +export default { + input: { + devtools: path.resolve(rootDir, 'src/devtools/main.js'), + panel: path.resolve(rootDir, 'src/panel/main.js'), + }, + output: { + dir: outDir, + format: 'es', + sourcemap: true, + entryFileNames: 'assets/[name].js', + chunkFileNames: 'assets/[name]-[hash].js', + assetFileNames: 'assets/[name][extname]', + }, + plugins: [ + cssBundle(), + replace({ + preventAssignment: true, + values: { + 'process.env.NODE_ENV': JSON.stringify( + isWatch ? 'development' : 'production', + ), + }, + }), + stylex.rollup({ devMode: 'off', useCSSLayers: true }), + babel({ + babelHelpers: 'bundled', + extensions, + babelrc: true, + configFile: path.resolve(rootDir, '.babelrc.js'), + include: [ + path.resolve(rootDir, 'src/**/*'), + path.resolve(rootDir, 'flow-types/**/*'), + ], + exclude: ['**/node_modules/**'], + }), + nodeResolve({ + browser: true, + extensions, + preferBuiltins: false, + }), + json(), + commonjs({ include: /node_modules/ }), + copyStatic({ + outDir, + targets: [ + { src: 'devtools.html', dest: 'devtools.html' }, + { src: 'panel.html', dest: 'panel.html' }, + { src: 'public/manifest.json', dest: 'manifest.json' }, + ], + }), + ], +}; diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index a2eba9d38..9602fb0dd 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -11,12 +11,6 @@ import type { StylexDebugData } from '../types.js'; -import { - isCSSMediaRule, - isCSSStyleRule, - isCSSSupportsRule, -} from '../utils/cssRuleType'; - type RuleData = $ReadOnly<{ selectorText: string, classNames: Array, @@ -31,6 +25,21 @@ export function collectStylexDebugData(): StylexDebugData { return String(value); } + function isCSSStyleRule(rule: CSSRule): implies rule is CSSStyleRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 1; + } + + function isCSSMediaRule(rule: CSSRule): implies rule is CSSMediaRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 4; + } + + function isCSSSupportsRule(rule: CSSRule): implies rule is CSSSupportsRule { + // $FlowExpectedError[incompatible-type-guard] + return rule.type === 12; + } + function parseDataStyleSrc(raw: string): Array { if (typeof raw !== 'string' || raw.trim() === '') return []; return raw diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index 53a9a475b..5870977df 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -44,6 +44,7 @@ export function App(): React.Node { setStatus({ message: 'Ready', kind: 'info' }); }) .catch((e) => { + console.error('RAN INTO ERROR', e); if (requestId !== requestIdRef.current) return; setStatus({ message: e instanceof Error ? e.message : 'Unknown error.', diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js new file mode 100644 index 000000000..41b50aa40 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js @@ -0,0 +1,210 @@ +/** + * 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 { useState, useCallback } from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type { SourcePreview } from '../../types'; +import { openInVsCodeFromStylexSource } from '../../utils/vscode'; +import { + getSourcePreview, + openSourceBestEffort, +} from '../../devtools/resources'; + +export function SourceRow({ + src, + index, + previewCache, + onError, +}: { + src: { raw: string, file: string, line: number | null, ... }, + index: number, + previewCache: Map, + onError: (message: string) => void, +}): React.Node { + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + const [preview, setPreview] = useState(null); + const [isLoadingPreview, setIsLoadingPreview] = useState(false); + + const key = src.raw ?? `${src.file}:${String(src.line ?? '')}`; + + const openPreview = useCallback(() => { + setIsPreviewOpen(true); + const cached = previewCache.get(key); + if (cached) { + setPreview(cached); + return; + } + setIsLoadingPreview(true); + getSourcePreview(src.file, src.line) + .then((value) => { + previewCache.set(key, value); + setPreview(value); + }) + .catch((e) => { + onError(e instanceof Error ? e.message : 'Failed to load preview.'); + }) + .finally(() => setIsLoadingPreview(false)); + }, [key, onError, previewCache, src.file, src.line]); + + const togglePreview = useCallback(() => { + if (isPreviewOpen) { + setIsPreviewOpen(false); + return; + } + openPreview(); + }, [isPreviewOpen, openPreview]); + + return ( +
+
+ + {index + 1} + + + {src.raw} + + + + +
+ + {isPreviewOpen ? ( +
+
+
+ {preview?.url ? preview.url : src.file} +
+ +
+
+            {isLoadingPreview ? 'Loading…' : (preview?.snippet ?? '')}
+          
+
+ ) : null} +
+ ); +} + +const styles = stylex.create({ + button: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: { + default: '#d1d9e0', + ':hover': '#0969da', + }, + backgroundColor: '#f6f8fa', + padding: '4px 8px', + borderRadius: 6, + cursor: 'pointer', + }, + pill: { + display: 'inline-block', + paddingTop: 1, + paddingRight: 6, + paddingBottom: 1, + paddingLeft: 6, + borderRadius: 999, + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + backgroundColor: '#f6f8fa', + color: '#5e636a', + fontSize: 11, + }, + + sourceEntry: { + display: 'grid', + gap: 6, + }, + sourceRow: { + display: 'flex', + alignItems: 'center', + gap: 8, + }, + sourcePath: { + flex: 1, + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: '#1f2328', + wordBreak: 'break-word', + }, + sourcePreview: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + borderRadius: 8, + backgroundColor: '#ffffff', + padding: 8, + marginLeft: 28, + }, + sourcePreviewHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + marginBottom: 6, + }, + sourcePreviewUrl: { + flex: 1, + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: '#5e636a', + wordBreak: 'break-word', + }, + sourcePreviewCode: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre', + overflow: 'auto', + maxHeight: 220, + backgroundColor: '#f6f8fa', + borderWidth: 1, + borderStyle: 'solid', + borderColor: '#d1d9e0', + borderRadius: 6, + padding: 8, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/index.css b/packages/@stylexjs/devtools-extension/src/panel/index.css index c40c289d6..8fbf27e29 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/index.css +++ b/packages/@stylexjs/devtools-extension/src/panel/index.css @@ -1,10 +1,11 @@ -html, -body, -#root { - height: 100%; -} +@layer reset { + html, + body, + #root { + height: 100%; + } -body { - margin: 0; + body { + margin: 0; + } } - diff --git a/packages/@stylexjs/devtools-extension/src/panel/main.jsx b/packages/@stylexjs/devtools-extension/src/panel/main.js similarity index 89% rename from packages/@stylexjs/devtools-extension/src/panel/main.jsx rename to packages/@stylexjs/devtools-extension/src/panel/main.js index 1e70d9d2c..6e7da8fa8 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/main.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/main.js @@ -10,7 +10,7 @@ 'use strict'; import * as React from 'react'; -import { createRoot } from 'react-dom'; +import { createRoot } from 'react-dom/client'; import { App } from './App.jsx'; import './index.css'; diff --git a/packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js b/packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js deleted file mode 100644 index f6222d0e0..000000000 --- a/packages/@stylexjs/devtools-extension/src/utils/cssRuleType.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * 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 - */ - -export function isCSSStyleRule(rule: CSSRule): implies rule is CSSStyleRule { - // $FlowExpectedError[incompatible-type-guard] - return rule.type === 1; -} - -export function isCSSMediaRule(rule: CSSRule): implies rule is CSSMediaRule { - // $FlowExpectedError[incompatible-type-guard] - return rule.type === 4; -} - -export function isCSSSupportsRule( - rule: CSSRule, -): implies rule is CSSSupportsRule { - // $FlowExpectedError[incompatible-type-guard] - return rule.type === 12; -} - -// CSSRule.STYLE_RULE 1 -// CSSRule.CHARSET_RULE 2 -// CSSRule.IMPORT_RULE 3 -// CSSRule.MEDIA_RULE 4 -// CSSRule.FONT_FACE_RULE 5 -// CSSRule.PAGE_RULE 6 -// CSSRule.KEYFRAMES_RULE 7 -// CSSRule.KEYFRAME_RULE 8 -// CSSRule.NAMESPACE_RULE 10 -// CSSRule.COUNTER_STYLE_RULE 11 -// CSSRule.SUPPORTS_RULE 12 -// CSSRule.FONT_FEATURE_VALUES_RULE 14 -// CSSRule.VIEWPORT_RULE 15 -// CSSRule.MARGIN_RULE 16 diff --git a/packages/@stylexjs/devtools-extension/vite.config.mjs b/packages/@stylexjs/devtools-extension/vite.config.mjs deleted file mode 100644 index 6f8c107e0..000000000 --- a/packages/@stylexjs/devtools-extension/vite.config.mjs +++ /dev/null @@ -1,43 +0,0 @@ -/** - * 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. - */ - -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import stylex from '@stylexjs/unplugin'; - -const rootDir = path.dirname(fileURLToPath(import.meta.url)); - -export default defineConfig({ - base: './', - publicDir: path.resolve(rootDir, 'public'), - build: { - outDir: path.resolve(rootDir, 'extension'), - emptyOutDir: true, - rollupOptions: { - input: { - devtools: path.resolve(rootDir, 'devtools.html'), - panel: path.resolve(rootDir, 'panel.html'), - }, - output: { - entryFileNames: 'assets/[name].js', - chunkFileNames: 'assets/[name]-[hash].js', - assetFileNames: 'assets/[name]-[hash][extname]', - }, - }, - }, - plugins: [ - stylex.vite({ devMode: 'off' }), - react({ - babel: { - babelrc: true, - plugins: ['@babel/plugin-transform-flow-strip-types'], - }, - }), - ], -}); From 630a05c04a6b2ced881e683a07bed48eea6b6a6b Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Fri, 19 Dec 2025 21:46:12 -0800 Subject: [PATCH 03/13] Improve the UI a bunch --- .eslintrc.js | 2 +- .flowconfig | 1 + .../devtools-extension/rollup.config.mjs | 10 +- .../src/inspected/collectStylexDebugData.js | 5 +- .../devtools-extension/src/panel/App.jsx | 378 ++---------------- .../src/panel/components/Button.js | 46 +++ .../src/panel/components/DeclarationsList.js | 103 +++++ .../src/panel/components/Logo.js | 179 +++++++++ .../src/panel/components/Section.js | 41 ++ .../src/panel/components/SourceRow.js | 79 ++-- .../src/panel/components/SourcesList.js | 58 +++ .../devtools-extension/src/panel/index.css | 1 + .../src/panel/theme.stylex.js | 23 ++ 13 files changed, 530 insertions(+), 396 deletions(-) create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/Button.js create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/Logo.js create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/Section.js create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js create mode 100644 packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js diff --git a/.eslintrc.js b/.eslintrc.js index 829991b32..16fc10361 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -143,7 +143,7 @@ module.exports = { 'no-unexpected-multiline': 2, 'no-unmodified-loop-condition': 2, 'no-unneeded-ternary': [2, { defaultAssignment: false }], - 'no-unreachable': 2, + 'no-unreachable': 0, 'no-unsafe-finally': 2, 'no-unused-vars': [ 2, diff --git a/.flowconfig b/.flowconfig index 002210a78..476b2cdf7 100644 --- a/.flowconfig +++ b/.flowconfig @@ -11,6 +11,7 @@ [options] experimental.pattern_matching=true +component_syntax=true enums=true emoji=true casting_syntax=as diff --git a/packages/@stylexjs/devtools-extension/rollup.config.mjs b/packages/@stylexjs/devtools-extension/rollup.config.mjs index f62d8f00e..676d0c877 100644 --- a/packages/@stylexjs/devtools-extension/rollup.config.mjs +++ b/packages/@stylexjs/devtools-extension/rollup.config.mjs @@ -13,6 +13,8 @@ import commonjs from '@rollup/plugin-commonjs'; import json from '@rollup/plugin-json'; import { nodeResolve } from '@rollup/plugin-node-resolve'; import replace from '@rollup/plugin-replace'; +import { browserslistToTargets } from 'lightningcss'; +import browserslist from 'browserslist'; import stylex from '@stylexjs/unplugin'; const rootDir = path.dirname(fileURLToPath(import.meta.url)); @@ -103,7 +105,13 @@ export default { ), }, }), - stylex.rollup({ devMode: 'off', useCSSLayers: true }), + stylex.rollup({ + devMode: 'off', + useCSSLayers: true, + lightningcssOptions: { + targets: browserslistToTargets(browserslist('>= 2%')), + }, + }), babel({ babelHelpers: 'bundled', extensions, diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index 9602fb0dd..d9ba97f36 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -18,6 +18,9 @@ type RuleData = $ReadOnly<{ order: number, }>; +// 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 { function safeString(value: mixed): string { if (typeof value === 'string') return value; @@ -314,7 +317,7 @@ export function collectStylexDebugData(): StylexDebugData { const rules: Array = []; const stylexStyleEls: Array = Array.from( - document.querySelectorAll('style[data-stylex]'), + document.querySelectorAll('style'), ) as $FlowFixMe; const preferredSheets = stylexStyleEls .map((el: HTMLStyleElement) => el.sheet) diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index 5870977df..b1e20942c 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -13,19 +13,20 @@ import * as React from 'react'; import { useState, useCallback, useEffect, useRef } from 'react'; import * as stylex from '@stylexjs/stylex'; -import type { SourcePreview, StatusState, StylexDebugData } from '../types.js'; +import type { StatusState, StylexDebugData } from '../types.js'; import { subscribeToSelectionAndNavigation } from '../devtools/events.js'; import { evalInInspectedWindow } from '../devtools/api.js'; -import { - openSourceBestEffort, - getSourcePreview, -} from '../devtools/resources.js'; -import { openInVsCodeFromStylexSource } from '../utils/vscode.js'; import { collectStylexDebugData } from '../inspected/collectStylexDebugData.js'; +import { Button } from './components/Button'; +import { DeclarationsList } from './components/DeclarationsList'; +import { SourcesList } from './components/SourcesList'; +import { Section } from './components/Section'; +import { colors } from './theme.stylex'; +import Logo from './components/Logo'; export function App(): React.Node { const [data, setData] = useState(null); - const [status, setStatus] = useState({ + const [_status, setStatus] = useState({ message: 'Loading…', kind: 'info', }); @@ -67,28 +68,22 @@ export function App(): React.Node {
- StyleX - {tagName} +
-
- +
+ {tagName} +
-
{status.message} -
+
*/}
-
+
); } -function Section({ - title, - children, -}: { - title: string, - children: React.Node, -}): React.Node { - return ( -
-

{title}

- {children} -
- ); -} - -function SourcesList({ - sources, - onError, -}: { - sources: $ReadOnlyArray<{ - raw: string, - file: string, - line: number | null, - ... - }>, - onError: (message: string) => void, -}): React.Node { - const previewCacheRef = useRef>(new Map()); - - if (sources.length === 0) { - return
No data-style-src found.
; - } - - return ( -
- {sources.map((src, index) => ( - - ))} -
- ); -} - -function SourceRow({ - src, - index, - previewCache, - onError, -}: { - src: { raw: string, file: string, line: number | null, ... }, - index: number, - previewCache: Map, - onError: (message: string) => void, -}): React.Node { - const [isPreviewOpen, setIsPreviewOpen] = useState(false); - const [preview, setPreview] = useState(null); - const [isLoadingPreview, setIsLoadingPreview] = useState(false); - - const key = src.raw ?? `${src.file}:${String(src.line ?? '')}`; - - const openPreview = useCallback(() => { - setIsPreviewOpen(true); - const cached = previewCache.get(key); - if (cached) { - setPreview(cached); - return; - } - setIsLoadingPreview(true); - getSourcePreview(src.file, src.line) - .then((value) => { - previewCache.set(key, value); - setPreview(value); - }) - .catch((e) => { - onError(e instanceof Error ? e.message : 'Failed to load preview.'); - }) - .finally(() => setIsLoadingPreview(false)); - }, [key, onError, previewCache, src.file, src.line]); - - const togglePreview = useCallback(() => { - if (isPreviewOpen) { - setIsPreviewOpen(false); - return; - } - openPreview(); - }, [isPreviewOpen, openPreview]); - - return ( -
-
- - {index + 1} - - - {src.raw} - - - - -
- - {isPreviewOpen ? ( -
-
-
- {preview?.url ? preview.url : src.file} -
- -
-
-            {isLoadingPreview ? 'Loading…' : (preview?.snippet ?? '')}
-          
-
- ) : null} -
- ); -} - -function DeclarationsList({ - classes, -}: { - classes: $ReadOnlyArray< - $ReadOnly<{ - name: string, - declarations: $ReadOnlyArray<{ - property: string, - value: string, - important: boolean, - ... - }>, - ... - }>, - >, -}): React.Node { - if (classes.length === 0) { - return ( -
- No matching StyleX CSS rules found for the selected element. -
- ); - } - - return ( -
- {classes.map((entry) => ( -
-
{entry.name}
-
- {entry.declarations.map((decl, i) => { - const value = decl.value + (decl.important ? ' !important' : ''); - return ( -
- {decl.property}: {value}; -
- ); - })} -
-
- ))} -
- ); -} - const styles = stylex.create({ root: { - padding: 10, - backgroundColor: '#ffffff', - color: '#1f2328', + backgroundColor: colors.bg, + color: colors.textPrimary, fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif', fontSize: 12, height: '100%', boxSizing: 'border-box', + display: 'flex', + flexDirection: 'column', + }, + logo: { + height: '2rem', + color: colors.textPrimary, }, mono: { fontFamily: @@ -327,7 +123,7 @@ const styles = stylex.create({ display: 'flex', alignItems: 'center', justifyContent: 'space-between', - marginBottom: 8, + padding: 8, }, title: { display: 'flex', @@ -336,45 +132,16 @@ const styles = stylex.create({ fontWeight: 600, fontSize: 13, }, - button: { - borderWidth: 1, - borderStyle: 'solid', - borderColor: '#d1d9e0', - backgroundColor: '#f6f8fa', - padding: '4px 8px', - borderRadius: 6, - cursor: 'pointer', - }, - buttonHover: { - borderColor: { ':hover': '#0969da' }, - }, + status: { - color: '#5e636a', + color: colors.textMuted, marginTop: 6, marginBottom: 10, }, statusError: { color: '#cf222e', }, - section: { - borderWidth: 1, - borderStyle: 'solid', - borderColor: '#d1d9e0', - borderRadius: 8, - padding: 8, - marginTop: 10, - marginBottom: 10, - backgroundColor: '#ffffff', - }, - sectionTitle: { - marginTop: 0, - marginBottom: 8, - fontSize: 12, - fontWeight: 600, - }, - muted: { - color: '#5e636a', - }, + pill: { display: 'inline-block', paddingTop: 1, @@ -384,94 +151,9 @@ const styles = stylex.create({ borderRadius: 999, borderWidth: 1, borderStyle: 'solid', - borderColor: '#d1d9e0', - backgroundColor: '#f6f8fa', - color: '#5e636a', + borderColor: colors.border, + backgroundColor: colors.bgRaised, + color: colors.textMuted, fontSize: 11, }, - sourcesList: { - display: 'grid', - gap: 6, - }, - sourceEntry: { - display: 'grid', - gap: 6, - }, - sourceRow: { - display: 'flex', - alignItems: 'center', - gap: 8, - }, - sourcePath: { - flex: 1, - fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - color: '#1f2328', - wordBreak: 'break-word', - }, - sourcePreview: { - borderWidth: 1, - borderStyle: 'solid', - borderColor: '#d1d9e0', - borderRadius: 8, - backgroundColor: '#ffffff', - padding: 8, - marginLeft: 28, - }, - sourcePreviewHeader: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8, - marginBottom: 6, - }, - sourcePreviewUrl: { - flex: 1, - fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - color: '#5e636a', - wordBreak: 'break-word', - }, - sourcePreviewCode: { - fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - whiteSpace: 'pre', - overflow: 'auto', - maxHeight: 220, - backgroundColor: '#f6f8fa', - borderWidth: 1, - borderStyle: 'solid', - borderColor: '#d1d9e0', - borderRadius: 6, - padding: 8, - }, - classList: { - display: 'grid', - gap: 8, - }, - classBlock: { - borderWidth: 1, - borderStyle: 'solid', - borderColor: '#d1d9e0', - borderRadius: 8, - padding: 8, - backgroundColor: '#f6f8fa', - }, - className: { - fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - fontWeight: 600, - marginBottom: 6, - wordBreak: 'break-word', - }, - declList: { - display: 'grid', - gap: 4, - }, - declLine: { - fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - whiteSpace: 'pre-wrap', - wordBreak: 'break-word', - }, }); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Button.js b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js new file mode 100644 index 000000000..209ff2a2b --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js @@ -0,0 +1,46 @@ +/** + * 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 * as stylex from '@stylexjs/stylex'; +import { colors } from '../theme.stylex'; + +export function Button({ + onClick, + children, + title, +}: { + onClick: (e: MouseEvent) => mixed, + children: React.Node, + title?: string, +}): React.Node { + return ( + + ); +} + +const styles = stylex.create({ + button: { + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + backgroundColor: { default: colors.bgRaised, ':active': colors.bg }, + paddingBlock: 4, + paddingInline: 8, + borderRadius: 8, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js new file mode 100644 index 000000000..f9f80769c --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -0,0 +1,103 @@ +/** + * 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 * as stylex from '@stylexjs/stylex'; +import { colors } from '../theme.stylex'; + +export function DeclarationsList({ + classes, +}: { + classes: $ReadOnlyArray< + $ReadOnly<{ + name: string, + declarations: $ReadOnlyArray<{ + property: string, + value: string, + important: boolean, + ... + }>, + ... + }>, + >, +}): React.Node { + if (classes.length === 0) { + return ( +
+ No matching StyleX CSS rules found for the selected element. +
+ ); + } + + return ( +
+ {classes.map((entry) => ( +
+
+ {entry.declarations.map((decl, i) => { + const value = decl.value + (decl.important ? ' !important' : ''); + return ( +
+ + {decl.property} + + : {value}; +
+ ); + })} +
+
{entry.name}
+
+ ))} +
+ ); +} + +const styles = stylex.create({ + muted: { + color: colors.textMuted, + }, + classList: { + display: 'flex', + flexDirection: 'column', + gap: 8, + paddingBlock: 8, + }, + classBlock: { + position: 'relative', + display: 'flex', + justifyContent: 'space-between', + }, + className: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + '::before': { + content: '.', + }, + color: colors.textMuted, + }, + declList: { + display: 'grid', + gap: 4, + }, + declLine: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + }, + declProperty: { + color: colors.textAccent, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Logo.js b/packages/@stylexjs/devtools-extension/src/panel/components/Logo.js new file mode 100644 index 000000000..69b46e6c8 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Logo.js @@ -0,0 +1,179 @@ +/** + * 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 + */ + +import * as React from 'react'; +import * as stylex from '@stylexjs/stylex'; +import type { StyleXStyles } from '@stylexjs/stylex'; + +export const viewBox = '0 0 644 435'; + +export function LogoText(): React.Node { + return ( + + + + ); +} + +export default function Logo({ + xstyle, +}: { + xstyle: StyleXStyles<>, +}): React.Node { + const idA = 'a'; + const idB = 'b'; + const idC = 'c'; + const idD = 'd'; + const idE = 'e'; + const idF = 'f'; + const idG = 'g'; + const idH = 'h'; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Section.js b/packages/@stylexjs/devtools-extension/src/panel/components/Section.js new file mode 100644 index 000000000..e4c6074fe --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Section.js @@ -0,0 +1,41 @@ +/** + * 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 * as stylex from '@stylexjs/stylex'; + +export function Section({ + title, + children, +}: { + title: string, + children: React.Node, +}): React.Node { + return ( +
+

{title}

+ {children} +
+ ); +} + +const styles = stylex.create({ + section: { + marginTop: 16, + padding: 8, + }, + sectionTitle: { + marginTop: 0, + marginBottom: 8, + fontSize: '1rem', + fontWeight: 800, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js index 41b50aa40..cd47f745a 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js @@ -13,11 +13,13 @@ import * as React from 'react'; import { useState, useCallback } from 'react'; import * as stylex from '@stylexjs/stylex'; import type { SourcePreview } from '../../types'; -import { openInVsCodeFromStylexSource } from '../../utils/vscode'; +// import { openInVsCodeFromStylexSource } from '../../utils/vscode'; import { getSourcePreview, openSourceBestEffort, } from '../../devtools/resources'; +import { Button } from './Button'; +import { colors } from '../theme.stylex'; export function SourceRow({ src, @@ -69,11 +71,8 @@ export function SourceRow({ {index + 1} - - {src.raw} - - - + {isPreviewOpen ? ( @@ -110,13 +104,7 @@ export function SourceRow({
{preview?.url ? preview.url : src.file}
- +
             {isLoadingPreview ? 'Loading…' : (preview?.snippet ?? '')}
@@ -128,18 +116,6 @@ export function SourceRow({
 }
 
 const styles = stylex.create({
-  button: {
-    borderWidth: 1,
-    borderStyle: 'solid',
-    borderColor: {
-      default: '#d1d9e0',
-      ':hover': '#0969da',
-    },
-    backgroundColor: '#f6f8fa',
-    padding: '4px 8px',
-    borderRadius: 6,
-    cursor: 'pointer',
-  },
   pill: {
     display: 'inline-block',
     paddingTop: 1,
@@ -149,9 +125,7 @@ const styles = stylex.create({
     borderRadius: 999,
     borderWidth: 1,
     borderStyle: 'solid',
-    borderColor: '#d1d9e0',
-    backgroundColor: '#f6f8fa',
-    color: '#5e636a',
+    borderColor: colors.border,
     fontSize: 11,
   },
 
@@ -165,18 +139,33 @@ const styles = stylex.create({
     gap: 8,
   },
   sourcePath: {
-    flex: 1,
+    appearance: 'none',
+    textAlign: 'start',
+    backgroundColor: 'transparent',
+    display: 'inline',
+    borderStyle: 'none',
+    cursor: 'pointer',
+    flexGrow: 1,
     fontFamily:
       'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
-    color: '#1f2328',
+    color: {
+      default: colors.textPrimary,
+      ':hover': colors.textAccent,
+      ':focus-visible': colors.textAccent,
+    },
+    textDecoration: {
+      default: 'none',
+      ':hover': 'underline',
+      ':focus-visible': 'underline',
+    },
     wordBreak: 'break-word',
   },
   sourcePreview: {
     borderWidth: 1,
     borderStyle: 'solid',
-    borderColor: '#d1d9e0',
+    borderColor: colors.border,
     borderRadius: 8,
-    backgroundColor: '#ffffff',
+    backgroundColor: colors.bgRaised,
     padding: 8,
     marginLeft: 28,
   },
@@ -191,7 +180,7 @@ const styles = stylex.create({
     flex: 1,
     fontFamily:
       'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace',
-    color: '#5e636a',
+    color: colors.textMuted,
     wordBreak: 'break-word',
   },
   sourcePreviewCode: {
@@ -200,10 +189,10 @@ const styles = stylex.create({
     whiteSpace: 'pre',
     overflow: 'auto',
     maxHeight: 220,
-    backgroundColor: '#f6f8fa',
+    backgroundColor: colors.bgRaised,
     borderWidth: 1,
     borderStyle: 'solid',
-    borderColor: '#d1d9e0',
+    borderColor: colors.border,
     borderRadius: 6,
     padding: 8,
   },
diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js
new file mode 100644
index 000000000..ca4051c27
--- /dev/null
+++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js
@@ -0,0 +1,58 @@
+/**
+ * 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
+ */
+
+import * as React from 'react';
+import { useRef } from 'react';
+import * as stylex from '@stylexjs/stylex';
+import type { SourcePreview } from '../../types';
+import { SourceRow } from './SourceRow';
+import { colors } from '../theme.stylex';
+
+export function SourcesList({
+  sources,
+  onError,
+}: {
+  sources: $ReadOnlyArray<{
+    raw: string,
+    file: string,
+    line: number | null,
+    ...
+  }>,
+  onError: (message: string) => void,
+}): React.Node {
+  const previewCacheRef = useRef>(new Map());
+
+  if (sources.length === 0) {
+    return 
No data-style-src found.
; + } + + return ( +
+ {sources.map((src, index) => ( + + ))} +
+ ); +} + +const styles = stylex.create({ + muted: { + color: colors.textMuted, + }, + sourcesList: { + display: 'grid', + gap: 6, + }, +}); diff --git a/packages/@stylexjs/devtools-extension/src/panel/index.css b/packages/@stylexjs/devtools-extension/src/panel/index.css index 8fbf27e29..1ac16b8e7 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/index.css +++ b/packages/@stylexjs/devtools-extension/src/panel/index.css @@ -3,6 +3,7 @@ body, #root { height: 100%; + color-scheme: light dark; } body { diff --git a/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js b/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js new file mode 100644 index 000000000..56532cbea --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js @@ -0,0 +1,23 @@ +/** + * 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 stylex from '@stylexjs/stylex'; + +const colorsValue = { + bg: 'light-dark(#ffffff, #282828)', + bgRaised: 'light-dark(#f6f8fa, #282828)', + textPrimary: 'light-dark(#000000, #ffffff)', + textMuted: 'light-dark(#757575, #999999)', + textAccent: 'light-dark(#dc362e, rgb(92 213 251 / 100%))', + border: 'light-dark(#d3e3fd, #5e5e5eff)', +}; + +export const colors: typeof colorsValue = stylex.defineConsts(colorsValue); From 5a3eed2912bd8d4381fa347551aa0378f102dcd5 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Fri, 19 Dec 2025 22:00:28 -0800 Subject: [PATCH 04/13] Use Suspense and use to resolve data promise --- .../devtools-extension/src/panel/App.jsx | 78 ++++++++++--------- .../src/panel/components/ErrorBoundary.js | 42 ++++++++++ 2 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index b1e20942c..bd4025cd4 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -10,10 +10,17 @@ 'use strict'; import * as React from 'react'; -import { useState, useCallback, useEffect, useRef } from 'react'; +import { + useState, + useCallback, + useEffect, + use, + startTransition, + Suspense, +} from 'react'; import * as stylex from '@stylexjs/stylex'; -import type { StatusState, StylexDebugData } from '../types.js'; +import type { StylexDebugData } from '../types.js'; import { subscribeToSelectionAndNavigation } from '../devtools/events.js'; import { evalInInspectedWindow } from '../devtools/api.js'; import { collectStylexDebugData } from '../inspected/collectStylexDebugData.js'; @@ -23,43 +30,47 @@ import { SourcesList } from './components/SourcesList'; import { Section } from './components/Section'; import { colors } from './theme.stylex'; import Logo from './components/Logo'; +import { ErrorBoundary } from './components/ErrorBoundary'; export function App(): React.Node { - const [data, setData] = useState(null); - const [_status, setStatus] = useState({ - message: 'Loading…', - kind: 'info', - }); - - const requestIdRef = useRef(0); + const [count, setCount] = useState(0); const refresh = useCallback(() => { - const requestId = requestIdRef.current + 1; - requestIdRef.current = requestId; - - setStatus({ message: 'Loading…', kind: 'info' }); - evalInInspectedWindow(collectStylexDebugData) - .then((result) => { - if (requestId !== requestIdRef.current) return; - setData(result); - setStatus({ message: 'Ready', kind: 'info' }); - }) - .catch((e) => { - console.error('RAN INTO ERROR', e); - if (requestId !== requestIdRef.current) return; - setStatus({ - message: e instanceof Error ? e.message : 'Unknown error.', - kind: 'error', - }); - }); + startTransition(() => { + setCount((x) => x + 1); + }); }, []); - useEffect(() => { - refresh(); - }, [refresh]); - useEffect(() => subscribeToSelectionAndNavigation(refresh), [refresh]); + return ( + Loading…}> +
Error: {error.message}
}> + +
+
+ ); +} + +let cache: ?[number, Promise] = null; +const debugDataPromise = (id: number): Promise => { + if (cache != null && cache[0] === id) { + return cache[1]; + } + const promise = evalInInspectedWindow(collectStylexDebugData); + cache = [id, promise]; + return promise; +}; + +function Panel({ + id, + refresh, +}: { + id: number, + refresh: () => void, +}): React.Node { + const data = use(debugDataPromise(id)); + const tagName = data?.element?.tagName ?? '—'; const classes = data?.applied?.classes ?? []; @@ -86,10 +97,7 @@ export function App(): React.Node { */}
- setStatus({ message: msg, kind: 'error' })} - sources={data?.sources ?? []} - /> + {}} sources={data?.sources ?? []} />
diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js b/packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js new file mode 100644 index 000000000..3520f9556 --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/panel/components/ErrorBoundary.js @@ -0,0 +1,42 @@ +/** + * 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'; + +type Props = { + children: React.Node, + fallback?: React.Node | ((error: Error) => React.Node), +}; + +type State = { + error: Error | null, +}; + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { error: null }; + } + + componentDidCatch(error: Error) { + this.setState({ error }); + } + + render(): React.Node { + const { fallback, children } = this.props; + if (this.state.error) { + return typeof fallback === 'function' + ? fallback(this.state.error) + : (fallback ?? null); + } + return children; + } +} From 3cf043e8049f390e9c5fb9a86022354d131fd2cc Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sat, 20 Dec 2025 00:29:00 -0800 Subject: [PATCH 05/13] bring back classNames. Decent for reading now --- .../src/app/components/Copy.tsx | 12 +- .../src/inspected/collectStylexDebugData.js | 179 ++++++++----- .../devtools-extension/src/panel/App.jsx | 5 +- .../src/panel/components/DeclarationsList.js | 239 +++++++++++++++--- .../@stylexjs/devtools-extension/src/types.js | 3 + 5 files changed, 331 insertions(+), 107 deletions(-) diff --git a/examples/example-redwoodsdk/src/app/components/Copy.tsx b/examples/example-redwoodsdk/src/app/components/Copy.tsx index d06537957..ec431fe37 100644 --- a/examples/example-redwoodsdk/src/app/components/Copy.tsx +++ b/examples/example-redwoodsdk/src/app/components/Copy.tsx @@ -22,7 +22,16 @@ export function Copy({ textToCopy }: { textToCopy: string }) { const styles = stylex.create({ copyButton: { - backgroundColor: 'transparent', + backgroundColor: { + default: 'transparent', + ':hover': 'rgba(255,255,255,0.1)', + ':focus-visible': 'rgba(255,255,255,0.1)', + '@media not (hover: hover)': { + default: 'rgba(255,255,255,0.1)', + ':hover': 'rgba(255,255,255,0.3)', + ':focus-visible': 'rgba(255,255,255,0.3)', + }, + }, color: '#ffad48', borderWidth: 0, borderRadius: 4, @@ -31,6 +40,5 @@ const styles = stylex.create({ cursor: 'pointer', fontSize: 16, fontWeight: 700, - ':hover': { backgroundColor: 'rgba(255,255,255,0.1)' }, }, }); diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index d9ba97f36..58712d673 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -14,6 +14,7 @@ import type { StylexDebugData } from '../types.js'; type RuleData = $ReadOnly<{ selectorText: string, classNames: Array, + conditions: Array, cssText: string, order: number, }>; @@ -33,16 +34,6 @@ export function collectStylexDebugData(): StylexDebugData { return rule.type === 1; } - function isCSSMediaRule(rule: CSSRule): implies rule is CSSMediaRule { - // $FlowExpectedError[incompatible-type-guard] - return rule.type === 4; - } - - function isCSSSupportsRule(rule: CSSRule): implies rule is CSSSupportsRule { - // $FlowExpectedError[incompatible-type-guard] - return rule.type === 12; - } - function parseDataStyleSrc(raw: string): Array { if (typeof raw !== 'string' || raw.trim() === '') return []; return raw @@ -73,6 +64,65 @@ export function collectStylexDebugData(): StylexDebugData { .filter(Boolean); } + const LAYER_POLYFILL_RE = /:not\(#\\#\)/g; + + function stripLayerPolyfill(selectorText: string): { + cleaned: string, + hasLayerPolyfill: boolean, + } { + if (!selectorText) { + return { cleaned: selectorText, hasLayerPolyfill: false }; + } + const cleaned = selectorText.replaceAll(LAYER_POLYFILL_RE, ''); + return { + cleaned, + hasLayerPolyfill: cleaned !== selectorText, + }; + } + + function getAtRuleCondition(rule: CSSRule): string | null { + if (!rule || typeof rule.cssText !== 'string') return null; + const braceIndex = rule.cssText.indexOf('{'); + if (braceIndex === -1) return null; + const prelude = rule.cssText.slice(0, braceIndex).trim(); + if (!prelude.startsWith('@')) return null; + if (prelude.startsWith('@layer')) return null; + return prelude; + } + + function parseSelectorCondition(selectorText: string): null | { + baseSelector: string, + pseudoCondition: string | null, + pseudoElementKey: string | null, + } { + const trimmed = selectorText.trim(); + if (!trimmed || trimmed[0] !== '.') return null; + const { cleaned } = stripLayerPolyfill(trimmed); + const firstColonIndex = cleaned.indexOf(':'); + if (firstColonIndex === -1) { + return { + baseSelector: cleaned, + pseudoCondition: null, + pseudoElementKey: null, + }; + } + const baseSelector = cleaned.slice(0, firstColonIndex).trim(); + const suffix = cleaned.slice(firstColonIndex).trim(); + const pseudoElementIndex = suffix.indexOf('::'); + if (pseudoElementIndex !== -1) { + return { + baseSelector, + pseudoCondition: null, + pseudoElementKey: suffix, + }; + } + return { + baseSelector, + pseudoCondition: suffix || null, + pseudoElementKey: null, + }; + } + function extractClassNames(selectorText: string): Array { const out = []; const re = /\.([_a-zA-Z0-9-]+)/g; @@ -99,7 +149,7 @@ export function collectStylexDebugData(): StylexDebugData { } if (!rules) return; - function walkRules(ruleList: CSSRuleList) { + function walkRules(ruleList: CSSRuleList, conditions: Array) { for (let i = 0; i < ruleList.length; i += 1) { const rule = ruleList[i]; if (!rule) continue; @@ -122,61 +172,34 @@ export function collectStylexDebugData(): StylexDebugData { out.push({ selectorText, classNames, + conditions, cssText: rule.cssText, order: state.ruleOrder++, }); continue; } - // CSSRule.MEDIA_RULE === 4 - if (isCSSMediaRule(rule) && rule.conditionText && rule.cssRules) { - let matches = false; - try { - matches = window.matchMedia(rule.conditionText).matches; - } catch { - matches = false; - } - if (matches) { - walkRules(rule.cssRules); - } - continue; - } - - // CSSRule.SUPPORTS_RULE === 12 - if (isCSSSupportsRule(rule) && rule.conditionText && rule.cssRules) { - let matches = false; - try { - // $FlowFixMe[cannot-resolve-name] - matches = CSS.supports(rule.conditionText); - } catch { - matches = false; - } - if (matches) { - walkRules(rule.cssRules); - } - continue; - } - if ('cssRules' in rule) { + const atCondition = getAtRuleCondition(rule); + const nextConditions = atCondition + ? [...conditions, atCondition] + : conditions; // $FlowFixMe[prop-missing] - walkRules(rule.cssRules); + walkRules(rule.cssRules, nextConditions); } } } - walkRules(rules); + walkRules(rules, []); } - function tryMatchSelector(element: HTMLElement, selectorText: string) { - const selectors = splitSelectors(selectorText); - for (const selector of selectors) { - try { - if (element.matches(selector)) return selector; - } catch { - // ignore invalid selectors (e.g. some pseudo-elements) - } + function matchesSelector(element: HTMLElement, selectorText: string) { + try { + return element.matches(selectorText); + } catch { + // ignore invalid selectors (e.g. some pseudo-elements) + return false; } - return null; } function stripCssComments(cssText: string) { @@ -334,24 +357,48 @@ export function collectStylexDebugData(): StylexDebugData { const classToDecls = new Map>(); for (const rule of rules) { - const matchedSelector = tryMatchSelector(element, rule.selectorText); - if (!matchedSelector) continue; - - const matchedClasses = rule.classNames.filter((cls: string) => - elementClassSet.has(cls), - ); - const uniqueMatchedClasses = Array.from(new Set(matchedClasses)); - if (uniqueMatchedClasses.length === 0) continue; - const decls = parseDeclarationsFromRuleCssText(rule.cssText); if (decls.length === 0) continue; - for (const cls of uniqueMatchedClasses) { - const declList = classToDecls.get(cls); - if (declList == null) { - classToDecls.set(cls, [...decls]); - } else { - declList.push(...decls); + const selectors = splitSelectors(rule.selectorText); + for (const selector of selectors) { + const selectorInfo = parseSelectorCondition(selector); + if (!selectorInfo) continue; + const { baseSelector, pseudoCondition, pseudoElementKey } = selectorInfo; + + const matchedClasses = extractClassNames(baseSelector).filter( + (cls: string) => elementClassSet.has(cls), + ); + const uniqueMatchedClasses = Array.from(new Set(matchedClasses)); + if (uniqueMatchedClasses.length === 0) continue; + + if (!baseSelector || !matchesSelector(element, baseSelector)) continue; + + const conditionParts: Array = []; + for (const entry of rule.conditions) { + if (!conditionParts.includes(entry)) conditionParts.push(entry); + } + + if (pseudoCondition && !conditionParts.includes(pseudoCondition)) { + conditionParts.push(pseudoCondition); + } + const condition = + conditionParts.length > 0 ? conditionParts.join(', ') : 'default'; + + for (const cls of uniqueMatchedClasses) { + const pseudoElementValue = pseudoElementKey || undefined; + const declsWithCondition = decls.map((decl) => ({ + ...decl, + condition, + className: cls, + ...(pseudoElementValue ? { pseudoElement: pseudoElementValue } : {}), + })); + const declList = classToDecls.get(cls); + if (declList == null) { + classToDecls.set(cls, [...declsWithCondition]); + } else { + declList.push(...declsWithCondition); + } } } } diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index bd4025cd4..d8b94e57f 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -32,11 +32,14 @@ import { colors } from './theme.stylex'; import Logo from './components/Logo'; import { ErrorBoundary } from './components/ErrorBoundary'; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + export function App(): React.Node { const [count, setCount] = useState(0); const refresh = useCallback(() => { - startTransition(() => { + startTransition(async () => { + await sleep(2000); setCount((x) => x + 1); }); }, []); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js index f9f80769c..f04cc6b88 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -13,18 +13,23 @@ import * as React from 'react'; import * as stylex from '@stylexjs/stylex'; import { colors } from '../theme.stylex'; +type TDeclaration = $ReadOnly<{ + property: string, + value: string, + important: boolean, + condition?: string, + pseudoElement?: string, + className?: string, + ... +}>; + export function DeclarationsList({ classes, }: { classes: $ReadOnlyArray< $ReadOnly<{ name: string, - declarations: $ReadOnlyArray<{ - property: string, - value: string, - important: boolean, - ... - }>, + declarations: $ReadOnlyArray, ... }>, >, @@ -37,29 +42,154 @@ export function DeclarationsList({ ); } - return ( -
- {classes.map((entry) => ( -
-
- {entry.declarations.map((decl, i) => { - const value = decl.value + (decl.important ? ' !important' : ''); - return ( -
- - {decl.property} - - : {value}; -
- ); + type TSection = $ReadOnly<{ + propertyOrder: Array, + propertyToEntries: Map>, + }>; + + const sectionOrder: Array = []; + const sectionMap = new Map(); + + for (const entry of classes) { + for (const decl of entry.declarations) { + const sectionKey = decl.pseudoElement ?? ''; + let section = sectionMap.get(sectionKey); + if (section == null) { + section = { + propertyOrder: [], + propertyToEntries: new Map(), + }; + sectionMap.set(sectionKey, section); + sectionOrder.push(sectionKey); + } + const bucket = section.propertyToEntries.get(decl.property); + if (bucket == null) { + section.propertyOrder.push(decl.property); + section.propertyToEntries.set(decl.property, [decl]); + } else { + bucket.push(decl); + } + } + } + + if (sectionOrder.length === 0) { + return ( +
+ No matching StyleX CSS rules found for the selected element. +
+ ); + } + + const baseIndex = sectionOrder.indexOf(''); + if (baseIndex > 0) { + sectionOrder.splice(baseIndex, 1); + sectionOrder.unshift(''); + } + + function renderProperties(sectionKey: string, section: TSection): React.Node { + const { propertyOrder, propertyToEntries } = section; + return propertyOrder.map((property) => { + const entries = propertyToEntries.get(property) ?? []; + if (entries.length === 1) { + const entry = entries[0]; + const value = entry.value + (entry.important ? ' !important' : ''); + return ( +
+
+ {property}:{' '} + {value} +
+ {entry.className ? ( + {entry.className} + ) : null} +
+ ); + } + + const conditionOrder = []; + const conditionMap = new Map>(); + for (const entry of entries) { + const condition = entry.condition ?? 'default'; + const bucket = conditionMap.get(condition); + if (bucket == null) { + conditionOrder.push(condition); + conditionMap.set(condition, [entry]); + } else { + bucket.push(entry); + } + } + const defaultIndex = conditionOrder.indexOf('default'); + if (defaultIndex > 0) { + conditionOrder.splice(defaultIndex, 1); + conditionOrder.unshift('default'); + } + + return ( +
+
+ {property}: +
+
+ {conditionOrder.map((condition) => { + const bucket = conditionMap.get(condition) ?? []; + return bucket.map((entry, index) => { + const value = + entry.value + (entry.important ? ' !important' : ''); + const label = + condition === 'default' ? 'default' : `'${condition}'`; + return ( +
+
+ + {label} + + : {value} +
+ {entry.className ? ( + + {entry.className} + + ) : null} +
+ ); + }); })}
-
{entry.name}
- ))} + ); + }); + } + + return ( +
+ {sectionOrder.map((sectionKey) => { + const section = sectionMap.get(sectionKey); + if (!section) return null; + if (sectionKey === '') { + return ( + + {renderProperties('base', section)} + + ); + } + return ( +
+
{sectionKey}
+
+ {renderProperties(sectionKey, section)} +
+
+ ); + })}
); } @@ -68,36 +198,69 @@ const styles = stylex.create({ muted: { color: colors.textMuted, }, - classList: { + declList: { display: 'flex', flexDirection: 'column', gap: 8, paddingBlock: 8, }, - classBlock: { - position: 'relative', + declRow: { display: 'flex', + alignItems: 'baseline', justifyContent: 'space-between', + gap: 12, }, - className: { + declText: { + flex: 1, + minWidth: 0, + }, + declLine: { fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - '::before': { - content: '.', - }, - color: colors.textMuted, + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', }, - declList: { + declProperty: { + color: colors.textAccent, + }, + declGroup: { display: 'grid', gap: 4, }, - declLine: { + declSubList: { + display: 'grid', + gap: 2, + paddingLeft: 12, + }, + declSubLine: { fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', whiteSpace: 'pre-wrap', wordBreak: 'break-word', }, - declProperty: { - color: colors.textAccent, + declCondition: { + color: colors.textMuted, + }, + pseudoSection: { + display: 'grid', + gap: 6, + }, + pseudoTitle: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + color: colors.textMuted, + }, + sectionList: { + display: 'flex', + flexDirection: 'column', + gap: 8, + }, + className: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + '::before': { + content: '.', + }, + color: colors.textMuted, }, }); diff --git a/packages/@stylexjs/devtools-extension/src/types.js b/packages/@stylexjs/devtools-extension/src/types.js index 706033b97..56dac2eb6 100644 --- a/packages/@stylexjs/devtools-extension/src/types.js +++ b/packages/@stylexjs/devtools-extension/src/types.js @@ -26,6 +26,9 @@ export type StylexDeclaration = { property: string, value: string, important: boolean, + condition?: string, + pseudoElement?: string, + className?: string, ... }; From 54f34f5932f7659abe40649d87850941aaa474f0 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sat, 20 Dec 2025 01:00:15 -0800 Subject: [PATCH 06/13] Fix delay issue. Better style reading. --- .../src/inspected/collectStylexDebugData.js | 12 ++---------- .../@stylexjs/devtools-extension/src/panel/App.jsx | 3 --- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index 58712d673..a92646c10 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -339,17 +339,9 @@ export function collectStylexDebugData(): StylexDebugData { const state = { ruleOrder: 0, skippedSheets: 0 }; const rules: Array = []; - const stylexStyleEls: Array = Array.from( - document.querySelectorAll('style'), + const sheets: Array = Array.from( + document.styleSheets, ) as $FlowFixMe; - const preferredSheets = stylexStyleEls - .map((el: HTMLStyleElement) => el.sheet) - .filter(Boolean); - - const sheets: Array = - preferredSheets.length > 0 - ? preferredSheets - : (Array.from(document.styleSheets) as $FlowFixMe); for (const sheet of sheets) { collectStyleRulesFromSheet(sheet, elementClassSet, rules, state); diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index d8b94e57f..bed76f87e 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -32,14 +32,11 @@ import { colors } from './theme.stylex'; import Logo from './components/Logo'; import { ErrorBoundary } from './components/ErrorBoundary'; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - export function App(): React.Node { const [count, setCount] = useState(0); const refresh = useCallback(() => { startTransition(async () => { - await sleep(2000); setCount((x) => x + 1); }); }, []); From b8c406030d2d24efd50954bf6f066c823a13580e Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sat, 20 Dec 2025 01:09:21 -0800 Subject: [PATCH 07/13] Tooltips with computed styles --- .../src/inspected/collectStylexDebugData.js | 16 ++++++++++++++ .../devtools-extension/src/panel/App.jsx | 3 ++- .../src/panel/components/DeclarationsList.js | 21 ++++++++++++++++--- .../@stylexjs/devtools-extension/src/types.js | 1 + 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index a92646c10..a76a6f83f 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -323,11 +323,20 @@ export function collectStylexDebugData(): StylexDebugData { return { element: { tagName: '—' }, sources: [], + computed: {}, applied: { classes: [] }, }; } const tagName = safeString(element.tagName).toLowerCase(); + const computedStyle = window.getComputedStyle(element); + const computed: { [string]: string } = {}; + for (let i = 0; i < computedStyle.length; i += 1) { + const prop = computedStyle[i]; + if (!prop) continue; + const value = computedStyle.getPropertyValue(prop); + computed[prop] = value ? value.trim() : ''; + } const classAttr: string = safeString(element.getAttribute('class')); const classesOrdered = classAttr.trim() ? classAttr.trim().split(/\s+/) : []; const elementClassSet = new Set(classesOrdered); @@ -391,6 +400,12 @@ export function collectStylexDebugData(): StylexDebugData { } else { declList.push(...declsWithCondition); } + for (const decl of decls) { + if (computed[decl.property] == null) { + const value = computedStyle.getPropertyValue(decl.property); + computed[decl.property] = value ? value.trim() : ''; + } + } } } } @@ -405,6 +420,7 @@ export function collectStylexDebugData(): StylexDebugData { return { element: { tagName }, sources, + computed, applied: { classes }, }; } diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index bed76f87e..384618428 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -74,6 +74,7 @@ function Panel({ const tagName = data?.element?.tagName ?? '—'; const classes = data?.applied?.classes ?? []; + const computed = data?.computed ?? {}; return (
@@ -101,7 +102,7 @@ function Panel({
- +
); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js index f04cc6b88..b09aaf90c 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -25,6 +25,7 @@ type TDeclaration = $ReadOnly<{ export function DeclarationsList({ classes, + computed, }: { classes: $ReadOnlyArray< $ReadOnly<{ @@ -33,6 +34,7 @@ export function DeclarationsList({ ... }>, >, + computed: { [string]: string, ... }, }): React.Node { if (classes.length === 0) { return ( @@ -90,6 +92,8 @@ export function DeclarationsList({ const { propertyOrder, propertyToEntries } = section; return propertyOrder.map((property) => { const entries = propertyToEntries.get(property) ?? []; + const computedValue = computed[property]; + const computedTitle = computedValue ? computedValue.trim() : ''; if (entries.length === 1) { const entry = entries[0]; const value = entry.value + (entry.important ? ' !important' : ''); @@ -99,8 +103,13 @@ export function DeclarationsList({ {...stylex.props(styles.declRow)} >
- {property}:{' '} - {value} + + {property} + + : {value}
{entry.className ? ( {entry.className} @@ -133,7 +142,13 @@ export function DeclarationsList({ {...stylex.props(styles.declGroup)} >
- {property}: + + {property} + + :
{conditionOrder.map((condition) => { diff --git a/packages/@stylexjs/devtools-extension/src/types.js b/packages/@stylexjs/devtools-extension/src/types.js index 56dac2eb6..970f9a460 100644 --- a/packages/@stylexjs/devtools-extension/src/types.js +++ b/packages/@stylexjs/devtools-extension/src/types.js @@ -42,6 +42,7 @@ export type StylexDebugData = $ReadOnly<{ tagName: string, }, sources: Array, + computed: { [string]: string, ... }, applied: { classes: Array, }, From b360018f9dd0d3ffa09cb0c11686a1f5e4f17eb7 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sat, 20 Dec 2025 01:48:12 -0800 Subject: [PATCH 08/13] Further group conditions --- package-lock.json | 55 ++++++ .../src/inspected/collectStylexDebugData.js | 8 +- .../src/panel/components/DeclarationsList.js | 156 ++++++++++++++---- .../@stylexjs/devtools-extension/src/types.js | 1 + 4 files changed, 180 insertions(+), 40 deletions(-) diff --git a/package-lock.json b/package-lock.json index e90996f16..f16bc0d04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38041,6 +38041,7 @@ "@babel/plugin-transform-flow-strip-types": "^7.27.1", "@rollup/plugin-babel": "^6.0.4", "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.3.0", "@rollup/plugin-replace": "^6.0.1", "@stylexjs/unplugin": "0.17.3", @@ -38096,6 +38097,60 @@ } } }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/babel-plugin": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/babel-plugin/-/babel-plugin-0.17.3.tgz", + "integrity": "sha512-C9KOQUa+PS7Ef6KGLfEuevzP5lA183g5iu+xito14tN1Tx4OfQe5oEo6O+ntHSVdyZGsnLj6aCr9elVg3yLoOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.8", + "@babel/helper-module-imports": "^7.25.9", + "@babel/traverse": "^7.26.8", + "@babel/types": "^7.26.8", + "@dual-bundle/import-meta-resolve": "^4.1.0", + "@stylexjs/shared": "0.17.3", + "@stylexjs/stylex": "0.17.3", + "postcss-value-parser": "^4.1.0" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/shared": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/shared/-/shared-0.17.3.tgz", + "integrity": "sha512-cnGYrskfEa8QGFFJG1JYCqPAstxFqe/aU7xuTDIIbL+38DQ4MDqSJOQFOWrqqma72UZXuGHAcJ7NqqnCVTKXTA==", + "dev": true, + "license": "MIT" + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/stylex": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/stylex/-/stylex-0.17.3.tgz", + "integrity": "sha512-uJKi6sWvsZo7A0Hku0SjnnxsYkQVyi/e1g34/Uik3PXLBVnGMZ+6N9NiwVGdfGQ+/ulqUqEHktROG02279Ug0Q==", + "license": "MIT", + "dependencies": { + "css-mediaquery": "^0.1.2", + "invariant": "^2.2.4", + "styleq": "0.2.1" + } + }, + "packages/@stylexjs/devtools-extension/node_modules/@stylexjs/unplugin": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@stylexjs/unplugin/-/unplugin-0.17.3.tgz", + "integrity": "sha512-v0b7CaLq1qD1qtOQtwly0ERPH+B9dAXwrNUpEFP4XupPRXAhcusmDnDtKddP2tOgS/aCsKgb0ElxOJ9ufJ2a/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.8", + "@babel/plugin-syntax-flow": "^7.26.0", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9", + "@stylexjs/babel-plugin": "0.17.3", + "browserslist": "^4.24.0", + "lightningcss": "^1.29.1" + }, + "peerDependencies": { + "unplugin": "^2.3.11" + } + }, "packages/@stylexjs/devtools-extension/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index a76a6f83f..f0cf86a18 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -68,15 +68,13 @@ export function collectStylexDebugData(): StylexDebugData { function stripLayerPolyfill(selectorText: string): { cleaned: string, - hasLayerPolyfill: boolean, } { if (!selectorText) { - return { cleaned: selectorText, hasLayerPolyfill: false }; + return { cleaned: selectorText }; } - const cleaned = selectorText.replaceAll(LAYER_POLYFILL_RE, ''); + const cleaned = selectorText.replace(LAYER_POLYFILL_RE, ''); return { cleaned, - hasLayerPolyfill: cleaned !== selectorText, }; } @@ -379,7 +377,6 @@ export function collectStylexDebugData(): StylexDebugData { for (const entry of rule.conditions) { if (!conditionParts.includes(entry)) conditionParts.push(entry); } - if (pseudoCondition && !conditionParts.includes(pseudoCondition)) { conditionParts.push(pseudoCondition); } @@ -392,6 +389,7 @@ export function collectStylexDebugData(): StylexDebugData { ...decl, condition, className: cls, + ...(conditionParts.length > 0 ? { conditions: conditionParts } : {}), ...(pseudoElementValue ? { pseudoElement: pseudoElementValue } : {}), })); const declList = classToDecls.get(cls); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js index b09aaf90c..e819a8d48 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -18,6 +18,7 @@ type TDeclaration = $ReadOnly<{ value: string, important: boolean, condition?: string, + conditions?: $ReadOnlyArray, pseudoElement?: string, className?: string, ... @@ -48,6 +49,10 @@ export function DeclarationsList({ propertyOrder: Array, propertyToEntries: Map>, }>; + type TAtRuleGroup = $ReadOnly<{ + conditionOrder: Array, + conditionMap: Map>, + }>; const sectionOrder: Array = []; const sectionMap = new Map(); @@ -88,6 +93,51 @@ export function DeclarationsList({ sectionOrder.unshift(''); } + function getConditionParts(entry: TDeclaration): Array { + if (entry.conditions && entry.conditions.length > 0) { + return [...entry.conditions]; + } + if (!entry.condition || entry.condition === 'default') return []; + return entry.condition + .split(',') + .map((part) => part.trim()) + .filter(Boolean); + } + + function renderConditionRows( + keyPrefix: string, + group: TAtRuleGroup, + ): React.Node { + const { conditionOrder, conditionMap } = group; + const ordered = conditionOrder.slice(); + const defaultIndex = ordered.indexOf('default'); + if (defaultIndex > 0) { + ordered.splice(defaultIndex, 1); + ordered.unshift('default'); + } + return ordered.map((condition) => { + const bucket = conditionMap.get(condition) ?? []; + const label = condition === 'default' ? 'default' : `'${condition}'`; + return bucket.map((entry, index) => { + const value = entry.value + (entry.important ? ' !important' : ''); + return ( +
+
+ {label}:{' '} + {value} +
+ {entry.className ? ( + {entry.className} + ) : null} +
+ ); + }); + }); + } + function renderProperties(sectionKey: string, section: TSection): React.Node { const { propertyOrder, propertyToEntries } = section; return propertyOrder.map((property) => { @@ -118,22 +168,40 @@ export function DeclarationsList({ ); } - const conditionOrder = []; - const conditionMap = new Map>(); + const atRuleOrder: Array = []; + const atRuleMap = new Map(); for (const entry of entries) { - const condition = entry.condition ?? 'default'; - const bucket = conditionMap.get(condition); + const parts = getConditionParts(entry); + const atRules = []; + const otherParts = []; + for (const part of parts) { + if (part.startsWith('@')) { + atRules.push(part); + } else if (part) { + otherParts.push(part); + } + } + const atRuleKey = atRules.join(', '); + const conditionKey = + otherParts.length > 0 ? otherParts.join(', ') : 'default'; + let group = atRuleMap.get(atRuleKey); + if (group == null) { + group = { conditionOrder: [], conditionMap: new Map() }; + atRuleMap.set(atRuleKey, group); + atRuleOrder.push(atRuleKey); + } + const bucket = group.conditionMap.get(conditionKey); if (bucket == null) { - conditionOrder.push(condition); - conditionMap.set(condition, [entry]); + group.conditionOrder.push(conditionKey); + group.conditionMap.set(conditionKey, [entry]); } else { bucket.push(entry); } } - const defaultIndex = conditionOrder.indexOf('default'); - if (defaultIndex > 0) { - conditionOrder.splice(defaultIndex, 1); - conditionOrder.unshift('default'); + const baseAtRuleIndex = atRuleOrder.indexOf(''); + if (baseAtRuleIndex > 0) { + atRuleOrder.splice(baseAtRuleIndex, 1); + atRuleOrder.unshift(''); } return ( @@ -151,32 +219,37 @@ export function DeclarationsList({ :
- {conditionOrder.map((condition) => { - const bucket = conditionMap.get(condition) ?? []; - return bucket.map((entry, index) => { - const value = - entry.value + (entry.important ? ' !important' : ''); - const label = - condition === 'default' ? 'default' : `'${condition}'`; + {atRuleOrder.map((atRuleKey) => { + const group = atRuleMap.get(atRuleKey); + if (!group) return null; + if (atRuleKey === '') { return ( -
-
- - {label} - - : {value} -
- {entry.className ? ( - - {entry.className} - - ) : null} -
+ + {renderConditionRows( + `${sectionKey}:${property}:base`, + group, + )} + ); - }); + } + return ( +
+
+ + {`'${atRuleKey}'`} + +
+
+ {renderConditionRows( + `${sectionKey}:${property}:${atRuleKey}`, + group, + )} +
+
+ ); })}
@@ -270,12 +343,25 @@ const styles = stylex.create({ flexDirection: 'column', gap: 8, }, + atRuleGroup: { + display: 'grid', + gap: 2, + }, + atRuleTitle: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + }, + atRuleList: { + display: 'grid', + gap: 2, + paddingLeft: 12, + }, className: { + color: colors.textMuted, fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', '::before': { content: '.', }, - color: colors.textMuted, }, }); diff --git a/packages/@stylexjs/devtools-extension/src/types.js b/packages/@stylexjs/devtools-extension/src/types.js index 970f9a460..1cd164500 100644 --- a/packages/@stylexjs/devtools-extension/src/types.js +++ b/packages/@stylexjs/devtools-extension/src/types.js @@ -27,6 +27,7 @@ export type StylexDeclaration = { value: string, important: boolean, condition?: string, + conditions?: $ReadOnlyArray, pseudoElement?: string, className?: string, ... From 91a96f21475b4686d4cab2ede9de8d25a2f69441 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sat, 20 Dec 2025 16:10:49 -0800 Subject: [PATCH 09/13] Better error recovery --- .../devtools-extension/src/devtools/api.js | 5 +- .../src/inspected/collectStylexDebugData.js | 10 ++- .../devtools-extension/src/panel/App.jsx | 87 ++++++++++++++++--- .../src/panel/components/Button.js | 7 +- .../src/panel/components/SourceRow.js | 17 ++-- 5 files changed, 98 insertions(+), 28 deletions(-) diff --git a/packages/@stylexjs/devtools-extension/src/devtools/api.js b/packages/@stylexjs/devtools-extension/src/devtools/api.js index 0026eb2d9..41ac4e5e7 100644 --- a/packages/@stylexjs/devtools-extension/src/devtools/api.js +++ b/packages/@stylexjs/devtools-extension/src/devtools/api.js @@ -24,7 +24,10 @@ export function evalInInspectedWindow( options?: InspectedWindowEvalOptions, ): Promise { const expression = `(${fn.toString()})()`; - const mergedOptions = { includeCommandLineAPI: true, ...options }; + const mergedOptions = { + // includeCommandLineAPI: true, + ...options, + }; return new Promise((resolve, reject) => { devtools.inspectedWindow.eval( diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index f0cf86a18..db429129f 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -385,12 +385,16 @@ export function collectStylexDebugData(): StylexDebugData { for (const cls of uniqueMatchedClasses) { const pseudoElementValue = pseudoElementKey || undefined; - const declsWithCondition = decls.map((decl) => ({ + const declsWithCondition = decls.map((decl): $FlowFixMe => ({ ...decl, condition, className: cls, - ...(conditionParts.length > 0 ? { conditions: conditionParts } : {}), - ...(pseudoElementValue ? { pseudoElement: pseudoElementValue } : {}), + ...((conditionParts.length > 0 + ? { conditions: conditionParts } + : {}) as $FlowFixMe), + ...((pseudoElementValue + ? { pseudoElement: pseudoElementValue } + : {}) as $FlowFixMe), })); const declList = classToDecls.get(cls); if (declList == null) { diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index 384618428..f09f4c3b4 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -44,14 +44,42 @@ export function App(): React.Node { useEffect(() => subscribeToSelectionAndNavigation(refresh), [refresh]); return ( - Loading…}> -
Error: {error.message}
}> - + }> + ( + + )} + key={count} + > + ); } +function Loading() { + return ( +
+ +
+ ); +} + +function ErrorFallback({ + errorMessage, + retry, +}: { + errorMessage: string, + retry: () => void, +}) { + return ( +
+
{errorMessage}
+ +
+ ); +} + let cache: ?[number, Promise] = null; const debugDataPromise = (id: number): Promise => { if (cache != null && cache[0] === id) { @@ -76,6 +104,10 @@ function Panel({ const classes = data?.applied?.classes ?? []; const computed = data?.computed ?? {}; + const hasSources = data?.sources?.length > 0; + const hasClasses = classes.length > 0; + const showEmptyState = !hasSources && !hasClasses; + return (
@@ -97,25 +129,58 @@ function Panel({ {status.message}
*/} -
- {}} sources={data?.sources ?? []} /> -
- -
- -
+ {hasSources && ( +
+ {}} sources={data.sources} /> +
+ )} + + {hasClasses && ( +
+ +
+ )} + + {showEmptyState && ( +
No styles found
+ )} ); } const styles = stylex.create({ + fallbackContainer: { + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: 16, + alignItems: 'center', + justifyContent: 'center', + }, + errorMessage: { + boxSizing: 'border-box', + color: 'tomato', + fontSize: '0.8rem', + fontWeight: 400, + padding: 16, + width: '100%', + whiteSpace: 'pre-wrap', + }, + emptyState: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + color: colors.textMuted, + padding: 16, + }, root: { backgroundColor: colors.bg, color: colors.textPrimary, fontFamily: 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif', fontSize: 12, - height: '100%', + minHeight: '100%', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Button.js b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js index 209ff2a2b..f6b9bae2d 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/Button.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js @@ -38,7 +38,12 @@ const styles = stylex.create({ borderWidth: 1, borderStyle: 'solid', borderColor: colors.border, - backgroundColor: { default: colors.bgRaised, ':active': colors.bg }, + backgroundColor: { + default: colors.bgRaised, + ':hover': colors.bg, + ':active': colors.bg, + }, + transform: { default: null, ':active': 'scale(0.95)' }, paddingBlock: 4, paddingInline: 8, borderRadius: 8, diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js index cd47f745a..e74cd893f 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourceRow.js @@ -100,12 +100,6 @@ export function SourceRow({ {isPreviewOpen ? (
-
-
- {preview?.url ? preview.url : src.file} -
- -
             {isLoadingPreview ? 'Loading…' : (preview?.snippet ?? '')}
           
@@ -161,12 +155,11 @@ const styles = stylex.create({ wordBreak: 'break-word', }, sourcePreview: { - borderWidth: 1, - borderStyle: 'solid', - borderColor: colors.border, - borderRadius: 8, - backgroundColor: colors.bgRaised, - padding: 8, + // borderWidth: 1, + // borderStyle: 'solid', + // borderColor: colors.border, + // borderRadius: 8, + // backgroundColor: colors.bgRaised, marginLeft: 28, }, sourcePreviewHeader: { From db768b7b279c95388543b67e9c1ddd3a5c4ab7e4 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sun, 21 Dec 2025 02:11:34 -0800 Subject: [PATCH 10/13] Improve styling a bunch --- .../src/panel/components/Button.js | 4 +- .../src/panel/components/DeclarationsList.js | 2 +- .../src/panel/components/EyeIcon.js | 33 ++++ .../src/panel/components/SourceRow.js | 177 ++++++++++-------- .../src/panel/components/SourcesList.js | 18 +- .../devtools-extension/src/panel/index.css | 4 + .../src/panel/theme.stylex.js | 3 +- 7 files changed, 146 insertions(+), 95 deletions(-) create mode 100644 packages/@stylexjs/devtools-extension/src/panel/components/EyeIcon.js diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/Button.js b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js index f6b9bae2d..8ad2a60bf 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/Button.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/Button.js @@ -16,15 +16,17 @@ export function Button({ onClick, children, title, + xstyle, }: { onClick: (e: MouseEvent) => mixed, children: React.Node, title?: string, + xstyle?: stylex.StyleXStyles<>, }): React.Node { return ( */} - + */}
{isPreviewOpen ? (
-
-            {isLoadingPreview ? 'Loading…' : (preview?.snippet ?? '')}
-          
+
) : null} ); } +const cache: { [string]: Promise } = {}; +function getSourcePreviewPromise(file: string, line: number) { + const cacheKey = `${file}:${line}`; + if (cache[cacheKey]) { + return cache[cacheKey]; + } + const promise = getSourcePreview(file, line); + cache[cacheKey] = promise; + return promise; +} + +function SourceSnippet({ file, line }: { file: string, line: number }) { + const preview = use(getSourcePreviewPromise(file, line)); + + return ( +
+      {preview?.snippet ?? 'no source found'}
+    
+ ); +} + +function SourceSnippetSuspense({ file, line }: { file: string, line: number }) { + return ( + }> + + + ); +} + +function SourceSnippetFallback() { + return ( +
+ Loading… +
+ ); +} + const styles = stylex.create({ + icon: { + width: 16, + height: 16, + }, pill: { + appearance: 'none', + backgroundColor: { + default: 'transparent', + ':hover': colors.bgRaised, + ':focus-visible': colors.bgRaised, + }, + transform: { + default: null, + ':active': 'scale(0.95)', + }, display: 'inline-block', - paddingTop: 1, - paddingRight: 6, - paddingBottom: 1, - paddingLeft: 6, - borderRadius: 999, + paddingTop: 8, + paddingBottom: 2, + paddingInline: 4, + borderRadius: 8, borderWidth: 1, - borderStyle: 'solid', - borderColor: colors.border, - fontSize: 11, + borderStyle: 'none', + }, + pillActive: { + color: colors.textAccent, + }, + loading: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: colors.textMuted, }, - sourceEntry: { - display: 'grid', - gap: 6, + width: '100%', + maxWidth: '100%', + display: 'flex', + flexDirection: 'column', + gap: 4, }, sourceRow: { + width: '100%', display: 'flex', alignItems: 'center', - gap: 8, }, sourcePath: { appearance: 'none', @@ -154,39 +194,22 @@ const styles = stylex.create({ }, wordBreak: 'break-word', }, - sourcePreview: { - // borderWidth: 1, - // borderStyle: 'solid', - // borderColor: colors.border, - // borderRadius: 8, - // backgroundColor: colors.bgRaised, - marginLeft: 28, - }, - sourcePreviewHeader: { - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: 8, - marginBottom: 6, - }, - sourcePreviewUrl: { - flex: 1, - fontFamily: - 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', - color: colors.textMuted, - wordBreak: 'break-word', + buttonPending: { + opacity: 0.5, }, + sourcePreview: {}, sourcePreviewCode: { fontFamily: 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', whiteSpace: 'pre', + width: '100%', overflow: 'auto', - maxHeight: 220, backgroundColor: colors.bgRaised, borderWidth: 1, borderStyle: 'solid', borderColor: colors.border, borderRadius: 6, + margin: 0, padding: 8, }, }); diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js b/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js index ca4051c27..13fbb150e 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/SourcesList.js @@ -8,11 +8,8 @@ */ import * as React from 'react'; -import { useRef } from 'react'; import * as stylex from '@stylexjs/stylex'; -import type { SourcePreview } from '../../types'; import { SourceRow } from './SourceRow'; -import { colors } from '../theme.stylex'; export function SourcesList({ sources, @@ -26,20 +23,12 @@ export function SourcesList({ }>, onError: (message: string) => void, }): React.Node { - const previewCacheRef = useRef>(new Map()); - - if (sources.length === 0) { - return
No data-style-src found.
; - } - return (
{sources.map((src, index) => ( ))} @@ -48,11 +37,10 @@ export function SourcesList({ } const styles = stylex.create({ - muted: { - color: colors.textMuted, - }, sourcesList: { - display: 'grid', + width: '100%', + display: 'flex', + flexDirection: 'column', gap: 6, }, }); diff --git a/packages/@stylexjs/devtools-extension/src/panel/index.css b/packages/@stylexjs/devtools-extension/src/panel/index.css index 1ac16b8e7..f710d7ade 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/index.css +++ b/packages/@stylexjs/devtools-extension/src/panel/index.css @@ -9,4 +9,8 @@ body { margin: 0; } + + * { + box-sizing: border-box; + } } diff --git a/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js b/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js index 56532cbea..9ada3340b 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js +++ b/packages/@stylexjs/devtools-extension/src/panel/theme.stylex.js @@ -16,7 +16,8 @@ const colorsValue = { bgRaised: 'light-dark(#f6f8fa, #282828)', textPrimary: 'light-dark(#000000, #ffffff)', textMuted: 'light-dark(#757575, #999999)', - textAccent: 'light-dark(#dc362e, rgb(92 213 251 / 100%))', + textAccent: 'light-dark(#dc362e, rgb(92 213 251))', + secondaryAccent: 'light-dark(#0F7913, #73C89C)', border: 'light-dark(#d3e3fd, #5e5e5eff)', }; From 90ef7cc3ea143515b8c087a63f4c6dc61432c326 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sun, 21 Dec 2025 02:51:03 -0800 Subject: [PATCH 11/13] Small fixes --- .../src/panel/components/DeclarationsList.js | 662 ++++++++++++------ 1 file changed, 456 insertions(+), 206 deletions(-) diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js index 4e635e1de..b1f2fd68c 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -24,10 +24,62 @@ type TDeclaration = $ReadOnly<{ ... }>; -export function DeclarationsList({ - classes, - computed, -}: { +type TPropertyGroup = $ReadOnly<{ + property: string, + entries: $ReadOnlyArray, +}>; + +type TSection = $ReadOnly<{ + key: string, + properties: $ReadOnlyArray, +}>; + +type TConditionNode = { + key: string, + label: string, + entries: Array, + children: Array, +}; + +function getConditionParts(entry: TDeclaration): Array { + if (entry.conditions && entry.conditions.length > 0) { + return [...entry.conditions]; + } + if (!entry.condition || entry.condition === 'default') return []; + return entry.condition + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +} + +function moveKeyToFront(list: Array, key: string): Array { + if (!list.includes(key)) { + return list.slice(); + } + return [key, ...list.filter((item) => item !== key)]; +} + +function formatConditionLabel(label: string): string { + if (label === 'default') return 'default'; + return `'${label}'`; +} + +function isAtRuleLabel(label: string): boolean { + return label.startsWith('@'); +} + +function collapseDefaultChild(node: TConditionNode): TConditionNode { + if (node.children.length !== 1) return node; + const child = node.children[0]; + if (child.label !== 'default') return node; + return { + ...node, + entries: [...node.entries, ...child.entries], + children: child.children, + }; +} + +function buildSections( classes: $ReadOnlyArray< $ReadOnly<{ name: string, @@ -35,27 +87,15 @@ export function DeclarationsList({ ... }>, >, - computed: { [string]: string, ... }, -}): React.Node { - if (classes.length === 0) { - return ( -
- No matching StyleX CSS rules found for the selected element. -
- ); - } - - type TSection = $ReadOnly<{ - propertyOrder: Array, - propertyToEntries: Map>, - }>; - type TAtRuleGroup = $ReadOnly<{ - conditionOrder: Array, - conditionMap: Map>, - }>; - +): Array { const sectionOrder: Array = []; - const sectionMap = new Map(); + const sectionMap: Map< + string, + { + propertyOrder: Array, + propertyToEntries: Map>, + }, + > = new Map(); for (const entry of classes) { for (const decl of entry.declarations) { @@ -79,7 +119,172 @@ export function DeclarationsList({ } } - if (sectionOrder.length === 0) { + const orderedSections = moveKeyToFront(sectionOrder, ''); + const result: Array = []; + for (const sectionKey of orderedSections) { + const section = sectionMap.get(sectionKey); + if (!section) continue; + const properties = section.propertyOrder.map((property) => ({ + property, + entries: section.propertyToEntries.get(property) ?? [], + })); + result.push({ key: sectionKey, properties }); + } + return result; +} + +type TConditionSplit = { + atRuleKey: string, + conditionKey: string, + entry: TDeclaration, +}; + +type TGroupData = { + atRuleKey: string, + conditionOrder: Array, + conditions: { [string]: TConditionNode, ... }, +}; + +type TGroupState = { + atRuleOrder: Array, + groups: { [string]: TGroupData, ... }, +}; + +function ensureUnique(list: Array, value: string): Array { + return list.includes(value) ? list : [...list, value]; +} + +function partitionParts(parts: Array): { + atRules: Array, + others: Array, +} { + return parts.reduce( + (acc, part) => + part.startsWith('@') + ? { atRules: [...acc.atRules, part], others: acc.others } + : part + ? { atRules: acc.atRules, others: [...acc.others, part] } + : acc, + { atRules: [], others: [] }, + ); +} + +function splitConditions(entry: TDeclaration): TConditionSplit { + const parts = getConditionParts(entry); + const { atRules, others } = partitionParts(parts); + const atRuleKey = atRules.join(', '); + const conditionKey = others.length > 0 ? others.join(', ') : 'default'; + return { atRuleKey, conditionKey, entry }; +} + +function createConditionNode( + atRuleKey: string, + conditionKey: string, +): TConditionNode { + return { + key: `cond:${atRuleKey}:${conditionKey}`, + label: conditionKey, + entries: [], + children: [], + }; +} + +function updateGroupWithEntry( + group: TGroupData, + split: TConditionSplit, +): TGroupData { + const existingNode = group.conditions[split.conditionKey]; + const nextNode = { + ...(existingNode ?? + createConditionNode(split.atRuleKey, split.conditionKey)), + entries: [...(existingNode?.entries ?? []), split.entry], + }; + + return { + ...group, + conditionOrder: ensureUnique(group.conditionOrder, split.conditionKey), + conditions: { ...group.conditions, [split.conditionKey]: nextNode }, + }; +} + +function updateStateWithEntry( + state: TGroupState, + split: TConditionSplit, +): TGroupState { + const existingGroup = state.groups[split.atRuleKey]; + const baseGroup = existingGroup ?? { + atRuleKey: split.atRuleKey, + conditionOrder: [], + conditions: {}, + }; + const nextGroup = updateGroupWithEntry(baseGroup, split); + + return { + atRuleOrder: ensureUnique(state.atRuleOrder, split.atRuleKey), + groups: { ...state.groups, [split.atRuleKey]: nextGroup }, + }; +} + +function toConditionNodes(state: TGroupState): Array { + const orderedAtRules = moveKeyToFront(state.atRuleOrder, ''); + // $FlowFixMe[incompatible-type] + return orderedAtRules.reduce( + (acc: Array, atRuleKey: string) => { + const group = state.groups[atRuleKey]; + if (!group) return acc; + const orderedConditions = moveKeyToFront(group.conditionOrder, 'default'); + const children = orderedConditions.reduce( + (childAcc: Array, conditionKey: string) => { + const node = group.conditions[conditionKey]; + return node ? [...childAcc, node] : childAcc; + }, + [] as Array, + ); + + if (atRuleKey === '') { + return [...acc, ...children]; + } + + return [ + ...acc, + { + key: `at:${atRuleKey || 'base'}`, + label: atRuleKey, + entries: [], + children, + }, + ]; + }, + [] as Array, + ); +} + +function buildConditionNodes( + entries: $ReadOnlyArray, +): Array { + const splits = entries.map(splitConditions); + // $FlowFixMe[incompatible-type] + const grouped = splits.reduce(updateStateWithEntry, { + atRuleOrder: [], + groups: {}, + }); + return toConditionNodes(grouped); +} + +export function DeclarationsList({ + classes, + computed, +}: { + classes: $ReadOnlyArray< + $ReadOnly<{ + name: string, + declarations: $ReadOnlyArray, + ... + }>, + >, + computed: { [string]: string, ... }, +}): React.Node { + if (classes.length === 0) { return (
No matching StyleX CSS rules found for the selected element. @@ -87,201 +292,246 @@ export function DeclarationsList({ ); } - const baseIndex = sectionOrder.indexOf(''); - if (baseIndex > 0) { - sectionOrder.splice(baseIndex, 1); - sectionOrder.unshift(''); + const sections = buildSections(classes); + if (sections.length === 0) { + return ( +
+ No matching StyleX CSS rules found for the selected element. +
+ ); } - function getConditionParts(entry: TDeclaration): Array { - if (entry.conditions && entry.conditions.length > 0) { - return [...entry.conditions]; - } - if (!entry.condition || entry.condition === 'default') return []; - return entry.condition - .split(',') - .map((part) => part.trim()) - .filter(Boolean); + return ( +
+ {sections.map((section) => ( + + ))} +
+ ); +} + +function PseudoSection({ + computed, + section, +}: { + computed: { [string]: string, ... }, + section: TSection, +}): React.Node { + if (section.key === '') { + return ; } + return ( +
+
{section.key}
+ +
+ ); +} - function renderConditionRows( - keyPrefix: string, - group: TAtRuleGroup, - ): React.Node { - const { conditionOrder, conditionMap } = group; - const ordered = conditionOrder.slice(); - const defaultIndex = ordered.indexOf('default'); - if (defaultIndex > 0) { - ordered.splice(defaultIndex, 1); - ordered.unshift('default'); - } - return ordered.map((condition) => { - const bucket = conditionMap.get(condition) ?? []; - const label = condition === 'default' ? 'default' : `'${condition}'`; - return bucket.map((entry, index) => { - const value = entry.value + (entry.important ? ' !important' : ''); - return ( -
-
- {label}:{' '} - {value} -
- {entry.className ? ( - {entry.className} - ) : null} -
- ); - }); - }); +function PropertyList({ + computed, + properties, +}: { + computed: { [string]: string, ... }, + properties: $ReadOnlyArray, +}): React.Node { + return ( +
+ {properties.map((group) => ( + + ))} +
+ ); +} + +function PropertyGroup({ + computedValue, + group, +}: { + computedValue?: string, + group: TPropertyGroup, +}): React.Node { + if (group.entries.length === 1) { + return ( + + ); } - function renderProperties(sectionKey: string, section: TSection): React.Node { - const { propertyOrder, propertyToEntries } = section; - return propertyOrder.map((property) => { - const entries = propertyToEntries.get(property) ?? []; - const computedValue = computed[property]; - const computedTitle = computedValue ? computedValue.trim() : ''; - if (entries.length === 1) { - const entry = entries[0]; - const value = entry.value + (entry.important ? ' !important' : ''); - return ( -
-
- - {property} - - : {value} -
- {entry.className ? ( - {entry.className} - ) : null} -
- ); - } + return ( + + ); +} - const atRuleOrder: Array = []; - const atRuleMap = new Map(); - for (const entry of entries) { - const parts = getConditionParts(entry); - const atRules = []; - const otherParts = []; - for (const part of parts) { - if (part.startsWith('@')) { - atRules.push(part); - } else if (part) { - otherParts.push(part); - } - } - const atRuleKey = atRules.join(', '); - const conditionKey = - otherParts.length > 0 ? otherParts.join(', ') : 'default'; - let group = atRuleMap.get(atRuleKey); - if (group == null) { - group = { conditionOrder: [], conditionMap: new Map() }; - atRuleMap.set(atRuleKey, group); - atRuleOrder.push(atRuleKey); - } - const bucket = group.conditionMap.get(conditionKey); - if (bucket == null) { - group.conditionOrder.push(conditionKey); - group.conditionMap.set(conditionKey, [entry]); - } else { - bucket.push(entry); - } - } - const baseAtRuleIndex = atRuleOrder.indexOf(''); - if (baseAtRuleIndex > 0) { - atRuleOrder.splice(baseAtRuleIndex, 1); - atRuleOrder.unshift(''); - } +function SingleDeclaration({ + computedValue, + entry, + property, +}: { + computedValue?: string, + entry: TDeclaration, + property: string, +}): React.Node { + const value = entry.value + (entry.important ? ' !important' : ''); + const computedTitle = computedValue ? computedValue.trim() : ''; + const line: React.Node = ( + <> + + {property} + + {`: ${value}`} + + ); - return ( -
-
- - {property} - - : -
-
- {atRuleOrder.map((atRuleKey) => { - const group = atRuleMap.get(atRuleKey); - if (!group) return null; - if (atRuleKey === '') { - return ( - - {renderConditionRows( - `${sectionKey}:${property}:base`, - group, - )} - - ); - } - return ( -
-
- - {`'${atRuleKey}'`} - -
-
- {renderConditionRows( - `${sectionKey}:${property}:${atRuleKey}`, - group, - )} -
-
- ); - })} -
-
- ); - }); - } + return ( +
+
{line}
+ {entry.className ? ( + {entry.className} + ) : null} +
+ ); +} + +function GroupedDeclaration({ + computedValue, + entries, + property, +}: { + computedValue?: string, + entries: $ReadOnlyArray, + property: string, +}): React.Node { + const nodes = buildConditionNodes(entries); + const computedTitle = computedValue ? computedValue.trim() : ''; + const line: React.Node = ( + <> + + {property} + + : + + ); return ( -
- {sectionOrder.map((sectionKey) => { - const section = sectionMap.get(sectionKey); - if (!section) return null; - if (sectionKey === '') { - return ( - - {renderProperties('base', section)} - - ); - } - return ( -
-
{sectionKey}
-
- {renderProperties(sectionKey, section)} -
-
- ); - })} +
+
{line}
+ +
+ ); +} + +function ConditionList({ + depth, + nodes, +}: { + depth: number, + nodes: $ReadOnlyArray, +}): React.Node { + if (nodes.length === 0) return null; + const listStyle = depth === 0 ? styles.declSubList : styles.atRuleList; + + return ( +
+ {nodes.map((node) => ( + + ))} +
+ ); +} + +function ConditionNode({ + depth, + node, +}: { + depth: number, + node: TConditionNode, +}): React.Node { + const displayNode = collapseDefaultChild(node); + const label = displayNode.label; + const isAtRule = isAtRuleLabel(label); + const hasEntries = displayNode.entries.length > 0; + const hasChildren = displayNode.children.length > 0; + const formattedLabel = label !== '' ? formatConditionLabel(label) : null; + const shouldInlineAtRule = isAtRule && hasEntries && !hasChildren; + const showLabel = isAtRule && formattedLabel != null && !shouldInlineAtRule; + const labelText = isAtRule + ? shouldInlineAtRule + ? formattedLabel + : null + : formattedLabel; + + return ( +
+ {showLabel ? ( +
+ {formattedLabel} +
+ ) : null} + {displayNode.entries.length > 0 ? ( + + ) : null} + {displayNode.children.length > 0 ? ( + + ) : null}
); } +function DeclarationEntries({ + entries, + label, +}: { + entries: $ReadOnlyArray, + label: string | null, +}): React.Node { + return entries.map((entry, index) => { + const value = entry.value + (entry.important ? ' !important' : ''); + const content: React.Node = label ? ( + <> + {label} + {`: ${value}`} + + ) : ( + value + ); + return ( +
+
+ {content} +
+ {entry.className ? ( + {entry.className} + ) : null} +
+ ); + }); +} + const styles = stylex.create({ muted: { color: colors.textMuted, From b1f95c3936673c358b14475e7287a2da0708941f Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Mon, 22 Dec 2025 03:01:21 -0800 Subject: [PATCH 12/13] Styles are editable now. Final refactor and polish remaining --- .../devtools-extension/src/devtools/api.js | 31 + .../src/devtools/overrides.js | 164 ++ .../src/inspected/collectStylexDebugData.js | 105 +- .../devtools-extension/src/panel/App.jsx | 31 +- .../src/panel/components/DeclarationsList.js | 1382 ++++++++++++++++- .../@stylexjs/devtools-extension/src/types.js | 24 + 6 files changed, 1662 insertions(+), 75 deletions(-) create mode 100644 packages/@stylexjs/devtools-extension/src/devtools/overrides.js diff --git a/packages/@stylexjs/devtools-extension/src/devtools/api.js b/packages/@stylexjs/devtools-extension/src/devtools/api.js index 41ac4e5e7..f1e0d2921 100644 --- a/packages/@stylexjs/devtools-extension/src/devtools/api.js +++ b/packages/@stylexjs/devtools-extension/src/devtools/api.js @@ -48,6 +48,37 @@ export function evalInInspectedWindow( }); } +export function evalInInspectedWindowWithArgs( + fn: (args: any) => T, + args: mixed, + options?: InspectedWindowEvalOptions, +): Promise { + const serializedArgs = JSON.stringify(args); + const expression = `(${fn.toString()})(${serializedArgs})`; + const mergedOptions = { + // includeCommandLineAPI: true, + ...options, + }; + + return new Promise((resolve, reject) => { + devtools.inspectedWindow.eval( + expression, + mergedOptions as any, + (result, exceptionInfo) => { + if (exceptionInfo && exceptionInfo.isException) { + const msg = + exceptionInfo.value != null + ? `Error: ${String(exceptionInfo.value)}` + : 'Error evaluating in inspected window.'; + reject(new Error(msg)); + return; + } + resolve(result as any as T); + }, + ); + }); +} + export function getResources(): Promise> { return new Promise((resolve) => { devtools.inspectedWindow.getResources((resources) => resolve(resources)); diff --git a/packages/@stylexjs/devtools-extension/src/devtools/overrides.js b/packages/@stylexjs/devtools-extension/src/devtools/overrides.js new file mode 100644 index 000000000..7bd92751d --- /dev/null +++ b/packages/@stylexjs/devtools-extension/src/devtools/overrides.js @@ -0,0 +1,164 @@ +/** + * 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 { evalInInspectedWindowWithArgs } from './api.js'; +import type { StylexOverride } from '../types.js'; + +type SwapClassArgs = { + from: string, + to: string, +}; + +type InlineStyleArgs = { + property: string, + value: string, + important?: boolean, +}; + +type ClearInlineArgs = { + property: string, +}; + +type SetOverridesArgs = { + overrides: Array, +}; + +function swapClassNameInInspectedWindow({ from, to }: SwapClassArgs): boolean { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = (window: any)[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + (window: any)[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element || !from || !to) return false; + element.classList.remove(from); + element.classList.add(to); + return true; +} + +function setInlineStyleInInspectedWindow({ + property, + value, + important, +}: InlineStyleArgs): boolean { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = (window: any)[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + (window: any)[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element || !property) return false; + element.style.setProperty(property, value, important ? 'important' : ''); + return true; +} + +function clearInlineStyleInInspectedWindow({ + property, +}: ClearInlineArgs): boolean { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = (window: any)[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + (window: any)[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element || !property) return false; + element.style.removeProperty(property); + return true; +} + +function setStylexOverridesInInspectedWindow({ + overrides, +}: SetOverridesArgs): boolean { + try { + const overrideElementKey = '__stylexDevtoolsOverrideElement__'; + const overrideStoreKey = '__stylexDevtoolsOverrides__'; + // $FlowExpectedError[cannot-resolve-name] + const current = typeof $0 !== 'undefined' ? $0 : null; + const stored = (window: any)[overrideElementKey]; + const sameNode = + stored && + current && + typeof stored.isSameNode === 'function' && + stored.isSameNode(current); + if (!sameNode && current) { + (window: any)[overrideElementKey] = current; + } + const element = sameNode + ? stored + : current || + (stored && typeof stored.isSameNode === 'function' ? stored : null); + if (!element) return false; + const existing = (window: any)[overrideStoreKey]; + const store: WeakMap = + existing && typeof existing.get === 'function' ? existing : new WeakMap(); + if (existing == null || existing !== store) { + (window: any)[overrideStoreKey] = store; + } + if (!Array.isArray(overrides) || overrides.length === 0) { + store.delete(element); + } else { + store.set(element, overrides); + } + return true; + } catch { + return false; + } +} + +export function swapClassName(args: SwapClassArgs): Promise { + return evalInInspectedWindowWithArgs(swapClassNameInInspectedWindow, args); +} + +export function setInlineStyle(args: InlineStyleArgs): Promise { + return evalInInspectedWindowWithArgs(setInlineStyleInInspectedWindow, args); +} + +export function clearInlineStyle(args: ClearInlineArgs): Promise { + return evalInInspectedWindowWithArgs(clearInlineStyleInInspectedWindow, args); +} + +export function setStylexOverrides( + overrides: Array, +): Promise { + return evalInInspectedWindowWithArgs(setStylexOverridesInInspectedWindow, { + overrides, + }); +} diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index db429129f..1c3b2f934 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -9,7 +9,7 @@ 'use strict'; -import type { StylexDebugData } from '../types.js'; +import type { AtomicStyleRule, StylexDebugData } from '../types.js'; type RuleData = $ReadOnly<{ selectorText: string, @@ -42,6 +42,29 @@ export function collectStylexDebugData(): StylexDebugData { .filter(Boolean); } + const OVERRIDE_STORE_KEY = '__stylexDevtoolsOverrides__'; + const OVERRIDE_ELEMENT_KEY = '__stylexDevtoolsOverrideElement__'; + + function getOverridesForElement(element: ?HTMLElement): Array { + if (!element) return []; + const store = (window: any)[OVERRIDE_STORE_KEY]; + if (!store || typeof store.get !== 'function') { + return []; + } + const stored = (window: any)[OVERRIDE_ELEMENT_KEY]; + const hasStored = stored && typeof stored.isSameNode === 'function'; + const sameNode = hasStored && stored.isSameNode(element); + const elementKey = sameNode ? stored : element; + if (!sameNode) { + (window: any)[OVERRIDE_ELEMENT_KEY] = element; + } + const value = store.get(elementKey); + if (!Array.isArray(value)) return []; + return value.map((item) => + item && typeof item === 'object' ? { ...item } : item, + ); + } + function parseSourceEntry(raw: mixed): { raw: string, file: string, @@ -78,6 +101,8 @@ export function collectStylexDebugData(): StylexDebugData { }; } + const SIMPLE_CLASS_SELECTOR = /^\.[_a-zA-Z0-9-]+$/; + function getAtRuleCondition(rule: CSSRule): string | null { if (!rule || typeof rule.cssText !== 'string') return null; const braceIndex = rule.cssText.indexOf('{'); @@ -121,6 +146,10 @@ export function collectStylexDebugData(): StylexDebugData { }; } + function isSimpleClassSelector(baseSelector: string): boolean { + return SIMPLE_CLASS_SELECTOR.test(baseSelector); + } + function extractClassNames(selectorText: string): Array { const out = []; const re = /\.([_a-zA-Z0-9-]+)/g; @@ -191,6 +220,72 @@ export function collectStylexDebugData(): StylexDebugData { walkRules(rules, []); } + function collectAtomicRulesFromSheet( + sheet: CSSStyleSheet, + out: Array, + state: { skippedSheets: number }, + ) { + let rules: ?CSSRuleList; + try { + rules = sheet.cssRules; + } catch { + state.skippedSheets += 1; + return; + } + if (!rules) return; + + function walkRules(ruleList: CSSRuleList, conditions: Array) { + for (let i = 0; i < ruleList.length; i += 1) { + const rule = ruleList[i]; + if (!rule) continue; + + if (isCSSStyleRule(rule) && rule.selectorText && rule.cssText) { + const decls = parseDeclarationsFromRuleCssText(rule.cssText); + if (decls.length !== 1) continue; + const [decl] = decls; + const selectors = splitSelectors(rule.selectorText); + for (const selector of selectors) { + const selectorInfo = parseSelectorCondition(selector); + if (!selectorInfo) continue; + const { baseSelector, pseudoCondition, pseudoElementKey } = + selectorInfo; + if (!isSimpleClassSelector(baseSelector)) continue; + const className = baseSelector.slice(1); + + const conditionParts: Array = []; + for (const entry of conditions) { + if (!conditionParts.includes(entry)) conditionParts.push(entry); + } + if (pseudoCondition && !conditionParts.includes(pseudoCondition)) { + conditionParts.push(pseudoCondition); + } + + out.push({ + className, + property: decl.property, + value: decl.value, + important: decl.important, + conditions: conditionParts, + ...(pseudoElementKey ? { pseudoElement: pseudoElementKey } : {}), + }); + } + continue; + } + + if ('cssRules' in rule) { + const atCondition = getAtRuleCondition(rule); + const nextConditions = atCondition + ? [...conditions, atCondition] + : conditions; + // $FlowFixMe[prop-missing] + walkRules(rule.cssRules, nextConditions); + } + } + } + + walkRules(rules, []); + } + function matchesSelector(element: HTMLElement, selectorText: string) { try { return element.matches(selectorText); @@ -322,6 +417,8 @@ export function collectStylexDebugData(): StylexDebugData { element: { tagName: '—' }, sources: [], computed: {}, + atomicRules: [], + overrides: [], applied: { classes: [] }, }; } @@ -342,9 +439,12 @@ export function collectStylexDebugData(): StylexDebugData { const dataStyleSrcRaw = safeString(element.getAttribute('data-style-src')); const sourcesRaw = parseDataStyleSrc(dataStyleSrcRaw); const sources = sourcesRaw.map(parseSourceEntry); + const overrides = getOverridesForElement(element); const state = { ruleOrder: 0, skippedSheets: 0 }; const rules: Array = []; + const atomicRules: Array = []; + const atomicState = { skippedSheets: 0 }; const sheets: Array = Array.from( document.styleSheets, @@ -352,6 +452,7 @@ export function collectStylexDebugData(): StylexDebugData { for (const sheet of sheets) { collectStyleRulesFromSheet(sheet, elementClassSet, rules, state); + collectAtomicRulesFromSheet(sheet, atomicRules, atomicState); } const classToDecls = new Map>(); @@ -423,6 +524,8 @@ export function collectStylexDebugData(): StylexDebugData { element: { tagName }, sources, computed, + atomicRules, + overrides, applied: { classes }, }; } diff --git a/packages/@stylexjs/devtools-extension/src/panel/App.jsx b/packages/@stylexjs/devtools-extension/src/panel/App.jsx index f09f4c3b4..8f5286808 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/App.jsx +++ b/packages/@stylexjs/devtools-extension/src/panel/App.jsx @@ -41,7 +41,14 @@ export function App(): React.Node { }); }, []); - useEffect(() => subscribeToSelectionAndNavigation(refresh), [refresh]); + const handleSelectionChange = useCallback(() => { + refresh(); + }, [refresh]); + + useEffect( + () => subscribeToSelectionAndNavigation(handleSelectionChange), + [handleSelectionChange], + ); return ( }> @@ -51,7 +58,11 @@ export function App(): React.Node { )} key={count} > - + ); @@ -103,10 +114,14 @@ function Panel({ const classes = data?.applied?.classes ?? []; const computed = data?.computed ?? {}; + const atomicRules = data?.atomicRules ?? []; + const overrides = data?.overrides ?? []; const hasSources = data?.sources?.length > 0; const hasClasses = classes.length > 0; - const showEmptyState = !hasSources && !hasClasses; + const hasOverrides = overrides.length > 0; + const showAppliedSection = hasClasses || hasOverrides; + const showEmptyState = !hasSources && !hasClasses && !hasOverrides; return (
@@ -135,9 +150,15 @@ function Panel({
)} - {hasClasses && ( + {showAppliedSection && (
- +
)} diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js index b1f2fd68c..1e96648f4 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -12,6 +12,13 @@ import * as React from 'react'; import * as stylex from '@stylexjs/stylex'; import { colors } from '../theme.stylex'; +import type { AtomicStyleRule, StylexOverride } from '../../types.js'; +import { + swapClassName, + setInlineStyle, + clearInlineStyle, + setStylexOverrides, +} from '../../devtools/overrides.js'; type TDeclaration = $ReadOnly<{ property: string, @@ -41,6 +48,26 @@ type TConditionNode = { children: Array, }; +type TOverrideValue = { + value: string, + important: boolean, +}; + +type TOverridesByEntry = { [string]: TOverrideValue, ... }; + +type TOverrideUpdater = (override: StylexOverride) => Promise; + +type TOverrideRemover = (id: string) => Promise; + +type TClassOverrideMap = { [string]: StylexOverride, ... }; + +type TAtomicValueIndex = { + values: Array, + valueToClassName: { [string]: string, ... }, +}; + +type TAtomicIndex = { [string]: TAtomicValueIndex, ... }; + function getConditionParts(entry: TDeclaration): Array { if (entry.conditions && entry.conditions.length > 0) { return [...entry.conditions]; @@ -68,10 +95,14 @@ function isAtRuleLabel(label: string): boolean { return label.startsWith('@'); } +function isDefaultLabel(label: string): boolean { + return label.trim() === 'default'; +} + function collapseDefaultChild(node: TConditionNode): TConditionNode { if (node.children.length !== 1) return node; const child = node.children[0]; - if (child.label !== 'default') return node; + if (!isDefaultLabel(child.label)) return node; return { ...node, entries: [...node.entries, ...child.entries], @@ -79,6 +110,241 @@ function collapseDefaultChild(node: TConditionNode): TConditionNode { }; } +function formatValue(value: string, important: boolean): string { + return important ? `${value} !important` : value; +} + +function parseValueInput(raw: string): TOverrideValue { + const trimmed = raw.trim(); + if (trimmed === '') { + return { value: '', important: false }; + } + if (!/\s!important\s*$/i.test(trimmed)) { + return { value: trimmed, important: false }; + } + return { + value: trimmed.replace(/\s!important\s*$/i, '').trim(), + important: true, + }; +} + +function buildInlineOverrideId( + property: string, + pseudoElement?: string, +): string { + return ['inline', property, pseudoElement ?? ''].join('::'); +} + +function buildClassOverrideId(originalClassName: string): string { + return `class::${originalClassName}`; +} + +function createInlineOverride( + entry: TDeclaration, + override: TOverrideValue, + entryKey: string, +): StylexOverride { + const conditions = getConditionParts(entry); + const base: StylexOverride = { + id: buildInlineOverrideId(entry.property, entry.pseudoElement), + kind: 'inline', + property: entry.property, + value: override.value, + important: override.important, + conditions, + sourceEntryKey: entryKey, + }; + return entry.pseudoElement + ? { ...base, pseudoElement: entry.pseudoElement } + : base; +} + +function createClassOverride( + entry: TDeclaration, + override: TOverrideValue, + entryKey: string, + nextClassName: string, + originalClassNameOverride?: string, +): StylexOverride { + const originalClassName = originalClassNameOverride ?? entry.className ?? ''; + const conditions = getConditionParts(entry); + const base: StylexOverride = { + id: buildClassOverrideId(originalClassName), + kind: 'class', + property: entry.property, + value: override.value, + important: override.important, + conditions, + className: nextClassName, + originalClassName, + sourceEntryKey: entryKey, + }; + return entry.pseudoElement + ? { ...base, pseudoElement: entry.pseudoElement } + : base; +} + +function upsertOverride( + overrides: $ReadOnlyArray, + nextOverride: StylexOverride, +): Array { + const index = overrides.findIndex((item) => item.id === nextOverride.id); + if (index === -1) { + return [...overrides, nextOverride]; + } + return overrides.map((item) => + item.id === nextOverride.id ? nextOverride : item, + ); +} + +function removeOverride( + overrides: $ReadOnlyArray, + id: string, +): Array { + return overrides.filter((item) => item.id !== id); +} + +function overridesToEntryMap( + overrides: $ReadOnlyArray, +): TOverridesByEntry { + return overrides.reduce( + (acc, override) => + override.kind === 'inline' && override.sourceEntryKey + ? { + ...acc, + [override.sourceEntryKey]: { + value: override.value, + important: override.important, + }, + } + : acc, + {}, + ); +} + +function buildClassOverrideMap( + overrides: $ReadOnlyArray, +): TClassOverrideMap { + return overrides.reduce( + (acc, override) => + override.kind === 'class' && override.className + ? { ...acc, [override.className]: override } + : acc, + {}, + ); +} + +function buildPropertyValues(rules: $ReadOnlyArray): { + [string]: Array, + ... +} { + return rules.reduce((acc, rule) => { + const displayValue = formatValue(rule.value, rule.important); + const key = rule.property.toLowerCase(); + const existing = acc[key] ?? []; + const values = existing.includes(displayValue) + ? existing + : [...existing, displayValue]; + return { ...acc, [key]: values }; + }, {}); +} + +function filterSuggestions( + values: $ReadOnlyArray, + query: string, +): Array { + const normalizedQuery = query.trim().toLowerCase(); + const filtered = + normalizedQuery === '' + ? values + : values.filter((value) => value.toLowerCase().includes(normalizedQuery)); + return filtered.slice(0, 8); +} + +function getNextIndex(current: number, delta: number, length: number): number { + if (length === 0) return -1; + if (current === -1) { + return delta > 0 ? 0 : length - 1; + } + return (current + delta + length) % length; +} + +function normalizeConditions(conditions: Array): Array { + return conditions + .map((condition) => condition.trim()) + .filter(Boolean) + .reduce( + (acc, condition) => (acc.includes(condition) ? acc : [...acc, condition]), + [], + ); +} + +function buildConditionKey(conditions: Array): string { + return normalizeConditions(conditions).join('||'); +} + +function buildAtomicKey( + property: string, + conditions: Array, + pseudoElement?: string, +): string { + return [property, pseudoElement ?? '', buildConditionKey(conditions)].join( + '::', + ); +} + +function buildEntryKey(entry: TDeclaration): string { + const conditions = getConditionParts(entry); + return [ + entry.className ?? '', + entry.property, + entry.pseudoElement ?? '', + buildConditionKey(conditions), + ].join('::'); +} + +function buildAtomicIndex( + rules: $ReadOnlyArray, +): TAtomicIndex { + const empty: TAtomicIndex = {}; + return rules.reduce((acc, rule) => { + const key = buildAtomicKey( + rule.property, + rule.conditions, + rule.pseudoElement, + ); + const displayValue = formatValue(rule.value, rule.important); + const existing = acc[key]; + const existingValues = existing?.values ?? []; + const existingMap = existing?.valueToClassName ?? {}; + const values = existingValues.includes(displayValue) + ? existingValues + : [...existingValues, displayValue]; + const valueToClassName = existingMap[displayValue] + ? existingMap + : { ...existingMap, [displayValue]: rule.className }; + return { + ...acc, + [key]: { + values, + valueToClassName, + }, + }; + }, empty); +} + +function getAtomicGroupForEntry( + atomicIndex: TAtomicIndex, + entry: TDeclaration, +): ?TAtomicValueIndex { + const key = buildAtomicKey( + entry.property, + getConditionParts(entry), + entry.pseudoElement, + ); + return atomicIndex[key]; +} + function buildSections( classes: $ReadOnlyArray< $ReadOnly<{ @@ -238,7 +504,7 @@ function toConditionNodes(state: TGroupState): Array { const node = group.conditions[conditionKey]; return node ? [...childAcc, node] : childAcc; }, - [] as Array, + [], ); if (atRuleKey === '') { @@ -255,7 +521,7 @@ function toConditionNodes(state: TGroupState): Array { }, ]; }, - [] as Array, + [], ); } @@ -274,6 +540,9 @@ function buildConditionNodes( export function DeclarationsList({ classes, computed, + atomicRules, + onRefresh, + overrides, }: { classes: $ReadOnlyArray< $ReadOnly<{ @@ -283,69 +552,232 @@ export function DeclarationsList({ }>, >, computed: { [string]: string, ... }, + atomicRules: $ReadOnlyArray, + onRefresh: () => void, + overrides: $ReadOnlyArray, }): React.Node { - if (classes.length === 0) { - return ( -
- No matching StyleX CSS rules found for the selected element. -
- ); - } + const atomicIndex = React.useMemo( + () => buildAtomicIndex(atomicRules), + [atomicRules], + ); + const propertyValues = React.useMemo( + () => buildPropertyValues(atomicRules), + [atomicRules], + ); + const overrideValues = React.useMemo( + () => overridesToEntryMap(overrides), + [overrides], + ); + const classOverrides = React.useMemo( + () => buildClassOverrideMap(overrides), + [overrides], + ); + const overridesRef = React.useRef(overrides); + overridesRef.current = overrides; + + const persistOverrides = React.useCallback( + async (nextOverrides: Array) => { + overridesRef.current = nextOverrides; + await setStylexOverrides(nextOverrides); + }, + [], + ); + + const upsertOverrideEntry = React.useCallback( + async (override: StylexOverride) => { + const nextOverrides = upsertOverride(overridesRef.current, override); + await persistOverrides(nextOverrides); + }, + [persistOverrides], + ); + const removeOverrideEntry = React.useCallback( + async (id: string) => { + const nextOverrides = removeOverride(overridesRef.current, id); + await persistOverrides(nextOverrides); + }, + [persistOverrides], + ); + const handleAddOverride = React.useCallback( + async (property: string, rawValue: string) => { + const normalizedProperty = property.trim(); + if (!normalizedProperty) return; + const parsed = parseValueInput(rawValue); + if (!parsed.value) return; + let didMutate = false; + try { + await setInlineStyle({ + property: normalizedProperty, + value: parsed.value, + important: parsed.important, + }); + didMutate = true; + await upsertOverrideEntry({ + id: buildInlineOverrideId(normalizedProperty), + kind: 'inline', + property: normalizedProperty, + value: parsed.value, + important: parsed.important, + conditions: [], + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + if (didMutate) { + onRefresh(); + } + } + }, + [onRefresh, upsertOverrideEntry], + ); + const handleRemoveOverride = React.useCallback( + async (override: StylexOverride) => { + let didMutate = false; + try { + if (override.kind === 'inline') { + await clearInlineStyle({ property: override.property }); + didMutate = true; + } + if ( + override.kind === 'class' && + override.className && + override.originalClassName + ) { + await swapClassName({ + from: override.className, + to: override.originalClassName, + }); + didMutate = true; + } + await removeOverrideEntry(override.id); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + if (didMutate) { + onRefresh(); + } + } + }, + [onRefresh, removeOverrideEntry], + ); const sections = buildSections(classes); - if (sections.length === 0) { - return ( -
- No matching StyleX CSS rules found for the selected element. -
- ); - } + const hasSections = sections.length > 0; return (
- {sections.map((section) => ( - - ))} + {hasSections ? ( + sections.map((section) => ( + + )) + ) : ( +
+ No matching StyleX CSS rules found for the selected element. +
+ )} +
); } function PseudoSection({ + atomicIndex, + classOverrides, computed, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, section, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, computed: { [string]: string, ... }, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, section: TSection, }): React.Node { if (section.key === '') { - return ; + return ( + + ); } return (
{section.key}
- +
); } function PropertyList({ + atomicIndex, + classOverrides, computed, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, properties, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, computed: { [string]: string, ... }, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, properties: $ReadOnlyArray, }): React.Node { return (
{properties.map((group) => ( ))}
@@ -353,17 +785,35 @@ function PropertyList({ } function PropertyGroup({ + atomicIndex, + classOverrides, computedValue, group, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, computedValue?: string, group: TPropertyGroup, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, }): React.Node { if (group.entries.length === 1) { return ( ); @@ -371,53 +821,85 @@ function PropertyGroup({ return ( ); } function SingleDeclaration({ + atomicIndex, + classOverrides, computedValue, entry, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, property, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, computedValue?: string, entry: TDeclaration, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, property: string, }): React.Node { - const value = entry.value + (entry.important ? ' !important' : ''); const computedTitle = computedValue ? computedValue.trim() : ''; - const line: React.Node = ( - <> - - {property} - - {`: ${value}`} - + const prefix: React.Node = ( + + {property} + ); return ( -
-
{line}
- {entry.className ? ( - {entry.className} - ) : null} -
+ ); } function GroupedDeclaration({ + atomicIndex, + classOverrides, computedValue, entries, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, property, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, computedValue?: string, entries: $ReadOnlyArray, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, property: string, }): React.Node { const nodes = buildConditionNodes(entries); @@ -437,17 +919,38 @@ function GroupedDeclaration({ return (
{line}
- +
); } function ConditionList({ + atomicIndex, + classOverrides, depth, nodes, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, depth: number, nodes: $ReadOnlyArray, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, }): React.Node { if (nodes.length === 0) return null; const listStyle = depth === 0 ? styles.declSubList : styles.atRuleList; @@ -455,27 +958,60 @@ function ConditionList({ return (
{nodes.map((node) => ( - + ))}
); } function ConditionNode({ - depth, + atomicIndex, + classOverrides, node, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + depth = 0, }: { - depth: number, + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, node: TConditionNode, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + depth: number, }): React.Node { - const displayNode = collapseDefaultChild(node); + const collapsedNode = collapseDefaultChild(node); + const displayNode = + isAtRuleLabel(collapsedNode.label) && + collapsedNode.entries.length === 0 && + collapsedNode.children.length === 1 && + isDefaultLabel(collapsedNode.children[0].label) + ? { + ...collapsedNode, + entries: collapsedNode.children[0].entries, + children: collapsedNode.children[0].children, + } + : collapsedNode; const label = displayNode.label; const isAtRule = isAtRuleLabel(label); const hasEntries = displayNode.entries.length > 0; - const hasChildren = displayNode.children.length > 0; const formattedLabel = label !== '' ? formatConditionLabel(label) : null; - const shouldInlineAtRule = isAtRule && hasEntries && !hasChildren; - const showLabel = isAtRule && formattedLabel != null && !shouldInlineAtRule; + const shouldInlineAtRule = isAtRule && hasEntries; + const showLabel = + isAtRule && formattedLabel != null && displayNode.entries.length === 0; const labelText = isAtRule ? shouldInlineAtRule ? formattedLabel @@ -490,46 +1026,602 @@ function ConditionNode({ ) : null} {displayNode.entries.length > 0 ? ( - + ) : null} {displayNode.children.length > 0 ? ( - + ) : null} ); } function DeclarationEntries({ + atomicIndex, + classOverrides, entries, label, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, }: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, entries: $ReadOnlyArray, label: string | null, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, }): React.Node { - return entries.map((entry, index) => { - const value = entry.value + (entry.important ? ' !important' : ''); - const content: React.Node = label ? ( + const prefix = label ? ( + {label} + ) : null; + const showColon = label != null; + + return entries.map((entry, index) => ( + + )); +} + +function DeclarationEntryRow({ + atomicIndex, + classOverrides, + entry, + isSubLine, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + overrideValues, + prefix, + showColon, +}: { + atomicIndex: TAtomicIndex, + classOverrides: TClassOverrideMap, + entry: TDeclaration, + isSubLine: boolean, + onOverrideRemove: TOverrideRemover, + onOverrideUpsert: TOverrideUpdater, + onRefresh: () => void, + overrideValues: TOverridesByEntry, + prefix: React.Node | null, + showColon: boolean, +}): React.Node { + const entryKey = buildEntryKey(entry); + const override = overrideValues[entryKey]; + const baseValue = formatValue(entry.value, entry.important); + const displayValue = override + ? formatValue(override.value, override.important) + : baseValue; + const existingClassOverride = + entry.className && classOverrides[entry.className] + ? classOverrides[entry.className] + : null; + const group = getAtomicGroupForEntry(atomicIndex, entry); + const suggestions = group?.values ?? []; + const valueToClassName = group?.valueToClassName ?? {}; + + const [isEditing, setIsEditing] = React.useState(false); + const [draftValue, setDraftValue] = React.useState(displayValue); + const [isPending, setIsPending] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(-1); + const listId = React.useId(); + + React.useEffect(() => { + if (!isEditing) { + setDraftValue(displayValue); + } + }, [displayValue, isEditing]); + + const filteredSuggestions = React.useMemo( + () => filterSuggestions(suggestions, draftValue), + [draftValue, suggestions], + ); + + React.useEffect(() => { + if (!isEditing || filteredSuggestions.length === 0) { + setActiveIndex(-1); + return; + } + setActiveIndex((prev) => + prev >= filteredSuggestions.length + ? filteredSuggestions.length - 1 + : prev, + ); + }, [filteredSuggestions, isEditing]); + + const commitChange = React.useCallback( + async (nextValue?: string) => { + if (isPending) { + return; + } + const rawValue = nextValue ?? draftValue; + const trimmed = rawValue.trim(); + const current = displayValue.trim(); + setIsEditing(false); + if (trimmed === current) { + return; + } + + const parsed = parseValueInput(trimmed); + const nextFormatted = formatValue(parsed.value, parsed.important); + // $FlowFixMe[invalid-computed-prop] + const nextClassName = valueToClassName[nextFormatted]; + + setIsPending(true); + let didMutate = false; + try { + if (parsed.value === '') { + await clearInlineStyle({ property: entry.property }); + didMutate = true; + await onOverrideRemove( + buildInlineOverrideId(entry.property, entry.pseudoElement), + ); + return; + } + + if (nextClassName && entry.className) { + const originalClassName = + existingClassOverride?.originalClassName ?? entry.className; + const shouldRemoveClassOverride = + existingClassOverride != null && + nextClassName === originalClassName; + + if (nextClassName !== entry.className) { + await swapClassName({ from: entry.className, to: nextClassName }); + didMutate = true; + } + await clearInlineStyle({ property: entry.property }); + didMutate = true; + await onOverrideRemove( + buildInlineOverrideId(entry.property, entry.pseudoElement), + ); + if (shouldRemoveClassOverride) { + await onOverrideRemove(existingClassOverride.id); + return; + } + await onOverrideUpsert( + createClassOverride( + entry, + parsed, + entryKey, + nextClassName, + existingClassOverride?.originalClassName, + ), + ); + return; + } + + await setInlineStyle({ + property: entry.property, + value: parsed.value, + important: parsed.important, + }); + didMutate = true; + await onOverrideUpsert(createInlineOverride(entry, parsed, entryKey)); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + setIsPending(false); + if (didMutate) { + onRefresh(); + } + } + }, + [ + displayValue, + draftValue, + entry, + entryKey, + existingClassOverride, + isPending, + onOverrideRemove, + onOverrideUpsert, + onRefresh, + valueToClassName, + ], + ); + + const handleKeyDown = React.useCallback( + ( + event: KeyboardEvent & { + currentTarget: HTMLInputElement | HTMLButtonElement, + }, + ) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (filteredSuggestions.length === 0) return; + event.preventDefault(); + const delta = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex((prev) => + getNextIndex(prev, delta, filteredSuggestions.length), + ); + return; + } + if (event.key === 'Enter') { + const activeValue = filteredSuggestions[activeIndex]; + if (activeValue) { + event.preventDefault(); + commitChange(activeValue); + return; + } + event.preventDefault(); + // $FlowFixMe[prop-missing] + event.currentTarget.blur(); + } + if (event.key === 'Escape') { + event.preventDefault(); + setIsEditing(false); + setDraftValue(displayValue); + setActiveIndex(-1); + } + }, + [activeIndex, commitChange, displayValue, filteredSuggestions], + ); + + const lineStyle = isSubLine ? styles.declSubLine : styles.declLine; + const prefixContent = + prefix && showColon ? ( <> - {label} - {`: ${value}`} + {prefix} + {': '} ) : ( - value + prefix ); - return ( -
-
- {content} + const hasSuggestions = filteredSuggestions.length > 0; + const activeDescendant = + activeIndex >= 0 && activeIndex < filteredSuggestions.length + ? `${listId}-option-${activeIndex}` + : undefined; + + return ( +
+
+ {prefixContent} + {isEditing ? ( +
+ commitChange()} + onChange={(event) => setDraftValue(event.currentTarget.value)} + onKeyDown={handleKeyDown} + role="combobox" + spellCheck={false} + value={draftValue} + /> + commitChange(value)} + suggestions={filteredSuggestions} + /> +
+ ) : ( + + )} +
+ {entry.className ? ( + {entry.className} + ) : null} +
+ ); +} + +function OverridesSection({ + onAddOverride, + onRemoveOverride, + overrides, + propertyValues, +}: { + onAddOverride: (property: string, rawValue: string) => Promise | void, + onRemoveOverride: (override: StylexOverride) => Promise | void, + overrides: $ReadOnlyArray, + propertyValues: { [string]: Array, ... }, +}): React.Node { + return ( +
+
Overrides
+ {overrides.length === 0 ? ( +
No overrides yet.
+ ) : ( +
+ {overrides.map((override) => ( + + ))}
- {entry.className ? ( - {entry.className} + )} + +
+ ); +} + +function OverrideRow({ + onRemove, + override, +}: { + onRemove: (override: StylexOverride) => Promise | void, + override: StylexOverride, +}): React.Node { + const [isPending, setIsPending] = React.useState(false); + + const handleRemove = React.useCallback(async () => { + if (isPending) return; + setIsPending(true); + try { + await onRemove(override); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } finally { + setIsPending(false); + } + }, [isPending, onRemove, override]); + + const value = formatValue(override.value, override.important); + const line: React.Node = ( + <> + {override.property} + {`: ${value}`} + + ); + + return ( +
+
{line}
+
+ {override.className ? ( + {override.className} ) : null} +
+
+ ); +} + +function OverrideComposer({ + onAddOverride, + propertyValues, +}: { + onAddOverride: (property: string, rawValue: string) => Promise | void, + propertyValues: { [string]: Array, ... }, +}): React.Node { + const [property, setProperty] = React.useState(''); + const [value, setValue] = React.useState(''); + const [isPending, setIsPending] = React.useState(false); + const [isValueFocused, setIsValueFocused] = React.useState(false); + const [activeIndex, setActiveIndex] = React.useState(-1); + const listId = React.useId(); + + const normalizedProperty = property.trim().toLowerCase(); + const suggestions = normalizedProperty + ? (propertyValues[normalizedProperty] ?? []) + : []; + const filteredSuggestions = React.useMemo( + () => filterSuggestions(suggestions, value), + [suggestions, value], + ); + const showSuggestions = isValueFocused && filteredSuggestions.length > 0; + + React.useEffect(() => { + if (!showSuggestions) { + setActiveIndex(-1); + return; + } + setActiveIndex((prev) => + prev >= filteredSuggestions.length + ? filteredSuggestions.length - 1 + : prev, ); - }); + }, [filteredSuggestions, showSuggestions]); + + const commitAdd = React.useCallback( + async (nextValue?: string) => { + if (isPending) return; + const prop = property.trim(); + const next = (nextValue ?? value).trim(); + if (!prop || !next) return; + setIsPending(true); + try { + await onAddOverride(prop, nextValue ?? value); + setValue(''); + } finally { + setIsPending(false); + } + }, + [isPending, onAddOverride, property, value], + ); + + const handleKeyDown = React.useCallback( + ( + event: KeyboardEvent & { + currentTarget: HTMLInputElement | HTMLButtonElement, + }, + ) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (!showSuggestions) return; + event.preventDefault(); + const delta = event.key === 'ArrowDown' ? 1 : -1; + setActiveIndex((prev) => + getNextIndex(prev, delta, filteredSuggestions.length), + ); + return; + } + if (event.key === 'Enter') { + event.preventDefault(); + const activeValue = filteredSuggestions[activeIndex]; + if (activeValue) { + commitAdd(activeValue); + return; + } + commitAdd(); + } + if (event.key === 'Escape') { + setActiveIndex(-1); + } + }, + [activeIndex, commitAdd, filteredSuggestions, showSuggestions], + ); + + return ( +
+ setProperty(event.currentTarget.value)} + placeholder="property" + spellCheck={false} + value={property} + /> +
+ = 0 ? `${listId}-option-${activeIndex}` : undefined + } + aria-autocomplete="list" + aria-controls={showSuggestions ? listId : undefined} + aria-expanded={showSuggestions} + aria-haspopup="listbox" + onBlur={() => setIsValueFocused(false)} + onChange={(event) => setValue(event.currentTarget.value)} + onFocus={() => setIsValueFocused(true)} + onKeyDown={handleKeyDown} + placeholder="value" + role="combobox" + spellCheck={false} + value={value} + /> + commitAdd(nextValue)} + suggestions={showSuggestions ? filteredSuggestions : []} + /> +
+ +
+ ); +} + +function SuggestionsList({ + activeIndex, + listId, + onActiveIndexChange, + onSelect, + suggestions, +}: { + activeIndex: number, + listId: string, + onActiveIndexChange?: (index: number) => void, + onSelect: (value: string) => void, + suggestions: $ReadOnlyArray, +}): React.Node { + if (suggestions.length === 0) return null; + return ( +
+ {suggestions.map((value, index) => { + const isActive = index === activeIndex; + return ( +
{ + event.preventDefault(); + onSelect(value); + }} + onMouseEnter={() => { + if (onActiveIndexChange) { + onActiveIndexChange(index); + } + }} + > + {value} +
+ ); + })} +
+ ); } const styles = stylex.create({ @@ -576,6 +1668,158 @@ const styles = stylex.create({ whiteSpace: 'pre-wrap', wordBreak: 'break-word', }, + overridesSection: { + display: 'grid', + gap: 6, + paddingTop: 12, + borderTopWidth: 1, + borderTopStyle: 'solid', + borderTopColor: colors.border, + }, + overridesTitle: { + fontWeight: 600, + fontSize: 12, + }, + overridesList: { + display: 'grid', + gap: 6, + }, + overrideMeta: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexShrink: 0, + }, + overrideButton: { + appearance: 'none', + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 6, + paddingInline: 6, + paddingBlock: 2, + cursor: 'pointer', + color: { + default: colors.textPrimary, + ':hover': colors.textAccent, + ':focus-visible': colors.textAccent, + }, + ':disabled': { + opacity: 0.6, + cursor: 'default', + }, + }, + overrideButtonPending: { + opacity: 0.6, + cursor: 'default', + }, + overrideComposer: { + display: 'flex', + alignItems: 'center', + gap: 8, + flexWrap: 'wrap', + }, + overrideInput: { + fontFamily: + 'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace', + fontSize: 12, + lineHeight: '1.4', + color: 'inherit', + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 4, + paddingInline: 6, + paddingBlock: 2, + minWidth: 0, + width: '100%', + boxSizing: 'border-box', + flex: 1, + }, + overrideValueWrap: { + position: 'relative', + flex: 1, + minWidth: 0, + }, + valueButton: { + appearance: 'none', + backgroundColor: 'transparent', + borderStyle: 'none', + padding: 0, + margin: 0, + color: { + default: 'inherit', + ':hover': colors.textAccent, + ':focus-visible': colors.textAccent, + }, + cursor: 'text', + textAlign: 'left', + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + }, + valueInput: { + fontFamily: 'inherit', + fontSize: 'inherit', + lineHeight: 'inherit', + color: 'inherit', + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 4, + paddingInline: 4, + paddingBlock: 1, + minWidth: 0, + width: '100%', + boxSizing: 'border-box', + }, + valuePending: { + opacity: 0.6, + }, + suggestionWrap: { + position: 'relative', + width: '100%', + }, + suggestionList: { + position: 'absolute', + top: '100%', + left: 0, + zIndex: 2, + marginTop: 4, + backgroundColor: colors.bgRaised, + borderWidth: 1, + borderStyle: 'solid', + borderColor: colors.border, + borderRadius: 6, + paddingBlock: 4, + minWidth: '100%', + maxHeight: 160, + overflowY: 'auto', + boxShadow: '0 6px 16px rgba(0, 0, 0, 0.18)', + }, + suggestionItem: { + appearance: 'none', + width: '100%', + textAlign: 'left', + borderStyle: 'none', + backgroundColor: 'transparent', + cursor: 'pointer', + paddingBlock: 4, + paddingInline: 8, + color: { + default: colors.textPrimary, + ':hover': colors.textAccent, + }, + fontFamily: 'inherit', + fontSize: 'inherit', + }, + suggestionItemActive: { + backgroundColor: colors.bg, + color: colors.textAccent, + }, declCondition: { color: colors.secondaryAccent, }, diff --git a/packages/@stylexjs/devtools-extension/src/types.js b/packages/@stylexjs/devtools-extension/src/types.js index 1cd164500..a13ee7add 100644 --- a/packages/@stylexjs/devtools-extension/src/types.js +++ b/packages/@stylexjs/devtools-extension/src/types.js @@ -38,12 +38,23 @@ export type AppliedStylexClass = { declarations: Array, }; +export type AtomicStyleRule = { + className: string, + property: string, + value: string, + important: boolean, + conditions: Array, + pseudoElement?: string, +}; + export type StylexDebugData = $ReadOnly<{ element: { tagName: string, }, sources: Array, computed: { [string]: string, ... }, + atomicRules: Array, + overrides: Array, applied: { classes: Array, }, @@ -53,3 +64,16 @@ export type SourcePreview = { url: string, snippet: string, }; + +export type StylexOverride = { + id: string, + kind: 'inline' | 'class', + property: string, + value: string, + important: boolean, + conditions: Array, + pseudoElement?: string, + className?: string, + originalClassName?: string, + sourceEntryKey?: string, +}; From 8b8acf0cb8728fd5b29e60b79ba089d3ca6c1281 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Tue, 23 Dec 2025 14:33:41 -0800 Subject: [PATCH 13/13] fix up styling of style editing and fix flow errors --- .../devtools-extension/src/devtools/api.js | 2 +- .../src/devtools/overrides.js | 22 ++-- .../src/inspected/collectStylexDebugData.js | 8 +- .../src/panel/components/DeclarationsList.js | 123 ++++++++++-------- 4 files changed, 89 insertions(+), 66 deletions(-) diff --git a/packages/@stylexjs/devtools-extension/src/devtools/api.js b/packages/@stylexjs/devtools-extension/src/devtools/api.js index f1e0d2921..784494325 100644 --- a/packages/@stylexjs/devtools-extension/src/devtools/api.js +++ b/packages/@stylexjs/devtools-extension/src/devtools/api.js @@ -54,7 +54,7 @@ export function evalInInspectedWindowWithArgs( options?: InspectedWindowEvalOptions, ): Promise { const serializedArgs = JSON.stringify(args); - const expression = `(${fn.toString()})(${serializedArgs})`; + const expression = `(${fn.toString()})(${serializedArgs ?? ''})`; const mergedOptions = { // includeCommandLineAPI: true, ...options, diff --git a/packages/@stylexjs/devtools-extension/src/devtools/overrides.js b/packages/@stylexjs/devtools-extension/src/devtools/overrides.js index 7bd92751d..4d1c42884 100644 --- a/packages/@stylexjs/devtools-extension/src/devtools/overrides.js +++ b/packages/@stylexjs/devtools-extension/src/devtools/overrides.js @@ -31,18 +31,20 @@ type SetOverridesArgs = { overrides: Array, }; +declare const window: any; + function swapClassNameInInspectedWindow({ from, to }: SwapClassArgs): boolean { const overrideElementKey = '__stylexDevtoolsOverrideElement__'; // $FlowExpectedError[cannot-resolve-name] const current = typeof $0 !== 'undefined' ? $0 : null; - const stored = (window: any)[overrideElementKey]; + const stored = window[overrideElementKey]; const sameNode = stored && current && typeof stored.isSameNode === 'function' && stored.isSameNode(current); if (!sameNode && current) { - (window: any)[overrideElementKey] = current; + window[overrideElementKey] = current; } const element = sameNode ? stored @@ -62,14 +64,14 @@ function setInlineStyleInInspectedWindow({ const overrideElementKey = '__stylexDevtoolsOverrideElement__'; // $FlowExpectedError[cannot-resolve-name] const current = typeof $0 !== 'undefined' ? $0 : null; - const stored = (window: any)[overrideElementKey]; + const stored = window[overrideElementKey]; const sameNode = stored && current && typeof stored.isSameNode === 'function' && stored.isSameNode(current); if (!sameNode && current) { - (window: any)[overrideElementKey] = current; + window[overrideElementKey] = current; } const element = sameNode ? stored @@ -86,14 +88,14 @@ function clearInlineStyleInInspectedWindow({ const overrideElementKey = '__stylexDevtoolsOverrideElement__'; // $FlowExpectedError[cannot-resolve-name] const current = typeof $0 !== 'undefined' ? $0 : null; - const stored = (window: any)[overrideElementKey]; + const stored = window[overrideElementKey]; const sameNode = stored && current && typeof stored.isSameNode === 'function' && stored.isSameNode(current); if (!sameNode && current) { - (window: any)[overrideElementKey] = current; + window[overrideElementKey] = current; } const element = sameNode ? stored @@ -112,25 +114,25 @@ function setStylexOverridesInInspectedWindow({ const overrideStoreKey = '__stylexDevtoolsOverrides__'; // $FlowExpectedError[cannot-resolve-name] const current = typeof $0 !== 'undefined' ? $0 : null; - const stored = (window: any)[overrideElementKey]; + const stored = window[overrideElementKey]; const sameNode = stored && current && typeof stored.isSameNode === 'function' && stored.isSameNode(current); if (!sameNode && current) { - (window: any)[overrideElementKey] = current; + window[overrideElementKey] = current; } const element = sameNode ? stored : current || (stored && typeof stored.isSameNode === 'function' ? stored : null); if (!element) return false; - const existing = (window: any)[overrideStoreKey]; + const existing = window[overrideStoreKey]; const store: WeakMap = existing && typeof existing.get === 'function' ? existing : new WeakMap(); if (existing == null || existing !== store) { - (window: any)[overrideStoreKey] = store; + window[overrideStoreKey] = store; } if (!Array.isArray(overrides) || overrides.length === 0) { store.delete(element); diff --git a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js index 1c3b2f934..4d4cc3428 100644 --- a/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js +++ b/packages/@stylexjs/devtools-extension/src/inspected/collectStylexDebugData.js @@ -19,6 +19,8 @@ type RuleData = $ReadOnly<{ order: number, }>; +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. @@ -47,16 +49,16 @@ export function collectStylexDebugData(): StylexDebugData { function getOverridesForElement(element: ?HTMLElement): Array { if (!element) return []; - const store = (window: any)[OVERRIDE_STORE_KEY]; + const store = window[OVERRIDE_STORE_KEY]; if (!store || typeof store.get !== 'function') { return []; } - const stored = (window: any)[OVERRIDE_ELEMENT_KEY]; + const stored = window[OVERRIDE_ELEMENT_KEY]; const hasStored = stored && typeof stored.isSameNode === 'function'; const sameNode = hasStored && stored.isSameNode(element); const elementKey = sameNode ? stored : element; if (!sameNode) { - (window: any)[OVERRIDE_ELEMENT_KEY] = element; + window[OVERRIDE_ELEMENT_KEY] = element; } const value = store.get(elementKey); if (!Array.isArray(value)) return []; diff --git a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js index 1e96648f4..82b016951 100644 --- a/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js +++ b/packages/@stylexjs/devtools-extension/src/panel/components/DeclarationsList.js @@ -10,6 +10,14 @@ 'use strict'; import * as React from 'react'; +import { + useState, + useMemo, + useEffect, + useCallback, + useRef, + useId, +} from 'react'; import * as stylex from '@stylexjs/stylex'; import { colors } from '../theme.stylex'; import type { AtomicStyleRule, StylexOverride } from '../../types.js'; @@ -207,7 +215,7 @@ function removeOverride( function overridesToEntryMap( overrides: $ReadOnlyArray, ): TOverridesByEntry { - return overrides.reduce( + return overrides.reduce( (acc, override) => override.kind === 'inline' && override.sourceEntryKey ? { @@ -225,7 +233,7 @@ function overridesToEntryMap( function buildClassOverrideMap( overrides: $ReadOnlyArray, ): TClassOverrideMap { - return overrides.reduce( + return overrides.reduce( (acc, override) => override.kind === 'class' && override.className ? { ...acc, [override.className]: override } @@ -238,7 +246,7 @@ function buildPropertyValues(rules: $ReadOnlyArray): { [string]: Array, ... } { - return rules.reduce((acc, rule) => { + return rules.reduce<{ [string]: Array, ... }>((acc, rule) => { const displayValue = formatValue(rule.value, rule.important); const key = rule.property.toLowerCase(); const existing = acc[key] ?? []; @@ -556,26 +564,26 @@ export function DeclarationsList({ onRefresh: () => void, overrides: $ReadOnlyArray, }): React.Node { - const atomicIndex = React.useMemo( + const atomicIndex = useMemo( () => buildAtomicIndex(atomicRules), [atomicRules], ); - const propertyValues = React.useMemo( + const propertyValues = useMemo( () => buildPropertyValues(atomicRules), [atomicRules], ); - const overrideValues = React.useMemo( + const overrideValues = useMemo( () => overridesToEntryMap(overrides), [overrides], ); - const classOverrides = React.useMemo( + const classOverrides = useMemo( () => buildClassOverrideMap(overrides), [overrides], ); - const overridesRef = React.useRef(overrides); + const overridesRef = useRef(overrides); overridesRef.current = overrides; - const persistOverrides = React.useCallback( + const persistOverrides = useCallback( async (nextOverrides: Array) => { overridesRef.current = nextOverrides; await setStylexOverrides(nextOverrides); @@ -583,21 +591,21 @@ export function DeclarationsList({ [], ); - const upsertOverrideEntry = React.useCallback( + const upsertOverrideEntry = useCallback( async (override: StylexOverride) => { const nextOverrides = upsertOverride(overridesRef.current, override); await persistOverrides(nextOverrides); }, [persistOverrides], ); - const removeOverrideEntry = React.useCallback( + const removeOverrideEntry = useCallback( async (id: string) => { const nextOverrides = removeOverride(overridesRef.current, id); await persistOverrides(nextOverrides); }, [persistOverrides], ); - const handleAddOverride = React.useCallback( + const handleAddOverride = useCallback( async (property: string, rawValue: string) => { const normalizedProperty = property.trim(); if (!normalizedProperty) return; @@ -630,7 +638,7 @@ export function DeclarationsList({ }, [onRefresh, upsertOverrideEntry], ); - const handleRemoveOverride = React.useCallback( + const handleRemoveOverride = useCallback( async (override: StylexOverride) => { let didMutate = false; try { @@ -1131,24 +1139,24 @@ function DeclarationEntryRow({ const suggestions = group?.values ?? []; const valueToClassName = group?.valueToClassName ?? {}; - const [isEditing, setIsEditing] = React.useState(false); - const [draftValue, setDraftValue] = React.useState(displayValue); - const [isPending, setIsPending] = React.useState(false); - const [activeIndex, setActiveIndex] = React.useState(-1); - const listId = React.useId(); + const [isEditing, setIsEditing] = useState(false); + const [draftValue, setDraftValue] = useState(''); + const [isPending, setIsPending] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listId = useId(); - React.useEffect(() => { - if (!isEditing) { - setDraftValue(displayValue); - } - }, [displayValue, isEditing]); + // useEffect(() => { + // if (!isEditing) { + // setDraftValue(displayValue); + // } + // }, [displayValue, isEditing]); - const filteredSuggestions = React.useMemo( + const filteredSuggestions = useMemo( () => filterSuggestions(suggestions, draftValue), [draftValue, suggestions], ); - React.useEffect(() => { + useEffect(() => { if (!isEditing || filteredSuggestions.length === 0) { setActiveIndex(-1); return; @@ -1160,12 +1168,13 @@ function DeclarationEntryRow({ ); }, [filteredSuggestions, isEditing]); - const commitChange = React.useCallback( + const commitChange = useCallback( async (nextValue?: string) => { - if (isPending) { + const rawValue = nextValue ?? draftValue; + if (isPending || rawValue === '') { return; } - const rawValue = nextValue ?? draftValue; + const trimmed = rawValue.trim(); const current = displayValue.trim(); setIsEditing(false); @@ -1206,7 +1215,7 @@ function DeclarationEntryRow({ await onOverrideRemove( buildInlineOverrideId(entry.property, entry.pseudoElement), ); - if (shouldRemoveClassOverride) { + if (shouldRemoveClassOverride && existingClassOverride) { await onOverrideRemove(existingClassOverride.id); return; } @@ -1253,7 +1262,7 @@ function DeclarationEntryRow({ ], ); - const handleKeyDown = React.useCallback( + const handleKeyDown = useCallback( ( event: KeyboardEvent & { currentTarget: HTMLInputElement | HTMLButtonElement, @@ -1289,7 +1298,6 @@ function DeclarationEntryRow({ [activeIndex, commitChange, displayValue, filteredSuggestions], ); - const lineStyle = isSubLine ? styles.declSubLine : styles.declLine; const prefixContent = prefix && showColon ? ( <> @@ -1307,7 +1315,12 @@ function DeclarationEntryRow({ return (
-
+
{prefixContent} {isEditing ? (
@@ -1326,15 +1339,17 @@ function DeclarationEntryRow({ onBlur={() => commitChange()} onChange={(event) => setDraftValue(event.currentTarget.value)} onKeyDown={handleKeyDown} + placeholder={draftValue} role="combobox" spellCheck={false} - value={draftValue} /> commitChange(value)} + onSelect={(value) => { + commitChange(value); + }} suggestions={filteredSuggestions} />
@@ -1397,9 +1412,9 @@ function OverrideRow({ onRemove: (override: StylexOverride) => Promise | void, override: StylexOverride, }): React.Node { - const [isPending, setIsPending] = React.useState(false); + const [isPending, setIsPending] = useState(false); - const handleRemove = React.useCallback(async () => { + const handleRemove = useCallback(async () => { if (isPending) return; setIsPending(true); try { @@ -1450,24 +1465,24 @@ function OverrideComposer({ onAddOverride: (property: string, rawValue: string) => Promise | void, propertyValues: { [string]: Array, ... }, }): React.Node { - const [property, setProperty] = React.useState(''); - const [value, setValue] = React.useState(''); - const [isPending, setIsPending] = React.useState(false); - const [isValueFocused, setIsValueFocused] = React.useState(false); - const [activeIndex, setActiveIndex] = React.useState(-1); - const listId = React.useId(); + const [property, setProperty] = useState(''); + const [value, setValue] = useState(''); + const [isPending, setIsPending] = useState(false); + const [isValueFocused, setIsValueFocused] = useState(false); + const [activeIndex, setActiveIndex] = useState(-1); + const listId = useId(); const normalizedProperty = property.trim().toLowerCase(); const suggestions = normalizedProperty ? (propertyValues[normalizedProperty] ?? []) : []; - const filteredSuggestions = React.useMemo( + const filteredSuggestions = useMemo( () => filterSuggestions(suggestions, value), [suggestions, value], ); const showSuggestions = isValueFocused && filteredSuggestions.length > 0; - React.useEffect(() => { + useEffect(() => { if (!showSuggestions) { setActiveIndex(-1); return; @@ -1479,7 +1494,7 @@ function OverrideComposer({ ); }, [filteredSuggestions, showSuggestions]); - const commitAdd = React.useCallback( + const commitAdd = useCallback( async (nextValue?: string) => { if (isPending) return; const prop = property.trim(); @@ -1496,7 +1511,7 @@ function OverrideComposer({ [isPending, onAddOverride, property, value], ); - const handleKeyDown = React.useCallback( + const handleKeyDown = useCallback( ( event: KeyboardEvent & { currentTarget: HTMLInputElement | HTMLButtonElement, @@ -1559,7 +1574,9 @@ function OverrideComposer({ activeIndex={activeIndex} listId={listId} onActiveIndexChange={setActiveIndex} - onSelect={(nextValue) => commitAdd(nextValue)} + onSelect={(nextValue) => { + commitAdd(nextValue); + }} suggestions={showSuggestions ? filteredSuggestions : []} />
@@ -1641,6 +1658,7 @@ const styles = stylex.create({ gap: 12, }, declText: { + display: 'flex', flex: 1, minWidth: 0, }, @@ -1719,6 +1737,7 @@ const styles = stylex.create({ alignItems: 'center', gap: 8, flexWrap: 'wrap', + flexGrow: 1, }, overrideInput: { fontFamily: @@ -1734,9 +1753,7 @@ const styles = stylex.create({ paddingInline: 6, paddingBlock: 2, minWidth: 0, - width: '100%', - boxSizing: 'border-box', - flex: 1, + flexGrow: 1, }, overrideValueWrap: { position: 'relative', @@ -1767,21 +1784,23 @@ const styles = stylex.create({ color: 'inherit', backgroundColor: colors.bgRaised, borderWidth: 1, + marginBlock: -2, borderStyle: 'solid', borderColor: colors.border, borderRadius: 4, paddingInline: 4, paddingBlock: 1, minWidth: 0, - width: '100%', + flexGrow: 1, boxSizing: 'border-box', }, valuePending: { opacity: 0.6, }, suggestionWrap: { + display: 'flex', position: 'relative', - width: '100%', + flexGrow: 1, }, suggestionList: { position: 'absolute',