From 8af8f0bfba2ebf7502e155a5e86aa35626a88493 Mon Sep 17 00:00:00 2001 From: Vojtech Toth Date: Fri, 18 Sep 2026 16:27:04 +0200 Subject: [PATCH] Add createAnimatedRestyleComponent and animated-components guide Reanimated 4.4+'s default-on FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS flag re-renders a wrapped component with its settled style values spread as top-level props. If that wrapped component is a Restyle component (Animated.createAnimatedComponent(createBox())), those raw values land in Restyle's theme-token prop namespace and getThemeValue throws (#355). Nesting the other way around - Restyle wrapping the already-animated component (createAnimatedRestyleComponent(Animated.createAnimatedComponent(View))) - avoids the collision entirely: the settled props land on the plain view Reanimated wraps internally, which Restyle's prop parsing never sees. No interception layer is needed; this is a design-spike finding, verified against real react-native-reanimated 4.5.1 in a scratch app (animated color, borderRadius and zIndex all settle correctly with zero errors) as well as with the unit tests added here. createAnimatedRestyleComponent is a thin wrapper around createBox that infers Props from the animated component handed to it, so its own prop types (style in particular) don't need to be redeclared by hand. Restyle still never imports an animation library - the caller constructs the animated component and hands it in. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 + .../guides/animating-restyle-components.md | 118 +++++++++++ documentation/sidebars.js | 1 + src/createAnimatedRestyleComponent.ts | 39 ++++ src/index.ts | 1 + .../createAnimatedRestyleComponent.test.tsx | 185 ++++++++++++++++++ 6 files changed, 346 insertions(+) create mode 100644 documentation/docs/guides/animating-restyle-components.md create mode 100644 src/createAnimatedRestyleComponent.ts create mode 100644 src/test/createAnimatedRestyleComponent.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ef51cf9d..3dda8552 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 +- Added: `createAnimatedRestyleComponent` helper and a guide explaining why animated Restyle components must nest the animated component inside Restyle (not the other way around) to stay compatible with `react-native-reanimated` 4.4+'s `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` flag [#TBD](https://github.com/Shopify/restyle/pull/TBD) by [tothvoj-gl](https://github.com/tothvoj-gl) + ## 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/documentation/docs/guides/animating-restyle-components.md b/documentation/docs/guides/animating-restyle-components.md new file mode 100644 index 00000000..2e4e71cb --- /dev/null +++ b/documentation/docs/guides/animating-restyle-components.md @@ -0,0 +1,118 @@ +--- +id: animating-restyle-components +title: Animating Restyle components +--- + +Restyle doesn't depend on any animation library, but it's common to want to animate a +themed component — for example, animating a `Box`'s `borderRadius` or `backgroundColor` +between two theme values. + +## Nest the animated component inside Restyle, not the other way around + +Given a themed component, there are two ways to make it animatable: + +```tsx +// A: animated outer, Restyle inner +const AnimatedBox = Animated.createAnimatedComponent(createBox()); +``` + +```tsx +// B: Restyle outer, animated inner +const AnimatedBox = createAnimatedRestyleComponent( + Animated.createAnimatedComponent(View), +); +``` + +These look interchangeable, but **only B is safe with `react-native-reanimated` 4.4+**. + +Reanimated 4.4 introduced a default-on performance flag, +`FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS`. Once an animation settles, Reanimated +re-renders the component it wraps with the resolved style values spread on as +top-level props, in addition to `style`: + +``` +borderRadius: 13 +backgroundColor: 'rgba(255,209,102,1)' +``` + +In ordering **A**, the component Reanimated wraps _is_ the Restyle component, so those +resolved values land as `borderRadius` / `backgroundColor` props on it — and Restyle +treats any theme-key prop as a token lookup. A raw number or color string is not a key +in your theme, so `getThemeValue` throws: + +``` +Value '13' does not exist in theme['borderRadii'] +Value 'rgba(255,209,102,1)' does not exist in theme['colors'] +``` + +In ordering **B**, Reanimated wraps a plain `View` that sits _inside_ Restyle's +`BaseComponent`. The settled props get spread onto that inner `View` — a component +Restyle's props never pass back through — so there's nothing to collide with. Restyle +only ever sees the props it was given in JSX (`style`, and whichever theme props you +passed explicitly). + +## `createAnimatedRestyleComponent` + +`createAnimatedRestyleComponent` is a small helper for ordering B: it's `createBox` +with its `Props` generic inferred from the animated component you hand it, so you get +that component's own prop types (its `style` prop in particular) without having to +redeclare them yourself. + +```tsx +import {Animated, View} from 'react-native'; +// or, for react-native-reanimated: +// import Animated from 'react-native-reanimated'; +import {createAnimatedRestyleComponent, useTheme} from '@shopify/restyle'; +import {Theme} from './theme'; + +const AnimatedBox = createAnimatedRestyleComponent( + Animated.createAnimatedComponent(View), +); + +const Example = () => { + const {borderRadii} = useTheme(); + const progress = useSharedValue(0); + + const animatedStyle = useAnimatedStyle(() => ({ + borderRadius: interpolate(progress.value, [0, 1], [ + borderRadii.sm, + borderRadii.lg, + ]), + })); + + return ( + + ); +}; +``` + +Restyle itself never imports an animation library — `createAnimatedRestyleComponent` +just takes whatever already-animated component you construct and wraps it, so this +works the same way with `react-native-reanimated`, the built-in `Animated` API, or +anything else that produces a component accepting a `style` prop. + +The component `createAnimatedRestyleComponent` returns behaves exactly like `Box`: +same restyle functions, same ref forwarding, and it composes with +[variants](/fundamentals/variants) the same way any [custom component](/fundamentals/components/custom-components) +does — pass `createVariant(...)` alongside `boxRestyleFunctions` to +`createRestyleComponent` directly if you need a variant-aware animated component. + +## If you're not using `createAnimatedRestyleComponent` + +The same ordering rule applies if you build your own animated component instead — +with `createRestyleComponent` or `createBox` directly: + +```tsx +const AnimatedBox = createBox>( + Animated.createAnimatedComponent(View), +); +``` + +The important part isn't the helper, it's the nesting order: give Restyle the +already-animated component as its `BaseComponent`, don't wrap a Restyle component in +`Animated.createAnimatedComponent`. diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 4da08813..ce78aeeb 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -61,6 +61,7 @@ module.exports = { collapsed: true, items: [ 'guides/dark-mode', + 'guides/animating-restyle-components', 'guides/fixture-app', 'guides/shopify-design-system', 'guides/migrating-to-v2', diff --git a/src/createAnimatedRestyleComponent.ts b/src/createAnimatedRestyleComponent.ts new file mode 100644 index 00000000..22748718 --- /dev/null +++ b/src/createAnimatedRestyleComponent.ts @@ -0,0 +1,39 @@ +import React from 'react'; + +import createBox from './createBox'; +import {BaseTheme} from './types'; + +/** + * Wraps an already-animated component (e.g. `Animated.createAnimatedComponent(View)` + * from `react-native-reanimated`, or `Animated.View` from the built-in `Animated` API) + * with Restyle's `Box` props, inferring the wrapped component's own prop types (its + * `style` prop in particular) instead of requiring them to be re-declared by hand. + * + * Restyle never imports an animation library itself: the caller constructs the animated + * component and hands it in, so this stays a zero-dependency wrapper around `createBox`. + * + * Ordering matters: nest the animated component *inside* Restyle + * (`createAnimatedRestyleComponent(Animated.createAnimatedComponent(View))`), not the + * other way around (`Animated.createAnimatedComponent(createBox())`). Outside-in, a + * library that re-renders with resolved style values spread as top-level props (as + * react-native-reanimated 4.4+ does once an animation settles, when its + * `FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS` flag is on) lands those raw values in + * Restyle's theme-token prop namespace. Inside-out, those props are spread on the + * plain wrapped component instead, never reaching Restyle's prop parsing. See the + * "Animating Restyle components" guide for the full explanation and an example. + */ +const createAnimatedRestyleComponent = < + Theme extends BaseTheme, + AnimatedComponentType extends React.ComponentType, + EnableShorthand extends boolean = true, +>( + AnimatedComponent: AnimatedComponentType, +) => { + return createBox< + Theme, + React.ComponentProps, + EnableShorthand + >(AnimatedComponent); +}; + +export default createAnimatedRestyleComponent; diff --git a/src/index.ts b/src/index.ts index 333a0903..de75cb35 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export * from './createText'; export {default as createVariant} from './createVariant'; export {default as createBox} from './createBox'; export {default as createText} from './createText'; +export {default as createAnimatedRestyleComponent} from './createAnimatedRestyleComponent'; export {ThemeProvider, ThemeContext} from './context'; export {default as useTheme} from './hooks/useTheme'; export {default as useRestyle} from './hooks/useRestyle'; diff --git a/src/test/createAnimatedRestyleComponent.test.tsx b/src/test/createAnimatedRestyleComponent.test.tsx new file mode 100644 index 00000000..65a6b9a5 --- /dev/null +++ b/src/test/createAnimatedRestyleComponent.test.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import {create as render, act} from 'react-test-renderer'; +import {View} from 'react-native'; + +import createAnimatedRestyleComponent from '../createAnimatedRestyleComponent'; +import createRestyleComponent from '../createRestyleComponent'; +import createVariant, {VariantProps} from '../createVariant'; +import {boxRestyleFunctions, BoxProps} from '../createBox'; +import {ThemeProvider} from '../context'; + +const theme = { + colors: { + primary: '#5A31F4', + }, + spacing: { + s: 8, + }, + borderRadii: { + sm: 4, + lg: 32, + }, + zIndices: { + base: 0, + top: 1, + }, + cardVariants: { + defaults: { + borderRadius: 'sm', + }, + raised: { + borderRadius: 'lg', + }, + }, +}; + +type Theme = typeof theme; + +/** + * Stands in for `Animated.createAnimatedComponent(View)` without depending on + * react-native-reanimated in the test suite. It reproduces the exact mechanism + * behind #355: once "settled" (triggered here via the imperative `settle` method, + * standing in for Reanimated's FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS flag), it + * spreads the given raw style values as top-level props onto the plain `View` it + * renders internally — via its own setState/render, never by re-rendering with new + * props passed down from its parent. That's the crux of what ordering fixes: those + * settled props land on this component's own child, not back up on whatever wraps it. + */ +class MockAnimatedView extends React.Component< + {style?: unknown; [key: string]: unknown}, + {settled: {[key: string]: unknown} | null} +> { + state: {settled: {[key: string]: unknown} | null} = {settled: null}; + + // Intentionally public: tests call this through a ref to simulate the + // imperative settle event a real animation library fires internally. + // eslint-disable-next-line @shopify/react-prefer-private-members + settle(values: {[key: string]: unknown}) { + this.setState({settled: values}); + } + + render() { + const {settled} = this.state; + return ; + } +} + +const AnimatedBox = createAnimatedRestyleComponent< + Theme, + typeof MockAnimatedView +>(MockAnimatedView); + +describe('createAnimatedRestyleComponent', () => { + it('does not throw when a settled animation spreads a raw color value', () => { + let instance: MockAnimatedView | null = null; + const {root} = render( + + (instance = r)} /> + , + ); + + expect(() => { + act(() => { + instance!.settle({backgroundColor: 'rgba(255,209,102,1)'}); + }); + }).not.toThrow(); + + expect(root.findByType(View).props.backgroundColor).toBe( + 'rgba(255,209,102,1)', + ); + }); + + it('does not throw when a settled animation spreads raw borderRadius and zIndex values', () => { + let instance: MockAnimatedView | null = null; + const {root} = render( + + (instance = r)} /> + , + ); + + expect(() => { + act(() => { + instance!.settle({borderRadius: 13, zIndex: 1}); + }); + }).not.toThrow(); + + expect(root.findByType(View).props).toMatchObject({ + borderRadius: 13, + zIndex: 1, + }); + }); + + it('still resolves theme tokens normally', () => { + const {root} = render( + + + , + ); + + expect(root.findByType(View).props.style).toStrictEqual([ + {backgroundColor: '#5A31F4', borderRadius: 32, zIndex: 1}, + ]); + }); + + it("still throws on a genuine typo'd theme key", () => { + expect(() => { + render( + + {/* @ts-expect-error intentionally invalid theme key, to prove typo detection is unaffected */} + + , + ); + }).toThrow("Value 'primaryy' does not exist in theme['colors']"); + }); + + it('supports variants when composed with createVariant, same as a non-animated custom component', () => { + // `Box` (and so `createAnimatedRestyleComponent`, which shares its restyle + // functions) doesn't bundle variant support out of the box — a variant-aware + // component is composed explicitly via createRestyleComponent, same as the + // "Custom components" guide shows for non-animated components. This proves + // that composition still works with an animated base component. + const cardVariant = createVariant({ + themeKey: 'cardVariants', + }); + type Props = BoxProps & + VariantProps & + React.ComponentProps; + const AnimatedCard = createRestyleComponent( + [...boxRestyleFunctions, cardVariant], + MockAnimatedView, + ); + + const {root} = render( + + + , + ); + + expect(root.findByType(View).props.style).toStrictEqual([ + {borderRadius: 32}, + ]); + }); + + it('supports responsive props', () => { + const {root} = render( + + + , + ); + + expect(root.findByType(View).props.style).toStrictEqual([ + {borderRadius: 4}, + ]); + }); + + it('forwards refs to the wrapped animated component', () => { + const spy = jest.fn(); + render( + + + , + ); + + expect(spy).toHaveBeenCalledWith(expect.any(MockAnimatedView)); + }); +});