From ed7dc351751e68479d28b325cdc572b8df6c1539 Mon Sep 17 00:00:00 2001 From: Vincent Riemer <1398555+vincentriemer@users.noreply.github.com> Date: Fri, 8 Mar 2024 13:51:19 -0800 Subject: [PATCH] Revamp custom property reference resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apologies in advance for the long PR. > [!IMPORTANT] > The last section of this description has an RFC with some points that I'd like to resolve with y'alls input before landing ## Description This PR marks the beginning of an overhaul to how the StyleX React Native shim parses/ resolves values to make it more flexible and robust. Here I've laid the ground work of this overhaul and use it to replace the previous implementation of CSS variable resolution. I've taken a spec-informed approach (try to match the spec as much as it makes sense for our use case) to implementing a tokenizer, parser, and object model for CSS values. To do this I heavily referenced the specs for [CSS Syntax](https://drafts.csswg.org/css-syntax-3/) (defines the tokenization and parsing algorithms) and the [CSS Typed OM](https://drafts.css-houdini.org/css-typed-om-1/) (defines the object model). The `CSSTokenizer` and `CSSParser` are largely implementation details in this work and all parsing should be triggered at a higher level abstraction in the object model. As for the object model this first PR defines the `CSSStyleValue` base/abstract class (doesn't really do much itself) that gets implemented by `CSSUnparsedValue` and `CSSVariableReferenceValue` which are responsible for the parsing/resolution of custom property references. [The spec](https://drafts.css-houdini.org/css-typed-om-1/#reify-tokens) goes into a lot more detail but the long and short of how this works is that whenever you're parsing a value contains a `var(` token it should always result in a `CSSUnparsedValue` instance. A `CSSUnparsedValue` is effectively a list whose elements are either a `string` or a `CSSVariableReferenceValue`. Resolving a `CSSUnparsedValue` effectively becomes a case of replacing each instance of a `CSSVariableReferenceValue` with either a string representation of what the variable was pointing to or an empty string. Once a `CSSUnparsedValue` only contains string items you can just concatenate them all and then do all your other sort of transforms. In this PR I've only implemented the branch of the parsing logic for ones that contain variable references but in the future you'd just run the `parse` method again on the resulting string from a `CSSUnparsedValue` and it should result in a concrete type since all the `var(` tokens have been removed. All of the infrastructure introduced above is leveraged by the `resolveVariableReferences` method in `customProperties.js` which where we walk the object map to turn the `CSSUnparsedValue` into a resolved string. > [!NOTE] > I frankly didn't focus too much on how this performs and was instead focusing on correctness so there are probably low-hanging-opportunities to improve perf including moving some of this work to a compiler/build step much like StyleX does on the web. ## Test Plan A lot of this work thankfully already had test coverage so a large portion of the work was ensuring that the existing tests continue to pass — though I had to make *some* adjustments to the tests as some of the expectations were to my eyes incorrect. The majority of new tests were written for the `CSSTokenizer` which gets complete coverage over the basic cases. I added a couple tests for the `CSSParser` and `CSSUnparsedValue` largely as a mechanism to debug why other pre-existing tests were failing but once I figured it out I felt like it would be a waste to get rid of them. I lastly added a couple additional end-to-end-ish tests (in `css-test.native.js`) that cover some of the new use cases this PR enables. ## Request for Help/Comments 1) My changes (at least locally) seemed to have broken the flow libdef generation (the script still runs successfully but its output has flow errors) and I don't have even an inkling as to why so I'd really appreciate help debugging the issue. 2) We sort of had undefined behavior previously in regards to what we should do when we try to resolve a variable which isn't defined. Since we only handled values which solely contained a single variable we could just resolve it to `null` and let our property pruning logic handle ignoring the property in its entirety — but now when a variable could be embedded in a compound value those assumptions fall apart (e.g. a `rgb(255, 255, var(--unknown))` gets resolved to `rgb(255, 255, )`). What would y'all think about changing the approach to be: if there's a variable we can't resolve in a `CSSUnparsedValue`, we **don't** promote the `CSSUnparsedValue` to a string and then ignore any properties whose value are still a `CSSUnparsedValue`. --- flow-typed/npm/postcss-value-parser_vx.x.x.js | 81 +++++ package-lock.json | 3 +- packages/react-strict-dom/package.json | 3 +- .../native/stylex/CSSCustomPropertyValue.js | 53 ---- .../src/native/stylex/CSSMediaQuery.js | 6 +- .../src/native/stylex/customProperties.js | 106 +++++++ .../src/native/stylex/flattenStyleXStyles.js | 2 +- .../src/native/stylex/index.js | 277 ++++++++++-------- .../native/stylex/typed-om/CSSStyleValue.js | 16 + .../stylex/typed-om/CSSUnparsedValue.js | 164 +++++++++++ .../typed-om/CSSVariableReferenceValue.js | 50 ++++ .../tests/CSSUnparsedValue-test.js | 74 +++++ .../react-strict-dom/tests/css-test.native.js | 94 +++++- 13 files changed, 740 insertions(+), 189 deletions(-) create mode 100644 flow-typed/npm/postcss-value-parser_vx.x.x.js delete mode 100644 packages/react-strict-dom/src/native/stylex/CSSCustomPropertyValue.js create mode 100644 packages/react-strict-dom/src/native/stylex/customProperties.js create mode 100644 packages/react-strict-dom/src/native/stylex/typed-om/CSSStyleValue.js create mode 100644 packages/react-strict-dom/src/native/stylex/typed-om/CSSUnparsedValue.js create mode 100644 packages/react-strict-dom/src/native/stylex/typed-om/CSSVariableReferenceValue.js create mode 100644 packages/react-strict-dom/tests/CSSUnparsedValue-test.js 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 378cffd..f454ead 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19797,7 +19797,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 a803e0f..491932b 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/flattenStyleXStyles.js b/packages/react-strict-dom/src/native/stylex/flattenStyleXStyles.js index df04668..eb8fd06 100644 --- a/packages/react-strict-dom/src/native/stylex/flattenStyleXStyles.js +++ b/packages/react-strict-dom/src/native/stylex/flattenStyleXStyles.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 7c39471..c34a52a 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 './flattenStyleXStyles'; 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 = {} as $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 as $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] as $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 }