diff --git a/packages/@stylexjs/atoms/README.md b/packages/@stylexjs/atoms/README.md new file mode 100644 index 000000000..f95651266 --- /dev/null +++ b/packages/@stylexjs/atoms/README.md @@ -0,0 +1,72 @@ +# @stylexjs/atoms + +Compile-time helpers for authoring StyleX atomic styles. + +This package exposes CSS properties as a namespaced object and lets you express +static and dynamic styles using normal JavaScript syntax. There is no new +runtime styling system and no design tokens — everything compiles to the same +output as stylex.create. + +The compiler treats atomic styles from this package as if they were authored +locally, enabling the same optimizations as normal StyleX styles. + +## Usage + +```js +import * as stylex from '@stylexjs/stylex'; +import x from '@stylexjs/atoms'; + +function Example({ color }) { + return ( +
+ ); +} +``` + +### Static values + +Static styles are expressed via property access and are fully resolved at +compile time. + +```js +x.display.flex; +x.flexDirection.column; +``` + +#### Values starting with numbers + +Use a leading underscore for values that begin with a number. The underscore is +ignored by the compiler and has no semantic meaning. + +```js +x.padding._16px; +x.fontSize._1rem; +``` + +### Complex literal values + +For values that are not valid JavaScript identifiers (for example, values that +contain spaces or symbols), use computed property syntax. + +```js +x.fontSize['1.25rem']; +x.width['calc(100% - 20cqi)']; +x.gridTemplateColumns['1fr minmax(0, 3fr)']; +``` + +### Dynamic values + +Dynamic styles use call syntax and should be used sparingly. + +```js +x.color(color); +x.marginLeft(offset); +``` diff --git a/packages/@stylexjs/atoms/package.json b/packages/@stylexjs/atoms/package.json new file mode 100644 index 000000000..708d71b27 --- /dev/null +++ b/packages/@stylexjs/atoms/package.json @@ -0,0 +1,26 @@ +{ + "name": "@stylexjs/atoms", + "version": "0.17.5", + "description": "Atomic style helpers for StyleX.", + "license": "MIT", + "main": "src/index.js", + "types": "src/index.d.ts", + "exports": { + ".": "./src/index.js", + "./babel-transform": "./src/babel-transform.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/facebook/stylex.git" + }, + "sideEffects": false, + "peerDependencies": { + "@stylexjs/stylex": "^0.17.5" + }, + "dependencies": { + "csstype": "^3.1.3" + }, + "files": [ + "src" + ] +} diff --git a/packages/@stylexjs/atoms/src/babel-transform.js b/packages/@stylexjs/atoms/src/babel-transform.js new file mode 100644 index 000000000..1b67584f0 --- /dev/null +++ b/packages/@stylexjs/atoms/src/babel-transform.js @@ -0,0 +1,232 @@ +/** + * 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'; + +const t = require('@babel/types'); + +const ATOMS_SOURCE = '@stylexjs/atoms'; + +/** + * Creates a visitor that transforms utility style expressions into raw style objects. + * The babel-plugin will handle compilation via stylexCreate. + * + * - css.display.flex -> { display: 'flex' } + * - css.color(myVar) -> { color: myVar } + * + * @param {object} state - StateManager from babel-plugin + * @returns {object} Babel visitor + */ +function createUtilityStylesVisitor(state) { + return { + MemberExpression(path) { + if (path.node._utilityStyleProcessed) { + return; + } + + const staticStyle = getStaticStyleFromPath(path, state); + if (staticStyle != null) { + path.node._utilityStyleProcessed = true; + transformStaticStyle(path, staticStyle); + } + }, + + CallExpression(path) { + if (path.node._utilityStyleProcessed) { + return; + } + + const dynamicStyle = getDynamicStyleFromPath(path, state); + if (dynamicStyle != null) { + path.node._utilityStyleProcessed = true; + transformDynamicStyle(path, dynamicStyle); + } + }, + }; +} + +function isUtilityStylesIdentifier(ident, state, path) { + if (state.inlineCSSImports && state.inlineCSSImports.has(ident.name)) { + return true; + } + + const binding = path.scope?.getBinding(ident.name); + if (!binding) { + return false; + } + + if ( + binding.path.isImportSpecifier() && + binding.path.parent.type === 'ImportDeclaration' && + binding.path.parent.source.value === ATOMS_SOURCE + ) { + return true; + } + + if ( + binding.path.isImportNamespaceSpecifier() && + binding.path.parent.type === 'ImportDeclaration' && + binding.path.parent.source.value === ATOMS_SOURCE + ) { + return true; + } + + if ( + binding.path.isImportDefaultSpecifier() && + binding.path.parent.type === 'ImportDeclaration' && + binding.path.parent.source.value === ATOMS_SOURCE + ) { + return true; + } + + return false; +} + +function getPropKey(prop, computed) { + if (!computed && t.isIdentifier(prop)) { + return prop.name; + } + if (computed && t.isStringLiteral(prop)) { + return prop.value; + } + if (computed && t.isNumericLiteral(prop)) { + return String(prop.value); + } + return null; +} + +function normalizeValue(value) { + if (typeof value === 'string' && value.startsWith('_')) { + return value.slice(1); + } + return value; +} + +function getStaticStyleFromPath(path, state) { + const node = path.node; + if (!t.isMemberExpression(node)) { + return null; + } + + if (path.parentPath?.isCallExpression() && path.parentPath.node.callee === node) { + return null; + } + + const valueKey = getPropKey(node.property, node.computed); + if (valueKey == null) { + return null; + } + + const parent = node.object; + + if (t.isMemberExpression(parent)) { + const propName = getPropKey(parent.property, parent.computed); + const base = parent.object; + if ( + propName != null && + t.isIdentifier(base) && + isUtilityStylesIdentifier(base, state, path) + ) { + return { property: propName, value: normalizeValue(valueKey) }; + } + } + + if (t.isIdentifier(parent) && isUtilityStylesIdentifier(parent, state, path)) { + const importedName = state.inlineCSSImports?.get(parent.name) ?? 'color'; + const property = importedName === '*' ? valueKey : importedName; + return { property, value: normalizeValue(valueKey) }; + } + + return null; +} + +function getDynamicStyleFromPath(path, state) { + const callee = path.get('callee'); + if (!callee.isMemberExpression()) { + return null; + } + + const valueKey = getPropKey(callee.node.property, callee.node.computed); + if (valueKey == null) { + return null; + } + + if (path.node.arguments.length !== 1) { + return null; + } + + const argPath = path.get('arguments')[0]; + if (!argPath || !argPath.node || !argPath.isExpression()) { + return null; + } + + const parent = callee.node.object; + + if (t.isIdentifier(parent) && isUtilityStylesIdentifier(parent, state, path)) { + return { + property: valueKey, + value: argPath.node, + }; + } + + if (t.isMemberExpression(parent)) { + const propName = getPropKey(parent.property, parent.computed); + const base = parent.object; + if ( + propName != null && + t.isIdentifier(base) && + isUtilityStylesIdentifier(base, state, path) + ) { + return { + property: propName, + value: argPath.node, + }; + } + } + + return null; +} + +/** + * Transform static utility style to raw style object + * css.display.flex -> { display: 'flex' } + */ +function transformStaticStyle(path, styleInfo) { + const { property, value } = styleInfo; + + const styleObj = t.objectExpression([ + t.objectProperty( + t.stringLiteral(property), + typeof value === 'number' + ? t.numericLiteral(value) + : t.stringLiteral(String(value)), + ), + ]); + + path.replaceWith(styleObj); +} + +/** + * Transform dynamic utility style to raw style object + * css.color(myVar) -> { color: myVar } + */ +function transformDynamicStyle(path, styleInfo) { + const { property, value } = styleInfo; + + const styleObj = t.objectExpression([ + t.objectProperty(t.stringLiteral(property), value), + ]); + + path.replaceWith(styleObj); +} + +module.exports = { + createUtilityStylesVisitor, + isUtilityStylesIdentifier, + getStaticStyleFromPath, + getDynamicStyleFromPath, +}; diff --git a/packages/@stylexjs/atoms/src/index.d.ts b/packages/@stylexjs/atoms/src/index.d.ts new file mode 100644 index 000000000..9b0bfe2b2 --- /dev/null +++ b/packages/@stylexjs/atoms/src/index.d.ts @@ -0,0 +1,44 @@ +/** + * 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 type { StyleXStyles } from '@stylexjs/stylex'; +import type { Properties } from 'csstype'; + +/** + * Static atom access returns CompiledStyles compatible with stylex.props + * e.g., css.display.flex -> { $$css: true, display: 'x78zum5' } + */ +type StaticAtom = StyleXStyles<{ [key: string]: V }>; + +/** + * Dynamic atom call returns StyleXStyles with inline styles + * e.g., css.color(myVar) -> [{ $$css: true, color: 'x14rh7hd' }, { '--x-color': myVar }] + */ +type DynamicAtom = (value: V) => StyleXStyles<{ [key: string]: V }>; + +/** + * Each CSS property provides both static access and dynamic function call + */ +type AtomProperty = { + [Key in string | number]: StaticAtom; +} & DynamicAtom; + +type CSSValue = string | number; + +/** + * The atoms namespace provides access to all CSS properties. + * All properties are defined (non-optional) to avoid undefined checks. + */ +type Atoms = { + [Key in keyof Properties]-?: AtomProperty< + NonNullable[Key]> + >; +}; + +declare const atoms: Atoms; + +export = atoms; diff --git a/packages/@stylexjs/atoms/src/index.js b/packages/@stylexjs/atoms/src/index.js new file mode 100644 index 000000000..5f69533b3 --- /dev/null +++ b/packages/@stylexjs/atoms/src/index.js @@ -0,0 +1,35 @@ +/** + * 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'; + +const valueProxy = (_propName) => + new Proxy(function () {}, { + get() { + return valueProxy(''); + }, + apply() { + return undefined; + }, + }); + +const utilityStyles = new Proxy(function () {}, { + get(_target, prop) { + if (typeof prop === 'string') { + return valueProxy(prop); + } + return undefined; + }, + apply() { + throw new Error( + '@stylexjs/atoms is a compile-time helper. Attempted to call it as a function, but the StyleX compiler did not run.', + ); + }, +}); + +module.exports = utilityStyles; +module.exports.default = utilityStyles; diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-props-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-props-test.js index cffa3edd3..c92228794 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-props-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-props-test.js @@ -231,6 +231,501 @@ describe('@stylexjs/babel-plugin', () => { }); }); + describe('props calls with atoms', () => { + test('inline static styles match stylex.create', () => { + const inline = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.display.flex); + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".x78zum5{display:flex}", + priority: 3000 + }); + ({ + className: "x78zum5" + });" + `); + const local = transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + flex: { display: 'flex' }, + }); + stylex.props(styles.flex); + `); + expect(local).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x78zum5{display:flex}", + priority: 3000 + }); + ({ + className: "x78zum5" + });" + `); + }); + + test('inline static supports leading underscore value', () => { + const inline = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.padding._16px); + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".x1tamke2{padding:16px}", + priority: 1000 + }); + ({ + className: "x1tamke2" + });" + `); + const local = transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + pad: { padding: '16px' }, + }); + stylex.props(styles.pad); + `); + expect(local).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x1tamke2{padding:16px}", + priority: 1000 + }); + ({ + className: "x1tamke2" + });" + `); + }); + + test('inline static supports computed key syntax', () => { + const inline = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.width['calc(100% - 20px)']); + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".xnlsq7q{width:calc(100% - 20px)}", + priority: 4000 + }); + ({ + className: "xnlsq7q" + });" + `); + const local = transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + w: { width: 'calc(100% - 20px)' }, + }); + stylex.props(styles.w); + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".xnlsq7q{width:calc(100% - 20px)}", + priority: 4000 + }); + ({ + className: "xnlsq7q" + });" + `); + expect(local).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".xnlsq7q{width:calc(100% - 20px)}", + priority: 4000 + }); + ({ + className: "xnlsq7q" + });" + `); + }); + + test('inline css supports named imports', () => { + const inline = transform(` + import stylex from 'stylex'; + import { color } from '@stylexjs/atoms'; + stylex.props(color.blue); + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import { color } from '@stylexjs/atoms'; + _inject2({ + ltr: ".xju2f9n{color:blue}", + priority: 3000 + }); + ({ + className: "xju2f9n" + });" + `); + }); + + test('dedupes duplicate properties across create and atoms', () => { + const output = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + const styles = stylex.create({ + base: { color: 'red', backgroundColor: 'white' }, + }); + stylex.props(styles.base, css.color.blue, css.backgroundColor.white); + `); + expect(output).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".x1e2nbdu{color:red}", + priority: 3000 + }); + _inject2({ + ltr: ".x12peec7{background-color:white}", + priority: 3000 + }); + _inject2({ + ltr: ".xju2f9n{color:blue}", + priority: 3000 + }); + _inject2({ + ltr: ".x12peec7{background-color:white}", + priority: 3000 + }); + ({ + className: "xju2f9n x12peec7" + });" + `); + }); + + test('dynamic style', () => { + const inline = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.color(color)); + `); + const local = transform(` + import stylex from 'stylex'; + const styles = stylex.create({ + color: (c) => ({ color: c }), + }); + stylex.props(styles.color(color)); + `); + expect(local).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + _inject2({ + ltr: ".x14rh7hd{color:var(--x-color)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-color { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const styles = { + color: c => [{ + kMwMTN: c != null ? "x14rh7hd" : c, + $$css: true + }, { + "--x-color": c != null ? c : undefined + }] + }; + stylex.props(styles.color(color));" + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".x14rh7hd{color:var(--x-color)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-color { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const _temp = { + color: _v => [{ + "kMwMTN": _v != null ? "x14rh7hd" : _v, + "$$css": true + }, { + "--x-color": _v != null ? _v : undefined + }] + }; + stylex.props(_temp.color(color));" + `); + }); + + test('inline static with inline dynamic', () => { + const output = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.display.flex, css.color(color)); + `); + expect(output).toContain('.x78zum5{display:flex}'); + expect(output).toContain('.x14rh7hd{color:var(--x-color)}'); + expect(output).toContain('--x-color'); + expect(output).toContain('_v != null ? "x14rh7hd" : _v'); + }); + + test('inline static with create dynamic', () => { + const output = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + const styles = stylex.create({ + opacity: (o) => ({ opacity: o }), + }); + stylex.props(css.display.flex, styles.opacity(0.5)); + `); + expect(output).toContain('.x78zum5{display:flex}'); + expect(output).toContain('.xb4nw82{opacity:var(--x-opacity)}'); + expect(output).toContain('--x-opacity'); + }); + + test('inline dynamic with create dynamic', () => { + const output = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + const styles = stylex.create({ + opacity: (o) => ({ opacity: o }), + }); + stylex.props(css.color(color), styles.opacity(0.5)); + `); + expect(output).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".xb4nw82{opacity:var(--x-opacity)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-opacity { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const styles = { + opacity: o => [{ + kSiTet: o != null ? "xb4nw82" : o, + $$css: true + }, { + "--x-opacity": o != null ? o : undefined + }] + }; + _inject2({ + ltr: ".x14rh7hd{color:var(--x-color)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-color { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const _temp = { + color: _v => [{ + "kMwMTN": _v != null ? "x14rh7hd" : _v, + "$$css": true + }, { + "--x-color": _v != null ? _v : undefined + }] + }; + stylex.props(_temp.color(color), styles.opacity(0.5));" + `); + expect(output).toContain('.x14rh7hd{color:var(--x-color)}'); + expect(output).toContain('.xb4nw82{opacity:var(--x-opacity)}'); + expect(output).toContain('--x-color'); + expect(output).toContain('--x-opacity'); + }); + + test('inline static + inline dynamic coexist', () => { + const inline = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.display.flex, css.color(color)); + `); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".x78zum5{display:flex}", + priority: 3000 + }); + _inject2({ + ltr: ".x14rh7hd{color:var(--x-color)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-color { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const _temp = { + color: _v => [{ + "kMwMTN": _v != null ? "x14rh7hd" : _v, + "$$css": true + }, { + "--x-color": _v != null ? _v : undefined + }] + }; + const _temp2 = { + k1xSpc: "x78zum5", + $$css: true + }; + stylex.props(_temp2, _temp.color(color));" + `); + }); + + test('inline static + create dynamic', () => { + const output = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + const styles = stylex.create({ + opacity: (o) => ({ opacity: o }), + }); + stylex.props(css.display.flex, styles.opacity(0.5)); + `); + expect(output).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".xb4nw82{opacity:var(--x-opacity)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-opacity { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const styles = { + opacity: o => [{ + kSiTet: o != null ? "xb4nw82" : o, + $$css: true + }, { + "--x-opacity": o != null ? o : undefined + }] + }; + _inject2({ + ltr: ".x78zum5{display:flex}", + priority: 3000 + }); + const _temp = { + k1xSpc: "x78zum5", + $$css: true + }; + stylex.props(_temp, styles.opacity(0.5));" + `); + }); + + test('inline dynamic + create dynamic', () => { + const output = transform(` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + const styles = stylex.create({ + opacity: (o) => ({ opacity: o }), + }); + stylex.props(css.color(color), styles.opacity(0.5)); + `); + expect(output).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".xb4nw82{opacity:var(--x-opacity)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-opacity { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const styles = { + opacity: o => [{ + kSiTet: o != null ? "xb4nw82" : o, + $$css: true + }, { + "--x-opacity": o != null ? o : undefined + }] + }; + _inject2({ + ltr: ".x14rh7hd{color:var(--x-color)}", + priority: 3000 + }); + _inject2({ + ltr: "@property --x-color { syntax: \\"*\\"; inherits: false;}", + priority: 0 + }); + const _temp = { + color: _v => [{ + "kMwMTN": _v != null ? "x14rh7hd" : _v, + "$$css": true + }, { + "--x-color": _v != null ? _v : undefined + }] + }; + stylex.props(_temp.color(color), styles.opacity(0.5));" + `); + }); + + describe('with options', () => { + test('dev/debug classnames for atoms', () => { + const inline = transform( + ` + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + stylex.props(css.display.flex); + `, + { + dev: true, + debug: true, + enableDevClassNames: true, + enableDebugClassNames: true, + filename: '/tmp/Foo.js', + }, + ); + expect(inline).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import stylex from 'stylex'; + import * as css from '@stylexjs/atoms'; + _inject2({ + ltr: ".display-x78zum5{display:flex}", + priority: 3000 + }); + ({ + className: "Foo____inline__ display-x78zum5" + });" + `); + }); + }); + }); + test('stylex call with number', () => { expect( transform(` diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index 7b4648a01..9ec62f850 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -38,6 +38,8 @@ import { LOGICAL_FLOAT_END_VAR, } from './shared/preprocess-rules/legacy-expand-shorthands'; import transformStyleXDefineMarker from './visitors/stylex-define-marker'; +import { createUtilityStylesVisitor } from '@stylexjs/atoms/babel-transform'; +import { convertObjectToAST } from './utils/js-to-ast'; const NAME = 'stylex'; @@ -160,6 +162,14 @@ function styleXTransform(): PluginObj<> { skipStylexPropsChildren(path, state); }, }); + // Run atoms visitor first to transform x.prop.value patterns + // This runs BEFORE stylex.props so that atomic styles are already + // compiled when stylex.props processes them + const atomsVisitor = createUtilityStylesVisitor(state, { + convertObjectToAST, + }); + path.traverse(atomsVisitor); + path.traverse({ CallExpression(path: NodePath) { transformStylexCall(path, state); diff --git a/packages/@stylexjs/babel-plugin/src/utils/state-manager.js b/packages/@stylexjs/babel-plugin/src/utils/state-manager.js index f9d7ccbcb..1ee398610 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/state-manager.js +++ b/packages/@stylexjs/babel-plugin/src/utils/state-manager.js @@ -13,6 +13,7 @@ import type { CompiledNamespaces, StyleXOptions as RuntimeOptions, } from '../shared'; +import type { FlatCompiledStyles } from '../shared/common-types'; import type { Check } from './validate'; import * as t from '@babel/types'; @@ -165,12 +166,20 @@ export default class StateManager { +stylexViewTransitionClassImport: Set = new Set(); +stylexDefaultMarkerImport: Set = new Set(); +stylexWhenImport: Set = new Set(); + // Map of local identifier -> imported name. + // For namespace/default imports we store '*'. + +inlineCSSImports: Map = new Map(); injectImportInserted: ?t.Identifier = null; // `stylex.create` calls +styleMap: Map = new Map(); +styleVars: Map> = new Map(); + +inlineStylesCache: Map = new Map(); + +inlineDynamicCache: Map< + string, + { className: string, varName: string, classKey: string }, + > = new Map(); // results of `stylex.create` calls that should be kept +styleVarsToKeep: Set<[string, true | string, true | Array]> = diff --git a/packages/@stylexjs/babel-plugin/src/visitors/imports.js b/packages/@stylexjs/babel-plugin/src/visitors/imports.js index 784e98f24..e344315e8 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/imports.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/imports.js @@ -12,6 +12,8 @@ import type { NodePath } from '@babel/traverse'; import * as t from '@babel/types'; import StateManager from '../utils/state-manager'; +const INLINE_CSS_SOURCES = new Set(['@stylexjs/inline-css']); + // Read imports of react and remember the name of the local variables for later export function readImportDeclarations( path: NodePath, @@ -22,6 +24,28 @@ export function readImportDeclarations( return; } const sourcePath = node.source.value; + + if (INLINE_CSS_SOURCES.has(sourcePath)) { + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportNamespaceSpecifier') { + state.inlineCSSImports.set(specifier.local.name, '*'); + } else if (specifier.type === 'ImportDefaultSpecifier') { + state.inlineCSSImports.set(specifier.local.name, '*'); + } else if ( + specifier.type === 'ImportSpecifier' && + (specifier.imported.type === 'Identifier' || + specifier.imported.type === 'StringLiteral') + ) { + const importedName = + specifier.imported.type === 'Identifier' + ? specifier.imported.name + : specifier.imported.value; + state.inlineCSSImports.set(specifier.local.name, importedName); + } + } + return; + } + if (state.importSources.includes(sourcePath)) { for (const specifier of node.specifiers) { if ( @@ -97,6 +121,9 @@ export function readImportDeclarations( if (importedName === 'defaultMarker') { state.stylexDefaultMarkerImport.add(localName); } + if (importedName === 'css') { + state.inlineCSSImports.set(localName, '*'); + } } } } @@ -126,6 +153,24 @@ export function readRequires( return; } state.importPaths.add(importPath); + if (INLINE_CSS_SOURCES.has(importPath)) { + if (node.id.type === 'Identifier') { + state.inlineCSSImports.set(node.id.name, '*'); + return; + } + if (node.id.type === 'ObjectPattern') { + for (const prop of node.id.properties) { + if ( + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.value.type === 'Identifier' + ) { + state.inlineCSSImports.set(prop.value.name, prop.key.name); + } + } + return; + } + } if (node.id.type === 'Identifier') { state.stylexImport.add(node.id.name); } @@ -179,6 +224,34 @@ export function readRequires( if (prop.key.name === 'defaultMarker') { state.stylexDefaultMarkerImport.add(value.name); } + if (prop.key.name === 'css') { + state.inlineCSSImports.set(value.name, '*'); + } + } + } + } + } + + if ( + init != null && + init.type === 'CallExpression' && + init.callee?.type === 'Identifier' && + init.callee?.name === 'require' && + init.arguments?.length === 1 && + init.arguments?.[0].type === 'StringLiteral' && + INLINE_CSS_SOURCES.has(init.arguments[0].value) + ) { + if (node.id.type === 'Identifier') { + state.inlineCSSImports.set(node.id.name, '*'); + } + if (node.id.type === 'ObjectPattern') { + for (const prop of node.id.properties) { + if ( + prop.type === 'ObjectProperty' && + prop.key.type === 'Identifier' && + prop.value.type === 'Identifier' + ) { + state.inlineCSSImports.set(prop.value.name, prop.key.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..3c0466e50 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js @@ -16,13 +16,17 @@ import { props } from '@stylexjs/stylex'; import { convertObjectToAST } from '../utils/js-to-ast'; import { evaluate } from '../utils/evaluate-path'; import stylexDefaultMarker from '../shared/stylex-defaultMarker'; +import styleXCreateSet from '../shared/stylex-create'; +import { hoistExpression } from '../utils/ast-helpers'; +import { injectDevClassNames } from '../utils/dev-classname'; type ClassNameValue = string | null | boolean | NonStringClassNameValue; type NonStringClassNameValue = [t.Expression, ClassNameValue, ClassNameValue]; -type StyleObject = { - [key: string]: string | null | boolean, -}; +type StyleObject = $ReadOnly<{ + [key: string]: string | null, + $$css?: true | string, +}>; class ConditionalStyle { test: t.Expression; @@ -102,6 +106,12 @@ export default function transformStylexProps( disableImports: true, }; + // Pre-process: compile any raw style objects (from atoms) before the main loop + // This transforms { property: 'value' } into { propKey: 'className', $$css: true } + for (const argPath of argsPath) { + compileRawStyleObjects(argPath, state, evaluatePathFnConfig); + } + const resolvedArgs: ResolvedArgs = []; for (const argPath of argsPath) { currentIndex++; @@ -109,11 +119,14 @@ export default function transformStylexProps( if ( argPath.isObjectExpression() || argPath.isIdentifier() || - argPath.isMemberExpression() + argPath.isMemberExpression() || + argPath.isCallExpression() ) { const resolved = parseNullableStyle(argPath, state, evaluatePathFnConfig); if (resolved === 'other') { - bailOutIndex = currentIndex; + if (bailOutIndex == null) { + bailOutIndex = currentIndex; + } bailOut = true; } else { resolvedArgs.push(resolved); @@ -135,7 +148,9 @@ export default function transformStylexProps( evaluatePathFnConfig, ); if (primary === 'other' || fallback === 'other') { - bailOutIndex = currentIndex; + if (bailOutIndex == null) { + bailOutIndex = currentIndex; + } bailOut = true; } else { resolvedArgs.push(new ConditionalStyle(test, primary, fallback)); @@ -162,7 +177,9 @@ export default function transformStylexProps( evaluatePathFnConfig, ); if (leftResolved !== 'other' || rightResolved === 'other') { - bailOutIndex = currentIndex; + if (bailOutIndex == null) { + bailOutIndex = currentIndex; + } bailOut = true; } else { resolvedArgs.push( @@ -171,14 +188,16 @@ export default function transformStylexProps( conditional++; } } else { - bailOutIndex = currentIndex; + if (bailOutIndex == null) { + bailOutIndex = currentIndex; + } bailOut = true; } if (conditional > 4) { bailOut = true; } if (bailOut) { - break; + continue; } } if (!state.options.enableInlinedConditionalMerge && conditional) { @@ -263,6 +282,32 @@ export default function transformStylexProps( MemberExpression, }); } + + // Hoist inline CSS objects (from atoms) to module level + // Raw objects are pre-compiled in compileRawStyleObjects, so this only hoists compiled objects + // eslint-disable-next-line no-inner-declarations + function ObjectExpression(objPath: NodePath) { + // Check if this object has $$css: true (compiled utility style object) + const hasCssMarker = objPath.node.properties.some( + (prop) => + t.isObjectProperty(prop) && + ((t.isIdentifier(prop.key) && prop.key.name === '$$css') || + (t.isStringLiteral(prop.key) && prop.key.value === '$$css')) && + t.isBooleanLiteral(prop.value, { value: true }), + ); + if (hasCssMarker) { + const hoisted = hoistExpression(objPath, objPath.node); + objPath.replaceWith(hoisted); + } + } + + if (argPath.isObjectExpression()) { + ObjectExpression(argPath); + } else { + argPath.traverse({ + ObjectExpression, + }); + } } } else { path.skip(); @@ -331,6 +376,19 @@ function parseNullableStyle( return null; } + // Local dynamic style functions (e.g., styles.opacity(1)) should bail out + // so runtime props merging keeps the conditional class/inline var semantics. + if (path.isCallExpression()) { + const callee = path.get('callee'); + if (callee.isMemberExpression()) { + const obj = callee.get('object'); + if (obj.isIdentifier() && state.styleMap.has(obj.node.name)) { + return 'other'; + } + } + return 'other'; + } + if (t.isMemberExpression(node)) { const { object, property, computed: computed } = node; let objName = null; @@ -358,8 +416,14 @@ function parseNullableStyle( if (objName != null && propName != null) { const style = state.styleMap.get(objName); if (style != null && style[String(propName)] != null) { + const memberVal = style[String(propName)]; + // Dynamic style functions (arrow/function expressions) should bail out + // so runtime props handling remains intact. + if (typeof memberVal === 'function') { + return 'other'; + } // $FlowFixMe[incompatible-type] - return style[String(propName)]; + return memberVal; } } } @@ -479,3 +543,218 @@ function isCalleeMemberExpression( state.stylexImport.has(node.callee.object.name) ); } + +/** + * Pre-compile raw style objects from atoms. + * This transforms { property: 'value' } into { propKey: 'className', $$css: true } + * and registers the CSS injection. + */ +function compileRawStyleObjects( + argPath: NodePath<>, + state: StateManager, + evaluatePathFnConfig: FunctionConfig, +): void { + function processObjectExpression(objPath: NodePath) { + // Check if this object already has $$css marker (already compiled) + const hasCssMarker = objPath.node.properties.some( + (prop) => + t.isObjectProperty(prop) && + ((t.isIdentifier(prop.key) && prop.key.name === '$$css') || + (t.isStringLiteral(prop.key) && prop.key.value === '$$css')), + ); + if (hasCssMarker) { + return; + } + + // Empty objects don't need processing + const properties = objPath.node.properties; + if (properties.length === 0) { + return; + } + + // Try to evaluate the object + const evalResult = evaluate(objPath, state, evaluatePathFnConfig); + + // Check if the object has dynamic values (non-evaluatable) + if (!evalResult.confident) { + // Check if this is a simple {property: dynamicValue} pattern + if (properties.length === 1 && t.isObjectProperty(properties[0])) { + const prop = properties[0]; + const key = t.isIdentifier(prop.key) && !prop.computed + ? prop.key.name + : t.isStringLiteral(prop.key) + ? prop.key.value + : null; + + if (key != null && t.isExpression(prop.value) && !t.isLiteral(prop.value)) { + // This is a dynamic style: { property: dynamicValue } + compileDynamicStyle(objPath, key, prop.value, state); + return; + } + } + return; + } + + if (evalResult.value == null) { + return; + } + + // Skip proxies + if (evalResult.value.__IS_PROXY === true) { + return; + } + + // This is a raw style object - compile it + const rawStyle = evalResult.value; + + // Create a namespace wrapper for styleXCreateSet: { __inline__: rawStyle } + const styleInput = { __inline__: rawStyle }; + + // eslint-disable-next-line prefer-const + let [compiledStyles, injectedStyles] = styleXCreateSet( + styleInput, + state.options, + ); + + // Inject dev class names if enabled + if (state.isDev && state.opts.enableDevClassNames) { + compiledStyles = { + ...injectDevClassNames(compiledStyles, null, state), + }; + } + + // Register the CSS + const listOfStyles = Object.entries(injectedStyles).map( + ([key, { priority, ...rest }]) => [key, rest, priority], + ); + state.registerStyles(listOfStyles, objPath); + + // Get the compiled style for __inline__ namespace + const compiledStyle = compiledStyles.__inline__; + if (compiledStyle == null) { + return; + } + + // Convert to AST and replace + const compiledAst = convertObjectToAST(compiledStyle); + objPath.replaceWith(compiledAst); + } + + if (argPath.isObjectExpression()) { + processObjectExpression(argPath); + } else if (argPath.isConditionalExpression()) { + const consequentPath = argPath.get('consequent'); + const alternatePath = argPath.get('alternate'); + if (consequentPath.isObjectExpression()) { + processObjectExpression(consequentPath); + } + if (alternatePath.isObjectExpression()) { + processObjectExpression(alternatePath); + } + } else if (argPath.isLogicalExpression()) { + const rightPath = argPath.get('right'); + if (rightPath.isObjectExpression()) { + processObjectExpression(rightPath); + } + } +} + +/** + * Compile a dynamic style { property: dynamicValue } into a hoisted function pattern. + * This creates: const _styles = { property: _v => [compiledStyle, inlineVars] } + * And replaces the original with: _styles.property(dynamicValue) + */ +function compileDynamicStyle( + objPath: NodePath, + property: string, + valueExpr: t.Expression, + state: StateManager, +): void { + // Use a CSS variable for the dynamic value + const varName = `--x-${property}`; + const rawStyle = { [property]: `var(${varName})` }; + + // Create a namespace wrapper for styleXCreateSet + const styleInput = { __inline__: rawStyle }; + + // eslint-disable-next-line prefer-const + let [compiledStyles, injectedStyles] = styleXCreateSet( + styleInput, + state.options, + ); + + // Inject dev class names if enabled + if (state.isDev && state.opts.enableDevClassNames) { + compiledStyles = { + ...injectDevClassNames(compiledStyles, null, state), + }; + } + + // Add @property rule for the CSS variable + injectedStyles[varName] = { + priority: 0, + ltr: `@property ${varName} { syntax: "*"; inherits: false;}`, + rtl: null, + }; + + // Register the CSS + const listOfStyles = Object.entries(injectedStyles).map( + ([key, { priority, ...rest }]) => [key, rest, priority], + ); + state.registerStyles(listOfStyles, objPath); + + // Get the compiled style + const compiledStyle = compiledStyles.__inline__; + if (compiledStyle == null) { + return; + } + + // Get the class name for the property + const propKey = Object.keys(compiledStyle).find((k) => k !== '$$css'); + const className = propKey != null ? compiledStyle[propKey] : null; + if (className == null || propKey == null) { + return; + } + + // Create the function pattern: + // _v => [{ propKey: _v != null ? className : _v, $$css: true }, { varName: _v != null ? _v : undefined }] + const param = t.identifier('_v'); + const nullCheck = t.binaryExpression('!=', param, t.nullLiteral()); + + const compiledObj = t.objectExpression([ + t.objectProperty( + t.stringLiteral(propKey), + t.conditionalExpression(nullCheck, t.stringLiteral(className), param), + ), + t.objectProperty(t.stringLiteral('$$css'), t.booleanLiteral(true)), + ]); + + const inlineVarsObj = t.objectExpression([ + t.objectProperty( + t.stringLiteral(varName), + t.conditionalExpression( + t.binaryExpression('!=', param, t.nullLiteral()), + param, + t.identifier('undefined'), + ), + ), + ]); + + const fnBody = t.arrayExpression([compiledObj, inlineVarsObj]); + const arrowFn = t.arrowFunctionExpression([param], fnBody); + + // Create the hoisted styles object: const _styles = { property: arrowFn } + const stylesObj = t.objectExpression([ + t.objectProperty(t.identifier(property), arrowFn), + ]); + + // Hoist the function + const hoistedId = hoistExpression(objPath, stylesObj); + + // Replace the original with: _hoistedId.property(valueExpr) + const callExpr = t.callExpression( + t.memberExpression(hoistedId, t.identifier(property)), + [valueExpr], + ); + objPath.replaceWith(callExpr); +} diff --git a/packages/@stylexjs/stylex/package.json b/packages/@stylexjs/stylex/package.json index e8fa7897d..355f81d71 100644 --- a/packages/@stylexjs/stylex/package.json +++ b/packages/@stylexjs/stylex/package.json @@ -37,6 +37,7 @@ "test": "cross-env BABEL_ENV=test jest --coverage" }, "dependencies": { + "@stylexjs/atoms": "0.17.5", "css-mediaquery": "^0.1.2", "invariant": "^2.2.4", "styleq": "0.2.1" diff --git a/packages/@stylexjs/stylex/src/stylex.js b/packages/@stylexjs/stylex/src/stylex.js index ff5185734..c00bb3cf8 100644 --- a/packages/@stylexjs/stylex/src/stylex.js +++ b/packages/@stylexjs/stylex/src/stylex.js @@ -284,3 +284,6 @@ _legacyMerge.when = when; _legacyMerge.viewTransitionClass = viewTransitionClass; export const legacyMerge: IStyleX = _legacyMerge; + +// Re-export atoms for cleaner imports +export { default as x } from '@stylexjs/atoms';