diff --git a/packages/@stylexjs/babel-plugin/__tests__/__fixtures__/constants.stylex.js b/packages/@stylexjs/babel-plugin/__tests__/__fixtures__/constants.stylex.js index e3efa0661..da0a683ef 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/__fixtures__/constants.stylex.js +++ b/packages/@stylexjs/babel-plugin/__tests__/__fixtures__/constants.stylex.js @@ -21,3 +21,12 @@ export const colors = stylex.defineConsts({ background: 'white', foreground: 'black', }); + +export const nestedTokens = stylex.defineConsts({ + button: { + fill: { + primary: 'blue', + }, + }, + 'button.fill.primary': 'red', +}); diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineConsts-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineConsts-test.js index a6a121d84..b2be5b6c0 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineConsts-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineConsts-test.js @@ -617,5 +617,268 @@ describe('@stylexjs/babel-plugin', () => { } `); }); + test('nested constants object', () => { + const { code, metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const tokens = stylex.defineConsts({ + button: { + fill: { + primary: 'blue', + secondary: 'gray', + }, + }, + }); + `); + + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const tokens = { + button: { + fill: { + primary: "blue", + secondary: "gray" + } + } + };" + `); + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "xreo3at", + { + "constKey": "xreo3at", + "constVal": "blue", + "ltr": "", + "rtl": null, + }, + 0, + ], + [ + "x3rbt3c", + { + "constKey": "x3rbt3c", + "constVal": "gray", + "ltr": "", + "rtl": null, + }, + 0, + ], + ], + } + `); + }); + + test('uses nested constants in stylex.create', () => { + const { code, metadata } = transformWithInlineConsts(` + import * as stylex from '@stylexjs/stylex'; + export const tokens = stylex.defineConsts({ + button: { + fill: { + primary: 'blue', + }, + }, + }); + export const styles = stylex.create({ + root: { + backgroundColor: tokens.button.fill.primary, + }, + }); + `); + + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const tokens = { + button: { + fill: { + primary: "blue" + } + } + }; + export const styles = { + root: { + kWkggS: "x1t391ir", + $$css: true + } + };" + `); + + // Verify const entry and CSS rule with inlined value + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "x1wyxh8f", + { + "constKey": "x1wyxh8f", + "constVal": "blue", + "ltr": "", + "rtl": null, + }, + 0, + ], + [ + "x1t391ir", + { + "ltr": ".x1t391ir{background-color:blue}", + "rtl": null, + }, + 3000, + ], + ], + } + `); + }); + + test('deeply nested constants', () => { + const { code, metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const tokens = stylex.defineConsts({ + level1: { + level2: { + level3: { + level4: 'deep-value', + }, + }, + }, + }); + `); + + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const tokens = { + level1: { + level2: { + level3: { + level4: "deep-value" + } + } + } + };" + `); + + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "x67bk05", + { + "constKey": "x67bk05", + "constVal": "deep-value", + "ltr": "", + "rtl": null, + }, + 0, + ], + ], + } + `); + }); + + test('mixed flat and nested constants', () => { + const { code, metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const tokens = stylex.defineConsts({ + flatColor: 'red', + nested: { + color: 'blue', + }, + }); + `); + + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + export const tokens = { + flatColor: "red", + nested: { + color: "blue" + } + };" + `); + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "x19gcf6u", + { + "constKey": "x19gcf6u", + "constVal": "red", + "ltr": "", + "rtl": null, + }, + 0, + ], + [ + "x49cdii", + { + "constKey": "x49cdii", + "constVal": "blue", + "ltr": "", + "rtl": null, + }, + 0, + ], + ], + } + `); + }); + + test('deep chained imported constants work in stylex.create', () => { + const { code, metadata } = transformWithInlineConsts(` + import * as stylex from '@stylexjs/stylex'; + import { nestedTokens } from './constants.stylex'; + export const styles = stylex.create({ + root: { + backgroundColor: nestedTokens.button.fill.primary, + }, + }); + `); + + expect(code).toMatchInlineSnapshot(` + "import * as stylex from '@stylexjs/stylex'; + import { nestedTokens } from './constants.stylex'; + export const styles = { + root: { + kWkggS: "x13bhmi6", + $$css: true + } + };" + `); + + // Should produce a style rule for backgroundColor referencing a CSS variable + // (The const entries themselves are in the metadata of constants.stylex, not this file) + expect(metadata).toMatchInlineSnapshot(` + { + "stylex": [ + [ + "x13bhmi6", + { + "ltr": ".x13bhmi6{background-color:var(--x529nz8)}", + "rtl": null, + }, + 3000, + ], + ], + } + `); + }); + + test('conflicting nested and flat constant names throws error', () => { + expect(() => { + transform(` + import * as stylex from '@stylexjs/stylex'; + export const tokens = stylex.defineConsts({ + button: { + fill: { + primary: 'blue', + }, + }, + 'button.fill.primary': 'red', + }); + `); + }).toThrow( + /Conflicting constant paths detected: "button\.fill\.primary"/, + ); + }); }); }); diff --git a/packages/@stylexjs/babel-plugin/src/shared/stylex-consts-utils.js b/packages/@stylexjs/babel-plugin/src/shared/stylex-consts-utils.js index 2138336b0..cc5436a25 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/stylex-consts-utils.js +++ b/packages/@stylexjs/babel-plugin/src/shared/stylex-consts-utils.js @@ -7,7 +7,10 @@ * @flow strict */ -export type ConstsConfigValue = string | number; +export type ConstsConfigValue = + | string + | number + | { [string]: ConstsConfigValue }; export type ConstsConfig = $ReadOnly<{ [string]: ConstsConfigValue, diff --git a/packages/@stylexjs/babel-plugin/src/shared/stylex-define-consts.js b/packages/@stylexjs/babel-plugin/src/shared/stylex-define-consts.js index 13451bab8..de1bd7f82 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/stylex-define-consts.js +++ b/packages/@stylexjs/babel-plugin/src/shared/stylex-define-consts.js @@ -15,11 +15,13 @@ import * as messages from './messages'; import createHash from './hash'; +type ConstsOutput = string | number | { [string]: ConstsOutput }; + export default function styleXDefineConsts( constants: Vars, options: $ReadOnly<{ ...Partial, exportId: string, ... }>, ): [ - { [string]: string | number }, // jsOutput JS output + { [string]: ConstsOutput }, // jsOutput JS output { [string]: InjectableConstStyle }, // metadata for registerinjectableStyles ] { const { classNamePrefix, exportId, debug, enableDebugClassNames } = { @@ -27,31 +29,64 @@ export default function styleXDefineConsts( ...options, }; - const jsOutput: { [string]: string | number } = {}; + const jsOutput: { [string]: ConstsOutput } = {}; const injectableStyles: { [string]: InjectableConstStyle } = {}; + const seenPaths: Set = new Set(); + + const processEntry = ( + key: string, + value: ConstsOutput, + path: Array, + ): ConstsOutput => { + if (typeof value === 'object' && value != null) { + const nested: { [string]: ConstsOutput } = {}; + for (const [k, v] of Object.entries(value)) { + nested[k] = processEntry(k, v, [...path, k]); + } + return nested; + } + + if (typeof value === 'string' || typeof value === 'number') { + const fullPath = path.join('.'); + + if (seenPaths.has(fullPath)) { + throw new Error( + `Conflicting constant paths detected: "${fullPath}". This can happen when you have both nested properties (e.g., {a: {b: 'value'}}) and a literal dotted key (e.g., {'a.b': 'value'}) that resolve to the same path.`, + ); + } + seenPaths.add(fullPath); + + const varSafeKey = path + .map((segment) => + segment[0] >= '0' && segment[0] <= '9' ? `_${segment}` : segment, + ) + .join('_') + .replace(/[^a-zA-Z0-9]/g, '_'); + + const constKey = + debug && enableDebugClassNames + ? `${varSafeKey}-${classNamePrefix}${createHash(`${exportId}.${fullPath}`)}` + : `${classNamePrefix}${createHash(`${exportId}.${fullPath}`)}`; + + injectableStyles[constKey] = { + constKey, + constVal: value, + priority: 0, + ltr: '', + rtl: null, + }; + return value; + } + + return value; + }; for (const [key, value] of Object.entries(constants)) { if (key.startsWith('--')) { throw new Error(messages.INVALID_CONST_KEY); } - const varSafeKey = ( - key[0] >= '0' && key[0] <= '9' ? `_${key}` : key - ).replace(/[^a-zA-Z0-9]/g, '_'); - - const constKey = - debug && enableDebugClassNames - ? `${varSafeKey}-${classNamePrefix}${createHash(`${exportId}.${key}`)}` - : `${classNamePrefix}${createHash(`${exportId}.${key}`)}`; - - jsOutput[key] = value; - injectableStyles[constKey] = { - constKey, - constVal: value, - priority: 0, - ltr: '', - rtl: null, - }; + jsOutput[key] = processEntry(key, value, [key]); } return [jsOutput, injectableStyles]; diff --git a/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js b/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js index 0c347815c..22560ada0 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js +++ b/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js @@ -31,7 +31,6 @@ import { utils } from '../shared'; import * as errMsgs from './evaluation-errors'; // This file contains Babels metainterpreter that can evaluate static code. - const VALID_CALLEES = ['String', 'Number', 'Math', 'Object', 'Array']; const INVALID_METHODS = [ 'random', @@ -43,6 +42,46 @@ const INVALID_METHODS = [ 'splice', ]; +const PROXY_MARKER = Symbol('StyleXProxyMarker'); + +function getFullMemberPath(path: NodePath) { + const parts: Array = []; + let current = path; + + while (current.isMemberExpression()) { + const prop = current.get('property'); + if (prop.isIdentifier()) { + parts.unshift(prop.node.name); + } else if (prop.isStringLiteral()) { + parts.unshift(prop.node.value); + } else if (current.node.computed) { + return null; + } + current = current.get('object'); + } + + return { + parts, + baseObject: current, + }; +} + +function getOuterMostMemberExpression(path: NodePath<>): NodePath<> { + let current = path; + while (current.parentPath) { + const parent = current.parentPath; + if ( + parent.isMemberExpression() && + parent.get('object').node === current.node + ) { + current = parent; + } else { + break; + } + } + return current; +} + function isValidCallee(val: string): boolean { return (VALID_CALLEES as $ReadOnlyArray).includes(val); } @@ -202,14 +241,10 @@ function evaluateThemeRef( {}, { get(_, key: string) { - if (key === '__IS_PROXY') { + if ((key as any) === PROXY_MARKER) { return true; } - if (key === 'toString') { - return () => - state.traversalState.options.classNamePrefix + - utils.hash(utils.genFileBasedIdentifier({ fileName, exportName })); - } + return resolveKey(key); }, set(_, key: string, value: string) { @@ -377,6 +412,25 @@ function _evaluate(path: NodePath<>, state: State): any { path.isMemberExpression() && !path.parentPath.isCallExpression({ callee: path.node }) ) { + const outerMost = getOuterMostMemberExpression(path); + + // to handle nested member expressions, we wait until we are at the outer most member expression + // and then we can extract the full path and evaluate it via the object proxy + if (outerMost === path) { + const pathInfo = getFullMemberPath(path); + + if (pathInfo != null && pathInfo.parts.length > 0) { + const baseObject = evaluateCached(pathInfo.baseObject, state); + if (!state.confident) { + return; + } + + if (baseObject[PROXY_MARKER]) { + return baseObject[pathInfo.parts.join('.')]; + } + } + } + const object = evaluateCached(path.get('object'), state); if (!state.confident) { return; diff --git a/packages/@stylexjs/stylex/src/stylex.js b/packages/@stylexjs/stylex/src/stylex.js index ff5185734..feaf5a80c 100644 --- a/packages/@stylexjs/stylex/src/stylex.js +++ b/packages/@stylexjs/stylex/src/stylex.js @@ -72,8 +72,10 @@ export const createTheme: StyleX$CreateTheme = (_baseTokens, _overrides) => { throw errorForFn('createTheme'); }; +type DefineConstsTokens = string | number | { +[string]: DefineConstsTokens }; + export const defineConsts: StyleX$DefineConsts = function stylexDefineConsts< - const T: { +[string]: number | string }, + T: DefineConstsTokens, >(_styles: T): T { throw errorForFn('defineConsts'); }; diff --git a/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts b/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts index a3af27b8e..6deee7fbf 100644 --- a/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts +++ b/packages/@stylexjs/stylex/src/types/StyleXTypes.d.ts @@ -268,11 +268,9 @@ type NestedVarObject = [key: AtRuleStr]: NestedVarObject; }>; -export type StyleX$DefineConsts = < - DefaultTokens extends { - [key: string]: number | string; - }, ->( +type DefineConstTokens = string | number | { [string]: DefineConstTokens }; + +export type StyleX$DefineConsts = ( tokens: DefaultTokens, ) => DefaultTokens; diff --git a/packages/@stylexjs/stylex/src/types/StyleXTypes.js b/packages/@stylexjs/stylex/src/types/StyleXTypes.js index 99898e2d6..b442b5121 100644 --- a/packages/@stylexjs/stylex/src/types/StyleXTypes.js +++ b/packages/@stylexjs/stylex/src/types/StyleXTypes.js @@ -225,9 +225,9 @@ export type StyleX$DefineVars = ( tokens: DefaultTokens, ) => VarGroup, ID>; -export type StyleX$DefineConsts = < - const DefaultTokens: { +[string]: number | string }, ->( +type DefineConstTokens = string | number | { +[string]: DefineConstTokens }; + +export type StyleX$DefineConsts = ( tokens: DefaultTokens, ) => DefaultTokens; diff --git a/packages/benchmarks/perf/fixtures/create-complex.js b/packages/benchmarks/perf/fixtures/create-complex.js index 0866e953f..79c862aac 100644 --- a/packages/benchmarks/perf/fixtures/create-complex.js +++ b/packages/benchmarks/perf/fixtures/create-complex.js @@ -6,7 +6,7 @@ */ import * as stylex from '@stylexjs/stylex'; -import { sizes } from './sizes.stylex'; +import { sizes, spacing } from './sizes.stylex'; export const styles = stylex.create({ root: { @@ -122,19 +122,32 @@ export const styles = stylex.create({ }, }, panel: { - borderRadius: sizes.borderRadiusLarge, - gap: sizes.gapMedium, - margin: sizes.marginLarge, - padding: sizes.paddingLarge, - paddingBlock: sizes.paddingMedium, - paddingInline: sizes.paddingLarge, + borderRadius: spacing.border.radius.large, + gap: spacing.gap.medium, + margin: spacing.margin.large, + padding: spacing.padding.large, + paddingBlock: spacing.padding.medium, + paddingInline: spacing.padding.large, }, panelHeader: { - borderRadius: sizes.borderRadiusMedium, - gap: sizes.gapSmall, - margin: sizes.marginSmall, - marginBlockEnd: sizes.marginMedium, - padding: sizes.paddingMedium, + borderRadius: spacing.border.radius.medium, + gap: spacing.gap.small, + margin: spacing.margin.small, + marginBlockEnd: spacing.margin.medium, + padding: spacing.padding.medium, + }, + panelBody: { + borderRadius: spacing.border.radius.medium, + gap: spacing.gap.medium, + margin: spacing.margin.small, + padding: spacing.padding.medium, + }, + panelFooter: { + borderRadius: spacing.border.radius.medium, + gap: spacing.gap.small, + margin: spacing.margin.small, + marginBlockStart: spacing.margin.medium, + padding: spacing.padding.medium, }, dynamicHeight: (height) => ({ height }), dynamicPadding: (paddingTop, paddingBottom) => ({ diff --git a/packages/benchmarks/perf/fixtures/sizes.stylex.js b/packages/benchmarks/perf/fixtures/sizes.stylex.js index eaed1df85..354a9f248 100644 --- a/packages/benchmarks/perf/fixtures/sizes.stylex.js +++ b/packages/benchmarks/perf/fixtures/sizes.stylex.js @@ -40,3 +40,36 @@ const SemanticSizes = { }; export const sizes = stylex.defineConsts(SemanticSizes); + +const SpacingTokens = { + border: { + radius: { + small: '0.25rem', + medium: '0.5rem', + large: '0.75rem', + xLarge: '1rem', + }, + }, + padding: { + tiny: '0.25rem', + small: '0.5rem', + medium: '1rem', + large: '1.5rem', + xLarge: '2rem', + }, + margin: { + tiny: '0.25rem', + small: '0.5rem', + medium: '1rem', + large: '1.5rem', + xLarge: '2rem', + }, + gap: { + tiny: '0.25rem', + small: '0.5rem', + medium: '1rem', + large: '1.5rem', + }, +}; + +export const spacing = stylex.defineConsts(SpacingTokens); diff --git a/packages/docs/docs/api/javascript/defineConsts.mdx b/packages/docs/docs/api/javascript/defineConsts.mdx index 812c7c0d7..dde3b8e87 100644 --- a/packages/docs/docs/api/javascript/defineConsts.mdx +++ b/packages/docs/docs/api/javascript/defineConsts.mdx @@ -45,7 +45,10 @@ export const zIndices = stylex.defineConsts({ export const animations = stylex.defineConsts({ easeInOut: 'cubic-bezier(0.4, 0, 0.2, 1)', - fast: '150ms', + speed: { + slow: '300ms', + fast: '150ms', + } }); ``` @@ -60,7 +63,7 @@ const styles = stylex.create({ position: 'relative', zIndex: zIndices.modal, transitionTimingFunction: animations.easeInOut, - transitionDuration: animations.fast, + transitionDuration: animations.speed.fast, color: { default: 'black', [breakpoints.small]: 'red', diff --git a/packages/typescript-tests/src/typetests.ts b/packages/typescript-tests/src/typetests.ts index cb0e9e43a..0b6a0c282 100644 --- a/packages/typescript-tests/src/typetests.ts +++ b/packages/typescript-tests/src/typetests.ts @@ -300,7 +300,11 @@ stylex.props(styles8.foo); const consts = stylex.defineConsts({ foo: 'bar', bar: 123, + baz: { + qux: 'quux', + }, } as const); consts.foo satisfies 'bar'; consts.bar satisfies 123; +consts.baz satisfies { qux: 'quux' };