Skip to content

fix(Android,Fabric): prevent another infinite state update loop for header subviews with zero size#2696

Merged
kkafar merged 11 commits intomainfrom
@kkafar/weird-header-behaviour-with-search-bar-present-android-2
Feb 17, 2025
Merged

fix(Android,Fabric): prevent another infinite state update loop for header subviews with zero size#2696
kkafar merged 11 commits intomainfrom
@kkafar/weird-header-behaviour-with-search-bar-present-android-2

Conversation

@kkafar
Copy link
Copy Markdown
Member

@kkafar kkafar commented Feb 13, 2025

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

  • Included code example that can be used to test this change
  • Ensured that CI passes

Copy link
Copy Markdown
Member Author

@kkafar kkafar left a comment

Choose a reason for hiding this comment

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

Currently blocked by regression detected in testing


auto state = std::static_pointer_cast<
const RNSScreenStackHeaderSubviewShadowNode::ConcreteState>(
shadowNode.getState());
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The bug I've reported in earlier comment was caused by accessing mounted state and not the most recent one.

@kkafar kkafar merged commit 31c7035 into main Feb 17, 2025
@kkafar kkafar deleted the @kkafar/weird-header-behaviour-with-search-bar-present-android-2 branch February 17, 2025 21:30
@marcel-happyfloat
Copy link
Copy Markdown

marcel-happyfloat commented Feb 18, 2025

@kkafar Thanks for the quick fix, unfortunately I can't install v4.7.0 on IOS now.
Getting this error (after running npx expo prebuild --clean and npx expo run:ios --no-build-cache) :

⚠️ ld: ignoring duplicate libraries: '-lc++'
❌ ld: framework 'RNScreens' not found

Android works now, but it looks like it is adding some safe area insets again (as a black bar) to the bottom of my screen, when I'm navigating (default presentation) to some other root screen.

modal and screens within the current Tab Stack are working fine...

Just to let you quickly know...

@kkafar
Copy link
Copy Markdown
Member Author

kkafar commented Feb 19, 2025

@marcel-happyfloat thanks for the report, however I do need more details to investigate any of the issues you mentioned.

Could you provide a recording of the bug and preferably a snippet / detailed description of navigation hierarchy where I can reproduce the issue?

kkafar added a commit that referenced this pull request Mar 27, 2025
When setting subviews via `setOptions` from `useEffect` hook in a
component, the first frame received might be computed by native
layout & completely invalid (zero height). RN layout is the source of a
subview **size** (not origin). When we send such update with zero height
Yoga might (or might not, depending on exact update timing in relation
to other ongoing commits / layouts) set the subview height to 0!
This causes the subview to become invisible & we want to avoid that.

This had not been a problem before #2696, because we would filter out
this kind of frame in the `setSize` guard in the
`ComponentDescriptor.adopt` method of the `HeaderSubview`. #2696 allowed
for zero-sized frames for other, unrelated reason & we must allow these
  as long as they come from React Native layout & not native one.
