From 2252b7cefb100033ac03044bfe47afb68eafe7a5 Mon Sep 17 00:00:00 2001 From: Mazin Zakaria Date: Thu, 19 Mar 2026 21:30:46 -0700 Subject: [PATCH] Adds initial support for calc --- .../src/native/css/CSSCalcValue.js | 350 ++++++++++++++++++ .../native/css/__tests__/CSSCalcValue-test.js | 296 +++++++++++++++ .../react-strict-dom/src/native/css/index.js | 15 + .../src/native/css/parseTransform.js | 46 ++- .../src/native/css/processStyle.js | 16 +- .../src/native/modules/useStyleTransition.js | 119 ++++-- .../css-create-test.native.js.snap | 30 +- .../tests/css/css-create-test.native.js | 5 +- 8 files changed, 826 insertions(+), 51 deletions(-) create mode 100644 packages/react-strict-dom/src/native/css/CSSCalcValue.js create mode 100644 packages/react-strict-dom/src/native/css/__tests__/CSSCalcValue-test.js diff --git a/packages/react-strict-dom/src/native/css/CSSCalcValue.js b/packages/react-strict-dom/src/native/css/CSSCalcValue.js new file mode 100644 index 00000000..0158921c --- /dev/null +++ b/packages/react-strict-dom/src/native/css/CSSCalcValue.js @@ -0,0 +1,350 @@ +/** + * 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 { CSSLengthUnitValue } from './CSSLengthUnitValue'; + +import valueParser from 'postcss-value-parser'; + +type CalcLeafToken = + | { type: 'literal', value: number } + | { type: 'length', value: CSSLengthUnitValue } + | { type: 'percentage', value: number }; + +type CalcOpToken = { type: 'op', value: '+' | '-' | '*' | '/' }; + +type CalcGroupToken = { type: 'group', tokens: Array }; + +type CalcToken = CalcLeafToken | CalcOpToken | CalcGroupToken; + +type CalcASTNode = + | CalcLeafToken + | { + type: 'binary', + op: '+' | '-' | '*' | '/', + left: CalcASTNode, + right: CalcASTNode + }; + +type DualValue = { percent: number, offset: number }; + +type ResolvePixelValueOptions = $ReadOnly<{ + fontScale?: number | void, + inheritedFontSize?: ?number, + viewportHeight?: number, + viewportScale?: number, + viewportWidth?: number +}>; + +export type CalcResult = + | number + | { __rsdCalc: true, percent: number, offset: number }; + +const memoizedCalcValues = new Map(); + +export class CSSCalcValue { + ast: CalcASTNode; + + static parse(input: string): CSSCalcValue | null { + const memoizedValue = memoizedCalcValues.get(input); + if (memoizedValue !== undefined) { + return memoizedValue; + } + try { + const parsed = valueParser(input); + const calcNode = parsed.nodes.find( + (n) => n.type === 'function' && n.value === 'calc' + ); + if (calcNode == null || !Array.isArray(calcNode.nodes)) { + memoizedCalcValues.set(input, null); + return null; + } + const tokens = CSSCalcValue._tokenize(calcNode.nodes); + if (tokens.length === 0) { + memoizedCalcValues.set(input, null); + return null; + } + const ast = CSSCalcValue._parseExpression(tokens, { pos: 0 }); + if (ast == null) { + memoizedCalcValues.set(input, null); + return null; + } + const instance = new CSSCalcValue(ast); + memoizedCalcValues.set(input, instance); + return instance; + } catch { + memoizedCalcValues.set(input, null); + return null; + } + } + + static _tokenize(nodes: $ReadOnlyArray<{ ... }>): Array { + const tokens: Array = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node.type === 'space') { + continue; + } + if (node.type === 'function') { + // Nested calc() or grouping parens (empty value) + if ( + (node.value === 'calc' || node.value === '') && + Array.isArray(node.nodes) + ) { + const innerTokens = CSSCalcValue._tokenize(node.nodes); + tokens.push({ type: 'group', tokens: innerTokens }); + } else { + return []; // unsupported function + } + continue; + } + if (node.type === 'word') { + const val: string = node.value; + // Pure operator + if (val === '*' || val === '/') { + tokens.push({ type: 'op', value: val }); + continue; + } + if (val === '+' || val === '-') { + tokens.push({ type: 'op', value: val }); + continue; + } + // Check if value starts with +/- and previous token was an operand + // This handles cases like "100vh" followed by "-64px" (space-separated) + if ( + (val.startsWith('+') || val.startsWith('-')) && + val.length > 1 && + tokens.length > 0 + ) { + const lastToken = tokens[tokens.length - 1]; + if (lastToken.type !== 'op') { + // Split into operator + value + tokens.push({ type: 'op', value: (val[0]: $FlowFixMe) }); + const rest = val.slice(1); + const leaf = CSSCalcValue._parseLeaf(rest); + if (leaf == null) { + return []; + } + tokens.push(leaf); + continue; + } + } + // Parse as leaf value + const leaf = CSSCalcValue._parseLeaf(val); + if (leaf == null) { + return []; + } + tokens.push(leaf); + continue; + } + } + return tokens; + } + + static _parseLeaf(val: string): CalcLeafToken | null { + // Percentage + if (val.endsWith('%')) { + const num = parseFloat(val.slice(0, -1)); + if (isNaN(num)) { + return null; + } + return { type: 'percentage', value: num }; + } + // Try CSSLengthUnitValue + const lengthVal = CSSLengthUnitValue.parse(val); + if (lengthVal != null) { + return { type: 'length', value: lengthVal }; + } + // Unitless number + const num = parseFloat(val); + if (!isNaN(num) && String(num) === val) { + return { type: 'literal', value: num }; + } + // Could be integer like "2" parsed differently + const numAlt = Number(val); + if (!isNaN(numAlt) && isFinite(numAlt)) { + return { type: 'literal', value: numAlt }; + } + return null; + } + + // Recursive descent parser: expression = term (('+' | '-') term)* + static _parseExpression( + tokens: $ReadOnlyArray, + state: { pos: number } + ): CalcASTNode | null { + let left = CSSCalcValue._parseTerm(tokens, state); + if (left == null) { + return null; + } + while (state.pos < tokens.length) { + const token = tokens[state.pos]; + if ( + token.type === 'op' && + (token.value === '+' || token.value === '-') + ) { + state.pos++; + const right = CSSCalcValue._parseTerm(tokens, state); + if (right == null) { + return null; + } + left = { type: 'binary', op: token.value, left, right }; + } else { + break; + } + } + return left; + } + + // term = primary (('*' | '/') primary)* + static _parseTerm( + tokens: $ReadOnlyArray, + state: { pos: number } + ): CalcASTNode | null { + let left = CSSCalcValue._parsePrimary(tokens, state); + if (left == null) { + return null; + } + while (state.pos < tokens.length) { + const token = tokens[state.pos]; + if ( + token.type === 'op' && + (token.value === '*' || token.value === '/') + ) { + state.pos++; + const right = CSSCalcValue._parsePrimary(tokens, state); + if (right == null) { + return null; + } + left = { type: 'binary', op: token.value, left, right }; + } else { + break; + } + } + return left; + } + + // primary = group | leaf + static _parsePrimary( + tokens: $ReadOnlyArray, + state: { pos: number } + ): CalcASTNode | null { + if (state.pos >= tokens.length) { + return null; + } + const token = tokens[state.pos]; + if (token.type === 'group') { + state.pos++; + const innerState = { pos: 0 }; + const result = CSSCalcValue._parseExpression(token.tokens, innerState); + return result; + } + if ( + token.type === 'literal' || + token.type === 'length' || + token.type === 'percentage' + ) { + state.pos++; + return token; + } + return null; + } + + constructor(ast: CalcASTNode) { + this.ast = ast; + } + + resolvePixelValue( + options: ResolvePixelValueOptions, + propertyName: string + ): CalcResult { + const result = CSSCalcValue._evaluateDual(this.ast, options, propertyName); + if (result.percent === 0) { + return result.offset; + } + // Has percentage component: return structured object for native + // post-layout resolution (percent resolved by Yoga, offset applied after) + return { __rsdCalc: true, percent: result.percent, offset: result.offset }; + } + + // Evaluates to {percent, offset} pair. Percentage stays symbolic; + // non-percentage parts (px, vh, rem, etc.) resolve to offset. + static _evaluateDual( + node: CalcASTNode, + options: ResolvePixelValueOptions, + propertyName: string + ): DualValue { + if (node.type === 'literal') { + return { percent: 0, offset: node.value }; + } + if (node.type === 'length') { + return { + percent: 0, + offset: node.value.resolvePixelValue((options: $FlowFixMe)) + }; + } + if (node.type === 'percentage') { + return { percent: node.value, offset: 0 }; + } + if (node.type === 'binary') { + const left = CSSCalcValue._evaluateDual( + node.left, + options, + propertyName + ); + const right = CSSCalcValue._evaluateDual( + node.right, + options, + propertyName + ); + switch (node.op) { + case '+': + return { + percent: left.percent + right.percent, + offset: left.offset + right.offset + }; + case '-': + return { + percent: left.percent - right.percent, + offset: left.offset - right.offset + }; + case '*': + // Multiplication: one side must be unitless (no percentage) + if (left.percent === 0 && right.percent === 0) { + return { percent: 0, offset: left.offset * right.offset }; + } + if (left.percent === 0) { + return { + percent: right.percent * left.offset, + offset: right.offset * left.offset + }; + } + if (right.percent === 0) { + return { + percent: left.percent * right.offset, + offset: left.offset * right.offset + }; + } + // percent * percent is invalid CSS + return { percent: 0, offset: 0 }; + case '/': + // Divisor must be unitless + if (right.percent !== 0 || right.offset === 0) { + return { percent: 0, offset: 0 }; + } + return { + percent: left.percent / right.offset, + offset: left.offset / right.offset + }; + default: + return { percent: 0, offset: 0 }; + } + } + return { percent: 0, offset: 0 }; + } +} diff --git a/packages/react-strict-dom/src/native/css/__tests__/CSSCalcValue-test.js b/packages/react-strict-dom/src/native/css/__tests__/CSSCalcValue-test.js new file mode 100644 index 00000000..58d15ddf --- /dev/null +++ b/packages/react-strict-dom/src/native/css/__tests__/CSSCalcValue-test.js @@ -0,0 +1,296 @@ +/** + * 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 { CSSCalcValue } from '../CSSCalcValue'; + +describe('CSSCalcValue parsing', () => { + test('simple subtraction: calc(100px - 64px)', () => { + const calc = CSSCalcValue.parse('calc(100px - 64px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(36); + }); + + test('simple multiplication: calc(2 * 16px)', () => { + const calc = CSSCalcValue.parse('calc(2 * 16px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(32); + }); + + test('simple division: calc(100px / 2)', () => { + const calc = CSSCalcValue.parse('calc(100px / 2)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(50); + }); + + test('single value: calc(64px)', () => { + const calc = CSSCalcValue.parse('calc(64px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(64); + }); + + test('negative result: calc(0px - 64px)', () => { + const calc = CSSCalcValue.parse('calc(0px - 64px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(-64); + }); + + test('division by zero returns 0', () => { + const calc = CSSCalcValue.parse('calc(100px / 0)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(0); + }); + + test('addition: calc(10px + 20px)', () => { + const calc = CSSCalcValue.parse('calc(10px + 20px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(30); + }); +}); + +describe('CSSCalcValue with mixed units', () => { + test('calc(100vh - 64px) with viewportHeight=812', () => { + const calc = CSSCalcValue.parse('calc(100vh - 64px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'height' + ); + expect(result).toBe(748); + }); + + test('calc(50vw + 50vw) with viewportWidth=375', () => { + const calc = CSSCalcValue.parse('calc(50vw + 50vw)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(375); + }); + + test('calc(1rem + 8px) with fontScale=1', () => { + const calc = CSSCalcValue.parse('calc(1rem + 8px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + // 1rem = 16px (fontScale * 16 * 1), so 16 + 8 = 24 + expect(result).toBe(24); + }); + + test('calc(2em + 4px) with inheritedFontSize=16', () => { + const calc = CSSCalcValue.parse('calc(2em + 4px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { + fontScale: 1, + viewportHeight: 812, + viewportWidth: 375, + inheritedFontSize: 16 + }, + 'width' + ); + // 2em = 32px (inheritedFontSize * 2), so 32 + 4 = 36 + expect(result).toBe(36); + }); +}); + +describe('CSSCalcValue operator precedence', () => { + test('calc(50px - 2 * 16px) should be 50 - 32 = 18', () => { + const calc = CSSCalcValue.parse('calc(50px - 2 * 16px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(18); + }); + + test('nested calc: calc(calc(100px - 64px) / 2) should be 18', () => { + const calc = CSSCalcValue.parse('calc(calc(100px - 64px) / 2)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(18); + }); + + test('nested parens: calc((100px - 64px) / 2) should be 18', () => { + const calc = CSSCalcValue.parse('calc((100px - 64px) / 2)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toBe(18); + }); +}); + +describe('CSSCalcValue invalid inputs', () => { + test('invalid calc expression returns null', () => { + const calc = CSSCalcValue.parse('calc(red + blue)'); + expect(calc).toBeNull(); + }); + + test('non-calc expression returns null', () => { + const calc = CSSCalcValue.parse('100px'); + expect(calc).toBeNull(); + }); +}); + +describe('CSSCalcValue memoization', () => { + test('same calc expression returns consistent results', () => { + const calc1 = CSSCalcValue.parse('calc(100px - 64px)'); + const calc2 = CSSCalcValue.parse('calc(100px - 64px)'); + expect(calc1).toBe(calc2); + const opts = { viewportHeight: 812, viewportWidth: 375, fontScale: 1 }; + expect(calc1?.resolvePixelValue(opts, 'width')).toBe(36); + expect(calc2?.resolvePixelValue(opts, 'width')).toBe(36); + }); +}); + +describe('CSSCalcValue with viewportScale', () => { + test('calc(100px - 64px) with viewportScale=2', () => { + const calc = CSSCalcValue.parse('calc(100px - 64px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { + fontScale: 1, + viewportHeight: 812, + viewportWidth: 375, + viewportScale: 2 + }, + 'width' + ); + // 100*2 - 64*2 = 200 - 128 = 72 + expect(result).toBe(72); + }); +}); + +describe('CSSCalcValue with percentages', () => { + test('calc(50% - 20px) produces __rsdCalc object', () => { + const calc = CSSCalcValue.parse('calc(50% - 20px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 50, offset: -20 }); + }); + + test('calc(100% - 64px) produces correct percent and offset', () => { + const calc = CSSCalcValue.parse('calc(100% - 64px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'height' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 100, offset: -64 }); + }); + + test('calc(50% + 10px) produces positive offset', () => { + const calc = CSSCalcValue.parse('calc(50% + 10px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 50, offset: 10 }); + }); + + test('calc(2 * 50%) produces doubled percentage', () => { + const calc = CSSCalcValue.parse('calc(2 * 50%)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 100, offset: 0 }); + }); + + test('calc(50% - 2 * 16px) produces percent with evaluated offset', () => { + const calc = CSSCalcValue.parse('calc(50% - 2 * 16px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + // 2 * 16px = 32px offset + expect(result).toEqual({ __rsdCalc: true, percent: 50, offset: -32 }); + }); + + test('calc(50% - 100vh) mixes percentage with viewport units', () => { + const calc = CSSCalcValue.parse('calc(50% - 100vh)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'height' + ); + // 100vh = 812px resolved at JS time + expect(result).toEqual({ __rsdCalc: true, percent: 50, offset: -812 }); + }); + + test('pure percentage calc(50%) still produces __rsdCalc', () => { + const calc = CSSCalcValue.parse('calc(50%)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 50, offset: 0 }); + }); + + test('calc(50% / 2) divides percentage', () => { + const calc = CSSCalcValue.parse('calc(50% / 2)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 25, offset: 0 }); + }); + + test('calc(50% - 10px - 10px - 10px) chains multiple subtractions', () => { + const calc = CSSCalcValue.parse('calc(50% - 10px - 10px - 10px)'); + expect(calc).not.toBeNull(); + const result = calc?.resolvePixelValue( + { fontScale: 1, viewportHeight: 812, viewportWidth: 375 }, + 'width' + ); + expect(result).toEqual({ __rsdCalc: true, percent: 50, offset: -30 }); + }); +}); diff --git a/packages/react-strict-dom/src/native/css/index.js b/packages/react-strict-dom/src/native/css/index.js index 1d12d559..e29f4469 100644 --- a/packages/react-strict-dom/src/native/css/index.js +++ b/packages/react-strict-dom/src/native/css/index.js @@ -13,6 +13,7 @@ import type { CustomProperties } from '../../types/styles'; import type { MutableCustomProperties } from '../../types/styles'; import type { IStyleX } from '../../types/styles'; +import { CSSCalcValue } from './CSSCalcValue'; import { CSSLengthUnitValue } from './CSSLengthUnitValue'; import { CSSTransformValue } from './CSSTransformValue'; import { CSSUnparsedValue } from './typed-om/CSSUnparsedValue'; @@ -209,6 +210,20 @@ function resolveStyle( } } + if (styleValue instanceof CSSCalcValue) { + result[propName] = styleValue.resolvePixelValue( + { + fontScale, + inheritedFontSize, + viewportHeight, + viewportScale, + viewportWidth + }, + propName + ); + continue; + } + if (styleValue instanceof CSSTransformValue) { result[propName] = styleValue.resolveTransformValue(viewportScale); continue; diff --git a/packages/react-strict-dom/src/native/css/parseTransform.js b/packages/react-strict-dom/src/native/css/parseTransform.js index aca32836..2a6415d8 100644 --- a/packages/react-strict-dom/src/native/css/parseTransform.js +++ b/packages/react-strict-dom/src/native/css/parseTransform.js @@ -9,6 +9,7 @@ import type { ReactNativeTransform } from '../../types/renderer.native'; +import { CSSCalcValue } from './CSSCalcValue'; import { CSSTransformValue } from './CSSTransformValue'; const transformRegex1 = @@ -18,12 +19,55 @@ const transformRegex3 = /matrix\((.*)\)/; const memoizedValues = new Map(); +// Pre-evaluate calc() expressions inside a transform string, replacing each +// calc(...) with its computed numeric value so the regex parser can handle it. +function resolveCalcInTransform(input: string): string { + if (!input.includes('calc(')) { + return input; + } + let result = ''; + let i = 0; + while (i < input.length) { + const calcIdx = input.indexOf('calc(', i); + if (calcIdx === -1) { + result += input.slice(i); + break; + } + result += input.slice(i, calcIdx); + // Find the matching closing paren for calc( + let depth = 0; + let j = calcIdx + 4; // position of '(' + for (; j < input.length; j++) { + if (input[j] === '(') { + depth++; + } else if (input[j] === ')') { + depth--; + if (depth === 0) { + break; + } + } + } + const calcStr = input.slice(calcIdx, j + 1); + const calcValue = CSSCalcValue.parse(calcStr); + if (calcValue != null) { + const evaluated = calcValue.resolvePixelValue({ fontScale: 1 }, 'width'); + result += typeof evaluated === 'number' ? String(evaluated) : '0'; + } else { + result += '0'; + } + i = j + 1; + } + return result; +} + export function parseTransform(transform: string): CSSTransformValue { const memoizedValue = memoizedValues.get(transform); if (memoizedValue != null) { return memoizedValue; } + const originalTransform = transform; + transform = resolveCalcInTransform(transform); const transforms = transform .split(')') .flatMap((s) => (s === '' ? ([] as string[]) : [s + ')'])); @@ -115,6 +159,6 @@ export function parseTransform(transform: string): CSSTransformValue { } const cssTransformValue = new CSSTransformValue(parsedTransforms); - memoizedValues.set(transform, cssTransformValue); + memoizedValues.set(originalTransform, cssTransformValue); return cssTransformValue; } diff --git a/packages/react-strict-dom/src/native/css/processStyle.js b/packages/react-strict-dom/src/native/css/processStyle.js index 70078a1c..a98aefef 100644 --- a/packages/react-strict-dom/src/native/css/processStyle.js +++ b/packages/react-strict-dom/src/native/css/processStyle.js @@ -7,6 +7,7 @@ * @flow strict */ +import { CSSCalcValue } from './CSSCalcValue'; import { CSSLengthUnitValue } from './CSSLengthUnitValue'; import { CSSUnparsedValue } from './typed-om/CSSUnparsedValue'; @@ -114,6 +115,16 @@ export function processStyle( if (stringContainsVariables(styleValue)) { result[propName] = CSSUnparsedValue.parse(propName, styleValue); continue; + } else if (propName !== 'transform' && styleValue.includes('calc(')) { + const calcValue = CSSCalcValue.parse(styleValue); + if (calcValue != null) { + result[propName] = calcValue; + } else if (__DEV__) { + warnMsg( + `unsupported calc expression in "${propName}:${styleValue}"` + ); + } + continue; } // Polyfill support for backgroundImage using experimental API else if (propName === 'backgroundImage') { @@ -219,10 +230,7 @@ export function processStyle( } continue; } - } else if ( - unsupportedValues.has(styleValue) || - styleValue.includes('calc(') - ) { + } else if (unsupportedValues.has(styleValue)) { if (__DEV__) { warnMsg( `unsupported style value in "${propName}:${String(styleValue)}"` diff --git a/packages/react-strict-dom/src/native/modules/useStyleTransition.js b/packages/react-strict-dom/src/native/modules/useStyleTransition.js index c9aca588..246bc811 100644 --- a/packages/react-strict-dom/src/native/modules/useStyleTransition.js +++ b/packages/react-strict-dom/src/native/modules/useStyleTransition.js @@ -91,6 +91,53 @@ function getTransitionProperties(property: mixed): ?(string[]) { return null; } +function createIdentityTransforms( + transforms: $ReadOnlyArray +): Array { + return transforms.map((t) => { + if (t.perspective != null) { + return { perspective: 1 }; + } + if (t.rotate != null) { + return { rotate: '0deg' }; + } + if (t.rotateX != null) { + return { rotateX: '0deg' }; + } + if (t.rotateY != null) { + return { rotateY: '0deg' }; + } + if (t.rotateZ != null) { + return { rotateZ: '0deg' }; + } + if (t.scale != null) { + return { scale: 1 }; + } + if (t.scaleX != null) { + return { scaleX: 1 }; + } + if (t.scaleY != null) { + return { scaleY: 1 }; + } + if (t.scaleZ != null) { + return { scaleZ: 1 }; + } + if (t.skewX != null) { + return { skewX: '0deg' }; + } + if (t.skewY != null) { + return { skewY: '0deg' }; + } + if (t.translateX != null) { + return { translateX: 0 }; + } + if (t.translateY != null) { + return { translateY: 0 }; + } + return t; + }); +} + function transformsHaveSameLengthTypesAndOrder( transformsA: $ReadOnlyArray, transformsB: $ReadOnlyArray @@ -176,12 +223,10 @@ function transitionStyleHasChanged( } // handle transform value differences - else if ( - Array.isArray(prevValue) && - Array.isArray(nextValue) && - !transformListsAreEqual(prevValue, nextValue) - ) { - return true; + else if (Array.isArray(prevValue) && Array.isArray(nextValue)) { + if (!transformListsAreEqual(prevValue, nextValue)) { + return true; + } } // handle literal value differences @@ -279,23 +324,29 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { ? _timingFunction : null; + const [currentStyle, setCurrentStyle] = + React.useState(style); + const [previousStyle, setPreviousStyle] = + React.useState(undefined); + const [animatedValue, setAnimatedValue] = + React.useState(undefined); + const transitionStyle = getTransitionProperties( _transitionProperty )?.reduce((output, property) => { const value = style[property]; if (isString(value) || isNumber(value) || Array.isArray(value)) { output[property] = value; + } else if ( + property === 'transform' && + value == null && + Array.isArray(currentStyle?.[property]) + ) { + output[property] = createIdentityTransforms(currentStyle[property]); } return output; }, {}); - const [currentStyle, setCurrentStyle] = - React.useState(style); - const [previousStyle, setPreviousStyle] = - React.useState(undefined); - const [animatedValue, setAnimatedValue] = - React.useState(undefined); - // This ref is utilized as a performance optimization so that the effect that contains the // animation trigger only is called when the animated value's identity changes. As far as the effect // is concerned it just needs the most up to date version of these transition properties; @@ -353,7 +404,7 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { } if (transitionStyleHasChanged(transitionStyle, currentStyle)) { - setCurrentStyle(style); + setCurrentStyle({ ...style, ...transitionStyle }); setPreviousStyle(currentStyle); setAnimatedValue(new ReactNative.Animated.Value(0)); // This commit will be thrown away due to the above state setters so we can bail out early @@ -367,7 +418,17 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { const outputAnimatedStyle: AnimatedStyle = Object.entries( transitionStyle ).reduce((animatedStyle, [property, value]) => { - const prevValue = previousStyle?.[property] ?? value; + const rawPrevValue = previousStyle?.[property]; + let prevValue; + if ( + property === 'transform' && + Array.isArray(value) && + !Array.isArray(rawPrevValue) + ) { + prevValue = createIdentityTransforms(value); + } else { + prevValue = rawPrevValue ?? value; + } if (animatedValue === undefined || prevValue === value) { animatedStyle[property] = value; @@ -385,16 +446,19 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { return animatedStyle; } else if (property === 'transform' && Array.isArray(value)) { const transforms = value; - const prevTransforms = prevValue; + let prevTransforms = prevValue; - // Check that there are the same number of transforms - if ( - !Array.isArray(prevTransforms) || - transforms.length !== prevTransforms.length + if (!Array.isArray(prevTransforms)) { + prevTransforms = createIdentityTransforms(transforms); + } else if ( + transforms.length !== prevTransforms.length || + !transformsHaveSameLengthTypesAndOrder(transforms, prevTransforms) ) { if (__DEV__) { warnMsg( - 'The number or types of transforms must be the same before and after the transition. The transition will not animate.' + 'The number or types of transforms must be the same before and after the transition. The transition will not animate.\n' + + `Before: ${JSON.stringify(prevTransforms)}\n` + + `After: ${JSON.stringify(transforms)}` ); } animatedStyle[property] = transforms; @@ -414,19 +478,6 @@ export function useStyleTransition(style: ReactNativeStyle): ReactNativeStyle { } } - // Check that the transforms have the same types in the same order - if (!transformsHaveSameLengthTypesAndOrder(transforms, prevTransforms)) { - if (__DEV__) { - warnMsg( - 'The types of transforms must be the same before and after the transition. The transition will not animate.\n' + - `Before: ${JSON.stringify(transforms)}\n` + - `After: ${JSON.stringify(prevTransforms)}` - ); - } - animatedStyle[property] = transforms; - return animatedStyle; - } - // Animate the transforms const animatedTransforms: Array = []; for (let i = 0; i < transforms.length; i++) { diff --git a/packages/react-strict-dom/tests/css/__snapshots__/css-create-test.native.js.snap b/packages/react-strict-dom/tests/css/__snapshots__/css-create-test.native.js.snap index c91419c5..be73143b 100644 --- a/packages/react-strict-dom/tests/css/__snapshots__/css-create-test.native.js.snap +++ b/packages/react-strict-dom/tests/css/__snapshots__/css-create-test.native.js.snap @@ -1879,10 +1879,28 @@ exports[`css.create() properties: "transition" transition with missing propertie "position": "static", "transform": [ { - "translateY": 50, + "translateY": { + "inputRange": [ + 0, + 1, + ], + "outputRange": [ + 0, + 50, + ], + }, }, { - "rotateX": "90deg", + "rotateX": { + "inputRange": [ + 0, + 1, + ], + "outputRange": [ + "0deg", + "90deg", + ], + }, }, ], } @@ -2008,14 +2026,6 @@ exports[`css.create() pseudo-elements ::placeholder syntax: placeholderTextColor } `; -exports[`css.create() values: general calc() 1`] = ` -{ - "boxSizing": "content-box", - "overflow": "visible", - "position": "static", -} -`; - exports[`css.create() values: general currentcolor 1`] = ` { "boxSizing": "content-box", diff --git a/packages/react-strict-dom/tests/css/css-create-test.native.js b/packages/react-strict-dom/tests/css/css-create-test.native.js index 2067238f..de01bc41 100644 --- a/packages/react-strict-dom/tests/css/css-create-test.native.js +++ b/packages/react-strict-dom/tests/css/css-create-test.native.js @@ -2023,14 +2023,15 @@ describe('css.create()', () => { width: 'calc(2 * 1rem)' } }); - expect(console.warn).toHaveBeenCalledWith( + expect(console.warn).not.toHaveBeenCalledWith( expect.stringContaining('React Strict DOM') ); let root; act(() => { root = create(); }); - expect(root.toJSON().props.style).toMatchSnapshot(); + // calc(2 * 1rem) = 2 * 16px = 32 + expect(root.toJSON().props.style.width).toBe(32); }); test('currentcolor', () => {