From 1969c3456741673e673512c4b44c8c2d92b2b21c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 27 Aug 2026 01:43:15 +0200 Subject: [PATCH 1/5] Trim string style values in the props builder CSS strips the whitespace around a declaration value, so a padded string has to parse like a bare one. Only transform and transformOrigin did that, each with its own trim, while a padded color or font weight threw and a padded gap was forwarded to the shadow node with the padding intact. Trimming once in the build loop covers every processor-backed property, so the two local trims are gone and processors can assume a clean string. Properties configured as pass-through are left alone: their values reach React Native untouched, exactly as they do outside an animated style. --- .../__tests__/createPropsBuilder.test.ts | 32 ++++++++++++++ .../style/__tests__/stylePropsBuilder.test.ts | 42 ++++++++++++++++++- .../src/common/style/createPropsBuilder.ts | 7 +++- .../processors/__tests__/transform.test.ts | 8 ---- .../__tests__/transformOrigin.test.ts | 14 ------- .../src/common/style/processors/transform.ts | 1 - .../style/processors/transformOrigin.ts | 3 +- 7 files changed, 80 insertions(+), 27 deletions(-) diff --git a/packages/react-native-reanimated/src/common/style/__tests__/createPropsBuilder.test.ts b/packages/react-native-reanimated/src/common/style/__tests__/createPropsBuilder.test.ts index 7e0578272bf1..ea8ddc563ff1 100644 --- a/packages/react-native-reanimated/src/common/style/__tests__/createPropsBuilder.test.ts +++ b/packages/react-native-reanimated/src/common/style/__tests__/createPropsBuilder.test.ts @@ -176,4 +176,36 @@ describe(createPropsBuilder, () => { ) ); }); + + describe('trimming string values', () => { + test('trims a string before handing it to the processor', () => { + const processor = jest.fn().mockReturnValue(0); + const builder = createBuilder({ margin: { process: processor } }); + + builder.build({ margin: ' 10px\n' }); + + expect(processor).toHaveBeenCalledWith('10px', { + target: ValueProcessorTarget.Default, + }); + }); + + test('leaves non-string values untouched', () => { + const processor = jest.fn().mockReturnValue(0); + const builder = createBuilder({ margin: { process: processor } }); + + builder.build({ margin: 10 }); + + expect(processor).toHaveBeenCalledWith(10, { + target: ValueProcessorTarget.Default, + }); + }); + + test('does not trim properties that have no processor', () => { + const builder = createBuilder({ margin: true }); + + expect(builder.build({ margin: ' 10px ' })).toEqual({ + margin: ' 10px ', + }); + }); + }); }); diff --git a/packages/react-native-reanimated/src/common/style/__tests__/stylePropsBuilder.test.ts b/packages/react-native-reanimated/src/common/style/__tests__/stylePropsBuilder.test.ts index 27a4f2ba4ff2..48262dd6613b 100644 --- a/packages/react-native-reanimated/src/common/style/__tests__/stylePropsBuilder.test.ts +++ b/packages/react-native-reanimated/src/common/style/__tests__/stylePropsBuilder.test.ts @@ -1,7 +1,7 @@ 'use strict'; import { ValueProcessorTarget } from '../../types'; -import { createNativePropsBuilder } from '../propsBuilder'; +import { createNativePropsBuilder, stylePropsBuilder } from '../propsBuilder'; describe('createNativePropsBuilder', () => { describe('build without context', () => { @@ -224,3 +224,43 @@ describe('createNativePropsBuilder', () => { }); }); }); + +describe('stylePropsBuilder', () => { + describe('a padded string value parses like a bare one', () => { + test.each([ + ['backgroundColor', ' red ', 'red'], + ['color', '\n#ff0000\n', '#ff0000'], + ['borderTopColor', ' rgba(0, 0, 0, 0.5) ', 'rgba(0, 0, 0, 0.5)'], + ['shadowColor', '\t#0f0', '#0f0'], + [ + 'transform', + ' translate(25, 25) scale(2) ', + 'translate(25, 25) scale(2)', + ], + ['transformOrigin', ' left top ', 'left top'], + ['boxShadow', ' 0px 4px 8px red ', '0px 4px 8px red'], + ['filter', ' blur(5px) brightness(0.5) ', 'blur(5px) brightness(0.5)'], + ['fontWeight', ' bold ', 'bold'], + ['aspectRatio', ' 1 / 2 ', '1 / 2'], + ['gap', ' 8 ', '8'], + ])('%s', (property, padded, bare) => { + expect(stylePropsBuilder.build({ [property]: padded })).toEqual( + stylePropsBuilder.build({ [property]: bare }) + ); + }); + }); + + test('trims every padded value of a multi-property style', () => { + expect( + stylePropsBuilder.build({ + backgroundColor: ' blue ', + transform: '\n rotate(45deg)\n', + opacity: 0.5, + }) + ).toEqual({ + backgroundColor: 4278190335, + transform: [{ rotate: '45deg' }], + opacity: 0.5, + }); + }); +}); diff --git a/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts b/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts index d505eaa4da6a..9a22111da61a 100644 --- a/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts +++ b/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts @@ -93,7 +93,12 @@ export default function createPropsBuilder< continue; } - const processedValue = configValue(value, context); + // CSS strips whitespace around a declaration value, so processors can + // assume they are given a trimmed string. + const processedValue = configValue( + typeof value === 'string' ? value.trim() : value, + context + ); if (isRecord(processedValue) && !isRecord(value)) { // The value processor may return multiple values for a single property diff --git a/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts b/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts index ce4d1adb2296..3956122a2976 100644 --- a/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts +++ b/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts @@ -289,14 +289,6 @@ describe(processTransform, () => { { rotate: '45deg' }, ], }, - { - input: ' translate(25, 25) scale(2) ', - output: [{ translateX: 25 }, { translateY: 25 }, { scale: 2 }], - }, - { - input: '\n translate(25, 25)\n scale(2)\n', - output: [{ translateX: 25 }, { translateY: 25 }, { scale: 2 }], - }, { input: 'translate(50, 50) scale(1.5, 2) skew(30deg, 15deg)', output: [ diff --git a/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts b/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts index 5c92a9a97b76..b4c9c4b5c523 100644 --- a/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts +++ b/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts @@ -114,20 +114,6 @@ describe(processTransformOrigin, () => { }, ], }, - { - name: 'padded string syntax', - cases: [ - { - name: 'surrounded by whitespace', - cases: [ - { input: ' 50% 50%', output: ['50%', '50%', 0] }, - { input: ' left top ', output: [0, 0, 0] }, - { input: '\n 25px 25% 25px\n', output: [25, '25%', 25] }, - { input: '\tcenter', output: ['50%', '50%', 0] }, - ], - }, - ], - }, { name: 'array syntax', cases: [ diff --git a/packages/react-native-reanimated/src/common/style/processors/transform.ts b/packages/react-native-reanimated/src/common/style/processors/transform.ts index 81a2ab72f6bc..3eda816756cd 100644 --- a/packages/react-native-reanimated/src/common/style/processors/transform.ts +++ b/packages/react-native-reanimated/src/common/style/processors/transform.ts @@ -186,7 +186,6 @@ export const processTransform: ValueProcessor = ( } return value - .trim() .split(/\)\s*/) .filter(Boolean) .flatMap((part) => { diff --git a/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts b/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts index cae21ac5da5a..ca55f5e250cb 100644 --- a/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts +++ b/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts @@ -150,8 +150,7 @@ export const processTransformOrigin: ValueProcessor< > = (value) => { 'worklet'; const isArray = Array.isArray(value); - let components = - typeof value === 'string' ? value.trim().split(/\s+/) : value; + let components = typeof value === 'string' ? value.split(/\s+/) : value; const customParse = isArray ? () => null : parsePx; if (components.length < 1 || components.length > 3) { From 633e011bbdad8dcd56dda40fb34ea578bfaf8744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 27 Aug 2026 01:44:16 +0200 Subject: [PATCH 2/5] Add changelog entry for the props builder trim --- packages/react-native-reanimated/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-reanimated/CHANGELOG.md b/packages/react-native-reanimated/CHANGELOG.md index cd92d2db29c5..427631f91c6d 100644 --- a/packages/react-native-reanimated/CHANGELOG.md +++ b/packages/react-native-reanimated/CHANGELOG.md @@ -16,6 +16,7 @@ ### 🐛 Bug fixes - Fix a short `animationDelay` list on web applying no delay to the animations past its end instead of repeating, the way CSS does and the native path already did. ([#10442](https://github.com/software-mansion/react-native-reanimated/pull/10442) by [@dennytosp](https://github.com/dennytosp)) +- Fix padded string style values, such as `backgroundColor: ' red '`, throwing or reaching the shadow node with the padding intact. ([#10422](https://github.com/software-mansion/react-native-reanimated/pull/10422) by [@matipl01](https://github.com/matipl01)) - Fix `filter` strings using the CSS `hue-rotate()` and `drop-shadow()` spellings being discarded together with every other filter in the same declaration. ([#10383](https://github.com/software-mansion/react-native-reanimated/pull/10383) by [@dennytosp](https://github.com/dennytosp)) - Fix single-argument `translate()` and `skew()` in transform strings repeating the argument on the Y axis instead of leaving it at zero, so `translate(100px)` no longer also moves the element down. ([#10385](https://github.com/software-mansion/react-native-reanimated/pull/10385) by [@dennytosp](https://github.com/dennytosp)) - Fix the Metro configuration type import to use the public package export. ([#10454](https://github.com/software-mansion/react-native-reanimated/pull/10454) by [@sneakykiwi](https://github.com/sneakykiwi)) From 398df5d826922cdb4cfab1c3cb9ded66c0d84d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Thu, 27 Aug 2026 02:12:38 +0200 Subject: [PATCH 3/5] Trim SVG percentages before slicing off the unit processPercentage trimmed the value to test for the '%' suffix but sliced the untrimmed string, so trailing whitespace left the '%' inside the number and the result fell back to 1. Leading whitespace survived only because Number() skips it. Gradient stops reach the processor through processSVGGradientStops, which reads offset and opacity from inside an array the props builder never descends into, so the padding is still there. processNumberArray runs behind a props builder only, and that now trims for it, so its own trim is gone. --- packages/react-native-reanimated/CHANGELOG.md | 1 + .../processors/__tests__/others.test.ts | 36 +++++++++++++++++ .../processors/__tests__/percentage.test.ts | 39 +++++++++++++++++++ .../src/css/svg/native/processors/others.ts | 2 +- .../css/svg/native/processors/percentage.ts | 8 ++-- 5 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/react-native-reanimated/src/css/svg/native/processors/__tests__/others.test.ts create mode 100644 packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts diff --git a/packages/react-native-reanimated/CHANGELOG.md b/packages/react-native-reanimated/CHANGELOG.md index 427631f91c6d..d41c7726ab55 100644 --- a/packages/react-native-reanimated/CHANGELOG.md +++ b/packages/react-native-reanimated/CHANGELOG.md @@ -17,6 +17,7 @@ - Fix a short `animationDelay` list on web applying no delay to the animations past its end instead of repeating, the way CSS does and the native path already did. ([#10442](https://github.com/software-mansion/react-native-reanimated/pull/10442) by [@dennytosp](https://github.com/dennytosp)) - Fix padded string style values, such as `backgroundColor: ' red '`, throwing or reaching the shadow node with the padding intact. ([#10422](https://github.com/software-mansion/react-native-reanimated/pull/10422) by [@matipl01](https://github.com/matipl01)) +- Fix an SVG gradient stop `offset` or `opacity` written with trailing whitespace, such as `'50% '`, collapsing to `1`. ([#10422](https://github.com/software-mansion/react-native-reanimated/pull/10422) by [@matipl01](https://github.com/matipl01)) - Fix `filter` strings using the CSS `hue-rotate()` and `drop-shadow()` spellings being discarded together with every other filter in the same declaration. ([#10383](https://github.com/software-mansion/react-native-reanimated/pull/10383) by [@dennytosp](https://github.com/dennytosp)) - Fix single-argument `translate()` and `skew()` in transform strings repeating the argument on the Y axis instead of leaving it at zero, so `translate(100px)` no longer also moves the element down. ([#10385](https://github.com/software-mansion/react-native-reanimated/pull/10385) by [@dennytosp](https://github.com/dennytosp)) - Fix the Metro configuration type import to use the public package export. ([#10454](https://github.com/software-mansion/react-native-reanimated/pull/10454) by [@sneakykiwi](https://github.com/sneakykiwi)) diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/others.test.ts b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/others.test.ts new file mode 100644 index 000000000000..2e528c6c5997 --- /dev/null +++ b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/others.test.ts @@ -0,0 +1,36 @@ +'use strict'; + +import { + getPropsBuilder, + registerComponentPropsBuilder, +} from '../../../../../common'; +import { SVG_TEXT_PROPERTIES_CONFIG } from '../../configs'; +import { processNumberArray } from '../others'; + +describe(processNumberArray, () => { + test.each([ + ['1 2 3', ['1', '2', '3']], + ['1,2,3', ['1', '2', '3']], + ['1, 2 , 3', ['1', '2', '3']], + ])('splits %j into %p', (input, expected) => { + expect(processNumberArray(input)).toEqual(expected); + }); + + test('wraps a lone number into an array', () => { + expect(processNumberArray(5)).toEqual([5]); + }); + + test('returns an array unchanged', () => { + expect(processNumberArray([1, 2])).toEqual([1, 2]); + }); + + // The processor is reached only through a props builder, which hands it an + // already trimmed value, so padding has to be covered through the builder. + test('the props builder strips the padding before splitting', () => { + registerComponentPropsBuilder('RNSVGText', SVG_TEXT_PROPERTIES_CONFIG); + + expect(getPropsBuilder('RNSVGText').build({ x: ' 1, 2, 3 ' })).toEqual({ + x: ['1', '2', '3'], + }); + }); +}); diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts new file mode 100644 index 000000000000..bdb9d3e66ed5 --- /dev/null +++ b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts @@ -0,0 +1,39 @@ +'use strict'; + +import { processPercentage } from '../percentage'; + +describe(processPercentage, () => { + test.each([ + ['50%', 0.5], + ['0%', 0], + ['100%', 1], + ['12.5%', 0.125], + ])('converts %s to %p', (input, expected) => { + expect(processPercentage(input)).toBe(expected); + }); + + test.each([' 50%', '50% ', ' 50% ', '\n50%\t'])( + 'ignores the whitespace padding %j', + (input) => { + expect(processPercentage(input)).toBe(0.5); + } + ); + + test.each([ + [0.25, 0.25], + ['0.25', 0.25], + [' 0.25 ', 0.25], + ])('passes %p through as a plain number', (input, expected) => { + expect(processPercentage(input)).toBe(expected); + }); + + test.each([ + ['150%', 1], + [2, 1], + ['-10%', 0], + [-1, 0], + ['nonsense', 1], + ])('clamps %p to %p', (input, expected) => { + expect(processPercentage(input)).toBe(expected); + }); +}); diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/others.ts b/packages/react-native-reanimated/src/css/svg/native/processors/others.ts index 6bb9989273bd..e1a9108b92d3 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/others.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/others.ts @@ -24,7 +24,7 @@ export const processNumberArray: ValueProcessor< } else if (typeof value === 'number') { return [value]; } else if (typeof value === 'string') { - return value.trim().replace(commaReg, ' ').split(spaceReg); + return value.replace(commaReg, ' ').split(spaceReg); } else { return []; } diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts b/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts index 39dfe8d1fbc0..6dd864240f9f 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts @@ -6,9 +6,11 @@ import type { ValueProcessor } from '../../../../common'; export const processPercentage: ValueProcessor = ( percentage ) => { + const trimmed = + typeof percentage === 'string' ? percentage.trim() : percentage; const value = - typeof percentage === 'string' && percentage.trim().endsWith('%') - ? +percentage.slice(0, -1) / 100 - : +percentage; + typeof trimmed === 'string' && trimmed.endsWith('%') + ? +trimmed.slice(0, -1) / 100 + : +trimmed; return isNaN(value) || value > 1 ? 1 : Math.max(value, 0); }; From 5e8f2f05cabf8531fbf02734f85a6abaf84ac5a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 11 Sep 2026 12:13:04 +0200 Subject: [PATCH 4/5] Preserve trimming for animated transform props --- packages/react-native-reanimated/CHANGELOG.md | 3 +-- .../style/processors/__tests__/transform.test.ts | 8 ++++++++ .../processors/__tests__/transformOrigin.test.ts | 14 ++++++++++++++ .../src/common/style/processors/transform.ts | 1 + .../src/common/style/processors/transformOrigin.ts | 3 ++- 5 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/react-native-reanimated/CHANGELOG.md b/packages/react-native-reanimated/CHANGELOG.md index d41c7726ab55..618a39fb6eaa 100644 --- a/packages/react-native-reanimated/CHANGELOG.md +++ b/packages/react-native-reanimated/CHANGELOG.md @@ -16,8 +16,7 @@ ### 🐛 Bug fixes - Fix a short `animationDelay` list on web applying no delay to the animations past its end instead of repeating, the way CSS does and the native path already did. ([#10442](https://github.com/software-mansion/react-native-reanimated/pull/10442) by [@dennytosp](https://github.com/dennytosp)) -- Fix padded string style values, such as `backgroundColor: ' red '`, throwing or reaching the shadow node with the padding intact. ([#10422](https://github.com/software-mansion/react-native-reanimated/pull/10422) by [@matipl01](https://github.com/matipl01)) -- Fix an SVG gradient stop `offset` or `opacity` written with trailing whitespace, such as `'50% '`, collapsing to `1`. ([#10422](https://github.com/software-mansion/react-native-reanimated/pull/10422) by [@matipl01](https://github.com/matipl01)) +- Fix padded string style values throwing, retaining their padding or parsing incorrectly, including SVG gradient stop offsets such as `'50% '`. ([#10422](https://github.com/software-mansion/react-native-reanimated/pull/10422) by [@matipl01](https://github.com/matipl01)) - Fix `filter` strings using the CSS `hue-rotate()` and `drop-shadow()` spellings being discarded together with every other filter in the same declaration. ([#10383](https://github.com/software-mansion/react-native-reanimated/pull/10383) by [@dennytosp](https://github.com/dennytosp)) - Fix single-argument `translate()` and `skew()` in transform strings repeating the argument on the Y axis instead of leaving it at zero, so `translate(100px)` no longer also moves the element down. ([#10385](https://github.com/software-mansion/react-native-reanimated/pull/10385) by [@dennytosp](https://github.com/dennytosp)) - Fix the Metro configuration type import to use the public package export. ([#10454](https://github.com/software-mansion/react-native-reanimated/pull/10454) by [@sneakykiwi](https://github.com/sneakykiwi)) diff --git a/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts b/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts index 3956122a2976..ce4d1adb2296 100644 --- a/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts +++ b/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts @@ -289,6 +289,14 @@ describe(processTransform, () => { { rotate: '45deg' }, ], }, + { + input: ' translate(25, 25) scale(2) ', + output: [{ translateX: 25 }, { translateY: 25 }, { scale: 2 }], + }, + { + input: '\n translate(25, 25)\n scale(2)\n', + output: [{ translateX: 25 }, { translateY: 25 }, { scale: 2 }], + }, { input: 'translate(50, 50) scale(1.5, 2) skew(30deg, 15deg)', output: [ diff --git a/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts b/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts index b4c9c4b5c523..5c92a9a97b76 100644 --- a/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts +++ b/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts @@ -114,6 +114,20 @@ describe(processTransformOrigin, () => { }, ], }, + { + name: 'padded string syntax', + cases: [ + { + name: 'surrounded by whitespace', + cases: [ + { input: ' 50% 50%', output: ['50%', '50%', 0] }, + { input: ' left top ', output: [0, 0, 0] }, + { input: '\n 25px 25% 25px\n', output: [25, '25%', 25] }, + { input: '\tcenter', output: ['50%', '50%', 0] }, + ], + }, + ], + }, { name: 'array syntax', cases: [ diff --git a/packages/react-native-reanimated/src/common/style/processors/transform.ts b/packages/react-native-reanimated/src/common/style/processors/transform.ts index 3eda816756cd..81a2ab72f6bc 100644 --- a/packages/react-native-reanimated/src/common/style/processors/transform.ts +++ b/packages/react-native-reanimated/src/common/style/processors/transform.ts @@ -186,6 +186,7 @@ export const processTransform: ValueProcessor = ( } return value + .trim() .split(/\)\s*/) .filter(Boolean) .flatMap((part) => { diff --git a/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts b/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts index ca55f5e250cb..cae21ac5da5a 100644 --- a/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts +++ b/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts @@ -150,7 +150,8 @@ export const processTransformOrigin: ValueProcessor< > = (value) => { 'worklet'; const isArray = Array.isArray(value); - let components = typeof value === 'string' ? value.split(/\s+/) : value; + let components = + typeof value === 'string' ? value.trim().split(/\s+/) : value; const customParse = isArray ? () => null : parsePx; if (components.length < 1 || components.length > 3) { From 499caecef4257b85be766e7920992ad827d5e2ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20=C5=81opaci=C5=84ski?= Date: Fri, 11 Sep 2026 14:32:40 +0200 Subject: [PATCH 5/5] Centralize trimming before style value processors --- .../src/common/style/createPropsBuilder.ts | 8 ++----- .../src/common/style/index.ts | 1 + .../src/common/style/processStyleValue.ts | 21 +++++++++++++++++++ .../processors/__tests__/transform.test.ts | 8 ------- .../__tests__/transformOrigin.test.ts | 14 ------------- .../src/common/style/processors/transform.ts | 1 - .../style/processors/transformOrigin.ts | 3 +-- .../processors/__tests__/percentage.test.ts | 8 ------- .../native/processors/__tests__/stops.test.ts | 8 +++++++ .../css/svg/native/processors/percentage.ts | 14 +++++-------- .../src/css/svg/native/processors/stops.ts | 10 ++++++--- .../src/updateProps/updateProps.native.ts | 11 ++++++++-- 12 files changed, 54 insertions(+), 53 deletions(-) create mode 100644 packages/react-native-reanimated/src/common/style/processStyleValue.ts diff --git a/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts b/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts index 9a22111da61a..5670cae9cf38 100644 --- a/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts +++ b/packages/react-native-reanimated/src/common/style/createPropsBuilder.ts @@ -6,6 +6,7 @@ import type { } from '../types'; import { ValueProcessorTarget } from '../types'; import { isRecord } from '../utils'; +import { processStyleValue } from './processStyleValue'; const MAX_PROCESS_DEPTH = 10; @@ -93,12 +94,7 @@ export default function createPropsBuilder< continue; } - // CSS strips whitespace around a declaration value, so processors can - // assume they are given a trimmed string. - const processedValue = configValue( - typeof value === 'string' ? value.trim() : value, - context - ); + const processedValue = processStyleValue(configValue, value, context); if (isRecord(processedValue) && !isRecord(value)) { // The value processor may return multiple values for a single property diff --git a/packages/react-native-reanimated/src/common/style/index.ts b/packages/react-native-reanimated/src/common/style/index.ts index a62394d1f9bf..62d07cd08a68 100644 --- a/packages/react-native-reanimated/src/common/style/index.ts +++ b/packages/react-native-reanimated/src/common/style/index.ts @@ -2,6 +2,7 @@ export * from './config'; export { default as createPropsBuilder } from './createPropsBuilder'; export * from './processors'; +export * from './processStyleValue'; export { type NativePropsBuilder, stylePropsBuilder } from './propsBuilder'; export * from './registry'; // `AllStyleProps` is intentionally not re-exported — it is internal to the diff --git a/packages/react-native-reanimated/src/common/style/processStyleValue.ts b/packages/react-native-reanimated/src/common/style/processStyleValue.ts new file mode 100644 index 000000000000..c7dad1e7e6b5 --- /dev/null +++ b/packages/react-native-reanimated/src/common/style/processStyleValue.ts @@ -0,0 +1,21 @@ +'use strict'; + +import type { + NonMutable, + ValueProcessor, + ValueProcessorContext, +} from '../types'; + +export function processStyleValue( + processor: ValueProcessor, + value: NonMutable, + context?: ValueProcessorContext +) { + 'worklet'; + // CSS strips whitespace around a declaration value, so processors can + // assume they are given a trimmed string. + const normalizedValue = ( + typeof value === 'string' ? value.trim() : value + ) as NonMutable; + return processor(normalizedValue, context); +} diff --git a/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts b/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts index ce4d1adb2296..3956122a2976 100644 --- a/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts +++ b/packages/react-native-reanimated/src/common/style/processors/__tests__/transform.test.ts @@ -289,14 +289,6 @@ describe(processTransform, () => { { rotate: '45deg' }, ], }, - { - input: ' translate(25, 25) scale(2) ', - output: [{ translateX: 25 }, { translateY: 25 }, { scale: 2 }], - }, - { - input: '\n translate(25, 25)\n scale(2)\n', - output: [{ translateX: 25 }, { translateY: 25 }, { scale: 2 }], - }, { input: 'translate(50, 50) scale(1.5, 2) skew(30deg, 15deg)', output: [ diff --git a/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts b/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts index 5c92a9a97b76..b4c9c4b5c523 100644 --- a/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts +++ b/packages/react-native-reanimated/src/common/style/processors/__tests__/transformOrigin.test.ts @@ -114,20 +114,6 @@ describe(processTransformOrigin, () => { }, ], }, - { - name: 'padded string syntax', - cases: [ - { - name: 'surrounded by whitespace', - cases: [ - { input: ' 50% 50%', output: ['50%', '50%', 0] }, - { input: ' left top ', output: [0, 0, 0] }, - { input: '\n 25px 25% 25px\n', output: [25, '25%', 25] }, - { input: '\tcenter', output: ['50%', '50%', 0] }, - ], - }, - ], - }, { name: 'array syntax', cases: [ diff --git a/packages/react-native-reanimated/src/common/style/processors/transform.ts b/packages/react-native-reanimated/src/common/style/processors/transform.ts index 81a2ab72f6bc..3eda816756cd 100644 --- a/packages/react-native-reanimated/src/common/style/processors/transform.ts +++ b/packages/react-native-reanimated/src/common/style/processors/transform.ts @@ -186,7 +186,6 @@ export const processTransform: ValueProcessor = ( } return value - .trim() .split(/\)\s*/) .filter(Boolean) .flatMap((part) => { diff --git a/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts b/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts index cae21ac5da5a..ca55f5e250cb 100644 --- a/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts +++ b/packages/react-native-reanimated/src/common/style/processors/transformOrigin.ts @@ -150,8 +150,7 @@ export const processTransformOrigin: ValueProcessor< > = (value) => { 'worklet'; const isArray = Array.isArray(value); - let components = - typeof value === 'string' ? value.trim().split(/\s+/) : value; + let components = typeof value === 'string' ? value.split(/\s+/) : value; const customParse = isArray ? () => null : parsePx; if (components.length < 1 || components.length > 3) { diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts index bdb9d3e66ed5..1c46565cd412 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/percentage.test.ts @@ -12,17 +12,9 @@ describe(processPercentage, () => { expect(processPercentage(input)).toBe(expected); }); - test.each([' 50%', '50% ', ' 50% ', '\n50%\t'])( - 'ignores the whitespace padding %j', - (input) => { - expect(processPercentage(input)).toBe(0.5); - } - ); - test.each([ [0.25, 0.25], ['0.25', 0.25], - [' 0.25 ', 0.25], ])('passes %p through as a plain number', (input, expected) => { expect(processPercentage(input)).toBe(expected); }); diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/stops.test.ts b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/stops.test.ts index 219f7ad196c4..adf508368264 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/stops.test.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/__tests__/stops.test.ts @@ -48,6 +48,14 @@ describe(processSVGGradientStops, () => { }); describe('Sorting and Offsets', () => { + test('trims string offsets and opacity before processing', () => { + const result = processSVGGradientStops([ + { offset: ' 50% ', color: 'red', opacity: ' 50% ' }, + ]); + + expect(result).toEqual([0.5, 2164195328]); + }); + test('sorts stops by offset in ascending order', () => { const input = [ { offset: 1, color: 'blue' }, diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts b/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts index 6dd864240f9f..7633b757e5dc 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/percentage.ts @@ -3,14 +3,10 @@ import type { NumberProp } from 'react-native-svg'; import type { ValueProcessor } from '../../../../common'; -export const processPercentage: ValueProcessor = ( - percentage -) => { - const trimmed = - typeof percentage === 'string' ? percentage.trim() : percentage; +export const processPercentage = ((percentage: NumberProp) => { const value = - typeof trimmed === 'string' && trimmed.endsWith('%') - ? +trimmed.slice(0, -1) / 100 - : +trimmed; + typeof percentage === 'string' && percentage.endsWith('%') + ? +percentage.slice(0, -1) / 100 + : +percentage; return isNaN(value) || value > 1 ? 1 : Math.max(value, 0); -}; +}) satisfies ValueProcessor; diff --git a/packages/react-native-reanimated/src/css/svg/native/processors/stops.ts b/packages/react-native-reanimated/src/css/svg/native/processors/stops.ts index 9efd66f3a94d..9531582dc2c8 100644 --- a/packages/react-native-reanimated/src/css/svg/native/processors/stops.ts +++ b/packages/react-native-reanimated/src/css/svg/native/processors/stops.ts @@ -1,6 +1,10 @@ 'use strict'; -import { logger, type ValueProcessor } from '../../../../common'; +import { + logger, + processStyleValue, + type ValueProcessor, +} from '../../../../common'; import type { CSSGradientStop } from '../../../types'; import { processColorSVG } from './colors'; import { processPercentage } from './percentage'; @@ -16,7 +20,7 @@ export const processSVGGradientStops = ((stops) => { } const intermediate = stops.map((stop) => { const rawColor = stop.color && processColorSVG(stop.color); - const stopOpacity = processPercentage(stop.opacity ?? 1); + const stopOpacity = processStyleValue(processPercentage, stop.opacity ?? 1); const finalColor = typeof rawColor === 'number' && typeof stopOpacity === 'number' ? ((Math.round(((rawColor >>> 24) & 0xff) * stopOpacity) << 24) | @@ -24,7 +28,7 @@ export const processSVGGradientStops = ((stops) => { 0 : rawColor; return { - offset: processPercentage(stop.offset ?? 0), + offset: processStyleValue(processPercentage, stop.offset ?? 0), color: finalColor, }; }) as { offset: number; color: number | false | string }[]; diff --git a/packages/react-native-reanimated/src/updateProps/updateProps.native.ts b/packages/react-native-reanimated/src/updateProps/updateProps.native.ts index 0e49a0dc0729..0f8add6a9156 100644 --- a/packages/react-native-reanimated/src/updateProps/updateProps.native.ts +++ b/packages/react-native-reanimated/src/updateProps/updateProps.native.ts @@ -4,6 +4,7 @@ import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; import { processColorsInProps, + processStyleValue, processTransform, processTransformOrigin, stylePropsBuilder, @@ -34,10 +35,16 @@ const updateProps: ( if (isAnimatedProps) { processColorsInProps(updates); if ('transformOrigin' in updates) { - updates.transformOrigin = processTransformOrigin(updates.transformOrigin); + updates.transformOrigin = processStyleValue( + processTransformOrigin, + updates.transformOrigin + ); } if ('transform' in updates) { - updates.transform = processTransform(updates.transform); + updates.transform = processStyleValue( + processTransform, + updates.transform + ); } }