kkafar added a commit that referenced this pull request Mar 27, 2025
…er subview (#2811)

## Description

When setting header subviews from the rendered component via
`setOptions` the native header enlarges (see the "before" video below
:point_down:).

### Bug mechanism

When `HeaderSubview` is set from `setOptions` its mounted after first
render has happened.
It means that `HeaderConfig` has already been laid out and possibly its
shadow state got
updated with its size. Now, when first layout for the `HeaderSubview` is
computed Yoga
will stretch-fit `HeaderSubview` height to fill available space - the
`HeaderSubview` will
receive height equal to the height of the `HeaderConfig`. Such frame
will be then send
to HostTree, triggering native header layout, which will expand to make
enough space for
such high `HeaderSubview` & additional padding. Thanks to #2696 the
update cycle will be broken
& the issue described in #2675 won't happen.

Note that there is no such buggy behaviour in case the `HeaderSubviews`
is passed directly as option
when defining a `Screen`. This is because Yoga resolves the
`childHeight` (`HeaderSubview` height)
differently depending on whether `containingNode`'s height
(`HeaderConfig`'s height) is defined upfront or not.
When the `containingNode` height is not known (case of initial render
with `HeaderSubview` present) the Yoga will
use `FitContent` or `MaxContent` (not sure here)
[`SizingMode`](https://github.com/facebook/yoga/blob/51e6095005fd713dbfcbaf2c6296009de782d966/yoga/algorithm/SizingMode.h#L21-L45).
In cases, it is known (`HeaderConfig` has received state from HT, case
of `HeaderSubview` rendered via `setOptions`) - `StretchFit` will be
used for some reason (taking into consideration all layout options,
including flex
direction which is `row` for both `HeaderConfig` and `HeaderSubview`).

I believe this regression has been introduced in #2466, where we added
state updates for `HeaderConfig`
and `HeaderSubviews`. We need these state updates though, however it
seems that we do not need to inform Yoga
with `HeaderConfig` height & therefore avoid this layout problem.

### Debugging trail 

It seems that the `SizingMode` for laying out `HeaderSubview` is
determined
[here.](https://github.com/facebook/yoga/blob/51e6095005fd713dbfcbaf2c6296009de782d966/yoga/algorithm/CalculateLayout.cpp#L208-L214),
which is being called from
[`computeFlexBasisForChild`](https://github.com/facebook/yoga/blob/main/yoga/algorithm/CalculateLayout.cpp#L66).
The `resolveChildAlignment` method returns there `Align::Strech` and
this leads to `SizingMode::StretchFit` being used later on when
measuring/laying out `HeaderSubview`.


### Recordings

| before | after |
| -- | -- |
| <video
src="https://github.com/user-attachments/assets/f74039f5-919f-4e28-a56d-ebb360b6ce3a"
alt="before" /> | <video
src="https://github.com/user-attachments/assets/a9bab5f8-8176-4d6d-a93e-cdd5f8e5cca3"
alt="after" /> |

## Changes

Now, we do set only width & horizontal padding of the `HeaderConfig`.
`YGUndefined` is passed as height argument to `setSize` call.

## Test code and steps to reproduce

Added `Test2811` that allows to test these changes directly. We need to
also check following test cases for regressions:

* [x] #2675 Infinite state update loop
* [x] #2466 Pressables in header
* [x] `TestHeaderTitle` Header title truncation
* [x] `TestHeaderTitle` Header spacing when changing orientation

> [!warning]
> There is a issue when header elements set in `setOptions` disappear /
become invisible. [link to internal
board](https://github.com/orgs/software-mansion/projects/3/views/1?pane=issue&itemId=103681490).
It seems that it happens also on 4.9.2 and therefore is not a
regression. However it is bad & must be fixed before stable release.

## Checklist

- [x] Included code example that can be used to test this change
- [x] Ensured that CI passes
(db55977)
kkafar added a commit that referenced this pull request Mar 27, 2025
When setting subviews via `setOptions` from `useEffect` hook in a
component, the first frame received might be computed by native
layout & completely invalid (zero height). RN layout is the source of a
subview **size** (not origin). When we send such update with zero height
Yoga might (or might not, depending on exact update timing in relation
to other ongoing commits / layouts) set the subview height to 0!
This causes the subview to become invisible & we want to avoid that.

This had not been a problem before #2696, because we would filter out
this kind of frame in the `setSize` guard in the
`ComponentDescriptor.adopt` method of the `HeaderSubview`. #2696 allowed
for zero-sized frames for other, unrelated reason & we must allow these
  as long as they come from React Native layout & not native one.
kkafar added a commit that referenced this pull request Mar 27, 2025
…`setOptions` (#2812)

## Description

* [x] Should be merged after #2811 & rebased.

When setting subviews via `setOptions` from `useEffect` hook in a
component, the first frame received might be computed by native
layout & completely invalid (zero height). RN layout is the source of a
subview **size** (not origin). When we send such update with zero height
Yoga might (or might not, depending on exact update timing in relation
to other ongoing commits / layouts) set the subview height to 0!
This causes the subview to become invisible & we want to avoid that.

This had not been a problem before
#2696,
because we would filter out
this kind of frame in the `setSize` guard in the
`ComponentDescriptor.adopt` method of the `HeaderSubview`.
#2696
allowed
for zero-sized frames for other, unrelated reason & we must allow these
  as long as they come from React Native layout & not native one.



## Changes

We now filter these invalid frames on the side of HostTree, by detecting
whether React has measured the subview or not.

## Test code and steps to reproduce

I've tested the problem on slightly modified `Test2466`:

<details>

<summary>Code snippet</summary>

```tsx
        import { NavigationContainer } from '@react-navigation/native';
        import { createNativeStackNavigator, NativeStackNavigationProp } from '@react-navigation/native-stack';
        import React from 'react';
        import { findNodeHandle, Text, View } from 'react-native';
        import PressableWithFeedback from '../shared/PressableWithFeedback';

        type StackParamList = {
          Home: undefined,
        }

        type RouteProps = {
          navigation: NativeStackNavigationProp<StackParamList>;
        }

        const Stack = createNativeStackNavigator<StackParamList>();

        function HeaderTitle(): React.JSX.Element {
          return (
            <PressableWithFeedback
              onLayout={event => {
                const { x, y, width, height } = event.nativeEvent.layout;
                console.log('Title onLayout', { x, y, width, height });
              }}
              onPressIn={() => {
                console.log('Pressable onPressIn');
              }}
              onPress={() => console.log('Pressable onPress')}
              onPressOut={() => console.log('Pressable onPressOut')}
              onResponderMove={() => console.log('Pressable onResponderMove')}
              ref={node => {
                console.log(findNodeHandle(node));
                node?.measure((x, y, width, height, pageX, pageY) => {
                  console.log('header component measure', { x, y, width, height, pageX, pageY });
                });
              }}
            >
              <View style={{ height: 40, justifyContent: 'center', alignItems: 'center' }}>
                <Text style={{ alignItems: 'center' }}>Regular Pressable</Text>
              </View>
            </PressableWithFeedback>
          );
        }

        function HeaderLeft(): React.JSX.Element {
          return (
            <HeaderTitle />
          );
        }

        function Home({ navigation }: RouteProps): React.JSX.Element {
          React.useEffect(() => {
            console.log('calling setOptions in useEffect');
            navigation.setOptions({
              //headerTitle: HeaderTitle,
              headerLeft: HeaderLeft,
              //headerRight: HeaderLeft,
            });
          }, [navigation]);
          return (
            <View style={{ flex: 1, backgroundColor: 'rgba(0, 0, 0, .8)' }}
            >
              <View style={{ flex: 1, alignItems: 'center', marginTop: 48 }}>
                <PressableWithFeedback
                  onPressIn={() => console.log('Pressable onPressIn')}
                  onPress={() => console.log('Pressable onPress')}
                  onPressOut={() => console.log('Pressable onPressOut')}
                >
                  <View style={{ height: 40, width: 200, justifyContent: 'center', alignItems: 'center' }}>
                    <Text style={{ alignItems: 'center' }}>Regular Pressable</Text>
                  </View>
                </PressableWithFeedback>
              </View>
            </View>
          );
        }

        function App(): React.JSX.Element {
          return (
            <NavigationContainer>
              <Stack.Navigator>
                <Stack.Screen
                  name="Home"
                  component={Home}
                  options={{
                    //headerTitle: HeaderTitle,
                    //headerLeft: HeaderLeft,
                    //headerRight: HeaderLeft,
                  }}
                />
              </Stack.Navigator>
            </NavigationContainer>
          );
        }

        export default App;
```

</details>

## Checklist

- [x] Included code example that can be used to test this change
- [x] Ensured that CI passes
(7d3205e)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android Header expands unexpectedly when headerSearchBarOptions are set in v4.6.0+

2 participants