From ad607469c58d337d23d05e3be73087d370f7d715 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 7 Dec 2021 20:04:12 -0500 Subject: [PATCH] StyleX plug-in for resolving atomic styles to values for props.xstyle (#22808) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the concept of "plugins" to the inspected element payload. Also adds the first plugin, one that resolves StyleX atomic style names to their values and displays them as a unified style object (rather than a nested array of objects and booleans). Source file names are displayed first, in dim color, followed by an ordered set of resolved style values. For builds with the new feature flag disabled, there is no observable change. A next step to build on top of this could be to make the style values editable, but change the logic such that editing one directly added an inline style to the item (rather than modifying the stylex class– which may be shared between multiple other components). --- .../backend/StyleX/__tests__/utils-test.js | 231 ++++++++++++++++++ .../src/backend/StyleX/utils.js | 110 +++++++++ .../src/backend/legacy/renderer.js | 4 + .../src/backend/renderer.js | 19 +- .../src/backend/types.js | 4 + .../react-devtools-shared/src/backendAPI.js | 2 + .../config/DevToolsFeatureFlags.core-fb.js | 7 +- .../config/DevToolsFeatureFlags.core-oss.js | 7 +- .../config/DevToolsFeatureFlags.default.js | 7 +- .../DevToolsFeatureFlags.extension-fb.js | 7 +- .../DevToolsFeatureFlags.extension-oss.js | 7 +- .../InspectedElementStyleXPlugin.css | 6 + .../InspectedElementStyleXPlugin.js | 76 ++++++ .../views/Components/InspectedElementView.js | 11 + .../src/devtools/views/Components/types.js | 5 +- packages/react-devtools-shared/src/types.js | 9 + 16 files changed, 495 insertions(+), 17 deletions(-) create mode 100644 packages/react-devtools-shared/src/backend/StyleX/__tests__/utils-test.js create mode 100644 packages/react-devtools-shared/src/backend/StyleX/utils.js create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.css create mode 100644 packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.js diff --git a/packages/react-devtools-shared/src/backend/StyleX/__tests__/utils-test.js b/packages/react-devtools-shared/src/backend/StyleX/__tests__/utils-test.js new file mode 100644 index 0000000000000..6783fea27f7ef --- /dev/null +++ b/packages/react-devtools-shared/src/backend/StyleX/__tests__/utils-test.js @@ -0,0 +1,231 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +describe('Stylex plugin utils', () => { + let getStyleXData; + let styleElements; + + function defineStyles(style) { + const styleElement = document.createElement('style'); + styleElement.type = 'text/css'; + styleElement.appendChild(document.createTextNode(style)); + + styleElements.push(styleElement); + + document.head.appendChild(styleElement); + } + + beforeEach(() => { + getStyleXData = require('../utils').getStyleXData; + + styleElements = []; + }); + + afterEach(() => { + styleElements.forEach(styleElement => { + document.head.removeChild(styleElement); + }); + }); + + it('should support simple style objects', () => { + defineStyles(` + .foo { + display: flex; + } + .bar: { + align-items: center; + } + .baz { + flex-direction: center; + } + `); + + expect( + getStyleXData({ + // The source/module styles are defined in + Example__style: 'Example__style', + + // Map of CSS style to StyleX class name, booleans, or nested structures + display: 'foo', + flexDirection: 'baz', + alignItems: 'bar', + }), + ).toMatchInlineSnapshot(` + Object { + "resolvedStyles": Object { + "alignItems": "center", + "display": "flex", + "flexDirection": "center", + }, + "sources": Array [ + "Example__style", + ], + } + `); + }); + + it('should support multiple style objects', () => { + defineStyles(` + .foo { + display: flex; + } + .bar: { + align-items: center; + } + .baz { + flex-direction: center; + } + `); + + expect( + getStyleXData([ + {Example1__style: 'Example1__style', display: 'foo'}, + { + Example2__style: 'Example2__style', + flexDirection: 'baz', + alignItems: 'bar', + }, + ]), + ).toMatchInlineSnapshot(` + Object { + "resolvedStyles": Object { + "alignItems": "center", + "display": "flex", + "flexDirection": "center", + }, + "sources": Array [ + "Example1__style", + "Example2__style", + ], + } + `); + }); + + it('should filter empty rules', () => { + defineStyles(` + .foo { + display: flex; + } + .bar: { + align-items: center; + } + .baz { + flex-direction: center; + } + `); + + expect( + getStyleXData([ + false, + {Example1__style: 'Example1__style', display: 'foo'}, + false, + false, + { + Example2__style: 'Example2__style', + flexDirection: 'baz', + alignItems: 'bar', + }, + false, + ]), + ).toMatchInlineSnapshot(` + Object { + "resolvedStyles": Object { + "alignItems": "center", + "display": "flex", + "flexDirection": "center", + }, + "sources": Array [ + "Example1__style", + "Example2__style", + ], + } + `); + }); + + it('should support pseudo-classes', () => { + defineStyles(` + .foo { + color: black; + } + .bar: { + color: blue; + } + .baz { + text-decoration: none; + } + `); + + expect( + getStyleXData({ + // The source/module styles are defined in + Example__style: 'Example__style', + + // Map of CSS style to StyleX class name, booleans, or nested structures + color: 'foo', + ':hover': { + color: 'bar', + textDecoration: 'baz', + }, + }), + ).toMatchInlineSnapshot(` + Object { + "resolvedStyles": Object { + ":hover": Object { + "color": "blue", + "textDecoration": "none", + }, + "color": "black", + }, + "sources": Array [ + "Example__style", + ], + } + `); + }); + + it('should support nested selectors', () => { + defineStyles(` + .foo { + display: flex; + } + .bar: { + align-items: center; + } + .baz { + flex-direction: center; + } + `); + + expect( + getStyleXData([ + {Example1__style: 'Example1__style', display: 'foo'}, + false, + [ + false, + {Example2__style: 'Example2__style', flexDirection: 'baz'}, + {Example3__style: 'Example3__style', alignItems: 'bar'}, + ], + false, + ]), + ).toMatchInlineSnapshot(` + Object { + "resolvedStyles": Object { + "alignItems": "center", + "display": "flex", + "flexDirection": "center", + }, + "sources": Array [ + "Example1__style", + "Example2__style", + "Example3__style", + ], + } + `); + }); +}); diff --git a/packages/react-devtools-shared/src/backend/StyleX/utils.js b/packages/react-devtools-shared/src/backend/StyleX/utils.js new file mode 100644 index 0000000000000..6bf5a2978927e --- /dev/null +++ b/packages/react-devtools-shared/src/backend/StyleX/utils.js @@ -0,0 +1,110 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {StyleXPlugin} from 'react-devtools-shared/src/types'; + +const cachedStyleNameToValueMap: Map = new Map(); + +export function getStyleXData(data: any): StyleXPlugin { + const sources = new Set(); + const resolvedStyles = {}; + + crawlData(data, sources, resolvedStyles); + + return { + sources: Array.from(sources).sort(), + resolvedStyles, + }; +} + +export function crawlData( + data: any, + sources: Set, + resolvedStyles: Object, +): void { + if (Array.isArray(data)) { + data.forEach(entry => { + if (Array.isArray(entry)) { + crawlData(entry, sources, resolvedStyles); + } else { + crawlObjectProperties(entry, sources, resolvedStyles); + } + }); + } else { + crawlObjectProperties(data, sources, resolvedStyles); + } + + resolvedStyles = Object.fromEntries( + Object.entries(resolvedStyles).sort(), + ); +} + +function crawlObjectProperties( + entry: Object, + sources: Set, + resolvedStyles: Object, +): void { + const keys = Object.keys(entry); + keys.forEach(key => { + const value = entry[key]; + if (typeof value === 'string') { + if (key === value) { + // Special case; this key is the name of the style's source/file/module. + sources.add(key); + } else { + resolvedStyles[key] = getPropertyValueForStyleName(value); + } + } else { + const nestedStyle = {}; + resolvedStyles[key] = nestedStyle; + crawlData([value], sources, nestedStyle); + } + }); +} + +function getPropertyValueForStyleName(styleName: string): string | null { + if (cachedStyleNameToValueMap.has(styleName)) { + return ((cachedStyleNameToValueMap.get(styleName): any): string); + } + + for ( + let styleSheetIndex = 0; + styleSheetIndex < document.styleSheets.length; + styleSheetIndex++ + ) { + const styleSheet = ((document.styleSheets[ + styleSheetIndex + ]: any): CSSStyleSheet); + // $FlowFixMe Flow doesn't konw about these properties + const rules = styleSheet.rules || styleSheet.cssRules; + for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex++) { + const rule = rules[ruleIndex]; + // $FlowFixMe Flow doesn't konw about these properties + const {cssText, selectorText, style} = rule; + + if (selectorText != null) { + if (selectorText.startsWith(`.${styleName}`)) { + const match = cssText.match(/{ *([a-z\-]+):/); + if (match !== null) { + const property = match[1]; + const value = style.getPropertyValue(property); + + cachedStyleNameToValueMap.set(styleName, value); + + return value; + } else { + return null; + } + } + } + } + } + + return null; +} diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index c5684f222fa31..b757d008ac294 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -838,6 +838,10 @@ export function attach( rootType: null, rendererPackageName: null, rendererVersion: null, + + plugins: { + stylex: null, + }, }; } diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 8a89a5406c6ef..7aebe6e507a0f 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -82,10 +82,14 @@ import { MEMO_SYMBOL_STRING, } from './ReactSymbols'; import {format} from './utils'; -import {enableProfilerChangedHookIndices} from 'react-devtools-feature-flags'; +import { + enableProfilerChangedHookIndices, + enableStyleXFeatures, +} from 'react-devtools-feature-flags'; import is from 'shared/objectIs'; import isArray from 'shared/isArray'; import hasOwnProperty from 'shared/hasOwnProperty'; +import {getStyleXData} from './StyleX/utils'; import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type { @@ -108,6 +112,7 @@ import type { import type { ComponentFilter, ElementType, + Plugins, } from 'react-devtools-shared/src/types'; type getDisplayNameForFiberType = (fiber: Fiber) => string | null; @@ -3234,6 +3239,16 @@ export function attach( targetErrorBoundaryID = getNearestErrorBoundaryID(fiber); } + const plugins: Plugins = { + stylex: null, + }; + + if (enableStyleXFeatures) { + if (memoizedProps.hasOwnProperty('xstyle')) { + plugins.stylex = getStyleXData(memoizedProps.xstyle); + } + } + return { id, @@ -3293,6 +3308,8 @@ export function attach( rootType, rendererPackageName: renderer.rendererPackageName, rendererVersion: renderer.version, + + plugins, }; } diff --git a/packages/react-devtools-shared/src/backend/types.js b/packages/react-devtools-shared/src/backend/types.js index 333ee30914cdd..3318c8b6a965e 100644 --- a/packages/react-devtools-shared/src/backend/types.js +++ b/packages/react-devtools-shared/src/backend/types.js @@ -13,6 +13,7 @@ import type {Fiber} from 'react-reconciler/src/ReactInternalTypes'; import type { ComponentFilter, ElementType, + Plugins, } from 'react-devtools-shared/src/types'; import type {ResolveNativeStyle} from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor'; @@ -265,6 +266,9 @@ export type InspectedElement = {| // Meta information about the renderer that created this element. rendererPackageName: string | null, rendererVersion: string | null, + + // UI plugins/visualizations for the inspected element. + plugins: Plugins, |}; export const InspectElementErrorType = 'error'; diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index 23b975e38d6ea..3849899b7df02 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -208,6 +208,7 @@ export function convertInspectedElementBackendToFrontend( owners, context, hooks, + plugins, props, rendererPackageName, rendererVersion, @@ -233,6 +234,7 @@ export function convertInspectedElementBackendToFrontend( hasLegacyContext, id, key, + plugins, rendererPackageName, rendererVersion, rootType, diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js index 50eb47967baff..f382a2b4b2e27 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-fb.js @@ -13,11 +13,12 @@ * It should always be imported from "react-devtools-feature-flags". ************************************************************************/ +export const consoleManagedByDevToolsDuringStrictMode = false; +export const enableLogger = true; +export const enableNamedHooksFeature = true; export const enableProfilerChangedHookIndices = true; +export const enableStyleXFeatures = true; export const isInternalFacebookBuild = true; -export const enableNamedHooksFeature = true; -export const enableLogger = true; -export const consoleManagedByDevToolsDuringStrictMode = false; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js index fd569a7c550dd..579efaedd619d 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.core-oss.js @@ -13,11 +13,12 @@ * It should always be imported from "react-devtools-feature-flags". ************************************************************************/ +export const consoleManagedByDevToolsDuringStrictMode = false; +export const enableLogger = false; +export const enableNamedHooksFeature = true; export const enableProfilerChangedHookIndices = true; +export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableNamedHooksFeature = true; -export const enableLogger = false; -export const consoleManagedByDevToolsDuringStrictMode = false; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js index 9552dd0bd6ef0..3bd4efb101c76 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.default.js @@ -13,8 +13,9 @@ * It should always be imported from "react-devtools-feature-flags". ************************************************************************/ +export const consoleManagedByDevToolsDuringStrictMode = true; +export const enableLogger = false; +export const enableNamedHooksFeature = true; export const enableProfilerChangedHookIndices = true; +export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableNamedHooksFeature = true; -export const enableLogger = false; -export const consoleManagedByDevToolsDuringStrictMode = true; diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js index b3bd5a7a92dcf..d86b0ddf73ab5 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-fb.js @@ -13,11 +13,12 @@ * It should always be imported from "react-devtools-feature-flags". ************************************************************************/ +export const consoleManagedByDevToolsDuringStrictMode = true; +export const enableLogger = true; +export const enableNamedHooksFeature = true; export const enableProfilerChangedHookIndices = true; +export const enableStyleXFeatures = true; export const isInternalFacebookBuild = true; -export const enableNamedHooksFeature = true; -export const enableLogger = true; -export const consoleManagedByDevToolsDuringStrictMode = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js index 53466bf84bb7f..f1a307306242a 100644 --- a/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js +++ b/packages/react-devtools-shared/src/config/DevToolsFeatureFlags.extension-oss.js @@ -13,11 +13,12 @@ * It should always be imported from "react-devtools-feature-flags". ************************************************************************/ +export const consoleManagedByDevToolsDuringStrictMode = true; +export const enableLogger = false; +export const enableNamedHooksFeature = true; export const enableProfilerChangedHookIndices = true; +export const enableStyleXFeatures = false; export const isInternalFacebookBuild = false; -export const enableNamedHooksFeature = true; -export const enableLogger = false; -export const consoleManagedByDevToolsDuringStrictMode = true; /************************************************************************ * Do not edit the code below. diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.css new file mode 100644 index 0000000000000..0aa7f62e27923 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.css @@ -0,0 +1,6 @@ +.Source { + color: var(--color-dim); + margin-left: 1rem; + overflow: auto; + text-overflow: ellipsis; +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.js new file mode 100644 index 0000000000000..8045803fd0118 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementStyleXPlugin.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import KeyValue from './KeyValue'; +import Store from '../../store'; +import sharedStyles from './InspectedElementSharedStyles.css'; +import styles from './InspectedElementStyleXPlugin.css'; +import {enableStyleXFeatures} from 'react-devtools-feature-flags'; + +import type {InspectedElement} from './types'; +import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type {Element} from 'react-devtools-shared/src/devtools/views/Components/types'; + +type Props = {| + bridge: FrontendBridge, + element: Element, + inspectedElement: InspectedElement, + store: Store, +|}; + +export default function InspectedElementStyleXPlugin({ + bridge, + element, + inspectedElement, + store, +}: Props) { + if (!enableStyleXFeatures) { + return null; + } + + const styleXPlugin = inspectedElement.plugins.stylex; + if (styleXPlugin == null) { + return null; + } + + const {resolvedStyles, sources} = styleXPlugin; + + return ( +
+
+
stylex
+
+ {sources.map(source => ( +
+ {source} +
+ ))} + {Object.entries(resolvedStyles).map(([name, value]) => ( +
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js index 8424621fcf16c..ad1272b722cb3 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementView.js @@ -23,6 +23,7 @@ import InspectedElementErrorsAndWarningsTree from './InspectedElementErrorsAndWa import InspectedElementHooksTree from './InspectedElementHooksTree'; import InspectedElementPropsTree from './InspectedElementPropsTree'; import InspectedElementStateTree from './InspectedElementStateTree'; +import InspectedElementStyleXPlugin from './InspectedElementStyleXPlugin'; import InspectedElementSuspenseToggle from './InspectedElementSuspenseToggle'; import NativeStyleEditor from './NativeStyleEditor'; import Badge from './Badge'; @@ -31,6 +32,7 @@ import { copyInspectedElementPath as copyInspectedElementPathAPI, storeAsGlobal as storeAsGlobalAPI, } from 'react-devtools-shared/src/backendAPI'; +import {enableStyleXFeatures} from 'react-devtools-feature-flags'; import styles from './InspectedElementView.css'; @@ -124,6 +126,15 @@ export default function InspectedElementView({ store={store} /> + {enableStyleXFeatures && ( + + )} + = {| reset: () => void, set: (key: K, value: V) => void, |}; + +export type StyleXPlugin = {| + sources: Array, + resolvedStyles: Object, +|}; + +export type Plugins = {| + stylex: StyleXPlugin | null, +|};