From ace004835b27c231b735e8864c52db7cc52742c4 Mon Sep 17 00:00:00 2001 From: Naman Goel Date: Sun, 25 Jan 2026 01:00:37 -0800 Subject: [PATCH] [feat] Bring back stylex.attrs --- flow-typed/environments/bom.js | 1 - flow-typed/environments/dom.js | 2 +- flow-typed/environments/node.js | 8 +- flow-typed/npm/@babel/traverse.js | 6 +- package.json | 2 +- .../__tests__/transform-stylex-attrs-test.js | 108 ++++ packages/@stylexjs/babel-plugin/src/index.js | 4 + .../src/utils/stylex-merge-utils.js | 299 +++++++++++ .../babel-plugin/src/visitors/imports.js | 6 + .../babel-plugin/src/visitors/stylex-attrs.js | 48 ++ .../babel-plugin/src/visitors/stylex-merge.js | 251 ++-------- .../src/visitors/stylex-props-utils.js | 274 +++++++++++ .../babel-plugin/src/visitors/stylex-props.js | 465 +----------------- .../@stylexjs/stylex/__tests__/stylex-test.js | 43 ++ packages/@stylexjs/stylex/src/stylex.js | 33 +- .../stylex/src/types/StyleXTypes.d.ts | 13 + .../@stylexjs/stylex/src/types/StyleXTypes.js | 14 +- yarn.lock | 49 +- 18 files changed, 901 insertions(+), 725 deletions(-) create mode 100644 packages/@stylexjs/babel-plugin/__tests__/transform-stylex-attrs-test.js create mode 100644 packages/@stylexjs/babel-plugin/src/utils/stylex-merge-utils.js create mode 100644 packages/@stylexjs/babel-plugin/src/visitors/stylex-attrs.js create mode 100644 packages/@stylexjs/babel-plugin/src/visitors/stylex-props-utils.js diff --git a/flow-typed/environments/bom.js b/flow-typed/environments/bom.js index 558f3f057..a2df2ddf3 100644 --- a/flow-typed/environments/bom.js +++ b/flow-typed/environments/bom.js @@ -873,7 +873,6 @@ declare class SharedWorker extends EventTarget { declare function importScripts(...urls: Array): void; declare class WorkerGlobalScope extends EventTarget { - self: this; location: WorkerLocation; navigator: WorkerNavigator; close(): void; diff --git a/flow-typed/environments/dom.js b/flow-typed/environments/dom.js index 85678bc66..f3d000d89 100644 --- a/flow-typed/environments/dom.js +++ b/flow-typed/environments/dom.js @@ -1285,7 +1285,7 @@ declare class HTMLCollection<+Elem: Element> { length: number; item(nameOrIndex?: any, optionalIndex?: any): Elem | null; namedItem(name: string): Elem | null; - [index: number | string]: Elem; + +[index: number | string]: Elem; } // from https://www.w3.org/TR/custom-elements/#extensions-to-document-interface-to-register diff --git a/flow-typed/environments/node.js b/flow-typed/environments/node.js index c9a66cea5..7c32e1b5c 100644 --- a/flow-typed/environments/node.js +++ b/flow-typed/environments/node.js @@ -1905,7 +1905,7 @@ type http$agentOptions = { declare class http$Agent<+SocketT = net$Socket> { constructor(options: http$agentOptions): void; destroy(): void; - freeSockets: { [name: string]: $ReadOnlyArray, ... }; + +freeSockets: { +[name: string]: $ReadOnlyArray, ... }; getName(options: { host: string, port: number, @@ -1914,11 +1914,11 @@ declare class http$Agent<+SocketT = net$Socket> { }): string; maxFreeSockets: number; maxSockets: number; - requests: { - [name: string]: $ReadOnlyArray>, + +requests: { + +[name: string]: $ReadOnlyArray>, ... }; - sockets: { [name: string]: $ReadOnlyArray, ... }; + +sockets: { +[name: string]: $ReadOnlyArray, ... }; } declare class http$IncomingMessage diff --git a/flow-typed/npm/@babel/traverse.js b/flow-typed/npm/@babel/traverse.js index 6d428d464..78d800444 100644 --- a/flow-typed/npm/@babel/traverse.js +++ b/flow-typed/npm/@babel/traverse.js @@ -284,7 +284,7 @@ declare module '@babel/traverse' { [K in keyof _NodeToTuple]: NodePath<_NodeToTuple[K]>, }; - type TParentPath = T extends t.Program ? null : NodePath<>; + type TParentPath<+T: Node> = T extends t.Program ? null : NodePath<>; interface object {} @@ -300,14 +300,14 @@ declare module '@babel/traverse' { state: any; opts: object; skipKeys: object; - parentPath: TParentPath; + +parentPath: TParentPath; context: TraversalContext; container: object | $ReadOnlyArray; listKey: string; inList: boolean; parentKey: string; key: string | number; - node: T; + +node: T; scope: Scope; type: T extends null | void diff --git a/package.json b/package.json index cde5623c8..a8e8f24b0 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "eslint-plugin-ft-flow": "^3.0.11", "eslint-plugin-headers": "~1.2.1", "eslint-plugin-react": "^7.37.1", - "flow-bin": "^0.291.0", + "flow-bin": "^0.295.0", "flow-typed": "^4.1.1", "hermes-eslint": "^0.32.1", "husky": "^8.0.0", diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-attrs-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-attrs-test.js new file mode 100644 index 000000000..2caf147c6 --- /dev/null +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-attrs-test.js @@ -0,0 +1,108 @@ +/** + * 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. + */ + +'use strict'; + +jest.autoMockOff(); + +import { transformSync } from '@babel/core'; +import jsx from '@babel/plugin-syntax-jsx'; +import stylexPlugin from '../src/index'; +import path from 'path'; + +function transform(source, opts = {}) { + return transformSync(source, { + filename: opts.filename, + parserOpts: { + sourceType: 'module', + flow: 'all', + }, + babelrc: false, + plugins: [ + jsx, + [ + stylexPlugin, + { + unstable_moduleResolution: { + rootDir: path.parse(process.cwd()).root, + type: 'commonJS', + }, + ...opts, + runtimeInjection: true, + }, + ], + ], + }).code; +} + +describe('@stylexjs/babel-plugin', () => { + describe('[transform] stylex.attrs() call', () => { + test('empty stylex.attrs call', () => { + expect( + transform(` + import stylex from 'stylex'; + stylex.attrs(); + `), + ).toMatchInlineSnapshot(` + "import stylex from 'stylex'; + ({});" + `); + }); + + test('basic stylex attrs call', () => { + expect( + transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + red: { + color: 'red', + } + }); + stylex.attrs(styles.red); + `), + ).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x1e2nbdu{color:red}", + priority: 3000 + }); + ({ + class: "x1e2nbdu" + });" + `); + }); + + test('attrs call within jsx spread', () => { + expect( + transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + red: { + color: 'red', + } + }); + function Foo() { + return
; + } + `), + ).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x1e2nbdu{color:red}", + priority: 3000 + }); + function Foo() { + return
; + }" + `); + }); + }); +}); diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index 7b4648a01..f5a94732d 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -31,6 +31,8 @@ import transformStylexCall, { } from './visitors/stylex-merge'; import transformStylexProps from './visitors/stylex-props'; import { skipStylexPropsChildren } from './visitors/stylex-props'; +import transformStylexAttrs from './visitors/stylex-attrs'; +import { skipStylexAttrsChildren } from './visitors/stylex-attrs'; import transformStyleXViewTransitionClass from './visitors/stylex-view-transition-class'; import transformStyleXDefaultMarker from './visitors/stylex-default-marker'; import { @@ -158,12 +160,14 @@ function styleXTransform(): PluginObj<> { // should be kept. skipStylexMergeChildren(path, state); skipStylexPropsChildren(path, state); + skipStylexAttrsChildren(path, state); }, }); path.traverse({ CallExpression(path: NodePath) { transformStylexCall(path, state); transformStylexProps(path, state); + transformStylexAttrs(path, state); }, }); diff --git a/packages/@stylexjs/babel-plugin/src/utils/stylex-merge-utils.js b/packages/@stylexjs/babel-plugin/src/utils/stylex-merge-utils.js new file mode 100644 index 000000000..9a93d6da0 --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/utils/stylex-merge-utils.js @@ -0,0 +1,299 @@ +/** + * 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 type { NodePath } from '@babel/traverse'; + +import * as t from '@babel/types'; +import StateManager from './state-manager'; + +type ClassNameValue = string | null | boolean | NonStringClassNameValue; +type NonStringClassNameValue = [t.Expression, ClassNameValue, ClassNameValue]; + +export type StyleObject = { + [key: string]: string | null | boolean, +}; + +export class ConditionalStyle { + test: t.Expression; + primary: ?StyleObject; + fallback: ?StyleObject; + constructor( + test: t.Expression, + primary: ?StyleObject, + fallback: ?StyleObject, + ) { + this.test = test; + this.primary = primary; + this.fallback = fallback; + } +} + +export type ResolvedArg = ?StyleObject | ConditionalStyle; +export type ResolvedArgs = $ReadOnlyArray; + +export type ResolveStyle = ( + path: NodePath, +) => null | StyleObject | 'other'; + +export type ResolveStylexArgumentsResult = { + resolvedArgs: ResolvedArgs, + bailOut: boolean, + bailOutIndex: ?number, + conditionalCount: number, +}; + +export function resolveStylexArguments( + argsPaths: $ReadOnlyArray>, + resolveStyle: ResolveStyle, + options?: { + allowStylePath?: (path: NodePath<>) => boolean, + }, +): ResolveStylexArgumentsResult { + let bailOut = false; + let conditionalCount = 0; + let currentIndex = -1; + let bailOutIndex: ?number = null; + const resolvedArgs: Array = []; + const allowStylePath = options ? options.allowStylePath : undefined; + + for (const argPath of argsPaths) { + currentIndex++; + + if (argPath.isConditionalExpression()) { + const arg = argPath.node; + const { test } = arg; + const consequentPath = argPath.get('consequent'); + const alternatePath = argPath.get('alternate'); + + if (!consequentPath.isExpression() || !alternatePath.isExpression()) { + bailOutIndex = currentIndex; + bailOut = true; + } else { + const primary = resolveStyle(consequentPath); + const fallback = resolveStyle(alternatePath); + if (primary === 'other' || fallback === 'other') { + bailOutIndex = currentIndex; + bailOut = true; + } else { + resolvedArgs.push(new ConditionalStyle(test, primary, fallback)); + conditionalCount++; + } + } + } else if (argPath.isLogicalExpression()) { + const arg = argPath.node; + if (arg.operator !== '&&') { + bailOutIndex = currentIndex; + bailOut = true; + } else { + const leftPath = argPath.get('left'); + const rightPath = argPath.get('right'); + if (!leftPath.isExpression() || !rightPath.isExpression()) { + bailOutIndex = currentIndex; + bailOut = true; + } else { + const leftResolved = resolveStyle(leftPath); + const rightResolved = resolveStyle(rightPath); + if (leftResolved !== 'other' || rightResolved === 'other') { + bailOutIndex = currentIndex; + bailOut = true; + } else { + resolvedArgs.push( + new ConditionalStyle(leftPath.node, rightResolved, null), + ); + conditionalCount++; + } + } + } + } else if (argPath.isExpression()) { + if (allowStylePath != null && !allowStylePath(argPath)) { + bailOutIndex = currentIndex; + bailOut = true; + } else { + const resolved = resolveStyle(argPath as any as NodePath); + if (resolved === 'other') { + bailOutIndex = currentIndex; + bailOut = true; + } else { + resolvedArgs.push(resolved); + } + } + } else { + bailOutIndex = currentIndex; + bailOut = true; + } + + if (conditionalCount > 4) { + bailOut = true; + } + if (bailOut) { + break; + } + } + + return { resolvedArgs, bailOut, bailOutIndex, conditionalCount }; +} + +export function collectStyleVarsToKeep( + argumentPaths: $ReadOnlyArray>, + state: StateManager, + options: { + bailOutIndex: ?number, + evaluateMemberExpression: ( + path: NodePath, + ) => $ReadOnly<{ + confident: boolean, + value: any, + ... + }>, + isProxyStyle?: (value: any) => boolean, + }, +): void { + const { bailOutIndex, evaluateMemberExpression, isProxyStyle } = options; + let nonNullProps: Array | true = []; + let index = -1; + + for (const argPath of argumentPaths) { + index++; + // eslint-disable-next-line no-loop-func, no-inner-declarations + function MemberExpression(path: NodePath) { + const object = path.get('object').node; + const property = path.get('property').node; + const computed = path.node.computed; + let objName: string | null = null; + let propName: number | string | null = null; + if (object.type === 'Identifier' && state.styleMap.has(object.name)) { + objName = object.name; + + if (property.type === 'Identifier' && !computed) { + propName = property.name; + } + if ( + (property.type === 'StringLiteral' || + property.type === 'NumericLiteral') && + computed + ) { + propName = property.value; + } + } + let styleNonNullProps: true | Array = []; + if (bailOutIndex != null && index > bailOutIndex) { + nonNullProps = true; + styleNonNullProps = true; + } + if (nonNullProps === true) { + styleNonNullProps = true; + } else { + const { confident, value: styleValue } = evaluateMemberExpression(path); + if ( + !confident || + styleValue == null || + (isProxyStyle != null && isProxyStyle(styleValue)) + ) { + nonNullProps = true; + styleNonNullProps = true; + } else { + styleNonNullProps = nonNullProps === true ? true : [...nonNullProps]; + if (nonNullProps !== true) { + nonNullProps = [ + ...nonNullProps, + ...Object.keys(styleValue).filter( + (key) => styleValue[key] !== null, + ), + ]; + } + } + } + + if (objName != null) { + state.styleVarsToKeep.add([ + objName, + propName != null ? String(propName) : true, + styleNonNullProps, + ]); + } + } + + if (argPath.isMemberExpression()) { + MemberExpression(argPath); + } else { + argPath.traverse({ + MemberExpression, + }); + } + } +} + +export function makeConditionalExpression( + values: ResolvedArgs, + buildResult: (styles: $ReadOnlyArray) => t.Expression, +): t.Expression { + const conditions = values + .filter( + (v: ResolvedArg): v is ConditionalStyle => v instanceof ConditionalStyle, + ) + .map((v: ConditionalStyle) => v.test); + + if (conditions.length === 0) { + return buildResult(values as any); + } + + const conditionPermutations = genConditionPermutations(conditions.length); + const objEntries = conditionPermutations.map((permutation) => { + let i = 0; + const args = values.map((v) => { + if (v instanceof ConditionalStyle) { + const { primary, fallback } = v; + return permutation[i++] ? primary : fallback; + } + return v; + }); + const key = permutation.reduce( + (soFar, bool) => (soFar << 1) | (bool ? 1 : 0), + 0, + ); + return t.objectProperty(t.numericLiteral(key), buildResult(args as any)); + }); + const objExpressions = t.objectExpression(objEntries); + const conditionsToKey = genBitwiseOrOfConditions(conditions); + return t.memberExpression(objExpressions, conditionsToKey, true); +} + +// A function to generate a list of all possible permutations of true and false for a given count of conditional expressions. +// For example, if there are 2 conditional expressions, this function will return: +// [[true, true], [true, false], [false, true], [false, false]] +function genConditionPermutations(count: number): Array> { + const result = []; + for (let i = 0; i < 2 ** count; i++) { + const combination = []; + for (let j = 0; j < count; j++) { + combination.push(Boolean(i & (1 << j))); + } + result.push(combination); + } + return result; +} + +// A function to generate a bitwise or of all the conditions. +// For example, if there are 2 conditional expressions, this function will return: +// `!!test1 << 2 | !!test2 << 1 +function genBitwiseOrOfConditions( + conditions: Array, +): t.Expression { + const binaryExpressions = conditions.map((condition, i) => { + const shift = conditions.length - i - 1; + return t.binaryExpression( + '<<', + t.unaryExpression('!', t.unaryExpression('!', condition)), + t.numericLiteral(shift), + ); + }); + return binaryExpressions.reduce((acc, expr) => { + return t.binaryExpression('|', acc, expr); + }); +} diff --git a/packages/@stylexjs/babel-plugin/src/visitors/imports.js b/packages/@stylexjs/babel-plugin/src/visitors/imports.js index 784e98f24..7b9306385 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/imports.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/imports.js @@ -61,6 +61,9 @@ export function readImportDeclarations( if (importedName === 'props') { state.stylexPropsImport.add(localName); } + if (importedName === 'attrs') { + state.stylexAttrsImport.add(localName); + } if (importedName === 'keyframes') { state.stylexKeyframesImport.add(localName); } @@ -143,6 +146,9 @@ export function readRequires( if (prop.key.name === 'props') { state.stylexPropsImport.add(value.name); } + if (prop.key.name === 'attrs') { + state.stylexAttrsImport.add(value.name); + } if (prop.key.name === 'keyframes') { state.stylexKeyframesImport.add(value.name); } diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-attrs.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-attrs.js new file mode 100644 index 000000000..39b6e2cac --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-attrs.js @@ -0,0 +1,48 @@ +/** + * 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 type { NodePath } from '@babel/traverse'; + +import * as t from '@babel/types'; +import { attrs } from '@stylexjs/stylex'; +import { convertObjectToAST } from '../utils/js-to-ast'; +import { + transformStylexPropsLike, + skipStylexPropsLikeChildren, +} from './stylex-props-utils'; +import StateManager from '../utils/state-manager'; + +export function skipStylexAttrsChildren( + path: NodePath, + state: StateManager, +) { + skipStylexPropsLikeChildren(path, state, { + importSet: state.stylexAttrsImport, + memberName: 'attrs', + }); +} + +// If a `stylex()` call uses styles that are all locally defined, +// This function is able to pre-compute that into a single string or +// a single expression of strings and ternary expressions. +export default function transformStylexAttrs( + path: NodePath, + state: StateManager, +) { + transformStylexPropsLike(path, state, { + importSet: state.stylexAttrsImport, + memberName: 'attrs', + buildResult: (values) => { + console.log('values', values); + const result = attrs(values as $FlowFixMe); + console.log('result', result); + return convertObjectToAST(result); + }, + }); +} diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-merge.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-merge.js index 3e283dee0..db00f0e80 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-merge.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-merge.js @@ -8,23 +8,18 @@ */ import type { NodePath } from '@babel/traverse'; +import type { StyleObject } from '../utils/stylex-merge-utils'; import * as t from '@babel/types'; import StateManager from '../utils/state-manager'; +import { + collectStyleVarsToKeep, + makeConditionalExpression, + resolveStylexArguments, +} from '../utils/stylex-merge-utils'; import { evaluate } from '../utils/evaluate-path'; import { legacyMerge } from '@stylexjs/stylex'; -type ClassNameValue = string | null | boolean | NonStringClassNameValue; -type NonStringClassNameValue = [t.Expression, ClassNameValue, ClassNameValue]; - -type StyleObject = { - [key: string]: string | null | boolean, -}; - -type ConditionalStyle = [t.Expression, ?StyleObject, ?StyleObject]; -type ResolvedArg = ?StyleObject | ConditionalStyle; -type ResolvedArgs = Array; - export function skipStylexMergeChildren( path: NodePath, state: StateManager, @@ -57,151 +52,35 @@ export default function transformStyleXMerge( return; } - let bailOut = false; - let conditional = 0; - - let currentIndex = -1; - let bailOutIndex: ?number = null; + const { resolvedArgs, bailOut, bailOutIndex, conditionalCount } = + resolveStylexArguments(path.get('arguments'), (argPath) => + parseNullableStyle(argPath.node, state), + ); - const resolvedArgs: ResolvedArgs = []; - for (const arg of node.arguments) { - currentIndex++; - switch (arg.type) { - case 'MemberExpression': { - const resolved = parseNullableStyle(arg, state); - if (resolved === 'other') { - bailOutIndex = currentIndex; - bailOut = true; - } else { - resolvedArgs.push(resolved); - } - break; - } - case 'ConditionalExpression': { - const { test, consequent, alternate } = arg; - const primary = parseNullableStyle(consequent, state); - const fallback = parseNullableStyle(alternate, state); - if (primary === 'other' || fallback === 'other') { - bailOutIndex = currentIndex; - bailOut = true; - } else { - resolvedArgs.push([test, primary, fallback]); - conditional++; - } - break; - } - case 'LogicalExpression': { - if (arg.operator !== '&&') { - bailOutIndex = currentIndex; - bailOut = true; - break; - } - const { left, right } = arg; - const leftResolved = parseNullableStyle(left, state); - const rightResolved = parseNullableStyle(right, state); - if (leftResolved !== 'other' || rightResolved === 'other') { - bailOutIndex = currentIndex; - bailOut = true; - } else { - resolvedArgs.push([left, rightResolved, null]); - conditional++; - } - break; - } - default: - bailOutIndex = currentIndex; - bailOut = true; - break; - } - if (conditional > 4) { - bailOut = true; - } - if (bailOut) { - break; - } - } - if (!state.options.enableInlinedConditionalMerge && conditional) { - bailOut = true; + if (!state.options.enableInlinedConditionalMerge && conditionalCount) { + collectStyleVarsToKeep(path.get('arguments'), state, { + bailOutIndex, + evaluateMemberExpression: (memberPath) => evaluate(memberPath, state), + }); + return; } if (bailOut) { - const argumentPaths = path.get('arguments'); - - let nonNullProps: Array | true = []; - - let index = -1; - for (const argPath of argumentPaths) { - index++; - // eslint-disable-next-line no-inner-declarations, no-loop-func - function MemberExpression(path: NodePath) { - const object = path.get('object').node; - const property = path.get('property').node; - const computed = path.node.computed; - let objName: string | null = null; - let propName: number | string | null = null; - if (object.type === 'Identifier' && state.styleMap.has(object.name)) { - objName = object.name; - - if (property.type === 'Identifier' && !computed) { - propName = property.name; - } - if ( - (property.type === 'StringLiteral' || - property.type === 'NumericLiteral') && - computed - ) { - propName = property.value; - } - } - let styleNonNullProps: true | Array = []; - if (bailOutIndex != null && index > bailOutIndex) { - nonNullProps = true; - styleNonNullProps = true; - } - if (nonNullProps === true) { - styleNonNullProps = true; - } else { - const { confident, value: styleValue } = evaluate(path, state); - if (!confident || styleValue == null) { - nonNullProps = true; - styleNonNullProps = true; - } else { - styleNonNullProps = - nonNullProps === true ? true : [...nonNullProps]; - if (nonNullProps !== true) { - nonNullProps = [ - ...nonNullProps, - ...Object.keys(styleValue).filter( - (key) => styleValue[key] !== null, - ), - ]; - } - } - } - - if (objName != null) { - state.styleVarsToKeep.add([ - objName, - propName != null ? String(propName) : true, - styleNonNullProps, - ]); - } - } - - if (argPath.isMemberExpression()) { - MemberExpression(argPath); - } else { - argPath.traverse({ - MemberExpression, - }); - } - } - } else { - path.skip(); - // convert resolvedStyles to a string + ternary expressions - // We no longer need the keys, so we can just use the values. - const stringExpression = makeStringExpression(resolvedArgs); - path.replaceWith(stringExpression); + collectStyleVarsToKeep(path.get('arguments'), state, { + bailOutIndex, + evaluateMemberExpression: (memberPath) => evaluate(memberPath, state), + }); + return; } + + path.skip(); + // convert resolvedStyles to a string + ternary expressions + // We no longer need the keys, so we can just use the values. + const stringExpression = makeConditionalExpression( + resolvedArgs, + (values: $ReadOnlyArray) => + t.stringLiteral(legacyMerge(values as $FlowFixMe)), + ); + path.replaceWith(stringExpression); } // Looks for Null or locally defined style namespaces. @@ -253,71 +132,3 @@ function parseNullableStyle( return 'other'; } - -function makeStringExpression(values: ResolvedArgs): t.Expression { - const conditions = values - .filter((v: ResolvedArg): v is ConditionalStyle => Array.isArray(v)) - .map((v: ConditionalStyle) => v[0]); - - if (conditions.length === 0) { - return t.stringLiteral(legacyMerge(...(values as any))); - } - - const conditionPermutations = genConditionPermutations(conditions.length); - const objEntries = conditionPermutations.map((permutation) => { - let i = 0; - const args = values.map((v) => { - if (Array.isArray(v)) { - const [_test, primary, fallback] = v; - return permutation[i++] ? primary : fallback; - } else { - return v; - } - }); - const key = permutation.reduce( - (soFar, bool) => (soFar << 1) | (bool ? 1 : 0), - 0, - ); - return t.objectProperty( - t.numericLiteral(key), - t.stringLiteral(legacyMerge(...(args as any))), - ); - }); - const objExpressions = t.objectExpression(objEntries); - const conditionsToKey = genBitwiseOrOfConditions(conditions); - return t.memberExpression(objExpressions, conditionsToKey, true); -} - -// A function to generate a list of all possible permutations of true and false for a given count of conditional expressions. -// For example, if there are 2 conditional expressions, this function will return: -// [[true, true], [true, false], [false, true], [false, false]] -function genConditionPermutations(count: number): Array> { - const result = []; - for (let i = 0; i < 2 ** count; i++) { - const combination = []; - for (let j = 0; j < count; j++) { - combination.push(Boolean(i & (1 << j))); - } - result.push(combination); - } - return result; -} - -// A function to generate a bitwise or of all the conditions. -// For example, if there are 2 conditional expressions, this function will return: -// `!!test1 << 2 | !!test2 << 1 -function genBitwiseOrOfConditions( - conditions: Array, -): t.Expression { - const binaryExpressions = conditions.map((condition, i) => { - const shift = conditions.length - i - 1; - return t.binaryExpression( - '<<', - t.unaryExpression('!', t.unaryExpression('!', condition)), - t.numericLiteral(shift), - ); - }); - return binaryExpressions.reduce((acc, expr) => { - return t.binaryExpression('|', acc, expr); - }); -} diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-props-utils.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props-utils.js new file mode 100644 index 000000000..02c9b173d --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props-utils.js @@ -0,0 +1,274 @@ +/** + * 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 type { NodePath } from '@babel/traverse'; +import type { FunctionConfig } from '../utils/evaluate-path'; +import type { StyleObject } from '../utils/stylex-merge-utils'; + +import * as t from '@babel/types'; +import StateManager from '../utils/state-manager'; +import { + collectStyleVarsToKeep, + makeConditionalExpression, + resolveStylexArguments, +} from '../utils/stylex-merge-utils'; +import { evaluate } from '../utils/evaluate-path'; +import stylexDefaultMarker from '../shared/stylex-defaultMarker'; + +const STYLEX_DEFAULT_MARKER_NAME = 'defaultMarker'; +const ALLOWED_EXPRESSION_TYPES = new Set([ + 'ObjectExpression', + 'Identifier', + 'MemberExpression', +]); + +const STYLEX_PROXY_FLAG = '__IS_PROXY'; + +type StylexPropsLikeConfig = { + importSet: $ReadOnlySet, + memberName: string, + buildResult: (values: $ReadOnlyArray) => t.Expression, +}; + +type StylexPropsLikeCalleeConfig = $ReadOnly<{ + importSet: $ReadOnlySet, + memberName: string, + ... +}>; + +export function skipStylexPropsLikeChildren( + path: NodePath, + state: StateManager, + config: StylexPropsLikeCalleeConfig, +) { + if ( + !isCalleeIdentifier(path, config) && + !isCalleeMemberExpression(path, state, config) + ) { + return; + } + path.skip(); +} + +export function transformStylexPropsLike( + path: NodePath, + state: StateManager, + config: StylexPropsLikeConfig, +) { + if ( + !isCalleeIdentifier(path, config) && + !isCalleeMemberExpression(path, state, config) + ) { + return; + } + + const argsPath = path + .get('arguments') + .flatMap((argPath: NodePath<>) => + argPath.isArrayExpression() ? argPath.get('elements') : [argPath], + ); + + const identifiers: FunctionConfig['identifiers'] = {}; + const memberExpressions: FunctionConfig['memberExpressions'] = {}; + + state.stylexDefaultMarkerImport.forEach((name) => { + identifiers[name] = () => stylexDefaultMarker(state.options); + }); + + state.stylexImport.forEach((name) => { + memberExpressions[name] = { + [STYLEX_DEFAULT_MARKER_NAME]: { + fn: () => stylexDefaultMarker(state.options), + }, + }; + }); + + const evaluatePathFnConfig: FunctionConfig = { + identifiers, + memberExpressions, + disableImports: true, + }; + + const { resolvedArgs, bailOut, bailOutIndex, conditionalCount } = + resolveStylexArguments( + argsPath, + (argPath) => parseNullableStyle(argPath, state, evaluatePathFnConfig), + { + allowStylePath: (argPath) => + ALLOWED_EXPRESSION_TYPES.has(argPath.node.type), + }, + ); + + if (!state.options.enableInlinedConditionalMerge && conditionalCount) { + collectStyleVarsToKeep(path.get('arguments'), state, { + bailOutIndex, + evaluateMemberExpression: (memberPath) => + evaluate(memberPath, state, evaluatePathFnConfig), + isProxyStyle: (value) => + value != null && value[STYLEX_PROXY_FLAG] === true, + }); + return; + } + if (bailOut) { + collectStyleVarsToKeep(path.get('arguments'), state, { + bailOutIndex, + evaluateMemberExpression: (memberPath) => + evaluate(memberPath, state, evaluatePathFnConfig), + isProxyStyle: (value) => + value != null && value[STYLEX_PROXY_FLAG] === true, + }); + return; + } + + path.skip(); + // convert resolvedStyles to a string + ternary expressions + // We no longer need the keys, so we can just use the values. + const stringExpression = makeConditionalExpression( + resolvedArgs, + config.buildResult, + ); + + // Check if this is used as a JSX spread attribute and optimize + // the output to avoid object creation and Babel helper + if (path.parentPath.node.type === 'JSXSpreadAttribute') { + if ( + t.isObjectExpression(stringExpression) && + stringExpression.properties.length > 0 && + stringExpression.properties.every( + (prop) => + t.isObjectProperty(prop) && + (t.isIdentifier(prop.key) || t.isStringLiteral(prop.key)) && + !prop.computed, + ) + ) { + // Convert each property to a JSX attribute + const jsxAttributes = stringExpression.properties + .filter((prop) => t.isObjectProperty(prop)) + .map((prop) => { + const objectProp = prop; + const key = objectProp.key; + let attrName = ''; + if (t.isIdentifier(key)) { + attrName = key.name; + } else if (t.isStringLiteral(key)) { + attrName = key.value; + } + // Handle JSX attribute value based on its type + let attributeValue; + if (t.isStringLiteral(objectProp.value)) { + attributeValue = objectProp.value; + } else { + attributeValue = t.stringLiteral(String(objectProp.value)); + } + return t.jsxAttribute(t.jsxIdentifier(attrName), attributeValue); + }); + + // Replace the spread element with multiple JSX attributes + path.parentPath.replaceWithMultiple(jsxAttributes); + return; + } + } + + path.replaceWith(stringExpression); +} + +// Looks for Null or locally defined style namespaces. +// Otherwise it returns the string "other" +// Which is used as an indicator to bail out of this optimization. +function parseNullableStyle( + path: NodePath, + state: StateManager, + evaluatePathFnConfig: FunctionConfig, +): null | StyleObject | 'other' { + const node = path.node; + if ( + t.isNullLiteral(node) || + (t.isIdentifier(node) && node.name === 'undefined') + ) { + return null; + } + + if (t.isMemberExpression(node)) { + const { object, property, computed: computed } = node; + let objName = null; + let propName: null | number | string = null; + if ( + object.type === 'Identifier' && + state.styleMap.has(object.name) && + property.type === 'Identifier' && + !computed + ) { + objName = object.name; + propName = property.name; + } + if ( + object.type === 'Identifier' && + state.styleMap.has(object.name) && + (property.type === 'StringLiteral' || + property.type === 'NumericLiteral') && + computed + ) { + objName = object.name; + propName = property.value; + } + + if (objName != null && propName != null) { + const style = state.styleMap.get(objName); + if (style != null && style[String(propName)] != null) { + // $FlowFixMe[incompatible-type] + return style[String(propName)]; + } + } + } + + const parsedObj = evaluate(path, state, evaluatePathFnConfig); + + if ( + parsedObj.confident && + parsedObj.value != null && + typeof parsedObj.value === 'object' + ) { + if (parsedObj.value[STYLEX_PROXY_FLAG] === true) { + return 'other'; + } + return parsedObj.value; + } + + return 'other'; +} + +function isCalleeIdentifier( + path: NodePath, + config: StylexPropsLikeCalleeConfig, +): boolean { + const { node } = path; + return ( + node != null && + node.callee != null && + node.callee.type === 'Identifier' && + config.importSet.has(node.callee.name) + ); +} + +function isCalleeMemberExpression( + path: NodePath, + state: StateManager, + config: StylexPropsLikeCalleeConfig, +): boolean { + const { node } = path; + return ( + node != null && + node.callee != null && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === config.memberName && + state.stylexImport.has(node.callee.object.name) + ); +} diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js index d32378994..5aae12b94 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js @@ -8,51 +8,23 @@ */ import type { NodePath } from '@babel/traverse'; -import type { FunctionConfig } from '../utils/evaluate-path'; - import * as t from '@babel/types'; -import StateManager from '../utils/state-manager'; import { props } from '@stylexjs/stylex'; import { convertObjectToAST } from '../utils/js-to-ast'; -import { evaluate } from '../utils/evaluate-path'; -import stylexDefaultMarker from '../shared/stylex-defaultMarker'; - -type ClassNameValue = string | null | boolean | NonStringClassNameValue; -type NonStringClassNameValue = [t.Expression, ClassNameValue, ClassNameValue]; - -type StyleObject = { - [key: string]: string | null | boolean, -}; - -class ConditionalStyle { - test: t.Expression; - primary: ?StyleObject; - fallback: ?StyleObject; - constructor( - test: t.Expression, - primary: ?StyleObject, - fallback: ?StyleObject, - ) { - this.test = test; - this.primary = primary; - this.fallback = fallback; - } -} - -type ResolvedArg = ?StyleObject | ConditionalStyle; -type ResolvedArgs = Array; +import { + transformStylexPropsLike, + skipStylexPropsLikeChildren, +} from './stylex-props-utils'; +import StateManager from '../utils/state-manager'; export function skipStylexPropsChildren( path: NodePath, state: StateManager, ) { - if ( - !isCalleeIdentifier(path, state) && - !isCalleeMemberExpression(path, state) - ) { - return; - } - path.skip(); + skipStylexPropsLikeChildren(path, state, { + importSet: state.stylexPropsImport, + memberName: 'props', + }); } // If a `stylex()` call uses styles that are all locally defined, @@ -62,420 +34,9 @@ export default function transformStylexProps( path: NodePath, state: StateManager, ) { - if ( - !isCalleeIdentifier(path, state) && - !isCalleeMemberExpression(path, state) - ) { - return; - } - - let bailOut = false; - let conditional = 0; - - const argsPath = path - .get('arguments') - .flatMap((argPath: NodePath<>) => - argPath.isArrayExpression() ? argPath.get('elements') : [argPath], - ); - - let currentIndex = -1; - let bailOutIndex: ?number = null; - - const identifiers: FunctionConfig['identifiers'] = {}; - const memberExpressions: FunctionConfig['memberExpressions'] = {}; - - state.stylexDefaultMarkerImport.forEach((name) => { - identifiers[name] = () => stylexDefaultMarker(state.options); - }); - - state.stylexImport.forEach((name) => { - memberExpressions[name] = { - defaultMarker: { - fn: () => stylexDefaultMarker(state.options), - }, - }; - }); - - const evaluatePathFnConfig: FunctionConfig = { - identifiers, - memberExpressions, - disableImports: true, - }; - - const resolvedArgs: ResolvedArgs = []; - for (const argPath of argsPath) { - currentIndex++; - - if ( - argPath.isObjectExpression() || - argPath.isIdentifier() || - argPath.isMemberExpression() - ) { - const resolved = parseNullableStyle(argPath, state, evaluatePathFnConfig); - if (resolved === 'other') { - bailOutIndex = currentIndex; - bailOut = true; - } else { - resolvedArgs.push(resolved); - } - } else if (argPath.isConditionalExpression()) { - const arg = argPath.node; - const { test } = arg; - const consequentPath = argPath.get('consequent'); - const alternatePath = argPath.get('alternate'); - - const primary = parseNullableStyle( - consequentPath, - state, - evaluatePathFnConfig, - ); - const fallback = parseNullableStyle( - alternatePath, - state, - evaluatePathFnConfig, - ); - if (primary === 'other' || fallback === 'other') { - bailOutIndex = currentIndex; - bailOut = true; - } else { - resolvedArgs.push(new ConditionalStyle(test, primary, fallback)); - conditional++; - } - } else if (argPath.isLogicalExpression()) { - const arg = argPath.node; - if (arg.operator !== '&&') { - bailOutIndex = currentIndex; - bailOut = true; - break; - } - const leftPath = argPath.get('left'); - const rightPath = argPath.get('right'); - - const leftResolved = parseNullableStyle( - leftPath, - state, - evaluatePathFnConfig, - ); - const rightResolved = parseNullableStyle( - rightPath, - state, - evaluatePathFnConfig, - ); - if (leftResolved !== 'other' || rightResolved === 'other') { - bailOutIndex = currentIndex; - bailOut = true; - } else { - resolvedArgs.push( - new ConditionalStyle(leftPath.node, rightResolved, null), - ); - conditional++; - } - } else { - bailOutIndex = currentIndex; - bailOut = true; - } - if (conditional > 4) { - bailOut = true; - } - if (bailOut) { - break; - } - } - if (!state.options.enableInlinedConditionalMerge && conditional) { - bailOut = true; - } - if (bailOut) { - const argumentPaths = path.get('arguments'); - - let nonNullProps: Array | true = []; - - let index = -1; - for (const argPath of argumentPaths) { - index++; - // eslint-disable-next-line no-loop-func, no-inner-declarations - function MemberExpression(path: NodePath) { - const object = path.get('object').node; - const property = path.get('property').node; - const computed = path.node.computed; - let objName: string | null = null; - let propName: number | string | null = null; - if (object.type === 'Identifier' && state.styleMap.has(object.name)) { - objName = object.name; - - if (property.type === 'Identifier' && !computed) { - propName = property.name; - } - if ( - (property.type === 'StringLiteral' || - property.type === 'NumericLiteral') && - computed - ) { - propName = property.value; - } - } - let styleNonNullProps: true | Array = []; - if (bailOutIndex != null && index > bailOutIndex) { - nonNullProps = true; - styleNonNullProps = true; - } - if (nonNullProps === true) { - styleNonNullProps = true; - } else { - const { confident, value: styleValue } = evaluate( - path, - state, - evaluatePathFnConfig, - ); - if ( - !confident || - styleValue == null || - styleValue.__IS_PROXY === true - ) { - nonNullProps = true; - styleNonNullProps = true; - } else { - styleNonNullProps = - nonNullProps === true ? true : [...nonNullProps]; - if (nonNullProps !== true) { - nonNullProps = [ - ...nonNullProps, - ...Object.keys(styleValue).filter( - (key) => styleValue[key] !== null, - ), - ]; - } - } - } - - if (objName != null) { - state.styleVarsToKeep.add([ - objName, - propName != null ? String(propName) : true, - styleNonNullProps, - ]); - } - } - - if (argPath.isMemberExpression()) { - MemberExpression(argPath); - } else { - argPath.traverse({ - MemberExpression, - }); - } - } - } else { - path.skip(); - // convert resolvedStyles to a string + ternary expressions - // We no longer need the keys, so we can just use the values. - const stringExpression = makeStringExpression(resolvedArgs); - - // Check if this is used as a JSX spread attribute and optimize - // the output to avoid object creation and Babel helper - if (path.parentPath.node.type === 'JSXSpreadAttribute') { - if ( - t.isObjectExpression(stringExpression) && - stringExpression.properties.length > 0 && - stringExpression.properties.every( - (prop) => - t.isObjectProperty(prop) && - (t.isIdentifier(prop.key) || t.isStringLiteral(prop.key)) && - !prop.computed, - ) - ) { - // Convert each property to a JSX attribute - const jsxAttributes = stringExpression.properties - .filter((prop) => t.isObjectProperty(prop)) - .map((prop) => { - const objectProp = prop; - const key = objectProp.key; - let attrName = ''; - if (t.isIdentifier(key)) { - attrName = key.name; - } else if (t.isStringLiteral(key)) { - attrName = key.value; - } - // Handle JSX attribute value based on its type - let attributeValue; - if (t.isStringLiteral(objectProp.value)) { - attributeValue = objectProp.value; - } else { - attributeValue = t.stringLiteral(String(objectProp.value)); - } - return t.jsxAttribute(t.jsxIdentifier(attrName), attributeValue); - }); - - // Replace the spread element with multiple JSX attributes - path.parentPath.replaceWithMultiple(jsxAttributes); - return; - } - } - - path.replaceWith(stringExpression); - } -} - -// Looks for Null or locally defined style namespaces. -// Otherwise it returns the string "other" -// Which is used as an indicator to bail out of this optimization. -function parseNullableStyle( - path: NodePath, - state: StateManager, - evaluatePathFnConfig: FunctionConfig, -): null | StyleObject | 'other' { - const node = path.node; - if ( - t.isNullLiteral(node) || - (t.isIdentifier(node) && node.name === 'undefined') - ) { - return null; - } - - if (t.isMemberExpression(node)) { - const { object, property, computed: computed } = node; - let objName = null; - let propName: null | number | string = null; - if ( - object.type === 'Identifier' && - state.styleMap.has(object.name) && - property.type === 'Identifier' && - !computed - ) { - objName = object.name; - propName = property.name; - } - if ( - object.type === 'Identifier' && - state.styleMap.has(object.name) && - (property.type === 'StringLiteral' || - property.type === 'NumericLiteral') && - computed - ) { - objName = object.name; - propName = property.value; - } - - if (objName != null && propName != null) { - const style = state.styleMap.get(objName); - if (style != null && style[String(propName)] != null) { - // $FlowFixMe[incompatible-type] - return style[String(propName)]; - } - } - } - - const parsedObj = evaluate(path, state, evaluatePathFnConfig); - - if ( - parsedObj.confident && - parsedObj.value != null && - typeof parsedObj.value === 'object' - ) { - if (parsedObj.value.__IS_PROXY === true) { - return 'other'; - } - return parsedObj.value; - } - - return 'other'; -} - -function makeStringExpression(values: ResolvedArgs): t.Expression { - const conditions = values - .filter( - (v: ResolvedArg): v is ConditionalStyle => v instanceof ConditionalStyle, - ) - .map((v: ConditionalStyle) => v.test); - - if (conditions.length === 0) { - const result = props(values as any); - return convertObjectToAST(result); - } - - const conditionPermutations = genConditionPermutations(conditions.length); - const objEntries = conditionPermutations.map((permutation) => { - let i = 0; - const args = values.map((v) => { - if (v instanceof ConditionalStyle) { - const { primary, fallback } = v; - return permutation[i++] ? primary : fallback; - } else { - return v; - } - }); - const key = permutation.reduce( - (soFar, bool) => (soFar << 1) | (bool ? 1 : 0), - 0, - ); - return t.objectProperty( - t.numericLiteral(key), - convertObjectToAST(props(args as any)), - ); + transformStylexPropsLike(path, state, { + importSet: state.stylexPropsImport, + memberName: 'props', + buildResult: (values) => convertObjectToAST(props(values as $FlowFixMe)), }); - const objExpressions = t.objectExpression(objEntries); - const conditionsToKey = genBitwiseOrOfConditions(conditions); - return t.memberExpression(objExpressions, conditionsToKey, true); -} - -// A function to generate a list of all possible permutations of true and false for a given count of conditional expressions. -// For example, if there are 2 conditional expressions, this function will return: -// [[true, true], [true, false], [false, true], [false, false]] -function genConditionPermutations(count: number): Array> { - const result = []; - for (let i = 0; i < 2 ** count; i++) { - const combination = []; - for (let j = 0; j < count; j++) { - combination.push(Boolean(i & (1 << j))); - } - result.push(combination); - } - return result; -} - -// A function to generate a bitwise or of all the conditions. -// For example, if there are 2 conditional expressions, this function will return: -// `!!test1 << 2 | !!test2 << 1 -function genBitwiseOrOfConditions( - conditions: Array, -): t.Expression { - const binaryExpressions = conditions.map((condition, i) => { - const shift = conditions.length - i - 1; - return t.binaryExpression( - '<<', - t.unaryExpression('!', t.unaryExpression('!', condition)), - t.numericLiteral(shift), - ); - }); - return binaryExpressions.reduce((acc, expr) => { - return t.binaryExpression('|', acc, expr); - }); -} - -function isCalleeIdentifier( - path: NodePath, - state: StateManager, -): boolean { - const { node } = path; - return ( - node != null && - node.callee != null && - node.callee.type === 'Identifier' && - state.stylexPropsImport.has(node.callee.name) - ); -} - -function isCalleeMemberExpression( - path: NodePath, - state: StateManager, -): boolean { - const { node } = path; - return ( - node != null && - node.callee != null && - node.callee.type === 'MemberExpression' && - node.callee.object.type === 'Identifier' && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'props' && - state.stylexImport.has(node.callee.object.name) - ); } diff --git a/packages/@stylexjs/stylex/__tests__/stylex-test.js b/packages/@stylexjs/stylex/__tests__/stylex-test.js index 005136ca3..ab4149721 100644 --- a/packages/@stylexjs/stylex/__tests__/stylex-test.js +++ b/packages/@stylexjs/stylex/__tests__/stylex-test.js @@ -293,4 +293,47 @@ describe('stylex', () => { `); }); }); + + describe('attrs', () => { + test('with class-only styles', () => { + expect( + stylex.attrs([ + { + backgroundColor: 'backgroundColor-red', + $$css: 'components/Foo.react.js:1', + }, + ]), + ).toMatchInlineSnapshot(` + { + "class": "backgroundColor-red", + "data-style-src": "components/Foo.react.js:1", + } + `); + }); + + test('with dynamic styles', () => { + expect( + stylex.attrs([ + { + backgroundColor: 'backgroundColor-red', + $$css: 'components/Foo.react.js:1', + }, + { + color: 'red', + marginTop: 8, + }, + ]), + ).toMatchInlineSnapshot(` + { + "class": "backgroundColor-red", + "data-style-src": "components/Foo.react.js:1", + "style": "color:red;marginTop:8", + } + `); + }); + + test('empty attrs call', () => { + expect(stylex.attrs()).toMatchInlineSnapshot('{}'); + }); + }); }); diff --git a/packages/@stylexjs/stylex/src/stylex.js b/packages/@stylexjs/stylex/src/stylex.js index ff5185734..4fb0dcb50 100644 --- a/packages/@stylexjs/stylex/src/stylex.js +++ b/packages/@stylexjs/stylex/src/stylex.js @@ -32,6 +32,7 @@ import type { StyleX$When, MapNamespace, StyleX$DefineMarker, + StyleX$Attrs, } from './types/StyleXTypes'; import type { ValueWithDefault } from './types/StyleXUtils'; import * as Types from './types/VarTypes'; @@ -52,6 +53,7 @@ export type { Types, VarGroup, PositionTry, + StyleX$Attrs, }; import { styleq } from 'styleq'; @@ -132,6 +134,32 @@ export function props( return result; } +export function attrs( + this: ?mixed, + ...styles: $ReadOnlyArray< + StyleXArray< + ?CompiledStyles | boolean | $ReadOnly<[CompiledStyles, InlineStyles]>, + >, + > +): $ReadOnly<{ + class?: string, + 'data-style-src'?: string, + style?: string, +}> { + const { className, style, ...rest } = props(...styles); + return { + ...(className ? { class: className } : null), + ...(style != null + ? { + style: Object.entries(style) + .map(([k, v]) => `${k}: ${v}`) + .join(';'), + } + : null), + ...rest, + }; +} + export const viewTransitionClass = ( _viewTransitionClass: ViewTransitionClass, ): string => { @@ -252,11 +280,13 @@ type IStyleX = { > ) => $ReadOnly<{ className?: string, - 'data-style-src'?: string, style?: $ReadOnly<{ [string]: string | number }>, + 'data-style-src'?: string, }>, + attrs: StyleX$Attrs, viewTransitionClass: (viewTransitionClass: ViewTransitionClass) => string, types: typeof types, + when: typeof when, __customProperties?: { [string]: mixed }, ... @@ -279,6 +309,7 @@ _legacyMerge.firstThatWorks = firstThatWorks; _legacyMerge.keyframes = keyframes; _legacyMerge.positionTry = positionTry; _legacyMerge.props = props; +_legacyMerge.attrs = attrs; _legacyMerge.types = types; _legacyMerge.when = when; _legacyMerge.viewTransitionClass = viewTransitionClass; diff --git a/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts b/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts index 1f596f624..37a582823 100644 --- a/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts +++ b/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts @@ -312,6 +312,19 @@ export type StyleX$DefineMarker = () => MapNamespace<{ readonly marker: typeof StyleXMarkerTag; }>; +export type StyleX$Attrs = ( + this: unknown, + ...styles: ReadonlyArray< + StyleXArray< + CompiledStyles | boolean | Readonly<[CompiledStyles, InlineStyles]> | null + > + > +) => Readonly<{ + class?: string; + 'data-style-src'?: string; + style?: string; +}>; + export type StyleX$When = { ancestor: < const Pseudo extends `:${string}` | `[${string}]`, diff --git a/packages/@stylexjs/stylex/src/types/StyleXTypes.js b/packages/@stylexjs/stylex/src/types/StyleXTypes.js index 57f41ec7a..a8bcd095c 100644 --- a/packages/@stylexjs/stylex/src/types/StyleXTypes.js +++ b/packages/@stylexjs/stylex/src/types/StyleXTypes.js @@ -256,7 +256,19 @@ export type StyleX$CreateTheme = < ) => Theme; export type StyleX$DefineMarker = () => MapNamespace<{ - +marker: 'custom-marker', + +marker: 'default-marker', +}>; + +export type StyleX$Attrs = ( + ...styles: $ReadOnlyArray< + StyleXArray< + ?CompiledStyles | boolean | $ReadOnly<[CompiledStyles, InlineStyles]>, + >, + > +) => $ReadOnly<{ + class?: string, + style?: string, + 'data-style-src'?: string, }>; export type StyleX$When = { diff --git a/yarn.lock b/yarn.lock index 0fb9e0d43..2d7e4fd19 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2223,7 +2223,7 @@ "@docusaurus/theme-search-algolia" "2.4.1" "@docusaurus/types" "2.4.1" -"@docusaurus/react-loadable@5.5.2": +"@docusaurus/react-loadable@5.5.2", "react-loadable@npm:@docusaurus/react-loadable@5.5.2": version "5.5.2" resolved "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz" integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== @@ -10992,10 +10992,10 @@ flow-api-translator@^0.32.1: hermes-transform "0.32.1" typescript "5.3.2" -flow-bin@^0.291.0: - version "0.291.0" - resolved "https://registry.npmjs.org/flow-bin/-/flow-bin-0.291.0.tgz" - integrity sha512-wux/RqOTXzFWxzzdQRwAPpYyJx4GOOaYKESQQ7bszr43egT3U2CW65IpDYTJijpxKjXBb0D3uv6Kmq0C+HAAKg== +flow-bin@^0.295.0: + version "0.295.0" + resolved "https://registry.yarnpkg.com/flow-bin/-/flow-bin-0.295.0.tgz#e404b4e0c1ab4994e09ed3527682014e6b555967" + integrity sha512-r5DHHyygFvMKGIV92elp5R8qzO5EbpBuXd6gMNxWc7W9i1ph4hLqpNnGaJbA6KycppP+hNdN3n0D43vNtIfkoA== flow-enums-runtime@^0.0.6: version "0.0.6" @@ -16579,14 +16579,6 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1: dependencies: "@babel/runtime" "^7.10.3" -"react-loadable@npm:@docusaurus/react-loadable@5.5.2": - version "5.5.2" - resolved "https://registry.npmjs.org/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz" - integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== - dependencies: - "@types/react" "*" - prop-types "^15.6.2" - react-medium-image-zoom@^5.4.0: version "5.4.0" resolved "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.4.0.tgz" @@ -18302,16 +18294,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.npmjs.org/string-natural-compare/-/string-natural-compare-3.0.1.tgz" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@4.2.3, string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@4.2.3, string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2, string-width@^4.2.3: version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -18437,14 +18420,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -20336,7 +20312,7 @@ wrangler@4.53.0, wrangler@^4.50.0: optionalDependencies: fsevents "~2.3.2" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -20354,15 +20330,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - wrap-ansi@^8.0.1, wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz"