Skip to content

[V3] createAnimatedComponent drops most useAnimatedStyle writes v3 Pressable/Touchable #4225

@isekovanic

Description

@isekovanic

Description

Hi Software Mansion team ! I've been trying out V3 on our SDK and it works really well, however I believe something got broken with the base components (at least the ones I looked at).

In react-native-gesture-handler@3.0.0, wrapping the new v3 composite components (Pressable, Touchable and likely the other v3 components routed through NativeDetector) with Animated.createAnimatedComponent from react-native-reanimated silently drops nearly every useAnimatedStyle style write applied to that wrapper. Empirically the only animated style prop that survives is transform every other prop I tested (width, height, opacity, …) is dropped.

The same useAnimatedStyle on the legacy LegacyPressable reexport or on a plain Animated.View,behaves correctly under identical setup. This isolates the regression to the v3 detector + composite component path (I believe).

Downgrading to react-native-gesture-handler@2.31.2 solves the issue of course.

Reproducer

Minimal reproduction repo: https://github.com/isekovanic/RNGH3LayoutAnimBug

The repro renders six ReanimatedSwipeable rows in a ScrollView and each row's right action is a colored "slot" containing an animated button driven from the swipeable's appliedTranslation SharedValue. The animated style differs per row to isolate which props survive the v3 path:

  • Row 1: createAnimatedComponent(Pressable) - v3, width + opacity - BROKEN (red button width stays 0)
  • Row 2: createAnimatedComponent(Touchable) - v3, width + opacity - BROKEN (red button width stays 0)
  • Row 3: createAnimatedComponent(LegacyPressable) - v2, width + opacity - WORKS (red button grows 0 -> 80)
  • Row 4: createAnimatedComponent(Pressable) - v3, transform-only (scale) - WORKS (the only animated prop that survives)
  • Row 5: createAnimatedComponent(Pressable) - v3, transform + opacity - PARTIAL (scale animates, opacity does not)
  • Row 6: Animated.View - baseline width+opacity+scale - WORKS (all three apply; isolates the regression to the v3 detector path)

Due to rows 4 and 6 together I'm under the impression that the worklet, viewTag wiring and Reanimated commit hook are healthy. Row 5 is the dispositive case: transform.scale and opacity written from the same useAnimatedStyle worklet on the same element - one applies, the other doesn't.

I did not test any other components than the touchables/Pressable specifically.

Why disable FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS ?

This one is disabled because of the fact that the forced React render after the animation settles actually fixes the bug (it will make the item appear), since I'm guessing it reconciles the ShadowNode state under the hood and applies the correct styles. While animating however this of course does not work still.

After a bunch of debugging here's what I was able to find:

  • I believe that there's a race condition somewhere, perhaps related to the rerenders that happen during animation bookkeeping that somehow get the internal ShadowNode and the actual style out of sync (the animation runs and all UI thread values are updated, but the ShadowNode is not aware of this)
    • This is way more obvious by enabling FORCE_REACT_RENDER_FOR_SETTLED_ANIMATIONS and seeing that after the animation settles, after like 100ms or so the content actually does appear since the rerender at the end of it reconciles everything; which is why I disabled it for the repro
    • In other words, the first value is read, snapshotted and used as a stale value for each ShadowNode update that happens under the hood (for example width: 0 for some of the rows reproducing this and opacity: 0.15 for some of the others)
  • Reanimated's UI thread worklet writes (global._updateProps -> Fabric shadow tree mutation) appear to be intercepted, dropped or reverted somewhere in the RNGestureHandlerDetectorNativeComponent native implementation when the target view is a descendant of HostGestureDetector in the v3 path. I think that transform survives because it's typically committed to the layer's transform matrix on a separate path that bypasses whatever step is dropping the other writes
  • Removing the GestureDetector here entirely or simply passing some random gesture not based on the hook API (i.e new NativeGesture() for example) fixes the issue
  • Other (failed) experiments:
    • Condensing the gesture used to something really simple (it's not any of the specific composite gestures that do this) while still using the hook API
    • Stripping all other props did not really do anything
    • Ref forwarding for const Pressable = ({ ref, ...rest }) and emit ref={ref} explicitly on <PureNativeButton>
    • Reanimated version. Reproduced on both 4.4.0 + worklets 0.9.1 and 4.3.1 + worklets 0.8.3, so doesn't seem like it's an issue in react-native-reanimated

Expected behavior

Wrapping a v3 Pressable/Touchable with createAnimatedComponent should apply every useAnimatedStyle style write the same way it does on Animated.View or the legacy v2 components.

Actual behavior

Every useAnimatedStyle style prop except transform is silently dropped - no warning, no error, the worklet still fires, the legacy and Animated.View controls in the same render confirm the rest of the plumbing is alive.

Workaround

In case anyone else needs this and as mentioned in the repro, avoiding to wrap v3 composite components with createAnimatedComponent and putting the animated style on an Animated.View wrapper and using a non-animated Pressable / Touchable as a positioned child works just fine:

<Animated.View style={[styles.action, animatedStyle]}>
    <Pressable onPress={onPress} style={StyleSheet.absoluteFill}>
        {/* content */}
    </Pressable>
</Animated.View>

Animated.View writes go through the well tested fast path and bypass the v3 detector entirely (see row 6 of the repro).

Steps to reproduce

  1. git clone https://github.com/isekovanic/RNGH3LayoutAnimBug
  2. Run npm install and npx pod-install
  3. Run the app on either ios or android
  4. Try to swipe all rows
  5. Observe how the rows that wrap the new Pressable/Touchable components do not work for anything other than transform, while the legacy and baseline one work just fine

A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.

https://github.com/isekovanic/RNGH3LayoutAnimBug

Gesture Handler version

3.0.0

React Native version

0.85.3

Platforms

iOS, Android

JavaScript runtime

Hermes

Workflow

React Native (without Expo)

Architecture

New Architecture (Fabric)

Build type

Debug mode

Device

Real device

Device model

Reproduced on iPhone 12 Pro and Xiaomi Redmi Note 13, but I don't think this is related as it's reproducible pretty much on any device.

Acknowledgements

Yes

Metadata

Metadata

Assignees

No one assigned

    Labels

    Platform: AndroidThis issue is specific to AndroidPlatform: iOSThis issue is specific to iOSRepro providedA reproduction with a snack or repo is provided

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions