Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 38 additions & 10 deletions src/components/Menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
} from 'react-native';

import Animated, {
cancelAnimation,
Easing,
ReduceMotion,
useAnimatedStyle,
Expand Down Expand Up @@ -225,6 +226,10 @@ const Menu = ({
const anchorRef = React.useRef<View | null>(null);
const menuRef = React.useRef<View | null>(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;
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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(
Expand All @@ -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) => {
Expand Down Expand Up @@ -466,6 +493,7 @@ const Menu = ({

if (visible && !rendered) {
// Mount the Portal before attempting to show.
renderedRef.current = true;
setRendered(true);
}

Expand Down
74 changes: 74 additions & 0 deletions src/components/__tests__/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Portal.Host>
<Menu
visible={visible}
onDismiss={jest.fn()}
anchor={
<Button mode="outlined" testID="anchor">
Open menu
</Button>
}
testID={testID}
>
<Menu.Item onPress={jest.fn()} title="Undo" />
<Menu.Item onPress={jest.fn()} title="Redo" />
</Menu>
</Portal.Host>
);
}

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({
Expand Down