Skip to content

Commit

Permalink
fix: CSS transition shorthand property behavior in react-native-web (#…
Browse files Browse the repository at this point in the history
…7019)

## Summary

This PR fixes CSS transition properties being overridden by
`react-native-web`. It happened because we didn't filter out CSS props
from the style object passed to the component in the `render()` method
of the `AnimatedComponent`.

I also made a few other changes to ensure that the transition shorthand
is validated on web as well and preprocessed in a similar way to the
native implementation.

## Example Recordings

In the previous implementation, the `transitionTimingFunction` prop
specified before the `transition` property shorthand was overriding the
`transition` prop easing. Web also shown an error in the console that
shorthand props shouldn't be mixed with longhand ones. We don't want
this error to show up and want to support usage of both at the same
time.

### Web

| Before | After |
|-|-|
| <video
src="https://github.com/user-attachments/assets/59edb895-5f5d-4202-bc8c-8080166251a9"
/> | <video
src="https://github.com/user-attachments/assets/bfb052c0-60e7-4431-a8b8-58c85e43da4c"
/> |

### iOS

This recording is just for comparison to see that the behavior on web is
also correct (the same as on the recording below) after changes
implemented in this PR


https://github.com/user-attachments/assets/6fb73423-28a0-4822-8a5d-5dc08e7e3bd6


<details>
  <summary>Source code</summary>

```tsx
/* eslint-disable perfectionist/sort-objects */
import React, { useReducer } from 'react';
import { Button, StyleSheet, View } from 'react-native';
import { createAnimatedComponent, steps } from 'react-native-reanimated';

const AnimatedView = createAnimatedComponent(View);

export default function EmptyExample() {
  const [state, toggleState] = useReducer((s) => !s, false);

  return (
    <View style={styles.container}>
      <AnimatedView
        style={{
          width: state ? 200 : 100,
          height: state ? 200 : 100,
          transform: [{ rotate: state ? '45deg' : '0deg' }],
          transitionTimingFunction: steps(2), // overridden by the shorthand
          backgroundColor: 'red',
          transition:
            '5s cubic-bezier(0, -0.54, 1, -0.5), transform 2s ease-in',
          transitionDuration: 1000, // overrides the shorthand
        }}
      />
      <Button title="Toggle width" onPress={toggleState} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    alignItems: 'center',
    flex: 1,
    justifyContent: 'center',
  },
});
```
</details>
  • Loading branch information
MatiPl01 authored Feb 20, 2025
1 parent ed49ccd commit 1729131
Show file tree
Hide file tree
Showing 25 changed files with 391 additions and 285 deletions.
2 changes: 0 additions & 2 deletions packages/react-native-reanimated/src/commonTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,6 @@ export interface StyleProps extends ViewStyle, TextStyle {
[key: string]: any;
}

export type PlainStyle = ViewStyle & TextStyle & ImageStyle;

