react-native-bottom-sheet icon indicating copy to clipboard operation
react-native-bottom-sheet copied to clipboard

[Bug]: Bottom sheet does not always open when React's Strict Mode is enabled

Open winghouchan opened this issue 10 months ago β€’ 15 comments

Version

v5

Reanimated Version

v3

Gesture Handler Version

v2

Platforms

iOS, Android

What happened?

When React's Strict Mode is enabled, the bottom sheet does not always open.

This is unrelated – they are thrown at the same time but do not cause the issue described here. ~The following errors are also thrown, which may be related (more details in https://github.com/software-mansion/react-native-reanimated/issues/6997):~

ERROR  Warning: findHostInstance_DEPRECATED is deprecated in StrictMode. findHostInstance_DEPRECATED
       was passed an instance of AnimatedComponent(Wrap) which is inside StrictMode. Instead, add a
       ref directly to the element you want to reference. Learn more about using refs safely here:
       https://react.dev/link/strict-mode-find-node
ERROR  Warning: findNodeHandle is deprecated in StrictMode. findNodeHandle was passed an instance of
       Wrap which is inside StrictMode. Instead, add a ref directly to the element you want to reference.
       Learn more about using refs safely here: https://react.dev/link/strict-mode-find-node

https://github.com/user-attachments/assets/676abe68-94c2-43a8-91ab-c8ce32eba26b

Reproduction steps

Using reproduction repository

  1. Clone the repository.
  2. Install dependencies (yarn install).
  3. Build and run the app (e.g. yarn ios).
  4. Press on button to open bottom sheet modal.

The bottom sheet modal does not open (most of the time). findHostInstance_DEPRECATED is deprecated in StrictMode and findNodeHandle is deprecated in StrictMode errors are reported.

Self-reproduction

Enable React's Strict Mode (wrap app components with <StrictMode>...</StrictMode>).

Reproduction sample

https://github.com/winghouchan/react-native-bottom-sheet-strict-mode-mcve

Ignore as Snack is broken, see #2131, but issue needs it other it will be auto-closed

https://snack.expo.dev/@gorhom/bottom-sheet---issue-reproduction-template

Relevant log output

 LOG  [BottomSheetContainer::handleContainerLayout] height:852
 LOG  [handlePresent] 
 LOG  [handlePortalOnUnmount] minimized:false forcedDismissed:false
 ERROR  Warning: findHostInstance_DEPRECATED is deprecated in StrictMode. findHostInstance_DEPRECATED was passed an instance of AnimatedComponent(Wrap) which is inside StrictMode. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here: https://react.dev/link/strict-mode-find-node
 ERROR  Warning: findNodeHandle is deprecated in StrictMode. findNodeHandle was passed an instance of Wrap which is inside StrictMode. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here: https://react.dev/link/strict-mode-find-node
 LOG  [BottomSheetView::handleLayout] height:426
 LOG  [BottomSheetHandleContainer::handleContainerLayout] height:24

winghouchan avatar Feb 08 '25 17:02 winghouchan

Hi @winghouchan , this issue is coming from Reanimated and it seems resolved in their package https://github.com/software-mansion/react-native-reanimated/pull/6736

gorhom avatar Feb 08 '25 20:02 gorhom

Hi @gorhom! I don't think it's solved with the linked PR. The changes there were released in 3.16.5. The reproduction uses 3.16.7 (reference). Also see the new issue I've opened in their repo (https://github.com/software-mansion/react-native-reanimated/issues/6997).

winghouchan avatar Feb 08 '25 20:02 winghouchan

https://github.com/user-attachments/assets/3c0a9957-b8e9-40c1-a926-8eff9c25624e

Interestingly, the bottom sheet is attached to the component tree but it isn't displayed on screen. Observe that the PortalHost has no children initially but when the "Present Modal" button is pressed, it does have children but the bottom sheet is not displayed.

winghouchan avatar Feb 08 '25 20:02 winghouchan

https://github.com/user-attachments/assets/a8815a36-688b-4848-a535-8e3bd8e2be8d

Another observation: the modal can be opened in Strict Mode if:

  1. Strict Mode was initially disabled.
  2. While Strict Mode was initially disabled, the modal was opened.
  3. Strict Mode is then disabled and the app's JavaScript bundle is not hard refreshed.

Important points:

  • If the modal wasn't opened while Strict Mode was disabled (point 2 above), the modal doesn't appear to open after Strict Mode is enabled and changes are introduced via Fast Refresh.
  • If the app's JavaScript bundle was hard refreshed after Strict Mode is enabled (point 3 above), the modal doesn't appear to open.

winghouchan avatar Feb 08 '25 20:02 winghouchan

Found there's a logger πŸ˜„

Here are the logs:

 LOG  [BottomSheetContainer::handleContainerLayout] height:852
 LOG  [handlePresent] 
 LOG  [handlePortalOnUnmount] minimized:false forcedDismissed:false
 ERROR  Warning: findHostInstance_DEPRECATED is deprecated in StrictMode. findHostInstance_DEPRECATED was passed an instance of AnimatedComponent(Wrap) which is inside StrictMode. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here: https://react.dev/link/strict-mode-find-node
 ERROR  Warning: findNodeHandle is deprecated in StrictMode. findNodeHandle was passed an instance of Wrap which is inside StrictMode. Instead, add a ref directly to the element you want to reference. Learn more about using refs safely here: https://react.dev/link/strict-mode-find-node
 LOG  [BottomSheetView::handleLayout] height:426
 LOG  [BottomSheetHandleContainer::handleContainerLayout] height:24

Interesting that the portal's unmount handler is run. Suspect this is a consequence of Strict Mode running Effects twice.

winghouchan avatar Feb 08 '25 21:02 winghouchan

For comparison, these are the logs when Strict Mode is disabled:

LOG  [BottomSheetContainer::handleContainerLayout] height:852
LOG  [handlePresent] 
LOG  [BottomSheetView::handleLayout] height:426
LOG  [BottomSheetHandleContainer::handleContainerLayout] height:24
LOG  [fun::useAnimatedReaction::OnSnapPointChange] result:426
LOG  [fun::bound animateToPosition_Gorhom_BottomSheetTsx12] currentPosition:852 nextPosition:426 source:1
LOG  [BottomSheet::handleOnAnimate] toIndex:0 fromIndex:-1
LOG  [fun::bound animateToPositionCompleted_Gorhom_BottomSheetTsx11] animatedCurrentIndex:-1 animatedNextPosition:426 animatedNextPositionIndex:0
LOG  [fun::useAnimatedReaction::OnChange] animatedCurrentIndex:-1 animatedIndex:0
LOG  [BottomSheet::handleOnChange] index:0 animatedCurrentIndex:0
LOG  [handleBottomSheetOnChange] minimized:false forcedDismissed:false

winghouchan avatar Feb 08 '25 21:02 winghouchan

Based on the first difference between the logs, I decided to take a look at the function where [fun::useAnimatedReaction::OnSnapPointChange] result:426 is logged: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L1546-L1581

Looking at the value of variables while this function is run, isLayoutCalculated.value has a value of false, which results in an early return: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L1560-L1565

This stops the log occurring and also stops the running of evaluatePosition: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L1567-L1578

evaluatePosition is significant because it determines the position of the bottom sheet – if it is not run, the sheet doesn't get positioned on the screen: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L901-L1022

Next step is to understand why isLayoutCalculated.value has a value of false.

winghouchan avatar Feb 09 '25 01:02 winghouchan

isLayoutCalculated is derived as follows: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L255-L297

In my observations, the values for the three values that determine isLayoutCalculated are:

  • isContainerHeightCalculated: true
  • isHandleHeightCalculated: false
  • isSnapPointsNormalized : true

isHandleHeightCalculated is false because the conditions check if handleComponent is null or if animatedHandleHeight is not the initial height, while handleComponent will be undefined if not set: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L269-L278

Setting the handleComponent prop on BottomSheetModal seems to resolve the issue – but obviously not acceptable unless you want to get rid of the handle component.

Another interesting observation is if the debugger is paused for long enough, isHandleHeightCalculated and therefore isLayoutCalculated get resolved to true, allowing for the evaluatePosition function to be run and the sheet displayed on screen.

winghouchan avatar Feb 09 '25 02:02 winghouchan

So BottomSheet passes animatedHandleHeight to BottomSheetHandleContainer: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheet/BottomSheet.tsx#L1925

BottomSheetHandleContainer then sets a value to animatedHandleHeight on layout: https://github.com/gorhom/react-native-bottom-sheet/blob/e5d8aba49de1788ec309698a7170c2d3cc2eef4d/src/components/bottomSheetHandleContainer/BottomSheetHandleContainer.tsx#L109-L129

This is observed in both Strict Mode enabled and disabled cases as the following is logged:

LOG  [BottomSheetHandleContainer::handleContainerLayout] height:24

A change in animatedHandleHeight causes isLayoutCalculated and animatedSnapPoints to react to this change.

In the case of Strict Mode enabled, it looks like animatedHandleHeight doesn't get its new value from the BottomSheetHandleContainer but retains the INITIAL_HANDLE_HEIGHT of -999 which causes isHandleHeightCalculated to remain false and therefore isLayoutCalculated to be false.

Need to determine why Strict Mode causes this to happen.

winghouchan avatar Feb 09 '25 12:02 winghouchan

In my previous comment, I said:

In the case of Strict Mode enabled, it looks like animatedHandleHeight doesn't get its new value from the BottomSheetHandleContainer but retains the INITIAL_HANDLE_HEIGHT of -999 which causes isHandleHeightCalculated to remain false and therefore isLayoutCalculated to be false.

I've found this is only partially true. What is true is animatedHandleHeight being the INITIAL_HANDLE_HEIGHT (-999) which causes isHandleHeightCalculated to remain false and therefore isLayoutCalculated to be false. However, it's not because the value doesn't change.

I added some more logs to reveal more of what is going on. Here are the logs when Strict Mode is disabled:

[handleMountSheet]
[handlePresent] 
[Portal::handleOnMount]
[useReactiveSharedValue] name:animatedHandleHeight value:-999
[useAnimatedSnapPoints::normalizedSnapPoints] snapPoints:50% containerHeight:852 handleHeight:-999 contentHeight:-999 footerHeight:0 enableDynamicSizing:false maxDynamicContentSize:undefined dynamicSnapPointIndex:-1
[useAnimatedSnapPoints::hasDynamicSnapPoint] snapPoints:50% containerHeight:852 contentHeight:-999 handleHeight:-999 footerHeight:0 enableDynamicSizing:false maxDynamicContentSize:undefined
[BottomSheet::isLayoutCalculated] isContainerHeightCalculated:true isHandleHeightCalculated:false isSnapPointsNormalized:true
[BottomSheetView::handleLayout] height:426
[BottomSheetHandleContainer::handleContainerLayout] height:24
[useAnimatedSnapPoints::normalizedSnapPoints] snapPoints:50% containerHeight:852 handleHeight:-999 contentHeight:-999 footerHeight:0 enableDynamicSizing:false maxDynamicContentSize:undefined dynamicSnapPointIndex:-1
[useAnimatedSnapPoints::hasDynamicSnapPoint] snapPoints:50% containerHeight:852 contentHeight:-999 handleHeight:-999 footerHeight:0 enableDynamicSizing:false maxDynamicContentSize:undefined
[fun::isLayoutCalculated] isContainerHeightCalculated:true isHandleHeightCalculated:false isSnapPointsNormalized:true
[fun::useAnimatedReaction::MaybeSnapPointChange] result:426 previous:null isLayoutCalculated:false
[useAnimatedSnapPoints::normalizedSnapPoints] snapPoints:50% containerHeight:852 handleHeight:24 contentHeight:-999 footerHeight:0 enableDynamicSizing:false maxDynamicContentSize:undefined dynamicSnapPointIndex:-1
[useAnimatedSnapPoints::hasDynamicSnapPoint] snapPoints:50% containerHeight:852 contentHeight:-999 handleHeight:24 footerHeight:0 enableDynamicSizing:false maxDynamicContentSize:undefined
[fun::isLayoutCalculated] isContainerHeightCalculated:true isHandleHeightCalculated:true isSnapPointsNormalized:true
[fun::useAnimatedReaction::MaybeSnapPointChange] result:426 previous:426 isLayoutCalculated:true
[fun::useAnimatedReaction::OnSnapPointChange] result:426
[fun::bound evaluatePosition_Gorhom_BottomSheetTsx15] animationConfigs:undefined source:5
[fun::bound animateToPosition_Gorhom_BottomSheetTsx12] currentPosition:852 nextPosition:426 source:1
[BottomSheet::handleOnAnimate] toIndex:0 fromIndex:-1
[fun::bound animateToPositionCompleted_Gorhom_BottomSheetTsx11] animatedCurrentIndex:-1 animatedNextPosition:426 animatedNextPositionIndex:0
[fun::useAnimatedReaction::OnChange] animatedCurrentIndex:-1 animatedIndex:0
[BottomSheet::handleOnChange] index:0 animatedCurrentIndex:0
[handleBottomSheetOnChange] minimized:false forcedDismissed:false

The log starting with [BottomSheetHandleContainer::handleContainerLayout] is where animatedHandleHeight gets a value set. Note that when deriving the new values in useAnimatedSnapPoints, handleHeight is still -999. It turns out, there's another pass of deriving the new values in useAnimatedSnapPoints which does have the updated handleHeight. When Strict Mode is enabled the second pass does not occur (the logs stop at the first [fun::useAnimatedReaction::MaybeSnapPointChange]).

I don't yet fully understand why the first time the values are derived in useAnimatedSnapPoints after animatedHandleHeight is changed does not have the updated animatedHandleHeight and why it requires a second run to get the updated values; or why Strict Mode is affecting this. It may have something to do with how React works, React Native Reanimated doing different pieces of work on different threads and the JS event loop.

It may be an issue that's the responsibility of React Native Reanimated. I'm trying to create an MCVE that's only uses React Native Reanimated but haven't successfully done so and am unsure if I will because I've found a workaround which means I'll be de-prioritising further investigation.

The workaround I've found is patching BottomSheetHandleContainer to set the animatedHandleHeight value in a new macro-task by using setTimeout(..., 0):

  const handleContainerLayout = useCallback(
    function handleContainerLayout({
      nativeEvent: {
        layout: { height },
      },
    }: LayoutChangeEvent) {
-      handleHeight.value = height;
+      setTimeout(() => {
+        handleHeight.value = height;
+     }, 0);

      if (__DEV__) {
        print({
          component: BottomSheetHandleContainer.displayName,
          method: 'handleContainerLayout',
          category: 'layout',
          params: {
            height,
          },
        });
      }
    },
    [handleHeight]
  );

Will drop more comments if I continue investigating and find something new. Hope the comments so far have been useful.

winghouchan avatar Feb 10 '25 21:02 winghouchan

Hmm, the setTimeout workaround doesn't seem to be foolproof. If enableDynamicSizing is true it doesn't always work (~10% of the time) and a different issue where the sheet takes the height of the screen happens (~30% of the time). Also, attempting to ignore the deprecated API error logs using LogBox.ignoreLogs when enableDynamicSizing is true stops the workaround from working at all πŸ˜΅β€πŸ’«

winghouchan avatar Feb 10 '25 21:02 winghouchan

thanks for diving into this, i'll look into it shortly

gorhom avatar Feb 11 '25 19:02 gorhom

Thanks for such a thorough investigation, following as I'm also seeing this same error.

jackiewung avatar Feb 13 '25 05:02 jackiewung

@gorhom: I'm not sure if the OR condition is correct here: https://github.com/gorhom/react-native-bottom-sheet/blob/2898ada5863f05fb26daadcb04068d1a7432dee9/src/components/bottomSheet/BottomSheet.tsx#L258-L263

When _providedContainerHeight is:

  • null, the condition gets evaluated like this:
    • null !== null || null !== undefined
    • false || true
    • true
  • undefined, the condition gets evaluated like this:
    • undefined !== null || undefined !== undefined
    • true || false
    • true

It doesn't solve the issue reported but I spotted it while looking at this issue so flagging here.

winghouchan avatar Feb 25 '25 14:02 winghouchan

@gorhom I'm seeing the same error in the following environment, could you kindly look into this? πŸ™

    "expo": "~52.0.42",
    "react": "18.3.1",
    "react-native": "0.76.8",
    "react-native-gesture-handler": "~2.20.2",
    "react-native-reanimated": "~3.16.7",
    "@gorhom/bottom-sheet": "^5.1.2",

sutarmin avatar Apr 09 '25 09:04 sutarmin

currently getting the same issue

lasfito avatar Sep 20 '25 11:09 lasfito

Also encountered this problem using react-nativeΒ 0.79.5 and the latest version of this lib

mate-from-mattr avatar Oct 06 '25 00:10 mate-from-mattr