Skip to content

Conversation

@bradstiff
Copy link

@bradstiff bradstiff commented Jan 15, 2026

Motivation

There is a race condition in the logic that dismisses the Menu. It sets the rendered flag to false, which starts the hide animation. The animation races the re-render logic that unmounts the Portal. The unmount wins, and the animation never finishes. This corrupts the internal state of the Menu.

The PR changes the logic so that the portal is unmounted when the hide animation finishes.

Additionally, the style change prevents the pressable overlay block touches during the hide animation for improved UX (pointerEvents: 'none').

Related issue

#4807

Test plan

The issue is easily reproduced:

  1. Tap a MenuButton on Android using the new architecture. It shows the first time.
  2. Dismiss the menu
  3. Tap the MenuButton again. It does not appear.

With the PR, the menu appears in step 3.

@callstack-bot
Copy link

callstack-bot commented Jan 15, 2026

Hey @bradstiff, thank you for your pull request 🤗. The documentation from this branch can be viewed here.

@bradstiff
Copy link
Author

Here is a patch while awaiting PR approval. It patches all three source Menu source files.

diff --git a/node_modules/react-native-paper/lib/commonjs/components/Menu/Menu.js b/node_modules/react-native-paper/lib/commonjs/components/Menu/Menu.js
index 0b9a9df..d6e46bb 100644
--- a/node_modules/react-native-paper/lib/commonjs/components/Menu/Menu.js
+++ b/node_modules/react-native-paper/lib/commonjs/components/Menu/Menu.js
@@ -284,7 +284,7 @@ const Menu = ({
         show();
         return;
       }
-      if (!display && prevRendered.current) {
+      if (!display) {
         hide();
       }
       return;
@@ -307,14 +307,21 @@ const Menu = ({
   React.useEffect(() => {
     if (prevVisible.current !== visible) {
       prevVisible.current = visible;
-      if (visible !== rendered) {
-        setRendered(visible);
+      if (visible) {
+        if (!rendered) {
+          setRendered(true);
+        }
+      } else {
+        // Keep the Portal mounted so the hide animation can finish.
+        updateVisibility(false);
       }
     }
-  }, [visible, rendered]);
+  }, [visible, rendered, updateVisibility]);
   React.useEffect(() => {
-    updateVisibility(rendered);
-  }, [rendered, updateVisibility]);
+    if (rendered && visible) {
+      updateVisibility(true);
+    }
+  }, [rendered, visible, updateVisibility]);
 
   // I don't know why but on Android measure function is wrong by 24
   const additionalVerticalValue = _reactNative.Platform.select({
@@ -455,6 +462,7 @@ const Menu = ({
     accessibilityLabel: overlayAccessibilityLabel,
     accessibilityRole: "button",
     onPress: onDismiss,
+    pointerEvents: visible ? 'auto' : 'none',
     style: styles.pressableOverlay
   }), /*#__PURE__*/React.createElement(_reactNative.View, {
     ref: ref => {
diff --git a/node_modules/react-native-paper/lib/module/components/Menu/Menu.js b/node_modules/react-native-paper/lib/module/components/Menu/Menu.js
index 46a45f4..eea5e4c 100644
--- a/node_modules/react-native-paper/lib/module/components/Menu/Menu.js
+++ b/node_modules/react-native-paper/lib/module/components/Menu/Menu.js
@@ -276,7 +276,7 @@ const Menu = ({
         show();
         return;
       }
-      if (!display && prevRendered.current) {
+      if (!display) {
         hide();
       }
       return;
@@ -299,14 +299,21 @@ const Menu = ({
   React.useEffect(() => {
     if (prevVisible.current !== visible) {
       prevVisible.current = visible;
-      if (visible !== rendered) {
-        setRendered(visible);
+      if (visible) {
+        if (!rendered) {
+          setRendered(true);
+        }
+      } else {
+        // Keep the Portal mounted so the hide animation can finish.
+        updateVisibility(false);
       }
     }
-  }, [visible, rendered]);
+  }, [visible, rendered, updateVisibility]);
   React.useEffect(() => {
-    updateVisibility(rendered);
-  }, [rendered, updateVisibility]);
+    if (rendered && visible) {
+      updateVisibility(true);
+    }
+  }, [rendered, visible, updateVisibility]);
 
   // I don't know why but on Android measure function is wrong by 24
   const additionalVerticalValue = Platform.select({
@@ -447,6 +454,7 @@ const Menu = ({
     accessibilityLabel: overlayAccessibilityLabel,
     accessibilityRole: "button",
     onPress: onDismiss,
+    pointerEvents: visible ? 'auto' : 'none',
     style: styles.pressableOverlay
   }), /*#__PURE__*/React.createElement(View, {
     ref: ref => {
diff --git a/node_modules/react-native-paper/src/components/Menu/Menu.tsx b/node_modules/react-native-paper/src/components/Menu/Menu.tsx
index 55922c1..e55ab67 100644
--- a/node_modules/react-native-paper/src/components/Menu/Menu.tsx
+++ b/node_modules/react-native-paper/src/components/Menu/Menu.tsx
@@ -397,7 +397,7 @@ const Menu = ({
           return;
         }
 
-        if (!display && prevRendered.current) {
+        if (!display) {
           hide();
         }
 
@@ -432,15 +432,23 @@ const Menu = ({
     if (prevVisible.current !== visible) {
       prevVisible.current = visible;
 
-      if (visible !== rendered) {
-        setRendered(visible);
+      if (visible) {
+        if (!rendered) {
+          // Mount the Portal before attempting to show.
+          setRendered(true);
+        }
+      } else {
+        // Keep the Portal mounted so the hide animation can finish.
+        updateVisibility(false);
       }
     }
-  }, [visible, rendered]);
+  }, [visible, rendered, updateVisibility]);
 
   React.useEffect(() => {
-    updateVisibility(rendered);
-  }, [rendered, updateVisibility]);
+    if (rendered && visible) {
+      updateVisibility(true);
+    }
+  }, [rendered, visible, updateVisibility]);
 
   // I don't know why but on Android measure function is wrong by 24
   const additionalVerticalValue = Platform.select({
@@ -641,6 +649,7 @@ const Menu = ({
             accessibilityLabel={overlayAccessibilityLabel}
             accessibilityRole="button"
             onPress={onDismiss}
+            pointerEvents={visible ? 'auto' : 'none'}
             style={styles.pressableOverlay}
           />
           <View

@bradstiff bradstiff changed the title (fix): defer unmounting Portal until hide animation finishes fix(Menu): defer unmounting Portal until hide animation finishes Jan 15, 2026
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.

2 participants