/**
* A value that can be used both on the [JavaScript
* thread](https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/glossary#javascript-thread)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
'use strict';
import '../layoutReanimation/animationsManager';

import type { Component } from 'react';
import React from 'react';
import { Platform } from 'react-native';
import type React from 'react';

import { removeFromPropsRegistry } from '../AnimatedPropsRegistry';
import { getReduceMotionFromConfig } from '../animation/util';
Expand Down Expand Up @@ -345,7 +343,7 @@ export default class AnimatedComponent
const filtered = filterStyles(flattenArray(props.style ?? []));
this._prevAnimatedStyles = this._animatedStyles;
this._animatedStyles = filtered.animatedStyles;
this._planStyle = filtered.plainStyle;
this._cssStyle = filtered.cssStyle;
}

_configureLayoutTransition() {
Expand Down Expand Up @@ -468,11 +466,6 @@ export default class AnimatedComponent
};
}

const platformProps = Platform.select({
web: {},
default: { collapsable: false },
});

const skipEntering = this.context?.current;
const nativeID =
skipEntering || !isFabric() ? undefined : `${this.reanimatedID}`;
Expand All @@ -484,18 +477,10 @@ export default class AnimatedComponent
}
: {};

const ChildComponent = this.ChildComponent;

return (
<ChildComponent
nativeID={nativeID}
{...filteredProps}
{...jestProps}
// Casting is used here, because ref can be null - in that case it cannot be assigned to HTMLElement.
// After spending some time trying to figure out what to do with this problem, we decided to leave it this way
ref={this._setComponentRef as (ref: Component) => void}
{...platformProps}
/>
);
return super.render({
nativeID,
...filteredProps,
...jestProps,
});
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';
import type { PlainStyle, StyleProps } from '../commonTypes';
import type { StyleProps } from '../commonTypes';
import type { CSSStyle } from '../css';
import type { NestedArray } from './commonTypes';

export function flattenArray<T>(array: NestedArray<T>): T[] {
Expand Down Expand Up @@ -36,25 +37,25 @@ export const has = <K extends string>(
};

export function filterStyles(styles: StyleProps[] | undefined): {
plainStyle: PlainStyle;
cssStyle: CSSStyle;
animatedStyles: StyleProps[];
} {
if (!styles) {
return { animatedStyles: [], plainStyle: {} };
return { animatedStyles: [], cssStyle: {} };
}

return styles.reduce<{
plainStyle: PlainStyle;
cssStyle: CSSStyle;
animatedStyles: StyleProps[];
}>(
({ animatedStyles, plainStyle }, style) => {
({ animatedStyles, cssStyle }, style) => {
if (style?.viewDescriptors) {
animatedStyles.push(style);
} else {
plainStyle = { ...plainStyle, ...style } as PlainStyle;
cssStyle = { ...cssStyle, ...style } as CSSStyle;
}
return { animatedStyles, plainStyle };
return { animatedStyles, cssStyle };
},
{ animatedStyles: [], plainStyle: {} }
{ animatedStyles: [], cssStyle: {} }
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
'use strict';
import type { MutableRefObject, Ref } from 'react';
import type { ComponentProps, MutableRefObject, Ref } from 'react';
import React, { Component } from 'react';
import type { StyleProp } from 'react-native';
import { Platform, StyleSheet } from 'react-native';
Expand All @@ -17,6 +17,7 @@ import { isFabric, isWeb, shouldBeUseWeb } from '../../PlatformChecker';
import { ReanimatedError } from '../errors';
import { CSSManager } from '../managers';
import type { AnyComponent, AnyRecord, CSSStyle, PlainStyle } from '../types';
import { filterNonCSSStyleProps } from './utils';

const SHOULD_BE_USE_WEB = shouldBeUseWeb();
const IS_WEB = isWeb();
Expand All @@ -37,7 +38,7 @@ export default class AnimatedComponent<
_CSSManager?: CSSManager;

_viewInfo?: ViewInfo;
_planStyle: CSSStyle = {};
_cssStyle: CSSStyle = {}; // RN style object with Reanimated CSS properties
_componentRef: AnimatedComponentRef | HTMLElement | null = null;
_hasAnimatedRef = false;
// Used only on web
Expand Down Expand Up @@ -148,15 +149,15 @@ export default class AnimatedComponent<
};

_updateStyles(props: P) {
this._planStyle = StyleSheet.flatten(props.style) ?? {};
this._cssStyle = StyleSheet.flatten(props.style) ?? {};
}

componentDidMount() {
this._updateStyles(this.props);

if (isFabric() || IS_WEB) {
this._CSSManager = new CSSManager(this._getViewInfo());
this._CSSManager?.attach(this._planStyle);
this._CSSManager?.attach(this._cssStyle);
}
}

Expand All @@ -168,14 +169,14 @@ export default class AnimatedComponent<
this._updateStyles(nextProps);

if (this._CSSManager) {
this._CSSManager.update(this._planStyle);
this._CSSManager.update(this._cssStyle);
}

// TODO - maybe check if the render is necessary instead of always returning true
return true;
}

render() {
render(props?: ComponentProps<AnyComponent>) {
const { ChildComponent } = this;

const platformProps = Platform.select({
Expand All @@ -186,7 +187,9 @@ export default class AnimatedComponent<
return (
<ChildComponent
{...this.props}
{...props}
{...platformProps}
style={filterNonCSSStyleProps(props?.style ?? this.props.style)}
// Casting is used here, because ref can be null - in that case it cannot be assigned to HTMLElement.
// After spending some time trying to figure out what to do with this problem, we decided to leave it this way
ref={this._setComponentRef as (ref: Component) => void}
Expand Down
42 changes: 42 additions & 0 deletions packages/react-native-reanimated/src/css/component/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict';
import type {
Falsy,
RecursiveArray,
RegisteredStyle,
StyleProp,
} from 'react-native';

import type { AnyRecord, CSSStyle } from '../types';
import { isCSSStyleProp } from '../utils/guards';

type BaseStyle = CSSStyle | Falsy | RegisteredStyle<CSSStyle>;
type StyleProps = BaseStyle | RecursiveArray<BaseStyle> | readonly BaseStyle[];

function filterNonCSSStylePropsRecursive(
props: StyleProps
): StyleProp<CSSStyle> {
if (Array.isArray(props)) {
return props.map(filterNonCSSStylePropsRecursive);
}

if (!props) {
return props;
}

if (typeof props === 'object') {
return Object.entries(props).reduce<AnyRecord>((acc, [key, value]) => {
if (!isCSSStyleProp(key)) {
acc[key] = value;
}
return acc;
}, {});
}

return props;
}

export function filterNonCSSStyleProps(
props: StyleProp<CSSStyle>
): StyleProp<CSSStyle> {
return filterNonCSSStylePropsRecursive(props);
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,9 @@ export const VALID_PREDEFINED_TIMING_FUNCTIONS: PredefinedTimingFunction[] = [
'step-start',
'step-end',
];

export const VALID_PARAMETRIZED_TIMING_FUNCTIONS: string[] = [
'cubic-bezier',
'steps',
'linear',
];
1 change: 1 addition & 0 deletions packages/react-native-reanimated/src/css/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ export type {
CSSTransitionProperties,
CSSTransitionProperty,
CSSTransitionSettings,
CSSTransitionShorthand,
CSSTransitionTimingFunction,
} from './types';
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,6 @@ export default class CSSAnimationsManager {
}

attach(animationProperties: ExistingCSSAnimationProperties | null) {
if (!animationProperties) {
return;
}

this.update(animationProperties);
}

Expand Down Expand Up @@ -154,24 +150,17 @@ export default class CSSAnimationsManager {
) {
this.element.style.animationName = animationNames.join(',');

const maybeDuration = maybeAddSuffixes(
this.element.style.animationDuration = maybeAddSuffixes(
animationSettings,
'animationDuration',
'ms'
);
).join(',');

if (maybeDuration) {
this.element.style.animationDuration = maybeDuration.join(',');
}

const maybeDelay = maybeAddSuffixes(
this.element.style.animationDelay = maybeAddSuffixes(
animationSettings,
'animationDelay',
'ms'
);
if (maybeDelay) {
this.element.style.animationDelay = maybeDelay.join(',');
}
).join(',');

if (animationSettings.animationIterationCount) {
this.element.style.animationIterationCount =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,7 @@ export default class CSSManager {
}

attach(style: CSSStyle): void {
const [animationConfig, transitionConfig] =
filterCSSAndStyleProperties(style);

if (animationConfig) {
this.animationsManager.attach(animationConfig);
}

if (transitionConfig) {
this.transitionsManager.attach(transitionConfig);
}
this.update(style);
}

update(style: CSSStyle): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
'use strict';
import type { ReanimatedHTMLElement } from '../../ReanimatedModule/js-reanimated';
import { maybeAddSuffixes, parseTimingFunction } from '../platform/web';
import {
maybeAddSuffixes,
normalizeCSSTransitionProperties,
parseTimingFunction,
} from '../platform/web';
import type { CSSTransitionProperties } from '../types';
import { convertPropertiesToArrays, kebabizeCamelCase } from '../utils';
import { kebabizeCamelCase } from '../utils';

export default class CSSTransitionsManager {
private readonly element: ReanimatedHTMLElement;
Expand All @@ -25,54 +29,46 @@ export default class CSSTransitionsManager {
return;
}

this.setElementAnimation(transitionProperties);
this.setElementTransition(transitionProperties);
}

detach() {
this.element.style.transition = '';
this.element.style.transitionProperty = '';
this.element.style.transitionDuration = '';
this.element.style.transitionDelay = '';
this.element.style.transitionProperty = '';
this.element.style.transitionTimingFunction = '';
// @ts-ignore this is correct
this.element.style.transitionBehavior = '';
}

private setElementAnimation(transitionProperties: CSSTransitionProperties) {
const propertiesAsArray = convertPropertiesToArrays(transitionProperties);
private setElementTransition(transitionProperties: CSSTransitionProperties) {
const normalizedProps =
normalizeCSSTransitionProperties(transitionProperties);

const maybeDuration = maybeAddSuffixes(
propertiesAsArray,
this.element.style.transitionProperty = normalizedProps.transitionProperty
.map(kebabizeCamelCase)
.join(',');

this.element.style.transitionDuration = maybeAddSuffixes(
normalizedProps,
'transitionDuration',
'ms'
);
if (maybeDuration) {
this.element.style.transitionDuration = maybeDuration.join(',');
}
).join(',');

const maybeDelay = maybeAddSuffixes(
propertiesAsArray,
this.element.style.transitionDelay = maybeAddSuffixes(
normalizedProps,
'transitionDelay',
'ms'
);
if (maybeDelay) {
this.element.style.transitionDelay = maybeDelay.join(',');
}
).join(',');

if (propertiesAsArray.transitionProperty) {
this.element.style.transitionProperty =
propertiesAsArray.transitionProperty.map(kebabizeCamelCase).join(',');
}

if (propertiesAsArray.transitionTimingFunction) {
this.element.style.transitionTimingFunction = parseTimingFunction(
propertiesAsArray.transitionTimingFunction
);
}
this.element.style.transitionTimingFunction = parseTimingFunction(
normalizedProps.transitionTimingFunction
);

if (propertiesAsArray.transitionBehavior) {
// @ts-ignore this is correct
this.element.style.transitionBehavior =
propertiesAsArray.transitionBehavior.map(kebabizeCamelCase).join(',');
}
// @ts-ignore this is correct
this.element.style.transitionBehavior = normalizedProps.transitionBehavior
.map(kebabizeCamelCase)
.join(',');
}
}
Loading

0 comments on commit 1729131

Please sign in to comment.