Commit 44e8d61
authored
[iOS] Re-sync layer opacity and transform from retained props when recycling buttons (#4360)
## Description
On Fabric, a v3 `Pressable` (or any `RNGestureHandlerButton`) with a
style opacity rendered correctly on first mount, but lost the opacity
once the component was unmounted and mounted again - the remount reuses
a recycled native view and the button renders fully opaque.
The root cause is a desync between the wrapper's layer and its retained
props, introduced by the recycle reset from #4131:
- `RNGestureHandlerButton.prepareForRecycle` resets `target.alpha = 1.0`
to clear residual press-animation state, but target is the
`animationTarget` - the wrapper `RNGestureHandlerButtonComponentView`,
whose `layer.opacity` carries React's style opacity.
- Fabric retains `_props` across recycling, and
`RCTViewComponentView.updateProps`: re-applies opacity only when it
differs from the retained props.
- Even when another prop change triggers `invalidateLayer` (which would
re-apply the correct opacity unconditionally), our `finalizeUpdates:`
save/restore detects that as an unwanted change and restores the stale
value.
The fix re-syncs the layer from the retained props right after the
button reset in `prepareForRecycle`, instead of leaving neutral values:
`opacity` from `viewProps.opacity`, `transform` via
`viewProps.resolveTransform(_layoutMetrics)`.
> [!NOTE]
> The transform gets the same treatment: the identity reset leaves the
layer desynced from the retained props in the same way, it just isn't
visible today because `updateLayoutMetrics` happens to recompute
transforms after recycling. Re-syncing removes the reliance on that
implementation detail.
Fixes #4353
## Test plan
<details>
<summary>Tested on the following code:</summary>
```tsx
import React, { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import { Pressable } from 'react-native-gesture-handler';
// Repro for #4353
// iOS + Fabric: style opacity (and transform) on a v3 Pressable is applied on
// the first mount, but lost after the component is unmounted and mounted again
// (the remount reuses a recycled native view). The plain Views on the right
// show the expected rendering and should stay identical to the Pressables
// after every toggle.
export default function EmptyExample() {
const [mounted, setMounted] = useState(true);
const [generation, setGeneration] = useState(0);
return (
<View style={styles.container}>
<Button
title={mounted ? 'Unmount' : 'Mount again'}
onPress={() => {
setMounted(!mounted);
if (!mounted) {
setGeneration(generation + 1);
}
}}
/>
<Text style={styles.caption}>
Remounts: {generation} — left column: Pressable, right column: expected
(plain View)
</Text>
<View style={styles.row}>
{mounted ? (
<Pressable
style={[styles.box, styles.opacityStyle]}
onPress={() => console.log('press (opacity)')}>
<Text style={styles.label}>opacity: 0.3</Text>
</Pressable>
) : (
<View style={styles.boxPlaceholder} />
)}
<View style={[styles.box, styles.opacityStyle]}>
<Text style={styles.label}>opacity: 0.3</Text>
</View>
</View>
<View style={styles.row}>
{mounted ? (
<Pressable
style={[styles.box, styles.transformStyle]}
onPress={() => console.log('press (transform)')}>
<Text style={styles.label}>rotate: 25deg</Text>
</Pressable>
) : (
<View style={styles.boxPlaceholder} />
)}
<View style={[styles.box, styles.transformStyle]}>
<Text style={styles.label}>rotate: 25deg</Text>
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
gap: 24,
},
row: {
flexDirection: 'row',
gap: 48,
},
box: {
width: 120,
height: 120,
backgroundColor: 'mediumpurple',
justifyContent: 'center',
alignItems: 'center',
},
boxPlaceholder: {
width: 120,
height: 120,
},
opacityStyle: {
opacity: 0.3,
},
transformStyle: {
transform: [{ rotate: '25deg' }],
},
caption: {
marginHorizontal: 24,
textAlign: 'center',
opacity: 0.5,
},
label: {
color: 'white',
},
});
```
</details>1 parent caf03a3 commit 44e8d61
1 file changed
Lines changed: 11 additions & 0 deletions
Lines changed: 11 additions & 0 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
131 | 131 | | |
132 | 132 | | |
133 | 133 | | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
134 | 145 | | |
135 | 146 | | |
136 | 147 | | |
| |||
0 commit comments