From 2739dbdabb62f3f304191fee022b80f35cba95bd Mon Sep 17 00:00:00 2001 From: skovhus Date: Thu, 11 Jun 2026 17:19:07 +0200 Subject: [PATCH 1/2] Improve arithmetic support for defineConsts --- .../__tests__/evaluation-import-test.js | 129 ++++++++++++++++++ .../__tests__/transform-process-test.js | 94 +++++++++++++ .../transform-stylex-defineVars-test.js | 14 ++ .../validation-stylex-createTheme-test.js | 10 +- .../validation-stylex-defineConsts-test.js | 8 +- .../validation-stylex-defineVars-test.js | 8 +- .../src/utils/__tests__/css-calc-test.js | 105 ++++++++++++++ .../babel-plugin/src/utils/css-calc.js | 77 +++++++++++ .../babel-plugin/src/utils/evaluate-path.js | 34 +++++ .../src/utils/evaluation-errors.js | 9 ++ .../src/visitors/stylex-create-theme.js | 35 ++--- .../src/visitors/stylex-define-consts.js | 6 +- .../src/visitors/stylex-define-vars.js | 6 +- .../@stylexjs/stylex/__tests__/inject-test.js | 36 +++++ 14 files changed, 536 insertions(+), 35 deletions(-) create mode 100644 packages/@stylexjs/babel-plugin/src/utils/__tests__/css-calc-test.js create mode 100644 packages/@stylexjs/babel-plugin/src/utils/css-calc.js diff --git a/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js b/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js index dc0fecb89..b1be88997 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js @@ -476,4 +476,133 @@ describe('Evaluation of imported values works based on configuration', () => { `); }); }); + + describe('Arithmetic on imported tokens compiles to calc()', () => { + const varName = (key) => + `var(--${options.classNamePrefix}${hash( + `otherFile.stylex.js//MyTheme.${key}`, + )})`; + + const transformStyle = (value) => + transform(` + import stylex from 'stylex'; + import { MyTheme } from 'otherFile.stylex'; + const styles = stylex.create({ + box: { + zIndex: ${value}, + } + }); + stylex(styles.box); + `).code; + + test('token + token', () => { + expect(transformStyle('MyTheme.a + MyTheme.b')).toContain( + `calc(${varName('a')} + ${varName('b')})`, + ); + }); + + test('token + number and number + token', () => { + expect(transformStyle('MyTheme.a + 4')).toContain( + `calc(${varName('a')} + 4)`, + ); + expect(transformStyle('4 + MyTheme.a')).toContain( + `calc(4 + ${varName('a')})`, + ); + }); + + test('token - number, token * number, token / number', () => { + expect(transformStyle('MyTheme.a - 1')).toContain( + `calc(${varName('a')} - 1)`, + ); + expect(transformStyle('MyTheme.a * 2')).toContain( + `calc(${varName('a')} * 2)`, + ); + expect(transformStyle('MyTheme.a / 2')).toContain( + `calc(${varName('a')} / 2)`, + ); + }); + + test('unary minus on a token', () => { + expect(transformStyle('-MyTheme.a')).toContain( + `calc(-1 * ${varName('a')})`, + ); + }); + + test('nested arithmetic flattens to parens', () => { + expect(transformStyle('(MyTheme.a + MyTheme.b) * 2')).toContain( + `calc((${varName('a')} + ${varName('b')}) * 2)`, + ); + }); + + test('token + unit string', () => { + expect(transformStyle("MyTheme.a + '10px'")).toContain( + `calc(${varName('a')} + 10px)`, + ); + }); + + test('string concatenation with non-numeric strings is preserved', () => { + const code = transform(` + import stylex from 'stylex'; + import { MyTheme } from 'otherFile.stylex'; + const styles = stylex.create({ + box: { + fontFamily: 'Arial, ' + MyTheme.font, + } + }); + stylex(styles.box); + `).code; + // The whitespace normalizer removes the space after the comma. + expect(code).toContain(`Arial,${varName('font')}`); + expect(code).not.toContain('calc'); + }); + + test('template literal interpolation is preserved', () => { + const code = transform(` + import stylex from 'stylex'; + import { MyTheme } from 'otherFile.stylex'; + const styles = stylex.create({ + box: { + width: \`\${MyTheme.a}px\`, + } + }); + stylex(styles.box); + `).code; + expect(code).toContain(`${varName('a')}px`); + expect(code).not.toContain('calc'); + }); + + test('unsupported operators throw a compile error', () => { + const unsupported = [ + 'MyTheme.a % 2', + 'MyTheme.a ** 2', + 'MyTheme.a & 1', + '~MyTheme.a', + '!MyTheme.a', + '+MyTheme.a', + ]; + for (const value of unsupported) { + expect(() => transformStyle(value)).toThrow( + /cannot be applied to a StyleX variable or constant/, + ); + } + }); + + test('comparisons on tokens throw a compile error', () => { + expect(() => transformStyle('MyTheme.a > MyTheme.b ? 1 : 2')).toThrow( + /cannot be applied to a StyleX variable or constant/, + ); + expect(() => transformStyle("MyTheme.a === 'red' ? 1 : 2")).toThrow( + /cannot be applied to a StyleX variable or constant/, + ); + }); + + test('arithmetic with a non-numeric operand throws a compile error', () => { + expect(() => transformStyle("MyTheme.a - 'foo'")).toThrow( + /requires the other operand/, + ); + expect(() => transformStyle("MyTheme.a * 'auto'")).toThrow( + /requires the other operand/, + ); + }); + }); }); diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js index 58c30ac1f..55d0e40ef 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-process-test.js @@ -1230,4 +1230,98 @@ describe('@stylexjs/babel-plugin', () => { `); }); }); + + describe('[transform] arithmetic on imported defineConsts (#1597)', () => { + function transformCrossFile(mainSource) { + const pluginOpts = { + unstable_moduleResolution: { type: 'haste' }, + }; + + const tokens = transformSync( + ` + import * as stylex from '@stylexjs/stylex'; + export const consts = stylex.defineConsts({ + A: 26, + B: 14, + D: 6, + gutter: '16px', + }); + export const vars = stylex.defineVars({ + gap: '8px', + }); + `, + { + filename: '/src/app/constants.stylex.js', + parserOpts: { flow: 'all' }, + babelrc: false, + plugins: [[stylexPlugin, pluginOpts]], + }, + ); + + const main = transformSync(mainSource, { + filename: '/src/app/main.js', + parserOpts: { flow: 'all' }, + babelrc: false, + plugins: [[stylexPlugin, pluginOpts]], + }); + + return [ + ...(tokens.metadata.stylex || []), + ...(main.metadata.stylex || []), + ]; + } + + test('numeric const arithmetic resolves to calc() with literal values', () => { + const metadata = transformCrossFile(` + import * as stylex from '@stylexjs/stylex'; + import { consts } from 'constants.stylex'; + export const styles = stylex.create({ + box: { + zIndex: consts.A + consts.B - consts.D, + opacity: consts.A / 4, + }, + }); + `); + + const css = stylexPlugin.processStylexRules(metadata, { + useLayers: false, + }); + expect(css).toContain('z-index:calc((26 + 14) - 6)'); + expect(css).toContain('opacity:calc(26 / 4)'); + }); + + test('unit const arithmetic stays as calc() with substituted values', () => { + const metadata = transformCrossFile(` + import * as stylex from '@stylexjs/stylex'; + import { consts } from 'constants.stylex'; + export const styles = stylex.create({ + box: { + paddingTop: consts.gutter * 2, + }, + }); + `); + + const css = stylexPlugin.processStylexRules(metadata, { + useLayers: false, + }); + expect(css).toContain('padding-top:calc(16px * 2)'); + }); + + test('mixed const and defineVars arithmetic keeps the var() in calc()', () => { + const metadata = transformCrossFile(` + import * as stylex from '@stylexjs/stylex'; + import { consts, vars } from 'constants.stylex'; + export const styles = stylex.create({ + box: { + marginTop: consts.A * vars.gap, + }, + }); + `); + + const css = stylexPlugin.processStylexRules(metadata, { + useLayers: false, + }); + expect(css).toMatch(/margin-top:calc\(26 \* var\(--[a-z0-9]+\)\)/); + }); + }); }); diff --git a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineVars-test.js b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineVars-test.js index 758c011c0..f953098f0 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineVars-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/transform-stylex-defineVars-test.js @@ -597,6 +597,20 @@ describe('@stylexjs/babel-plugin', () => { `); }); + test('arithmetic on same-group references compiles to calc()', () => { + const { metadata } = transform(` + import * as stylex from '@stylexjs/stylex'; + export const layout = stylex.defineVars({ + gap: '8px', + doubleGap: () => layout.gap * 2, + }); + `); + + expect(metadata.stylex[0][1].ltr).toContain( + '--x1gpkec6:calc(var(--x1kbodq4) * 2)', + ); + }); + test('same-group references can point to later keys', () => { const { code, metadata } = transform(` import * as stylex from '@stylexjs/stylex'; diff --git a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-createTheme-test.js b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-createTheme-test.js index ca02c5f93..c2ded2759 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-createTheme-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-createTheme-test.js @@ -66,7 +66,7 @@ describe('@stylexjs/babel-plugin', () => { import stylex from 'stylex'; const variables = stylex.createTheme(genStyles(), {}); `); - }).toThrow(messages.nonStaticValue('createTheme')); + }).toThrow('Referenced constant is not defined.'); expect(() => { transform(` @@ -82,7 +82,7 @@ describe('@stylexjs/babel-plugin', () => { import stylex from 'stylex'; const variables = stylex.createTheme({__varGroupHash__: 'x568ih9'}, genStyles()); `); - }).toThrow(messages.nonStaticValue('createTheme')); + }).toThrow('Referenced constant is not defined.'); expect(() => { transform(` @@ -102,7 +102,7 @@ describe('@stylexjs/babel-plugin', () => { {__varGroupHash__: 'x568ih9', labelColor: 'var(--labelColorHash)'}, {[labelColor]: 'red',}); `); - }).toThrow(messages.nonStaticValue('createTheme')); + }).toThrow('Referenced constant is not defined.'); }); /* Values */ @@ -139,7 +139,7 @@ describe('@stylexjs/babel-plugin', () => { {labelColor: labelColor,} ); `); - }).toThrow(messages.nonStaticValue('createTheme')); + }).toThrow('Referenced constant is not defined.'); expect(() => { transform(` @@ -149,7 +149,7 @@ describe('@stylexjs/babel-plugin', () => { {labelColor: labelColor(),} ); `); - }).toThrow(messages.nonStaticValue('createTheme')); + }).toThrow('Referenced constant is not defined.'); }); }); }); diff --git a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineConsts-test.js b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineConsts-test.js index 72204f3c7..0cf7ba886 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineConsts-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineConsts-test.js @@ -98,7 +98,7 @@ describe('@stylexjs/babel-plugin', () => { import * as stylex from '@stylexjs/stylex'; export const constants = stylex.defineConsts(genStyles()); `); - }).toThrow(messages.nonStaticValue('defineConsts')); + }).toThrow('Referenced constant is not defined.'); }); test('valid argument: object', () => { @@ -181,7 +181,7 @@ describe('@stylexjs/babel-plugin', () => { [labelColor]: 'red', }); `); - }).toThrow(messages.nonStaticValue('defineConsts')); + }).toThrow('Referenced constant is not defined.'); }); /* Values */ @@ -194,7 +194,7 @@ describe('@stylexjs/babel-plugin', () => { labelColor: labelColor, }); `); - }).toThrow(messages.nonStaticValue('defineConsts')); + }).toThrow('Referenced constant is not defined.'); expect(() => { transform(` @@ -203,7 +203,7 @@ describe('@stylexjs/babel-plugin', () => { labelColor: labelColor(), }); `); - }).toThrow(messages.nonStaticValue('defineConsts')); + }).toThrow('Referenced constant is not defined.'); }); test('valid value: number', () => { diff --git a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js index 9a33aeb9e..c9dc85441 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js @@ -91,7 +91,7 @@ describe('@stylexjs/babel-plugin', () => { import * as stylex from '@stylexjs/stylex'; export const vars = stylex.defineVars(genStyles()); `); - }).toThrow(messages.nonStaticValue('defineVars')); + }).toThrow('Referenced constant is not defined.'); }); test('valid argument: object', () => { @@ -163,7 +163,7 @@ describe('@stylexjs/babel-plugin', () => { [labelColor]: 'red', }); `); - }).toThrow(messages.nonStaticValue('defineVars')); + }).toThrow('Referenced constant is not defined.'); }); /* Values */ @@ -176,7 +176,7 @@ describe('@stylexjs/babel-plugin', () => { labelColor: labelColor, }); `); - }).toThrow(messages.nonStaticValue('defineVars')); + }).toThrow('Referenced constant is not defined.'); expect(() => { transform(` @@ -185,7 +185,7 @@ describe('@stylexjs/babel-plugin', () => { labelColor: labelColor(), }); `); - }).toThrow(messages.nonStaticValue('defineVars')); + }).toThrow('Referenced constant is not defined.'); }); test('valid value: number', () => { diff --git a/packages/@stylexjs/babel-plugin/src/utils/__tests__/css-calc-test.js b/packages/@stylexjs/babel-plugin/src/utils/__tests__/css-calc-test.js new file mode 100644 index 000000000..2fcbf6c43 --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/utils/__tests__/css-calc-test.js @@ -0,0 +1,105 @@ +/** + * 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 { + isCssVarOrCalc, + isCalcTerm, + buildBinaryCalc, + buildUnaryMinusCalc, +} from '../css-calc'; + +describe('isCssVarOrCalc', () => { + test('matches full-string var() references', () => { + expect(isCssVarOrCalc('var(--x568ih9)')).toBe(true); + expect(isCssVarOrCalc('var(--foreground-x568ih9)')).toBe(true); + }); + + test('matches balanced calc() expressions', () => { + expect(isCssVarOrCalc('calc(var(--a) + var(--b))')).toBe(true); + expect(isCssVarOrCalc('calc((1 + 2) * 3)')).toBe(true); + }); + + test('rejects partial or compound strings', () => { + expect(isCssVarOrCalc('var(--a) var(--b)')).toBe(false); + expect(isCssVarOrCalc('calc(1) + calc(2)')).toBe(false); + expect(isCssVarOrCalc('solid var(--a)')).toBe(false); + expect(isCssVarOrCalc('10px')).toBe(false); + expect(isCssVarOrCalc(10)).toBe(false); + expect(isCssVarOrCalc(null)).toBe(false); + }); +}); + +describe('isCalcTerm', () => { + test('accepts finite numbers', () => { + expect(isCalcTerm(10)).toBe(true); + expect(isCalcTerm(-0.5)).toBe(true); + expect(isCalcTerm(NaN)).toBe(false); + expect(isCalcTerm(Infinity)).toBe(false); + }); + + test('accepts var()/calc() strings', () => { + expect(isCalcTerm('var(--x568ih9)')).toBe(true); + expect(isCalcTerm('calc(var(--a) + 1)')).toBe(true); + }); + + test('accepts numeric strings with CSS units', () => { + expect(isCalcTerm('10px')).toBe(true); + expect(isCalcTerm('1.5rem')).toBe(true); + expect(isCalcTerm('50%')).toBe(true); + expect(isCalcTerm('-2em')).toBe(true); + expect(isCalcTerm('10')).toBe(true); + }); + + test('rejects non-numeric strings', () => { + expect(isCalcTerm('solid ')).toBe(false); + expect(isCalcTerm('auto')).toBe(false); + expect(isCalcTerm('px')).toBe(false); + expect(isCalcTerm('10px 20px')).toBe(false); + }); +}); + +describe('buildBinaryCalc', () => { + test('builds calc() from var operands', () => { + expect(buildBinaryCalc('var(--a)', '+', 'var(--b)')).toBe( + 'calc(var(--a) + var(--b))', + ); + }); + + test('builds calc() from mixed operands', () => { + expect(buildBinaryCalc('var(--a)', '*', 2)).toBe('calc(var(--a) * 2)'); + expect(buildBinaryCalc(4, '-', 'var(--a)')).toBe('calc(4 - var(--a))'); + expect(buildBinaryCalc('var(--a)', '+', '10px')).toBe( + 'calc(var(--a) + 10px)', + ); + }); + + test('rounds numeric operands to 4 decimals', () => { + expect(buildBinaryCalc(1 / 3, '*', 'var(--a)')).toBe( + 'calc(0.3333 * var(--a))', + ); + }); + + test('strips nested calc() operands down to parens', () => { + expect(buildBinaryCalc('calc(var(--a) + var(--b))', '*', 2)).toBe( + 'calc((var(--a) + var(--b)) * 2)', + ); + }); +}); + +describe('buildUnaryMinusCalc', () => { + test('negates a var reference', () => { + expect(buildUnaryMinusCalc('var(--a)')).toBe('calc(-1 * var(--a))'); + }); + + test('negates a calc expression', () => { + expect(buildUnaryMinusCalc('calc(var(--a) + 1)')).toBe( + 'calc(-1 * (var(--a) + 1))', + ); + }); +}); diff --git a/packages/@stylexjs/babel-plugin/src/utils/css-calc.js b/packages/@stylexjs/babel-plugin/src/utils/css-calc.js new file mode 100644 index 000000000..65928dcde --- /dev/null +++ b/packages/@stylexjs/babel-plugin/src/utils/css-calc.js @@ -0,0 +1,77 @@ +/** + * 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 + */ + +const CSS_VAR_PATTERN = /^var\(--[\w-]+\)$/; + +const CSS_DIMENSION_PATTERN = /^[-+]?(\d+(\.\d*)?|\.\d+)([a-zA-Z]{1,18}|%)?$/; + +function isBalancedCalc(value: string): boolean { + if (!value.startsWith('calc(') || !value.endsWith(')')) { + return false; + } + let depth = 0; + for (let i = 4; i < value.length; i++) { + const char = value[i]; + if (char === '(') { + depth++; + } else if (char === ')') { + depth--; + // The opening paren of `calc(` must close at the very last character, + // so strings like `calc(1) + calc(2)` are not a single calc term. + if (depth === 0) { + return i === value.length - 1; + } + } + } + return false; +} + +export function isCssVarOrCalc(value: mixed): boolean { + return ( + typeof value === 'string' && + (CSS_VAR_PATTERN.test(value) || isBalancedCalc(value)) + ); +} + +export function isCalcTerm(value: mixed): boolean { + if (typeof value === 'number') { + return Number.isFinite(value); + } + return ( + typeof value === 'string' && + (isCssVarOrCalc(value) || CSS_DIMENSION_PATTERN.test(value)) + ); +} + +function roundNumber(value: number): number { + return Math.round(value * 10000) / 10000; +} + +function calcOperandToString(value: number | string): string { + if (typeof value === 'number') { + return String(roundNumber(value)); + } + if (isBalancedCalc(value)) { + // Strip nested `calc(...)` down to plain parens to keep the output flat. + return `(${value.slice(5, -1)})`; + } + return value; +} + +export function buildBinaryCalc( + left: number | string, + operator: string, + right: number | string, +): string { + return `calc(${calcOperandToString(left)} ${operator} ${calcOperandToString(right)})`; +} + +export function buildUnaryMinusCalc(arg: number | string): string { + return `calc(-1 * ${calcOperandToString(arg)})`; +} diff --git a/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js b/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js index e17cf2ff8..3e331b572 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js +++ b/packages/@stylexjs/babel-plugin/src/utils/evaluate-path.js @@ -29,6 +29,12 @@ import * as t from '@babel/types'; import StateManager from './state-manager'; import { utils } from '../shared'; import * as errMsgs from './evaluation-errors'; +import { + isCssVarOrCalc, + isCalcTerm, + buildBinaryCalc, + buildUnaryMinusCalc, +} from './css-calc'; import fs from 'node:fs'; // This file contains Babels metainterpreter that can evaluate static code. @@ -707,6 +713,18 @@ function _evaluate(path: NodePath<>, state: State): any { const arg = evaluateCached(argument, state); if (!state.confident) return; + if (isCssVarOrCalc(arg)) { + if (path.node.operator === '-') { + return buildUnaryMinusCalc(arg); + } + if (['!', '+', '~'].includes(path.node.operator)) { + return deopt( + path, + state, + errMsgs.UNSUPPORTED_CSS_VAR_OPERATOR(path.node.operator), + ); + } + } switch (path.node.operator) { case '!': return !arg; @@ -892,6 +910,22 @@ function _evaluate(path: NodePath<>, state: State): any { const right = evaluateCached(path.get('right'), state); if (!state.confident) return; + if (isCssVarOrCalc(left) || isCssVarOrCalc(right)) { + const op = path.node.operator; + if (op === '+' || op === '-' || op === '*' || op === '/') { + if (isCalcTerm(left) && isCalcTerm(right)) { + return buildBinaryCalc(left, op, right); + } + // '+' with a non-numeric operand stays plain string concatenation, + // e.g. `'solid ' + colors.border`. + if (op !== '+') { + return deopt(path, state, errMsgs.INVALID_CALC_OPERAND(op)); + } + } else { + return deopt(path, state, errMsgs.UNSUPPORTED_CSS_VAR_OPERATOR(op)); + } + } + switch (path.node.operator) { case '-': return left - right; diff --git a/packages/@stylexjs/babel-plugin/src/utils/evaluation-errors.js b/packages/@stylexjs/babel-plugin/src/utils/evaluation-errors.js index b5040d97f..3c3e72fd2 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/evaluation-errors.js +++ b/packages/@stylexjs/babel-plugin/src/utils/evaluation-errors.js @@ -52,6 +52,15 @@ export const UNDEFINED_CONST = 'Referenced constant is not defined.'; export const UNSUPPORTED_OPERATOR = (op: string): string => `Unsupported operator: ${op}\n\n`; +export const UNSUPPORTED_CSS_VAR_OPERATOR = (op: string): string => + `The "${op}" operator cannot be applied to a StyleX variable or constant. +Only +, -, * and / are supported and compile to a CSS calc() expression.\n\n`; + +export const INVALID_CALC_OPERAND = (op: string): string => + `Arithmetic ("${op}") on a StyleX variable or constant requires the other operand +to be a number, a numeric string with a CSS unit (e.g. '10px'), or another +variable or constant, so it can compile to a CSS calc() expression.\n\n`; + export const OBJECT_METHOD = 'Unsupported object method.\n\n'; export const UNSUPPORTED_EXPRESSION = (type: string): string => diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-create-theme.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-create-theme.js index e6504eb0f..fd9b25542 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-create-theme.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-create-theme.js @@ -68,13 +68,15 @@ export default function transformStyleXCreateTheme( const firstArg = args[0]; const secondArg = args[1]; - const { confident: confident1, value: variables } = evaluate( - firstArg, - state, - ); + const { + confident: confident1, + value: variables, + reason: reason1, + deopt: deopt1, + } = evaluate(firstArg, state); if (!confident1) { - throw callExpressionPath.buildCodeFrameError( - messages.nonStaticValue('createTheme'), + throw (deopt1 ?? callExpressionPath).buildCodeFrameError( + reason1 ?? messages.nonStaticValue('createTheme'), SyntaxError, ); } @@ -143,17 +145,18 @@ export default function transformStyleXCreateTheme( state.applyStylexEnv(identifiers); - const { confident: confident2, value: overrides } = evaluate( - secondArg, - state, - { - identifiers, - memberExpressions, - }, - ); + const { + confident: confident2, + value: overrides, + reason: reason2, + deopt: deopt2, + } = evaluate(secondArg, state, { + identifiers, + memberExpressions, + }); if (!confident2) { - throw callExpressionPath.buildCodeFrameError( - messages.nonStaticValue('createTheme'), + throw (deopt2 ?? callExpressionPath).buildCodeFrameError( + reason2 ?? messages.nonStaticValue('createTheme'), SyntaxError, ); } diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-consts.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-consts.js index 84c2eea66..4f00b3f45 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-consts.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-consts.js @@ -58,14 +58,14 @@ export default function transformStyleXDefineConsts( }; state.applyStylexEnv(evaluatePathFnConfig.identifiers); - const { confident, value } = evaluate( + const { confident, value, reason, deopt } = evaluate( firstArg, state, evaluatePathFnConfig, ); if (!confident) { - throw callExpressionPath.buildCodeFrameError( - messages.nonStaticValue('defineConsts'), + throw (deopt ?? callExpressionPath).buildCodeFrameError( + reason ?? messages.nonStaticValue('defineConsts'), SyntaxError, ); } diff --git a/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-vars.js b/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-vars.js index 2fa630339..456349daf 100644 --- a/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-vars.js +++ b/packages/@stylexjs/babel-plugin/src/visitors/stylex-define-vars.js @@ -171,13 +171,13 @@ export default function transformStyleXDefineVars( }, }); - const { confident, value } = evaluate(firstArg, state, { + const { confident, value, reason, deopt } = evaluate(firstArg, state, { identifiers, memberExpressions, }); if (!confident) { - throw callExpressionPath.buildCodeFrameError( - messages.nonStaticValue('defineVars'), + throw (deopt ?? callExpressionPath).buildCodeFrameError( + reason ?? messages.nonStaticValue('defineVars'), SyntaxError, ); } diff --git a/packages/@stylexjs/stylex/__tests__/inject-test.js b/packages/@stylexjs/stylex/__tests__/inject-test.js index 618f4f94f..726f4dcac 100644 --- a/packages/@stylexjs/stylex/__tests__/inject-test.js +++ b/packages/@stylexjs/stylex/__tests__/inject-test.js @@ -106,6 +106,42 @@ describe('inject', () => { ); }); + test('substitutes constants inside calc() expressions', () => { + inject({ ltr: '', priority: 0, constKey: 'xcalca1', constVal: 26 }); + inject({ ltr: '', priority: 0, constKey: 'xcalcb2', constVal: 14 }); + + const cssText = + '.calc { z-index: calc(var(--xcalca1) + var(--xcalcb2)) }'; + const result = inject({ ltr: cssText, priority: 1000 }); + + expect(result).toMatchInlineSnapshot( + '".calc:not(#\\#){ z-index: calc(26 + 14) }"', + ); + }); + + test('substitutes unit constants inside calc() expressions', () => { + inject({ ltr: '', priority: 0, constKey: 'xcalcpx1', constVal: '16px' }); + + const cssText = '.calcpx { padding: calc(var(--xcalcpx1) * 2) }'; + const result = inject({ ltr: cssText, priority: 1000 }); + + expect(result).toMatchInlineSnapshot( + '".calcpx:not(#\\#){ padding: calc(16px * 2) }"', + ); + }); + + test('leaves unresolved var() inside calc() intact', () => { + inject({ ltr: '', priority: 0, constKey: 'xcalcc3', constVal: 26 }); + + const cssText = + '.calcvar { margin: calc(var(--xcalcc3) * var(--xunresolved)) }'; + const result = inject({ ltr: cssText, priority: 1000 }); + + expect(result).toMatchInlineSnapshot( + '".calcvar:not(#\\#){ margin: calc(26 * var(--xunresolved)) }"', + ); + }); + test('leaves unreferenced constants unchanged in CSS', () => { inject({ ltr: '', priority: 0, constKey: 'xunused', constVal: 'purple' }); From 48ee4262d85756a2769161aa350b7e363653a021 Mon Sep 17 00:00:00 2001 From: skovhus Date: Thu, 11 Jun 2026 19:44:36 +0200 Subject: [PATCH 2/2] Address review: stricter and safer token arithmetic semantics - Allow ==/!=/===/!== null and undefined guards on token refs (previously-correct defensive patterns keep compiling) - Add comparison-specific compile error message for other comparisons - Fail on jammed '+' concatenation (token + '10px') instead of emitting invalid CSS; separated list concatenation ('solid ' + token) still works - Reject calc() expressions in computed style keys - Throw token-misuse errors inside dynamic styles instead of silently degrading to runtime inline styles - Preserve evaluator deopt reasons through defineVars function values - Widen var() detection to cover unicode custom-property names - Validate calc dimension operands via postcss-value-parser + CSS unit whitelist (rejects bogus units like '10foo') - Share evaluationError helper across all evaluating visitors (keyframes, positionTry, viewTransitionClass, nested variants included) - Extract shared roundForCss from transform-value Co-Authored-By: Claude Fable 5 --- .../__tests__/evaluation-import-test.js | 83 +++++++++++++++-- .../validation-stylex-defineVars-test.js | 2 +- .../src/shared/utils/transform-value.js | 7 +- .../babel-plugin/src/utils/css-calc.js | 89 ++++++++++++++++--- .../babel-plugin/src/utils/evaluate-path.js | 86 +++++++++++++----- .../src/utils/evaluation-errors.js | 21 +++++ .../src/visitors/parse-stylex-create-arg.js | 38 +++++++- .../visitors/stylex-create-theme-nested.js | 37 +++++--- .../src/visitors/stylex-create-theme.js | 17 ++-- .../src/visitors/stylex-create.js | 9 +- .../visitors/stylex-define-consts-nested.js | 14 ++- .../src/visitors/stylex-define-consts.js | 9 +- .../src/visitors/stylex-define-vars-nested.js | 15 +++- .../src/visitors/stylex-define-vars.js | 19 ++-- .../src/visitors/stylex-keyframes.js | 9 +- .../src/visitors/stylex-position-try.js | 9 +- .../visitors/stylex-view-transition-class.js | 9 +- .../src/visitors/visitor-utils.js | 28 ++++-- 18 files changed, 408 insertions(+), 93 deletions(-) diff --git a/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js b/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js index b1be88997..85349fef1 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/evaluation-import-test.js @@ -534,10 +534,26 @@ describe('Evaluation of imported values works based on configuration', () => { ); }); - test('token + unit string', () => { - expect(transformStyle("MyTheme.a + '10px'")).toContain( - `calc(${varName('a')} + 10px)`, + test('token + jammed string throws instead of emitting broken CSS', () => { + // A unit-string operand is excluded from calc() addition on purpose: + // `token + '4px'` vs `token + ' 4px'` (list shorthand) must not + // silently mean different things. And since the concatenation + // 'var(--x)10px' is invalid CSS, it fails instead. + expect(() => transformStyle("MyTheme.a + '10px'")).toThrow( + /would\s+produce invalid CSS/, ); + expect(() => transformStyle("MyTheme.a + 'px'")).toThrow( + /would\s+produce invalid CSS/, + ); + expect(() => transformStyle("'10px' + MyTheme.a")).toThrow( + /would\s+produce invalid CSS/, + ); + }); + + test('token + separated string stays list concatenation', () => { + const code = transformStyle("MyTheme.a + ' 4px'"); + expect(code).not.toContain('calc'); + expect(code).toContain(`${varName('a')} 4px`); }); test('string concatenation with non-numeric strings is preserved', () => { @@ -589,13 +605,70 @@ describe('Evaluation of imported values works based on configuration', () => { test('comparisons on tokens throw a compile error', () => { expect(() => transformStyle('MyTheme.a > MyTheme.b ? 1 : 2')).toThrow( - /cannot be applied to a StyleX variable or constant/, + /cannot be compared with ">" at compile time/, ); expect(() => transformStyle("MyTheme.a === 'red' ? 1 : 2")).toThrow( - /cannot be applied to a StyleX variable or constant/, + /cannot be compared with "===" at compile time/, + ); + }); + + test('null and undefined guards on tokens still compile', () => { + expect(transformStyle('MyTheme.a != null ? 5 : 7')).toContain( + 'z-index:5', + ); + expect(transformStyle('MyTheme.a === undefined ? 5 : 7')).toContain( + 'z-index:7', + ); + expect(transformStyle('MyTheme.a ?? 7')).toContain( + `z-index:${varName('a')}`, ); }); + test('arithmetic in a computed style key throws a compile error', () => { + expect(() => + transform(` + import stylex from 'stylex'; + import { MyTheme } from 'otherFile.stylex'; + const styles = stylex.create({ + box: { + [MyTheme.a + MyTheme.b]: 'red', + } + }); + stylex(styles.box); + `), + ).toThrow(/cannot be used as a style property key/); + }); + + test('token misuse inside a dynamic style throws instead of degrading', () => { + expect(() => + transform(` + import stylex from 'stylex'; + import { MyTheme } from 'otherFile.stylex'; + const styles = stylex.create({ + box: (opacity) => ({ + opacity, + zIndex: MyTheme.a === 'big' ? 1 : 2, + }), + }); + stylex.props(styles.box(0.5)); + `), + ).toThrow(/cannot be compared with "===" at compile time/); + }); + + test('unicode custom property keys work with arithmetic', () => { + const { metadata } = transform(` + import stylex from 'stylex'; + import { MyTheme } from 'otherFile.stylex'; + const styles = stylex.create({ + box: { + zIndex: MyTheme['--größe'] * 2, + } + }); + stylex(styles.box); + `); + expect(metadata.stylex[0][1].ltr).toContain('calc(var(--größe) * 2)'); + }); + test('arithmetic with a non-numeric operand throws a compile error', () => { expect(() => transformStyle("MyTheme.a - 'foo'")).toThrow( /requires the other operand/, diff --git a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js index c9dc85441..4157d7260 100644 --- a/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js +++ b/packages/@stylexjs/babel-plugin/__tests__/validation-stylex-defineVars-test.js @@ -257,7 +257,7 @@ describe('@stylexjs/babel-plugin', () => { textMuted: () => getColor(colors.text), }); `); - }).toThrow(messages.nonStaticValue('defineVars')); + }).toThrow('Referenced constant is not defined.'); }); test('valid function value: returns stylex.types', () => { diff --git a/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js b/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js index d9340b265..2c50443ed 100644 --- a/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js +++ b/packages/@stylexjs/babel-plugin/src/shared/utils/transform-value.js @@ -14,6 +14,11 @@ import normalizeValue from './normalize-value'; /** * Convert a CSS value in JS to the final CSS string value */ +// Numbers are rendered into CSS with at most 4 decimal places. +export function roundForCss(value: number): number { + return Math.round(value * 10000) / 10000; +} + export default function transformValue( key: string, rawValue: string | number, @@ -21,7 +26,7 @@ export default function transformValue( ): string { const value = typeof rawValue === 'number' - ? String(Math.round(rawValue * 10000) / 10000) + getNumberSuffix(key) + ? String(roundForCss(rawValue)) + getNumberSuffix(key) : rawValue; if ( diff --git a/packages/@stylexjs/babel-plugin/src/utils/css-calc.js b/packages/@stylexjs/babel-plugin/src/utils/css-calc.js index 65928dcde..2b6f482cd 100644 --- a/packages/@stylexjs/babel-plugin/src/utils/css-calc.js +++ b/packages/@stylexjs/babel-plugin/src/utils/css-calc.js @@ -7,9 +7,64 @@ * @flow strict */ -const CSS_VAR_PATTERN = /^var\(--[\w-]+\)$/; +import parser from 'postcss-value-parser'; +import { roundForCss } from '../shared/utils/transform-value'; -const CSS_DIMENSION_PATTERN = /^[-+]?(\d+(\.\d*)?|\.\d+)([a-zA-Z]{1,18}|%)?$/; +// Custom property names may contain any character except ')' — including +// unicode identifiers, which `defineVars`/`defineConsts` pass through +// verbatim for keys that start with '--'. +const CSS_VAR_PATTERN = /^var\(--[^)]+\)$/; + +const CSS_UNITS: Set = new Set([ + // + 'px', + 'em', + 'rem', + 'ex', + 'ch', + 'cap', + 'ic', + 'lh', + 'rlh', + 'vw', + 'vh', + 'vmin', + 'vmax', + 'vb', + 'vi', + 'svw', + 'svh', + 'lvw', + 'lvh', + 'dvw', + 'dvh', + 'cm', + 'mm', + 'q', + 'in', + 'pt', + 'pc', + // + 'deg', + 'grad', + 'rad', + 'turn', + //