Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove non-layout style and prop updates path via synchronouslyUpdatePropsOnUIThread #7014

Merged

Conversation

tomekzaw
Copy link
Member

@tomekzaw tomekzaw commented Feb 11, 2025

Motivation

Currently, there are two ways to update native view props and styles in Reanimated. The default path (so-called slow path) is to apply all props changes to the ShadowTree via C++ API and let React Native mount the changes. However, if all props updated in given batch are non-layout props (i.e. those that don't require layout recalculation, like background color or opacity) we use a fast path that calls synchronouslyUpdatePropsOnUIThread from React Native and applies the changes directly to platform views, without making changes to ShadowTree in C++. Turns out, some features like view measurement or touch detection system use C++ ShadowTree which is not consistent with what's currently on the screen. Because of that, we're removing the fast path (turns out it's not that fast, especially on iOS) to restore the correctness of view measurement and touch detection for animated components.

Benchmarks

  • Performance monitor example → Bokeh Example
  • Android emulator / iPhone 14 Pro real device
  • Debug mode
  • Animating transform prop using useAnimatedStyle
Platform Before (main) After (this PR)
Android (count=200) 20 fps 15 fps
iOS (count=500) 22 fps 22 fps
App.tsx
import React, { useState } from 'react';
import { Dimensions, StyleSheet, View } from 'react-native';
import Animated, {
  Easing,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

const dimensions = Dimensions.get('window');

function randBetween(min: number, max: number) {
  return min + Math.random() * (max - min);
}

function Circle() {
  const shouldReduceMotion = useReducedMotion();

  const [power] = useState(randBetween(0, 1));
  const [duration] = useState(randBetween(2000, 3000));

  const size = 100 + power * 250;
  const width = size;
  const height = size;
  const hue = randBetween(100, 200);
  const backgroundColor = `hsl(${hue},100%,50%)`;

  const opacity = 0.1 + (1 - power) * 0.1;
  const config = { duration, easing: Easing.linear };

  const left = useSharedValue(randBetween(0, dimensions.width) - size / 2);
  const top = useSharedValue(randBetween(0, dimensions.height) - size / 2);

  const update = () => {
    left.value = withTiming(left.value + randBetween(-100, 100), config);
    top.value = withTiming(top.value + randBetween(-100, 100), config);
  };

  React.useEffect(() => {
    update();
    if (shouldReduceMotion) {
      return;
    }
    const id = setInterval(update, duration);
    return () => clearInterval(id);
  });

  const animatedStyle = useAnimatedStyle(
    () => ({
      transform: [{ translateX: left.value }, { translateY: top.value }],
    }),
    []
  );

  return (
    <Animated.View
      style={[
        styles.circle,
        { width, height, backgroundColor, opacity },
        animatedStyle,
      ]}
    />
  );
}

interface BokehProps {
  count: number;
}

function Bokeh({ count }: BokehProps) {
  return (
    <>
      {[...Array(count)].map((_, i) => (
        <Circle key={i} />
      ))}
    </>
  );
}

export default function App() {
  return (
    <View style={styles.container}>
      <Bokeh count={200} />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    alignItems: 'center',
    justifyContent: 'center',
    backgroundColor: 'black',
    overflow: 'hidden',
  },
  circle: {
    position: 'absolute',
    borderRadius: 999,
  },
});

Summary

Test plan

@tomekzaw tomekzaw changed the title Remove synchronouslyUpdateUIPropsFunction_ call in performOperations Remove synchronouslyUpdatePropsOnUIThread path for non-layout prop updates Feb 12, 2025
@tomekzaw tomekzaw changed the title Remove synchronouslyUpdatePropsOnUIThread path for non-layout prop updates Remove non-layout style and prop updates path via synchronouslyUpdatePropsOnUIThread Feb 12, 2025
Copy link
Collaborator

@tjzel tjzel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, should we also merge/rework props lists in TS?

@tomekzaw
Copy link
Member Author

tomekzaw commented Feb 12, 2025

should we also merge/rework props lists in TS?

@tjzel We'll need to discuss this. There's no need to keep separate sets for UI and native props (since I've merged the logic of applying UI and native props) but I think we still need to expose addWhitelistedNativeProps and addWhitelistedUIProps for backward-compatibility of third-party libraries built on top on Reanimated, to some extent.

@tomekzaw tomekzaw marked this pull request as ready for review February 14, 2025 09:50
@tjzel
Copy link
Collaborator

tjzel commented Feb 14, 2025

@tomekzaw We can keep both these methods and mark one of them as deprecated.

@tomekzaw tomekzaw added this pull request to the merge queue Feb 25, 2025
Merged via the queue into main with commit 466e42a Feb 25, 2025
19 checks passed
@tomekzaw tomekzaw deleted the @tomekzaw/remove-synchronously-update-props-on-ui-thread branch February 25, 2025 15:03
github-merge-queue bot pushed a commit that referenced this pull request Feb 26, 2025
<!-- Thanks for submitting a pull request! We appreciate you spending
the time to work on these changes. Please follow the template so that
the reviewers can easily understand what the code changes affect. -->

## Summary

This PR is very annoying. Until now we assumed that when
`componentWillUnmount` is called, the component will never reappear.
This is false when using `<Suspense>`
([docs](https://react.dev/reference/react/Suspense#caveats)), which is
exactly what `react-freeze` does. To fix the issue I had to move our
animation cleanup functions to be called only when we are sure that the
component will not reappear. This is how the new approach works for each
registry:
- `AnimatedPropsRegistry`: we remove a record from this registry only
when we fail to apply it onto the ShadowTree (in the commit hook or
`perfromOperations`)
- `CSSAnimationsRegistry`: we remove a record from this registry only
when we fail to apply it onto the ShadowTree (in the commit hook or
`perfromOperations`)
- `CSSTransitionsRegistry`: we remove a record from this registry when
`componentWillUnmount` is called and there is no ongoing transition OR
we failed to apply it onto the ShadowTree (in the commit hook or
`perfromOperations`)
- `StaticPropsRegistry` remains the same as it doesn't store any state
that needs to be persisted when freeze is used

One problem with this approach is that we are running animations on
components that are freezed. This is a waste of resources. This will be
addressed in a separate PR, but the main idea is to pause animations in
`componentWillUnmount` and resume them (with the new timestamp) in
`compnentDidMount`.

Also currently the leak check will say that `AnimatedPropsRegistry`
leaked if we don't reload it. This is because an animation frame comes
after the cleanup from the commit hook. Normally this would be cleaned
up in `performOperations`, but because of the `sunchronouslyUpdateProps`
fast-path it won't. The cleanup will still happen on the next commit
hook invocation (i.e. if you hit reload on the leak check). This issue
soon will be gone as we are removing the fast path in #7014.

| Before | After |
| -------- | ------- |
| <video
src="https://github.com/user-attachments/assets/249acaac-f1f0-4846-a38e-4dab54cfb49b"/>
| <video
src="https://github.com/user-attachments/assets/5d3c578b-fb80-45e8-9955-542bf47d0573"/>
|

## Test plan

Go through the app, and then visit the `FreezeExample`. Use the `check
for registry leaks` button.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
5 participants