diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 12b3c536c7..52b1c15169 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -19,6 +19,7 @@ import type { } from 'react-native'; import Animated, { + cancelAnimation, Easing, ReduceMotion, useAnimatedStyle, @@ -225,6 +226,10 @@ const Menu = ({ const anchorRef = React.useRef(null); const menuRef = React.useRef(null); const isShownRef = React.useRef(false); + // Mirrors the `rendered` state so animation callbacks (which may fire late + // or never on React Native >= 0.80, see #4763) can tell whether the reset + // already happened in `hide()`. + const renderedRef = React.useRef(rendered); const keyboardDidShow = React.useCallback((e: RNKeyboardEvent) => { const keyboardHeight = e.endCoordinates.height; @@ -322,15 +327,14 @@ const Menu = ({ focusFirstDOMNode(menuRef.current); }); - const handleHideAnimationFinished = useLatestCallback((finished: boolean) => { - if (!finished || prevVisible.current) { - return; + const handleHideAnimationFinished = useLatestCallback(() => { + // The completion callback never fires on React Native >= 0.80 (see + // #4763), so `hide()` already reset the portal and focus synchronously. + // Only run the layout cleanup if the menu was not reopened before the + // animation finished (reopening resets `renderedRef` via the state path). + if (!renderedRef.current) { + setMenuLayout({ width: 0, height: 0 }); } - - setMenuLayout({ width: 0, height: 0 }); - setRendered(false); - isShownRef.current = false; - focusFirstDOMNode(anchorRef.current); }); const show = React.useCallback(async () => { @@ -416,6 +420,22 @@ const Menu = ({ removeListeners(); isShownRef.current = false; + // Cancel any in-flight show animation so its `finished` callback cannot + // mark the menu as shown after it was hidden. + cancelAnimation(opacity); + cancelAnimation(scaleX); + cancelAnimation(scaleY); + + // Reset synchronously instead of waiting for the hide animation's + // completion callback, which is never delivered on React Native >= 0.80 + // (see #4763). Without this the portal stays mounted and the anchor + // never regains focus. `menuLayout` is deliberately left alone here: + // the scale transform reads it while the fade-out runs, and `show()` + // overwrites it with fresh measurements on the next open. + renderedRef.current = false; + setRendered(false); + focusFirstDOMNode(anchorRef.current); + const { animation } = theme; opacity.value = withTiming( @@ -425,9 +445,16 @@ const Menu = ({ easing: EASING, reduceMotion: ReduceMotion.Never, }, - (finished) => scheduleOnRN(handleHideAnimationFinished, finished ?? false) + () => scheduleOnRN(handleHideAnimationFinished) ); - }, [handleHideAnimationFinished, opacity, removeListeners, theme]); + }, [ + handleHideAnimationFinished, + opacity, + removeListeners, + scaleX, + scaleY, + theme, + ]); const updateVisibility = React.useCallback( async (display: boolean) => { @@ -466,6 +493,7 @@ const Menu = ({ if (visible && !rendered) { // Mount the Portal before attempting to show. + renderedRef.current = true; setRendered(true); } diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index fc6369599c..44106fc0c6 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -134,6 +134,80 @@ it('uses the default anchorPosition of top', async () => { dimensionsSpy.mockRestore(); }); +it('reopens after being closed', async () => { + const testID = 'reopen-menu'; + const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({ + width: 400, + height: 800, + scale: 2, + fontScale: 2, + }); + const measureSpy = jest + .spyOn(View.prototype, 'measureInWindow') + .mockImplementation((fn) => fn(100, 100, 80, 32)); + + function makeMenu(visible: boolean) { + return ( + + + Open menu + + } + testID={testID} + > + + + + + ); + } + + const { rerender } = await render(makeMenu(false)); + + // Open the menu. + await act(async () => { + await rerender(makeMenu(true)); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByTestId(`${testID}-view`)).toHaveStyle({ + position: 'absolute', + left: 100, + top: 100, + }); + }); + + // Close the menu. + await act(async () => { + await rerender(makeMenu(false)); + await Promise.resolve(); + }); + + // Reopen the menu. Without the fix, `setRendered(false)` was deferred to + // the hide animation callback (which never fires on RN >= 0.80, see #4763) + // so the portal stayed mounted and the menu appeared non-functional. + await act(async () => { + await rerender(makeMenu(true)); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByTestId(`${testID}-view`)).toHaveStyle({ + position: 'absolute', + left: 100, + top: 100, + }); + }); + + measureSpy.mockRestore(); + dimensionsSpy.mockRestore(); +}); + it('respects anchorPosition bottom', async () => { const testID = 'bottom-positioned-menu'; const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({