Skip to content

Commit 31c7035

Browse files
authored
fix(Android,Fabric): prevent another infinite state update loop for header subviews with zero size (#2696)
## Description Closes #2675 ### Error mechanism This one was tricky to figure out. Few concurrent facts first: 1. When `statusBarTranslucent` is `true` we apply `paddingTop` to native `Toolbar` to heighten the header. 2. Since 4.6.0 we update frames of header subviews in ShadowTree to the values from HostTree, everytime the frame changes in HostTree. 3. We had a check in header subview shadow node that would prevent node size update if the frame was (0, 0). Header subview that represents the search bar has no content. Therefore native layout sets it frame to exactly (0, 0), but with origin `(x, toolbar.paddingTop)`. This frame - `((x, toolbar.paddingTop), (0, 0))` is send to shadow node, **where it is ignored**, but the layout is triggered by subsequent *header config* update. Therefore Yoga resolves height of the subview to full height of the parent (usually `154 px = 40 dip`). New layout metrics are sent to HostTree, where next native toolbar layout determines toolbar size to be its content height + padding == subview height + paddingTop. This gets send to ShadowTree, then back to HostTree, native layout is triggered and another `paddingTop` value is added to the overall header height, and so on. This is a regression introduced in 4.6.0 and limited to Fabric. The fix is simple - accept the (0, 0) size for the subview in ShadowTree. We just need to use more reasonable value to denote the uninitialized frame - `{-1, -1}` seems like a better choice as it is an invalid frame. ## Changes - **Use different initial value in state so that we can unambiguously distinguish between un- and initialized state** ## Test code and steps to reproduce `Test2675` Tested on all combinations: * Android SDK 34 (w/o edge-to-edge) and 35 (w/ edge-to-edge enabled), * `statusBarTranslucent: true` and `false`, * search bar present, not present * other header elements present / not present. Also tested iOS on the same example, because the code changes affect shared C++ code. ## Checklist - [x] Included code example that can be used to test this change - [x] Ensured that CI passes
1 parent 8de5d70 commit 31c7035

7 files changed

Lines changed: 142 additions & 8 deletions

File tree

android/src/fabric/java/com/swmansion/rnscreens/FabricEnabledHeaderSubviewViewGroup.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ abstract class FabricEnabledHeaderSubviewViewGroup(
1616

1717
private var lastWidth = 0f
1818
private var lastHeight = 0f
19+
private var lastOffsetX = 0f
20+
private var lastOffsetY = 0f
1921

2022
fun setStateWrapper(wrapper: StateWrapper?) {
2123
mStateWrapper = wrapper
@@ -45,13 +47,17 @@ abstract class FabricEnabledHeaderSubviewViewGroup(
4547
// Check incoming state values. If they're already the correct value, return early to prevent
4648
// infinite UpdateState/SetState loop.
4749
if (abs(lastWidth - realWidth) < DELTA &&
48-
abs(lastHeight - realHeight) < DELTA
50+
abs(lastHeight - realHeight) < DELTA &&
51+
abs(lastOffsetX - offsetXDip) < DELTA &&
52+
abs(lastOffsetY - offsetYDip) < DELTA
4953
) {
5054
return
5155
}
5256

5357
lastWidth = realWidth
5458
lastHeight = realHeight
59+
lastOffsetX = offsetXDip
60+
lastOffsetY = offsetYDip
5561

5662
val map: WritableMap =
5763
WritableNativeMap().apply {

apps/src/tests/Test2675.tsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { NavigationContainer } from '@react-navigation/native';
2+
import { NativeStackNavigationProp, createNativeStackNavigator } from '@react-navigation/native-stack';
3+
import React from 'react';
4+
import { Button, StyleSheet, Text, View } from 'react-native';
5+
6+
type RouteParams = {
7+
Home: undefined;
8+
DynamicHeader: undefined;
9+
}
10+
11+
type NavigationProps = {
12+
navigation: NativeStackNavigationProp<RouteParams>;
13+
}
14+
15+
const Stack = createNativeStackNavigator();
16+
17+
function HomeScreen({ navigation }: NavigationProps) {
18+
return (
19+
<View style={styles.container}>
20+
<Text>Home Screen</Text>
21+
<Button title="Navigate DynamicHeaderScreen" onPress={() => navigation.navigate('DynamicHeader')} />
22+
</View>
23+
);
24+
}
25+
26+
function DynamicHeaderScreen({ navigation }: NavigationProps) {
27+
React.useLayoutEffect(() => {
28+
navigation.setOptions({
29+
headerRight: HeaderRight,
30+
});
31+
}, [navigation]);
32+
33+
React.useEffect(() => {
34+
const timerId = setTimeout(() => {
35+
navigation.setOptions({
36+
headerLeft: HeaderLeft,
37+
headerRight: HeaderLeft,
38+
});
39+
}, 1300);
40+
41+
return () => {
42+
clearTimeout(timerId);
43+
};
44+
}, [navigation]);
45+
46+
return (
47+
<View style={styles.container}>
48+
<Text>DynamicHeaderScreen</Text>
49+
<Button title="Go back" onPress={() => navigation.popTo('Home')} />
50+
</View>
51+
);
52+
}
53+
54+
function HeaderLeft() {
55+
return (
56+
<View>
57+
<Text>Left</Text>
58+
</View>
59+
);
60+
}
61+
62+
function HeaderTitle() {
63+
return (
64+
<View>
65+
<Text>Title</Text>
66+
</View>
67+
);
68+
}
69+
70+
function HeaderRight() {
71+
return (
72+
<View>
73+
<Text>Right</Text>
74+
</View>
75+
);
76+
}
77+
78+
export default function App() {
79+
return (
80+
<NavigationContainer>
81+
<Stack.Navigator>
82+
<Stack.Screen
83+
name="Home"
84+
component={HomeScreen}
85+
options={{
86+
statusBarTranslucent: true,
87+
statusBarStyle: 'dark',
88+
//headerTitle: HeaderTitle,
89+
//headerRight: HeaderRight,
90+
headerSearchBarOptions: {
91+
placeholder: 'Search...',
92+
onChangeText: (event) => {
93+
console.log('Search text:', event.nativeEvent.text);
94+
},
95+
},
96+
}}
97+
/>
98+
<Stack.Screen name="DynamicHeader" component={DynamicHeaderScreen} options={{
99+
statusBarTranslucent: true,
100+
statusBarStyle: 'dark',
101+
headerTitle: HeaderTitle,
102+
headerSearchBarOptions: {
103+
placeholder: 'Search...',
104+
onChangeText: (event) => {
105+
console.log('Search text:', event.nativeEvent.text);
106+
},
107+
},
108+
}} />
109+
</Stack.Navigator>
110+
</NavigationContainer>
111+
);
112+
}
113+
114+
const styles = StyleSheet.create({
115+
container: {
116+
flex: 1,
117+
justifyContent: 'center',
118+
alignItems: 'center',
119+
},
120+
});
121+

apps/src/tests/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export { default as Test2466 } from './Test2466';
121121
export { default as Test2552 } from './Test2552';
122122
export { default as Test2631 } from './Test2631';
123123
export { default as Test2668 } from './Test2668';
124+
export { default as Test2675 } from './Test2675';
124125
export { default as TestScreenAnimation } from './TestScreenAnimation';
125126
export { default as TestScreenAnimationV5 } from './TestScreenAnimationV5';
126127
export { default as TestHeader } from './TestHeader';

common/cpp/react/renderer/components/rnscreens/RNSScreenStackHeaderConfigComponentDescriptor.h

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ class RNSScreenStackHeaderConfigComponentDescriptor final
3535
auto stateData = state->getData();
3636

3737
if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
38-
layoutableShadowNode.setSize(
39-
{stateData.frameSize.width, stateData.frameSize.height});
38+
layoutableShadowNode.setSize(stateData.frameSize);
4039
#ifdef ANDROID
4140
layoutableShadowNode.setPadding({
4241
stateData.paddingStart,

common/cpp/react/renderer/components/rnscreens/RNSScreenStackHeaderSubviewComponentDescriptor.h

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
#include <react/renderer/components/rnscreens/utils/RectUtil.h>
99
#include <react/renderer/core/ConcreteComponentDescriptor.h>
1010
#include "RNSScreenStackHeaderSubviewShadowNode.h"
11+
#include "utils/RectUtil.h"
1112

1213
namespace facebook::react {
1314

@@ -32,12 +33,11 @@ class RNSScreenStackHeaderSubviewComponentDescriptor final
3233

3334
auto state = std::static_pointer_cast<
3435
const RNSScreenStackHeaderSubviewShadowNode::ConcreteState>(
35-
shadowNode.getState());
36+
shadowNode.getMostRecentState());
3637
auto stateData = state->getData();
3738

38-
if (stateData.frameSize.width != 0 && stateData.frameSize.height != 0) {
39-
layoutableShadowNode.setSize(
40-
Size{stateData.frameSize.width, stateData.frameSize.height});
39+
if (!isSizeEmpty(stateData.frameSize)) {
40+
layoutableShadowNode.setSize(stateData.frameSize);
4141
}
4242

4343
ConcreteComponentDescriptor::adopt(shadowNode);

common/cpp/react/renderer/components/rnscreens/RNSScreenStackHeaderSubviewState.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ class JSI_EXPORT RNSScreenStackHeaderSubviewState final {
4444

4545
#endif // ANDROID
4646

47-
const Size frameSize{};
47+
const Size frameSize{-1.f, -1.f};
4848
Point contentOffset{};
4949

5050
#pragma mark - Getters

common/cpp/react/renderer/components/rnscreens/utils/RectUtil.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,11 @@ inline constexpr bool checkFrameSizesEqualWithEps(
3333
equalWithRespectToEps(first.height, second.height, eps);
3434
}
3535

36+
/**
37+
* @return false if any component value is less than 0
38+
*/
39+
inline constexpr bool isSizeEmpty(const react::Size &size) {
40+
return size.width < 0 || size.height < 0;
41+
}
42+
3643
} // namespace rnscreens

0 commit comments

Comments
 (0)