From 1e561dcce03a3d18bf061234d7894b42a63db0a2 Mon Sep 17 00:00:00 2001 From: KAMRONBEK Date: Thu, 16 Jul 2026 17:03:19 +0500 Subject: [PATCH] Pass raw numeric and color values through when they are not theme keys When a theme-key prop received a value that is not a key in the theme scale, getThemeValue threw even for valid raw style values. react-native-reanimated 4.4+ re-renders settled animations with the resolved raw values set as top-level props (e.g. borderRadius: 13, backgroundColor: 'rgba(255, 209, 102, 1)'), crashing animated restyle components. Numeric literals and unambiguous raw color strings (hex / rgb / rgba / hsl / hsla / hwb) now pass through as-is, matching styled-system and dripsy, while unresolvable string keys (likely typos) still throw. Fixes #355 --- CHANGELOG.md | 2 + src/test/createRestyleComponent.test.tsx | 33 +++++++++- src/test/createRestyleFunction.test.ts | 79 +++++++++++++++++++++++- src/utilities/getThemeValue.ts | 24 ++++++- 4 files changed, 135 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef51cf9d..ed2d90d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## Next +- Fixed: pass raw numeric values and raw color strings through instead of throwing when they are not theme keys, restoring compatibility with `react-native-reanimated` 4.4+ settled animations [#355](https://github.com/Shopify/restyle/issues/355) by [KAMRONBEK](https://github.com/KAMRONBEK) + ## 2.4.5 - 2025-03-19 - Fixed: dist folder not being generated when building the project [#302](https://github.com/Shopify/restyle/pull/302) by [kelset](https://github.com/naqvitalha) diff --git a/src/test/createRestyleComponent.test.tsx b/src/test/createRestyleComponent.test.tsx index 9fe1abce..aeaf52d0 100644 --- a/src/test/createRestyleComponent.test.tsx +++ b/src/test/createRestyleComponent.test.tsx @@ -6,6 +6,8 @@ import createRestyleComponent from '../createRestyleComponent'; import { backgroundColor, BackgroundColorProps, + border, + BorderProps, SpacingProps, spacing, SpacingShorthandProps, @@ -25,6 +27,9 @@ const theme = { spacing: { s: 8, }, + borderRadii: { + s: 4, + }, breakpoints: { phone: 0, tablet: 376, @@ -61,12 +66,13 @@ jest.mock('react-native/Libraries/Utilities/useWindowDimensions', () => ({ const Component = createRestyleComponent< BackgroundColorProps & + BorderProps & SpacingProps & SpacingShorthandProps & OpacityProps & ViewProps, Theme ->([backgroundColor, spacing, spacingShorthand, opacity]); +>([backgroundColor, border, spacing, spacingShorthand, opacity]); const cardVariant = createVariant({ themeKey: 'cardVariants', }); @@ -230,5 +236,30 @@ describe('createRestyleComponent', () => { style: [{gap: 8, columnGap: 8, rowGap: 8}], }); }); + + // react-native-reanimated 4.4+ re-renders settled animations with the + // resolved raw style values set as top-level props (bypassing TypeScript, + // hence the casts), e.g. borderRadius={13} on an animated restyle component + it('passes raw numeric values through for numeric theme scales', () => { + const {root} = render( + + + , + ); + expect(root.findByType(View).props.style).toStrictEqual([ + {borderRadius: 13}, + ]); + }); + + it('passes raw color strings through for the colors theme scale', () => { + const {root} = render( + + + , + ); + expect(root.findByType(View).props.style).toStrictEqual([ + {backgroundColor: 'rgba(255, 209, 102, 1)'}, + ]); + }); }); }); diff --git a/src/test/createRestyleFunction.test.ts b/src/test/createRestyleFunction.test.ts index 33f43d6c..9a6f7a3c 100644 --- a/src/test/createRestyleFunction.test.ts +++ b/src/test/createRestyleFunction.test.ts @@ -2,8 +2,14 @@ import createRestyleFunction from '../createRestyleFunction'; import {RNStyle} from '../types'; const theme = { - colors: {}, + colors: { + primary: '#FFB6C1', + }, spacing: {}, + borderRadii: { + s: 4, + m: 8, + }, opacities: { invisible: 0, barelyVisible: 0.1, @@ -128,5 +134,76 @@ describe('createRestyleFunction', () => { ).not.toThrow(/does not exist/); }); }); + + describe('with a numeric theme scale', () => { + const styleFunc = createRestyleFunction({ + property: 'borderRadius', + themeKey: 'borderRadii', + }); + + it('picks values from the theme', () => { + expect( + styleFunc.func({borderRadius: 'm'}, {theme, dimensions}), + ).toStrictEqual({ + borderRadius: 8, + }); + }); + + it('passes a raw numeric value through when it is not a theme key', () => { + // react-native-reanimated 4.4+ re-renders settled animations with the + // resolved raw values set as top-level props, e.g. borderRadius={13} + expect( + styleFunc.func({borderRadius: 13}, {theme, dimensions}), + ).toStrictEqual({ + borderRadius: 13, + }); + }); + + it('passes a raw numeric value through for screen-size specific props', () => { + expect( + styleFunc.func({borderRadius: {phone: 13}}, {theme, dimensions}), + ).toStrictEqual({ + borderRadius: 13, + }); + }); + + it('throws an error when trying to use an invalid string theme value', () => { + expect(() => + styleFunc.func({borderRadius: 'xxl'}, {theme, dimensions}), + ).toThrow(/does not exist/); + }); + }); + + describe('with the colors theme scale', () => { + const styleFunc = createRestyleFunction({ + property: 'backgroundColor', + themeKey: 'colors', + }); + + it('passes a raw rgba color string through when it is not a theme key', () => { + expect( + styleFunc.func( + {backgroundColor: 'rgba(255, 209, 102, 1)'}, + {theme, dimensions}, + ), + ).toStrictEqual({ + backgroundColor: 'rgba(255, 209, 102, 1)', + }); + }); + + it('passes a raw hex color string through when it is not a theme key', () => { + expect( + styleFunc.func({backgroundColor: '#FFE6E4'}, {theme, dimensions}), + ).toStrictEqual({ + backgroundColor: '#FFE6E4', + }); + }); + + it('throws an error when trying to use a misspelled theme color', () => { + expect(() => + styleFunc.func({backgroundColor: 'primaryy'}, {theme, dimensions}), + ).toThrow(/does not exist/); + }); + }); }); }); diff --git a/src/utilities/getThemeValue.ts b/src/utilities/getThemeValue.ts index 0de26d6d..6c9dab17 100644 --- a/src/utilities/getThemeValue.ts +++ b/src/utilities/getThemeValue.ts @@ -21,10 +21,13 @@ export function getThemeValue< ) { if (transform) return transform({value, theme, themeKey}); if (isThemeKey(theme, themeKey)) { - if (value && theme[themeKey][value as string] === undefined) + if (value && theme[themeKey][value as string] === undefined) { + if (isRawStyleValue(value)) return value; + throw new Error( `Value '${value}' does not exist in theme['${String(themeKey)}']`, ); + } return value ? theme[themeKey][value as string] : value; } @@ -32,6 +35,25 @@ export function getThemeValue< return value; } +const rawColorValueRegex = + /^(#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|(rgb|rgba|hsl|hsla|hwb)\(.*\))$/i; + +/** + * Whether a value that is not a key in the theme scale is a valid raw style + * value: a numeric literal (e.g. `borderRadius={13}`) or a raw color string + * (e.g. `backgroundColor="rgba(255, 209, 102, 1)"`). Such values are passed + * through as-is instead of throwing, so that libraries setting resolved style + * values as top-level props keep working (e.g. react-native-reanimated 4.4+ + * re-renders settled animations with raw values), while unresolvable string + * keys (likely typos) still throw. + */ +function isRawStyleValue(value: PropValue): boolean { + return ( + typeof value === 'number' || + (typeof value === 'string' && rawColorValueRegex.test(value)) + ); +} + function isThemeKey( theme: Theme, K: keyof Theme | undefined,