diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineMarker-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineMarker-test.js new file mode 100644 index 000000000..56b9cf6dc --- /dev/null +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineMarker-test.js @@ -0,0 +1,75 @@ +/** + * 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 stylexPlugin from '../src/index'; + +function transform(source, opts = {}) { + const { code, metadata } = transformSync(source, { + filename: opts.filename || '/stylex/packages/vars.stylex.js', + parserOpts: { + flow: 'all', + }, + babelrc: false, + plugins: [ + [ + stylexPlugin, + { + unstable_moduleResolution: { + rootDir: '/stylex/packages/', + type: 'commonJS', + }, + ...opts, + }, + ], + ], + }); + return { code, metadata }; +} + +describe('@stylexjs/babel-plugin', () => { + describe('[transform] stylex.defineMarker()', () => { + test('member call', () => { + const { code, metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const fooBar = stylex.defineMarker(); + `); + + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const fooBar = { + x1jdyizh: "x1jdyizh", + $$css: true + };" + `); + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [], + } + `); + }); + + test('named import call', () => { + const { code } = transform(` + import { defineMarker } from '@stylexjs/stylex'; + export const baz = defineMarker(); + `); + + expect(code).toMatchInlineSnapshot(` + "import { defineMarker } from '@stylexjs/stylex'; + export const baz = { + x1i61hkd: "x1i61hkd", + $$css: true + };" + `); + }); + }); +}); diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js index 9ed858d19..002c377e7 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-when-test.js @@ -19,7 +19,16 @@ function transform(source, opts = {}) { flow: 'all', }, babelrc: false, - plugins: [[stylexPlugin, { ...opts }]], + plugins: [ + [ + stylexPlugin, + { + treeshakeCompensation: true, + unstable_moduleResolution: { type: 'haste' }, + ...opts, + }, + ], + ], }); return result; @@ -336,4 +345,42 @@ describe('@stylexjs/babel-plugin', () => { `); }); }); + + describe('[transform] using custom markers', () => { + test('named import of custom marker', () => { + const { code } = transform( + ` + import * as stylex from '@stylexjs/stylex'; + import {customMarker} from 'custom-marker.stylex'; + + const styles = stylex.create({ + foo: { + backgroundColor: { + default: 'blue', + [stylex.when.ancestor(':hover', customMarker)]: 'red', + }, + }, + }); + + const container = stylex.props(customMarker); + const classNames = stylex.props(styles.foo); + `, + { runtimeInjection: true }, + ); + + expect(code).toMatchInlineSnapshot(` + "import _inject from "@stylexjs/stylex/lib/stylex-inject"; + var _inject2 = _inject; + import * as stylex from '@stylexjs/stylex'; + import 'custom-marker.stylex'; + import { customMarker } from 'custom-marker.stylex'; + _inject2(".x1t391ir{background-color:blue}", 3000); + _inject2(".x7rpj1w:where(.x1lc2aw:hover *){background-color:red}", 3011.3); + const container = stylex.props(customMarker); + const classNames = { + className: "x1t391ir x7rpj1w" + };" + `); + }); + }); }); diff --git a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineMarker-test.js b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineMarker-test.js new file mode 100644 index 000000000..9838e352e --- /dev/null +++ b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineMarker-test.js @@ -0,0 +1,59 @@ +/** + * 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 stylexPlugin from '../src/index'; +import * as messages from '../src/shared/messages'; + +function transform(source, opts = {}) { + const { code, metadata } = transformSync(source, { + filename: opts.filename || '/stylex/packages/vars.stylex.js', + parserOpts: { + flow: 'all', + }, + babelrc: false, + plugins: [ + [ + stylexPlugin, + { + unstable_moduleResolution: { + rootDir: '/stylex/packages/', + type: 'commonJS', + }, + ...opts, + }, + ], + ], + }); + return { code, metadata }; +} + +describe('@stylexjs/babel-plugin', () => { + describe('[validation] stylex.defineMarker()', () => { + test('must be bound to a named export', () => { + expect(() => { + transform(` + import * as stylex from '@stylexjs/stylex'; + const marker = stylex.defineMarker(); + `); + }).toThrow(messages.nonExportNamedDeclaration('defineMarker')); + }); + + test('no arguments allowed', () => { + expect(() => { + transform(` + import * as stylex from '@stylexjs/stylex'; + export const marker = stylex.defineMarker(1); + `); + }).toThrow(messages.illegalArgumentLength('defineMarker', 0)); + }); + }); +}); diff --git a/packages/@stylexjs/babel-plugin/src/index.js b/packages/@stylexjs/babel-plugin/src/index.js index 7009ccdcd..3b3e977be 100644 --- a/packages/@stylexjs/babel-plugin/src/index.js +++ b/packages/@stylexjs/babel-plugin/src/index.js @@ -33,6 +33,7 @@ import transformStylexProps from './visitors/stylex-props'; import { skipStylexPropsChildren } from './visitors/stylex-props'; import transformStyleXViewTransitionClass from './visitors/stylex-view-transition-class'; import transformStyleXDefaultMarker from './visitors/stylex-default-marker'; +import transformStyleXDefineMarker from './visitors/stylex-define-marker'; const NAME = 'stylex'; @@ -306,6 +307,7 @@ function styleXTransform(): PluginObj<> { } transformStyleXDefaultMarker(path, state); + transformStyleXDefineMarker(path, state); transformStyleXDefineVars(path, state); transformStyleXDefineConsts(path, state); transformStyleXCreateTheme(path, state); diff --git a/packages/@stylexjs/babel-plugin/src/shared/when/when.js b/packages/@stylexjs/babel-plugin/src/shared/when/when.js index 34359d054..3332b6c5e 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/when/when.js +++ b/packages/@stylexjs/babel-plugin/src/shared/when/when.js @@ -11,9 +11,36 @@ import type { StyleXOptions } from '../common-types'; import { defaultOptions } from '../utils/default-options'; +function fromProxy(value: mixed): ?string { + if ( + typeof value === 'object' && + value != null && + value.__IS_PROXY === true && + typeof value.toString === 'function' + ) { + return value.toString(); + } + return null; +} + +function fromStyleXStyle(value: mixed): ?string { + if (typeof value === 'object' && value != null && value.$$css === true) { + return Object.keys(value).find((key) => key !== '$$css'); + } + return null; +} + function getDefaultMarkerClassName( options: StyleXOptions = defaultOptions, ): string { + const valueFromProxy = fromProxy(options); + if (valueFromProxy != null) { + return valueFromProxy; + } + const valueFromStyleXStyle = fromStyleXStyle(options); + if (valueFromStyleXStyle != null) { + return valueFromStyleXStyle; + } const prefix = options.classNamePrefix != null ? `${options.classNamePrefix}-` : ''; return `${prefix}default-marker`; diff --git a/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js b/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js index 8cf70663f..566c9f520 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js +++ b/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js @@ -201,6 +201,14 @@ function evaluateThemeRef( {}, { get(_, key: string) { + if (key === '__IS_PROXY') { + return true; + } + if (key === 'toString') { + return () => + state.traversalState.options.classNamePrefix + + utils.hash(utils.genFileBasedIdentifier({ fileName, exportName })); + } return resolveKey(key); }, set(_, key: string, value: string) { diff --git a/packages/@stylexjs/babel-plugin/src/utils/state-manager.js b/packages/@stylexjs/babel-plugin/src/utils/state-manager.js index 1607f7fd8..4b49e9639 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/state-manager.js +++ b/packages/@stylexjs/babel-plugin/src/utils/state-manager.js @@ -132,6 +132,7 @@ export default class StateManager { +stylexKeyframesImport: Set = new Set(); +stylexPositionTryImport: Set = new Set(); +stylexDefineVarsImport: Set = new Set(); + +stylexDefineMarkerImport: Set = new Set(); +stylexDefineConstsImport: Set = new Set(); +stylexCreateThemeImport: Set = new Set(); +stylexTypesImport: Set = new Set(); diff --git a/packages/@stylexjs/babel-plugin/src/visitors/imports.js b/packages/@stylexjs/babel-plugin/src/visitors/imports.js index fadad208b..784e98f24 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/imports.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/imports.js @@ -79,6 +79,9 @@ export function readImportDeclarations( if (importedName === 'defineVars') { state.stylexDefineVarsImport.add(localName); } + if (importedName === 'defineMarker') { + state.stylexDefineMarkerImport.add(localName); + } if (importedName === 'defineConsts') { state.stylexDefineConstsImport.add(localName); } @@ -158,6 +161,9 @@ export function readRequires( if (prop.key.name === 'defineVars') { state.stylexDefineVarsImport.add(value.name); } + if (prop.key.name === 'defineMarker') { + state.stylexDefineMarkerImport.add(value.name); + } if (prop.key.name === 'defineConsts') { state.stylexDefineConstsImport.add(value.name); } diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-marker.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-marker.js new file mode 100644 index 000000000..45ef06981 --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-marker.js @@ -0,0 +1,110 @@ +/** + * 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 '../utils/state-manager'; +import * as messages from '../shared/messages'; +import { utils } from '../shared'; +import { convertObjectToAST } from '../utils/js-to-ast'; + +/** + * Transforms calls to `stylex.defineMarker()` (or imported `defineMarker()`) + * into an object: { $$css: true, [hash]: hash } where `hash` is generated from + * the file path and the export name. + */ +export default function transformStyleXDefineMarker( + path: NodePath, + state: StateManager, +): void { + const { node } = path; + + if (node.type !== 'CallExpression') { + return; + } + + const isDefineMarkerCall = + (node.callee.type === 'Identifier' && + state.stylexDefineMarkerImport.has(node.callee.name)) || + (node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'defineMarker' && + state.stylexImport.has(node.callee.object.name)); + + if (!isDefineMarkerCall) { + return; + } + + // Validate call shape and location: must be bound to an exported const + validateStyleXDefineMarker(path); + + // We know the parent is a VariableDeclarator + const variableDeclaratorPath = path.parentPath; + if (!variableDeclaratorPath.isVariableDeclarator()) { + return; + } + const variableDeclaratorNode = variableDeclaratorPath.node; + if (variableDeclaratorNode.id.type !== 'Identifier') { + return; + } + + // No arguments allowed + if (node.arguments.length !== 0) { + throw path.buildCodeFrameError( + messages.illegalArgumentLength('defineMarker', 0), + SyntaxError, + ); + } + + const fileName = state.fileNameForHashing; + if (fileName == null) { + throw new Error(messages.cannotGenerateHash('defineMarker')); + } + + const exportName = variableDeclaratorNode.id.name; + const exportId = utils.genFileBasedIdentifier({ fileName, exportName }); + const id = state.options.classNamePrefix + utils.hash(exportId); + + const markerObj = { + [id]: id, + $$css: true, + }; + + path.replaceWith(convertObjectToAST(markerObj)); +} + +function validateStyleXDefineMarker(path: NodePath) { + const variableDeclaratorPath: any = path.parentPath; + const exportNamedDeclarationPath = + variableDeclaratorPath.parentPath?.parentPath; + + if ( + variableDeclaratorPath == null || + variableDeclaratorPath.isExpressionStatement() || + !variableDeclaratorPath.isVariableDeclarator() || + variableDeclaratorPath.node.id.type !== 'Identifier' + ) { + throw path.buildCodeFrameError( + messages.unboundCallValue('defineMarker'), + SyntaxError, + ); + } + + if ( + exportNamedDeclarationPath == null || + !exportNamedDeclarationPath.isExportNamedDeclaration() + ) { + throw path.buildCodeFrameError( + messages.nonExportNamedDeclaration('defineMarker'), + SyntaxError, + ); + } +} diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js index 2fd6e0dfa..ada419f14 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-props.js @@ -189,7 +189,11 @@ export default function transformStylexProps( state, evaluatePathFnConfig, ); - if (!confident || styleValue == null) { + if ( + !confident || + styleValue == null || + styleValue.__IS_PROXY === true + ) { nonNullProps = true; styleNonNullProps = true; } else { diff --git a/packages/@stylexjs/stylex/__tests__/stylex-test.js b/packages/@stylexjs/stylex/__tests__/stylex-test.js index 25065aeae..005136ca3 100644 --- a/packages/@stylexjs/stylex/__tests__/stylex-test.js +++ b/packages/@stylexjs/stylex/__tests__/stylex-test.js @@ -23,6 +23,7 @@ describe('stylex', () => { 'positionTry', 'viewTransitionClass', 'defaultMarker', + 'defineMarker', ].forEach((api) => { test(`stylex.${api}`, () => { expect(() => stylex[api]()).toThrow(); diff --git a/packages/@stylexjs/stylex/src/stylex.js b/packages/@stylexjs/stylex/src/stylex.js index 783cd21f1..25ce69efa 100644 --- a/packages/@stylexjs/stylex/src/stylex.js +++ b/packages/@stylexjs/stylex/src/stylex.js @@ -81,6 +81,10 @@ export const defineVars: StyleX$DefineVars = function stylexDefineVars( throw errorForFn('defineVars'); }; +export const defineMarker = (): StaticStyles<> => { + throw errorForFn('defineMarker'); +}; + export const firstThatWorks = ( ..._styles: $ReadOnlyArray ): $ReadOnlyArray => { @@ -220,6 +224,7 @@ type IStyleX = { create: StyleX$Create, createTheme: StyleX$CreateTheme, defineVars: StyleX$DefineVars, + defineMarker: () => StaticStyles<>, defaultMarker: () => StaticStyles<>, firstThatWorks: ( ...v: $ReadOnlyArray @@ -254,6 +259,7 @@ function _legacyMerge( _legacyMerge.create = create; _legacyMerge.createTheme = createTheme; +_legacyMerge.defineMarker = defineMarker; _legacyMerge.defineVars = defineVars; _legacyMerge.defaultMarker = defaultMarker; _legacyMerge.firstThatWorks = firstThatWorks;