diff --git a/flow-typed/npm/postcss-value-parser_vx.x.x.js b/flow-typed/npm/postcss-value-parser_vx.x.x.js new file mode 100644 index 0000000..449e386 --- /dev/null +++ b/flow-typed/npm/postcss-value-parser_vx.x.x.js @@ -0,0 +1,81 @@ +/** + * 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 + */ + +type PostCSSValueASTNode = + | { + type: 'word' | 'unicode-range', + value: string, + sourceIndex: number, + sourceEndIndex: number + } + | { + type: 'string' | 'comment', + value: string, + quote: '"' | "'", + sourceIndex: number, + sourceEndIndex: number, + unclosed?: boolean + } + | { + type: 'comment', + value: string, + sourceIndex: number, + sourceEndIndex: number, + unclosed?: boolean + } + | { + type: 'div', + value: ',' | '/' | ':', + sourceIndex: number, + sourceEndIndex: number, + before: '' | ' ' | ' ' | ' ', + after: '' | ' ' | ' ' | ' ' + } + | { + type: 'space', + value: ' ' | ' ' | ' ', + sourceIndex: number, + sourceEndIndex: number + } + | { + type: 'function', + value: string, + before: '' | ' ' | ' ' | ' ', + after: '' | ' ' | ' ' | ' ', + nodes: Array, + unclosed?: boolean, + sourceIndex: number, + sourceEndIndex: number + }; + +declare interface PostCSSValueAST { + nodes: Array; + walk( + callback: (PostCSSValueASTNode, number, PostCSSValueAST) => ?false, + bubble?: boolean + ): void; +} + +type PostCSSValueParser = { + (string): PostCSSValueAST, + unit(string): { number: string, unit: string } | false, + stringify( + nodes: PostCSSValueAST | PostCSSValueASTNode | PostCSSValueAST['nodes'], + custom?: (PostCSSValueASTNode) => string + ): string, + walk( + ast: PostCSSValueAST, + callback: (PostCSSValueASTNode, number, PostCSSValueAST) => ?false, + bubble?: boolean + ): void +}; + +declare module 'postcss-value-parser' { + declare module.exports: PostCSSValueParser; +} diff --git a/package-lock.json b/package-lock.json index ee02234..a602e35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19677,7 +19677,8 @@ "license": "MIT", "dependencies": { "@stylexjs/stylex": "0.5.1", - "css-mediaquery": "0.1.2" + "css-mediaquery": "0.1.2", + "postcss-value-parser": "^4.1.0" }, "devDependencies": { "@stylexjs/babel-plugin": "0.5.1" diff --git a/packages/react-strict-dom/package.json b/packages/react-strict-dom/package.json index 5412611..42fb4e3 100644 --- a/packages/react-strict-dom/package.json +++ b/packages/react-strict-dom/package.json @@ -18,7 +18,8 @@ }, "dependencies": { "@stylexjs/stylex": "0.5.1", - "css-mediaquery": "0.1.2" + "css-mediaquery": "0.1.2", + "postcss-value-parser": "^4.1.0" }, "devDependencies": { "@stylexjs/babel-plugin": "0.5.1" diff --git a/packages/react-strict-dom/src/native/stylex/CSSCustomPropertyValue.js b/packages/react-strict-dom/src/native/stylex/CSSCustomPropertyValue.js deleted file mode 100644 index 05a8c47..0000000 --- a/packages/react-strict-dom/src/native/stylex/CSSCustomPropertyValue.js +++ /dev/null @@ -1,53 +0,0 @@ -/** - * 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 - */ - -/** - * catch the css variable with an optional default parameter. breakdown: - * ^var\(-- start of sequence at start of input) - * ([\w-\+) capture group 1: the variable name - * * optional whitespace - * (?: start of non-capturing group, used to group | clauses - * \) (option1) end of sequence at end of input - * , *([^),]+) (option2) capture group 2: the default parameter - */ -const CUSTOM_PROPERTY_REGEX: RegExp = /^var\(--([\w-]+) *(?:\)|, *(.+)\))$/; - -function camelize(s: string): string { - return s.replace(/-./g, (x) => x.toUpperCase()[1]); -} - -/** - * Either create a custom property value or return null if the input is not a string - * containing a 'var(--name)' or 'var(--name, default)' sequence. - * - * Made this a single function to test and create to avoid parsing the RegExp twice. - */ -export function createCSSCustomPropertyValue( - value: string -): CSSCustomPropertyValue | null { - if (typeof value === 'string') { - const match = CUSTOM_PROPERTY_REGEX.exec(value); - if (match) { - return new CSSCustomPropertyValue(match[1], match[2]); - } - } - return null; -} - -/** - * Class representing a custom property value with an optional fallback. - */ -export class CSSCustomPropertyValue { - name: string; - defaultValue: mixed; - constructor(kebabCasePropName: string, fallback: mixed) { - this.name = camelize(kebabCasePropName); - this.defaultValue = fallback ?? null; - } -} diff --git a/packages/react-strict-dom/src/native/stylex/CSSMediaQuery.js b/packages/react-strict-dom/src/native/stylex/CSSMediaQuery.js index d8a9347..1e1709f 100644 --- a/packages/react-strict-dom/src/native/stylex/CSSMediaQuery.js +++ b/packages/react-strict-dom/src/native/stylex/CSSMediaQuery.js @@ -57,14 +57,14 @@ export class CSSMediaQuery { } query: string; - matchedStyle: { [string]: mixed }; + matchedStyle: { +[string]: mixed }; - constructor(query: string, matchedStyle: { [string]: mixed }) { + constructor(query: string, matchedStyle: { +[string]: mixed }) { this.query = query.replace(MQ_PREFIX, ''); this.matchedStyle = matchedStyle; } - resolve(matchObject: MatchObject): { [string]: mixed } { + resolve(matchObject: MatchObject): { +[string]: mixed } { const { width, height, direction } = matchObject; const matches = mediaQuery.match(this.query, { width, diff --git a/packages/react-strict-dom/src/native/stylex/customProperties.js b/packages/react-strict-dom/src/native/stylex/customProperties.js new file mode 100644 index 0000000..6682dce --- /dev/null +++ b/packages/react-strict-dom/src/native/stylex/customProperties.js @@ -0,0 +1,106 @@ +/** + * 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 { CSSUnparsedValue } from './typed-om/CSSUnparsedValue'; +import { CSSVariableReferenceValue } from './typed-om/CSSVariableReferenceValue'; + +export type MutableCustomProperties = { [string]: string | number }; +export type CustomProperties = $ReadOnly; + +function camelize(s: string): string { + return s.replace(/-./g, (x) => x.toUpperCase()[1]); +} + +function normalizeVariableName(name: string): string { + if (!name.startsWith('--')) { + throw new Error("Invalid variable name, must begin with '--'"); + } + return camelize(name.substring(2)); +} + +export function stringContainsVariables(input: string): boolean { + return input.includes('var('); +} + +function resolveVariableReferenceValue( + propName: string, + variable: CSSVariableReferenceValue, + propertyRegistry: CustomProperties +) { + const variableName = normalizeVariableName(variable.variable); + const fallbackValue = variable.fallback; + + let variableValue: string | number | null = propertyRegistry[variableName]; + + // Perform variable resolution on the variable's resolved value if it itself + // contains variables + if ( + typeof variableValue === 'string' && + stringContainsVariables(variableValue) + ) { + variableValue = resolveVariableReferences( + propName, + CSSUnparsedValue.parse(propName, variableValue), + propertyRegistry + ); + } + + if (variableValue != null) { + return variableValue; + } else if (fallbackValue != null) { + const resolvedFallback = resolveVariableReferences( + propName, + fallbackValue, + propertyRegistry + ); + if (resolvedFallback != null) { + return resolvedFallback; + } + } + + console.error( + `React Strict DOM: Unrecognized custom property "${variable.variable}"` + ); + return null; +} + +// Takes a CSSUnparsedValue and registry of variable values and resolves it down to a string +export function resolveVariableReferences( + propName: string, + propValue: CSSUnparsedValue, + propertyRegistry: CustomProperties +): string | number | null { + const result: Array = []; + for (const value of propValue.values()) { + if (value instanceof CSSVariableReferenceValue) { + const resolvedValue = resolveVariableReferenceValue( + propName, + value, + propertyRegistry + ); + if (resolvedValue == null) { + // Failure to resolve a css variable in a value means the entire value is unparsable so we bail out and + // resolve the entire value as null + return null; + } + result.push(resolvedValue); + } else { + result.push(value); + } + } + + // special case for signular number value + if (result.length === 1 && typeof result[0] === 'number') { + return result[0]; + } + + // consider empty string as a null value + const output = result.join('').trim(); + return output === '' ? null : output; +} diff --git a/packages/react-strict-dom/src/native/stylex/flattenStyle.js b/packages/react-strict-dom/src/native/stylex/flattenStyle.js index df04668..eb8fd06 100644 --- a/packages/react-strict-dom/src/native/stylex/flattenStyle.js +++ b/packages/react-strict-dom/src/native/stylex/flattenStyle.js @@ -8,7 +8,7 @@ */ type InlineStyle = { - [key: string]: mixed + +[key: string]: mixed }; type StylesArray<+T> = T | $ReadOnlyArray>; diff --git a/packages/react-strict-dom/src/native/stylex/index.js b/packages/react-strict-dom/src/native/stylex/index.js index faac3be..758cd87 100644 --- a/packages/react-strict-dom/src/native/stylex/index.js +++ b/packages/react-strict-dom/src/native/stylex/index.js @@ -10,11 +10,6 @@ import type { IStyleX } from '../../types/styles'; import type { SpreadOptions } from './SpreadOptions'; -import { - CSSCustomPropertyValue, - createCSSCustomPropertyValue -} from './CSSCustomPropertyValue'; - import { CSSLengthUnitValue } from './CSSLengthUnitValue'; import { CSSMediaQuery } from './CSSMediaQuery'; import { errorMsg, warnMsg } from '../../shared/errorMsg'; @@ -22,6 +17,15 @@ import { fixContentBox } from './fixContentBox'; import { flattenStyle } from './flattenStyle'; import { parseShadow } from './parseShadow'; import { parseTimeValue } from './parseTimeValue'; +import { + resolveVariableReferences, + stringContainsVariables +} from './customProperties'; +import { CSSUnparsedValue } from './typed-om/CSSUnparsedValue'; +import type { + CustomProperties, + MutableCustomProperties +} from './customProperties'; const stylePropertyAllowlistSet = new Set([ 'alignContent', @@ -210,120 +214,165 @@ function isReactNativeStyleValue(propValue: mixed): boolean { return true; } -function preprocessPropertyValue(propValue: mixed): mixed { - if (typeof propValue === 'string') { - const customPropValue = createCSSCustomPropertyValue(propValue); - if (customPropValue != null) { - return customPropValue; - } +function processStyle(style: S): S { + const result = { ...style }; - const maybeLengthUnitValue = CSSLengthUnitValue.parse(propValue); - if (maybeLengthUnitValue != null) { - return maybeLengthUnitValue[1] === 'px' - ? maybeLengthUnitValue[0] - : new CSSLengthUnitValue(...maybeLengthUnitValue); - } - } + const propNames = Object.keys(result); + for (let i = 0; i < propNames.length; i++) { + const propName = propNames[i]; + let styleValue = result[propName]; - return propValue; -} + // Polyfill unitless lineHeight + // React Native treats unitless as a 'px' value + // Web treats unitless as an 'em' value + if (propName === 'lineHeight') { + if ( + typeof styleValue === 'number' || + (typeof styleValue === 'string' && + CSSLengthUnitValue.parse(styleValue) == null) + ) { + styleValue = styleValue + 'em'; + } + } -function preprocessCreate(style: S): S { - const processedStyle: S = ({}: $FlowFixMe); - for (const propName in style) { - const styleValue = style[propName]; + // React Native shadows on iOS cannot polyfill box-shadow + if (propName === 'boxShadow' && typeof styleValue === 'string') { + warnMsg('"boxShadow" is not supported in React Native.'); + delete result.boxShadow; + continue; + } if ( CSSMediaQuery.isMediaQueryString(propName) && typeof styleValue === 'object' && styleValue != null ) { - // have to spread styleValue into a copied object to appease flow - const processedSubStyle = preprocessCreate({ ...styleValue }); - processedStyle[propName] = new CSSMediaQuery(propName, processedSubStyle); + const processedSubstyle = processStyle(styleValue); + result[propName] = new CSSMediaQuery(propName, processedSubstyle); continue; } - // React Native shadows on iOS cannot polyfill box-shadow - if (propName === 'boxShadow' && typeof styleValue === 'string') { - warnMsg('"boxShadow" is not supported in React Native.'); + if ( + typeof styleValue === 'object' && + styleValue != null && + Object.hasOwn(styleValue, 'default') + ) { + // TODO: customize processStyle to be able to override the canidate "prop name" + result[propName] = processStyle(styleValue); + continue; } - // React Native only supports non-standard text-shadow styles - else if (propName === 'textShadow' && typeof styleValue === 'string') { - const parsedShadow = parseShadow(styleValue); - if (parsedShadow.length > 1) { - warnMsg( - 'Multiple "textShadow" values are not supported in React Native.' - ); + + if (typeof styleValue === 'string') { + if (stringContainsVariables(styleValue)) { + result[propName] = CSSUnparsedValue.parse(propName, styleValue); + continue; } - const { offsetX, offsetY, blurRadius, color } = parsedShadow[0]; - processedStyle.textShadowColor = color; - processedStyle.textShadowOffset = { height: offsetY, width: offsetX }; - processedStyle.textShadowRadius = blurRadius; - } else { - processedStyle[propName] = styleValue; - } - } - // Process values that need to be resolved during render - for (const prop in processedStyle) { - let value = processedStyle[prop]; - // Polyfill unitless lineHeight - // React Native treats unitless as a 'px' value - // Web treats unitless as an 'em' value - if (prop === 'lineHeight') { - if ( - typeof value === 'number' || - (typeof value === 'string' && CSSLengthUnitValue.parse(value) == null) - ) { - value = value + 'em'; + // React Native only supports non-standard text-shadow styles + if (propName === 'textShadow') { + const parsedShadow = parseShadow(styleValue); + if (parsedShadow.length > 1) { + warnMsg( + 'Multiple "textShadow" values are not supported in React Native.' + ); + } + const { offsetX, offsetY, blurRadius, color } = parsedShadow[0]; + result.textShadowColor = color; + result.textShadowOffset = processStyle({ + height: offsetY, + width: offsetX + }); + result.textShadowRadius = blurRadius; + propNames.push('textShadowColor', 'textShadowRadius'); + delete result.textShadow; + continue; + } + + const maybeLengthUnitValue = CSSLengthUnitValue.parse(styleValue); + if (maybeLengthUnitValue != null) { + result[propName] = + maybeLengthUnitValue[1] === 'px' + ? maybeLengthUnitValue[0] + : new CSSLengthUnitValue(...maybeLengthUnitValue); + continue; } } - const processedStyleValue = preprocessPropertyValue(value); - processedStyle[prop] = processedStyleValue; + + result[propName] = styleValue; } - return processedStyle; + return result; } -/** - * Take a value which may be a CSS custom property or a length unit value and resolve it to a final value - * suitable for passing to React Native. - */ -function finalizeValue(unfinalizedValue: mixed, options: SpreadOptions): mixed { - let styleValue = unfinalizedValue; - - if ( - typeof styleValue === 'object' && - styleValue != null && - Object.hasOwn(styleValue, 'default') - ) { - if (options.hover === true && Object.hasOwn(styleValue, ':hover')) { - styleValue = preprocessPropertyValue(styleValue[':hover']); - } else { +function resolveStyle( + style: S, + options: SpreadOptions +): S { + const customProperties = options.customProperties || {}; + + const result: { [string]: mixed } = {}; + + const stylesToReprocess: { [string]: mixed } = {}; + + const propNames = Object.keys(style); + for (let i = 0; i < propNames.length; i++) { + const propName = propNames[i]; + const styleValue = style[propName]; + + // Resolve the stylex media variant value object syntax + if ( + typeof styleValue === 'object' && + styleValue != null && + Object.hasOwn(styleValue, 'default') + ) { + let variant = 'default'; + if (options.hover === true && Object.hasOwn(styleValue, ':hover')) { + variant = ':hover'; + } // TODO: resolve media queries - styleValue = preprocessPropertyValue(styleValue.default); + + stylesToReprocess[propName] = styleValue[variant]; + continue; + } + + // resolve custom property references + if (styleValue instanceof CSSUnparsedValue) { + const resolvedValue = resolveVariableReferences( + propName, + styleValue, + customProperties + ); + if (resolvedValue != null) { + stylesToReprocess[propName] = resolvedValue; + } + continue; } - } - // resolve custom property references - while (styleValue instanceof CSSCustomPropertyValue) { - const customProperties = options.customProperties || {}; - const resolvedValue = - customProperties[styleValue.name] ?? styleValue.defaultValue; - if (resolvedValue == null) { - errorMsg(`Unrecognized custom property "--${styleValue.name}"`); - return null; + // resolve length units + if (styleValue instanceof CSSLengthUnitValue) { + result[propName] = styleValue.resolvePixelValue(options); + continue; } - // preprocess the value again in case the custom property value is a reference to another var or a length type unit - styleValue = preprocessPropertyValue(resolvedValue); + + // resolve textShadowOffset nested object values + if ( + propName === 'textShadowOffset' && + typeof styleValue === 'object' && + styleValue != null + ) { + result[propName] = resolveStyle(styleValue, options); + } + + result[propName] = styleValue; } - // resolve length units - if (styleValue instanceof CSSLengthUnitValue) { - styleValue = styleValue.resolvePixelValue(options); + const propNamesToReprocess = Object.keys(stylesToReprocess); + if (propNamesToReprocess.length > 0) { + const processedStyles = processStyle(stylesToReprocess); + Object.assign(result, resolveStyle(processedStyles, options)); } - return styleValue; + + return (result: $FlowIssue); } /** @@ -341,10 +390,10 @@ function _create(styles: S): { if (typeof val === 'function') { result[styleName] = (...args: $FlowFixMe) => { const style = val(...args); - return preprocessCreate(style); + return processStyle(style); }; } else { - result[styleName] = preprocessCreate(styles[styleName]); + result[styleName] = processStyle(styles[styleName]); } } return result; @@ -400,29 +449,16 @@ export function props( } = options; const nativeProps: { [string]: $FlowFixMe } = {}; + flatStyle = CSSMediaQuery.resolveMediaQueries(flatStyle, { + width: viewportWidth, + height: viewportHeight, + direction: writingDirection ?? 'ltr' + }); + + flatStyle = resolveStyle(flatStyle, options); + for (const styleProp in flatStyle) { - let styleValue = flatStyle[styleProp]; - - // resolve media queries - if (styleValue instanceof CSSMediaQuery) { - const maybeExistingMediaQuery = flatStyle[styleProp]; - if (maybeExistingMediaQuery instanceof CSSMediaQuery) { - const s = flattenStyle([ - maybeExistingMediaQuery.matchedStyle, - styleValue.matchedStyle - ]); - if (s != null) { - maybeExistingMediaQuery.matchedStyle = s; - } - continue; - } - } - // resolve any outstanding custom properties or length unit values - styleValue = finalizeValue(styleValue, options); - if (styleValue == null) { - delete flatStyle[styleProp]; - continue; - } + const styleValue = flatStyle[styleProp]; // Filter out any unexpected style property names so RN doesn't crash but give // the developer a warning to let them know that there's a new prop we should either @@ -592,12 +628,6 @@ export function props( } if (flatStyle != null && Object.keys(flatStyle).length > 0) { - flatStyle = CSSMediaQuery.resolveMediaQueries(flatStyle, { - width: viewportWidth, - height: viewportHeight, - direction: writingDirection ?? 'ltr' - }); - // polyfill boxSizing:"content-box" const boxSizingValue = flatStyle.boxSizing; if (boxSizingValue === 'content-box') { @@ -655,11 +685,10 @@ export function props( return nativeProps; } -type CustomProperties = { [string]: string | number }; type Tokens = { [string]: string }; let count = 1; -export const __customProperties: CustomProperties = {}; +export const __customProperties: MutableCustomProperties = {}; export const defineVars = (tokens: CustomProperties): Tokens => { const result: Tokens = {}; @@ -679,7 +708,7 @@ export const createTheme = ( baseTokens: Tokens, overrides: CustomProperties ): CustomProperties => { - const result: CustomProperties = { $$theme: 'theme' }; + const result: MutableCustomProperties = { $$theme: 'theme' }; for (const key in baseTokens) { const varName: string = (baseTokens[key]: $FlowFixMe); const normalizedKey = varName.replace(/^var\(--(.*)\)$/, '$1'); diff --git a/packages/react-strict-dom/src/native/stylex/typed-om/CSSStyleValue.js b/packages/react-strict-dom/src/native/stylex/typed-om/CSSStyleValue.js new file mode 100644 index 0000000..11b8d12 --- /dev/null +++ b/packages/react-strict-dom/src/native/stylex/typed-om/CSSStyleValue.js @@ -0,0 +1,16 @@ +/** + * 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 + */ + +export class CSSStyleValue { + toString(): string { + throw new Error( + '[CSSStyleValue] toString() must be called by a subclass of CSSStyleValue' + ); + } +} diff --git a/packages/react-strict-dom/src/native/stylex/typed-om/CSSUnparsedValue.js b/packages/react-strict-dom/src/native/stylex/typed-om/CSSUnparsedValue.js new file mode 100644 index 0000000..842c15e --- /dev/null +++ b/packages/react-strict-dom/src/native/stylex/typed-om/CSSUnparsedValue.js @@ -0,0 +1,164 @@ +/** + * 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 valueParser from 'postcss-value-parser'; +import { CSSStyleValue } from './CSSStyleValue'; +import { CSSVariableReferenceValue } from './CSSVariableReferenceValue'; + +type CSSUnparsedSegment = string | CSSVariableReferenceValue; + +// Arbitrary recursive depth limit in order to exit/throw early +const MAX_RESOLVE_DEPTH = 50; + +function splitComponentValueListByComma( + input: PostCSSValueASTNode[] +): PostCSSValueASTNode[][] { + const output = []; + + let current = []; + for (const value of input) { + if (value.type === 'div' && value.value === ',') { + output.push(current); + current = []; + } else { + current.push(value); + } + } + + if (current.length > 0) { + output.push(current); + } + + return output; +} + +// https://drafts.css-houdini.org/css-typed-om-1/#cssunparsedvalue +export class CSSUnparsedValue extends CSSStyleValue { + static #resolveVariableName(input: PostCSSValueASTNode[]): string | null { + const cleanedInput = input.filter((i) => i.type === 'word'); + if (cleanedInput.length !== 1) { + return null; + } + return valueParser.stringify(cleanedInput[0]); + } + + static #resolveUnparsedValue( + input: PostCSSValueASTNode[], + depth: number = 0 + ): CSSUnparsedValue { + if (depth > MAX_RESOLVE_DEPTH) { + console.warn( + `Reached maximum recursion limit (${MAX_RESOLVE_DEPTH}) while resolving custom properties — please ensure you don't have a custom property reference cycle.` + ); + return new CSSUnparsedValue([]); + } + + const tokens: CSSUnparsedSegment[] = []; + + const appendString = (str: string) => { + if (tokens.length > 0) { + const lastToken = tokens.at(-1); + if (typeof lastToken === 'string') { + tokens[tokens.length - 1] = lastToken + str; + return; + } + } + tokens.push(str); + }; + + for (const currentValue of input) { + if (currentValue.type === 'function') { + if (currentValue.value === 'var') { + const args = splitComponentValueListByComma(currentValue.nodes); + const variableName = CSSUnparsedValue.#resolveVariableName(args[0]); + if (variableName == null) { + console.warn( + `Failed to resolve variable name from '${valueParser.stringify( + args[0] + )}'` + ); + return new CSSUnparsedValue([]); + } + + const fallbackValue = + args[1] != null + ? CSSUnparsedValue.#resolveUnparsedValue(args[1], depth + 1) + : undefined; + + try { + tokens.push( + new CSSVariableReferenceValue(variableName, fallbackValue) + ); + } catch (err) { + console.warn( + `Error creating CSSVariableReferenceValue: ${err.toString()}` + ); + return new CSSUnparsedValue([]); + } + } else { + // stringify the function manually but still attempt to resolve the args + appendString(`${currentValue.value}(`); + const functionArgs = CSSUnparsedValue.#resolveUnparsedValue( + currentValue.nodes, + depth + 1 + ); + for (const arg of functionArgs.values()) { + if (typeof arg === 'string') { + appendString(arg); + } else { + tokens.push(arg); + } + } + appendString(')'); + } + } else { + appendString(valueParser.stringify(currentValue)); + } + } + return new CSSUnparsedValue(tokens); + } + + // TODO: in the full spec this should take into account the property name + // to determine what the value should be parsed to but as we currently are only taking + // unparsed & variable references we can ignore it for now + static parse(_property: string, input: string): CSSUnparsedValue { + const componentValueList = valueParser(input).nodes; + return CSSUnparsedValue.#resolveUnparsedValue(componentValueList); + } + + #tokens: CSSUnparsedSegment[]; + + constructor(members: CSSUnparsedSegment[]) { + super(); + this.#tokens = members; + } + + get(index: number): CSSUnparsedSegment { + return this.#tokens[index]; + } + + set(index: number, value: CSSUnparsedSegment): void { + this.#tokens[index] = value; + } + + get size(): number { + return this.#tokens.length; + } + + values(): Iterator { + return this.#tokens.values(); + } + + toString(): string { + return this.#tokens + .map((t) => t.toString()) + .join('') + .trim(); + } +} diff --git a/packages/react-strict-dom/src/native/stylex/typed-om/CSSVariableReferenceValue.js b/packages/react-strict-dom/src/native/stylex/typed-om/CSSVariableReferenceValue.js new file mode 100644 index 0000000..762a005 --- /dev/null +++ b/packages/react-strict-dom/src/native/stylex/typed-om/CSSVariableReferenceValue.js @@ -0,0 +1,50 @@ +/** + * 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 { CSSUnparsedValue } from './CSSUnparsedValue'; + +import { CSSStyleValue } from './CSSStyleValue'; + +// https://drafts.css-houdini.org/css-typed-om-1/#cssvariablereferencevalue +export class CSSVariableReferenceValue extends CSSStyleValue { + // https://drafts.css-houdini.org/css-typed-om-1/#custom-property-name-string + static #validateVariableName(variable: string): void { + if (!variable.startsWith('--')) { + throw new TypeError(`Invalid custom property name: ${variable}`); + } + } + + #variable: string; + #fallback: CSSUnparsedValue | null; + + constructor(variable: string, fallback?: CSSUnparsedValue) { + CSSVariableReferenceValue.#validateVariableName(variable); + super(); + this.#variable = variable; + this.#fallback = fallback ?? null; + } + + get variable(): string { + return this.#variable; + } + + set variable(variable: string): void { + this.#variable = variable; + } + + get fallback(): CSSUnparsedValue | null { + return this.#fallback; + } + + toString(): string { + return `var(${this.#variable}${ + this.#fallback ? `, ${this.#fallback.toString()}` : '' + })`; + } +} diff --git a/packages/react-strict-dom/tests/CSSUnparsedValue-test.js b/packages/react-strict-dom/tests/CSSUnparsedValue-test.js new file mode 100644 index 0000000..c5b577a --- /dev/null +++ b/packages/react-strict-dom/tests/CSSUnparsedValue-test.js @@ -0,0 +1,74 @@ +/** + * 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 { CSSUnparsedValue } from '../src/native/stylex/typed-om/CSSUnparsedValue'; +import { CSSVariableReferenceValue } from '../src/native/stylex/typed-om/CSSVariableReferenceValue'; + +describe('CSSUnparsedValue', () => { + test('parses variable reference value containing spaces and embedded variables in the fallback', () => { + const underTest = + 'var(--boxShadowVarNotFound, 0px 0px 0px var(--testVar2))'; + const actual = CSSUnparsedValue.parse('box-shadow', underTest); + + expect(actual).toBeInstanceOf(CSSUnparsedValue); + expect(actual.size).toBe(1); + + const iterator = actual.values(); + + const { value: variableInstance } = iterator.next(); + expect(variableInstance).toBeInstanceOf(CSSVariableReferenceValue); + expect(variableInstance.variable).toBe('--boxShadowVarNotFound'); + + const fallback = variableInstance.fallback; + expect(fallback).toBeInstanceOf(CSSUnparsedValue); + expect(fallback.size).toBe(2); + + const fallbackIterator = fallback.values(); + + const { value: fallbackValue1 } = fallbackIterator.next(); + expect(fallbackValue1).toBe('0px 0px 0px '); + + const { value: fallbackValue2 } = fallbackIterator.next(); + expect(fallbackValue2).toBeInstanceOf(CSSVariableReferenceValue); + expect(fallbackValue2.variable).toBe('--testVar2'); + expect(fallbackValue2.fallback).toBeNull(); + + expect(fallbackIterator.next().done).toBe(true); + }); + + test('correctly parses a var with a complex fallback which itself contains a var', () => { + const underTest = 'var(--colorNotFound, rgb(255,255,var(--test)))'; + const actual = CSSUnparsedValue.parse('background-color', underTest); + + expect(actual).toBeInstanceOf(CSSUnparsedValue); + expect(actual.size).toBe(1); + + const iterator = actual.values(); + const { value: variableInstance } = iterator.next(); + expect(variableInstance).toBeInstanceOf(CSSVariableReferenceValue); + expect(variableInstance.variable).toBe('--colorNotFound'); + + const fallback = variableInstance.fallback; + expect(fallback).toBeInstanceOf(CSSUnparsedValue); + expect(fallback.size).toBe(3); + + const fallbackIterator = fallback.values(); + + const { value: fallbackValue1 } = fallbackIterator.next(); + expect(fallbackValue1).toBe('rgb(255,255,'); + + const { value: fallbackValue2 } = fallbackIterator.next(); + expect(fallbackValue2).toBeInstanceOf(CSSVariableReferenceValue); + expect(fallbackValue2.variable).toBe('--test'); + expect(fallbackValue2.fallback).toBeNull(); + + const { value: fallbackValue3 } = fallbackIterator.next(); + expect(fallbackValue3).toBe(')'); + + expect(fallbackIterator.next().done).toBe(true); + }); +}); diff --git a/packages/react-strict-dom/tests/css-test.native.js b/packages/react-strict-dom/tests/css-test.native.js index ef0676e..26b4a45 100644 --- a/packages/react-strict-dom/tests/css-test.native.js +++ b/packages/react-strict-dom/tests/css-test.native.js @@ -989,7 +989,7 @@ function resolveCustomPropertyValue( } }); return css.props.call({ ...mockOptions, customProperties }, styles.root) - .style[key]; + .style?.[key]; } describe('properties: custom property', () => { @@ -1066,10 +1066,10 @@ describe('properties: custom property', () => { 'color', 'var(--testUnfinished' ]) - ).toEqual('var(--testUnfinished'); + ).toEqual(undefined); expect( resolveCustomPropertyValue(customProperties, ['color', 'var(bad--input)']) - ).toEqual('var(bad--input)'); + ).toEqual(undefined); expect( resolveCustomPropertyValue(customProperties, ['color', '--testMulti']) ).toEqual('--testMulti'); @@ -1157,7 +1157,7 @@ describe('properties: custom property', () => { 'color', 'var(--colorNotFound, rgb( 1 , 1 , 1 ))' ]) - ).toEqual('rgb( 1 , 1 , 1 )'); + ).toEqual('rgb(1 , 1 , 1)'); }); test('parses a var and falls back to default value containing a var', () => { @@ -1171,13 +1171,15 @@ describe('properties: custom property', () => { }); test('parses a var and falls back to a default value containing spaces and embedded var', () => { - const customProperties = {}; + const customProperties = { + test: '255' + }; expect( resolveCustomPropertyValue(customProperties, [ 'color', 'var(--colorNotFound, rgb(255,255,var(--test))' ]) - ).toEqual('rgb(255,255,var(--test)'); + ).toEqual('rgb(255,255,255)'); }); test('basic var value lookup works', () => { @@ -1251,6 +1253,84 @@ describe('properties: custom property', () => { ) ).toMatchSnapshot(); }); + + test('rgb(a) function with args applied through a single var', () => { + const customProperties = { example: '24, 48, 96' }; + expect( + resolveCustomPropertyValue(customProperties, [ + 'color', + 'rgb(var(--example))' + ]) + ).toEqual('rgb(24, 48, 96)'); + expect( + resolveCustomPropertyValue(customProperties, [ + 'color', + 'rgba(var(--example), 0.5)' + ]) + ).toEqual('rgba(24, 48, 96, 0.5)'); + }); + + test('rgba function with args applied through multiple (& nested) vars', () => { + const customProperties = { + red: 255, + green: 96, + blue: 16, + rgb: 'var(--red), var(--green), var(--blue)', + alpha: 0.42 + }; + expect( + resolveCustomPropertyValue(customProperties, [ + 'color', + 'rgba(var(--rgb), var(--alpha))' + ]) + ).toEqual('rgba(255, 96, 16, 0.42)'); + }); + + test('text shadow with nested/multiple vars', () => { + const customProperties = { + height: '2px', + width: '1px', + size: 'var(--width) var(--height)', + radius: '3px', + red: 'red' + }; + const styles = css.create({ + test: { + textShadow: 'var(--size) var(--radius) var(--red)' + } + }); + expect( + css.props.call({ ...mockOptions, customProperties }, styles.test).style + ).toStrictEqual({ + textShadowColor: 'red', + textShadowOffset: { + height: 2, + width: 1 + }, + textShadowRadius: 3 + }); + }); + + test('css variable declaration inside a media query', () => { + const customProperties = { + example: '42px' + }; + const styles = css.create({ + test: { + '@media (min-width: 400px)': { + inlineSize: 'var(--example)' + } + } + }); + expect( + css.props.call( + { ...mockOptions, viewportWidth: 450, customProperties }, + styles.test + ).style + ).toStrictEqual({ + width: 42 + }); + }); }); /** @@ -1323,9 +1403,11 @@ describe('units: length', () => { expect.extend({ toMatchWindowDimensions(query, windowSize) { const { height, width } = windowSize; + const UNEXPECTED_MATCHED_VALUE = 420; const EXPECTED_MATCHED_VALUE = 500; const { underTest } = css.create({ underTest: { + width: UNEXPECTED_MATCHED_VALUE, [`@media ${query}`]: { width: EXPECTED_MATCHED_VALUE }