From 26358d515fa8cd0f2f9dc00fe192ced911c32ae8 Mon Sep 17 00:00:00 2001 From: Manisha choudhary Date: Mon, 14 Sep 2026 23:24:24 +0530 Subject: [PATCH 1/5] fix(menu): account for keyboard already open when Menu mounts Use Keyboard.metrics() when measuring layout so a Menu that mounts while the keyboard is visible still avoids it. Fixes #5096. --- src/components/Menu/Menu.tsx | 9 +++- src/components/__tests__/Menu.test.tsx | 69 +++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 96dd4096b7..4a354bae91 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -117,6 +117,9 @@ const EASING = Easing.bezier(0.4, 0, 0.2, 1); const WINDOW_LAYOUT = Dimensions.get('window'); +const getKeyboardHeight = (fallback: number) => + Keyboard.metrics()?.height ?? fallback; + const DEFAULT_ELEVATION: Elevation = 2; const DEFAULT_MODE = 'elevated'; @@ -380,7 +383,9 @@ const Menu = ({ }); setWindowLayout({ - height: windowLayoutResult.height - keyboardHeightRef.current, + height: + windowLayoutResult.height - + getKeyboardHeight(keyboardHeightRef.current), width: windowLayoutResult.width, }); @@ -461,6 +466,8 @@ const Menu = ({ 'keyboardDidHide', keyboardDidHide ); + // iOS does not replay keyboardDidShow for a keyboard that is already open. + keyboardHeightRef.current = getKeyboardHeight(keyboardHeightRef.current); return () => { removeListeners(); diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index c35880b48f..8c0d12a520 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -1,4 +1,4 @@ -import { Dimensions, StyleSheet, View } from 'react-native'; +import { Dimensions, Keyboard, StyleSheet, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; import { act, screen, waitFor } from '@testing-library/react-native'; @@ -234,3 +234,70 @@ it('renders menu with mode "flat"', async () => { expect(styles).not.toHaveProperty('shadowColor'); expect(styles).not.toHaveProperty('shadowOpacity'); }); + +it('accounts for a keyboard that is already open when the menu mounts', async () => { + const testID = 'keyboard-aware-menu'; + const dimensionsSpy = jest.spyOn(Dimensions, 'get').mockReturnValue({ + width: 400, + height: 800, + scale: 2, + fontScale: 2, + }); + const keyboardMetricsSpy = jest.spyOn(Keyboard, 'metrics').mockReturnValue({ + screenX: 0, + screenY: 500, + width: 400, + height: 300, + }); + + let measureCalls = 0; + const measureSpy = jest + .spyOn(View.prototype, 'measureInWindow') + .mockImplementation((fn) => { + measureCalls += 1; + if (measureCalls % 2 === 1) { + // Menu content is tall enough to overflow the remaining window. + fn(100, 100, 200, 400); + } else { + fn(100, 100, 80, 32); + } + }); + + function makeMenu(visible: boolean) { + return ( + + + Open menu + + } + testID={testID} + > + + + + + ); + } + + const { rerender } = await render(makeMenu(false)); + + await act(async () => { + await rerender(makeMenu(true)); + await Promise.resolve(); + }); + + await waitFor(() => { + // eslint-disable-next-line no-restricted-syntax -- layout height is not otherwise exposed. + const styles = StyleSheet.flatten(screen.getByTestId(testID).props.style); + // Available height is window (800) minus keyboard (300) minus top (100) minus SCREEN_INDENT (8). + expect(styles).toEqual(expect.objectContaining({ height: 392 })); + }); + + measureSpy.mockRestore(); + keyboardMetricsSpy.mockRestore(); + dimensionsSpy.mockRestore(); +}); From 3823ea15aa4af3670918d474df03cb2d05f782c3 Mon Sep 17 00:00:00 2001 From: Manisha Choudhary <58603111+manchoudhary@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:35:37 +0530 Subject: [PATCH 2/5] Update src/components/Menu/Menu.tsx Co-authored-by: Mikita Kliushun --- src/components/Menu/Menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index 4a354bae91..d000db8367 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -118,7 +118,7 @@ const EASING = Easing.bezier(0.4, 0, 0.2, 1); const WINDOW_LAYOUT = Dimensions.get('window'); const getKeyboardHeight = (fallback: number) => - Keyboard.metrics()?.height ?? fallback; + Keyboard.metrics?.()?.height ?? fallback; const DEFAULT_ELEVATION: Elevation = 2; const DEFAULT_MODE = 'elevated'; From de8dc017ee7396c91cea150d64f98fb9298eaa06 Mon Sep 17 00:00:00 2001 From: Manisha Choudhary <58603111+manchoudhary@users.noreply.github.com> Date: Sat, 31 Oct 2026 17:27:29 +0530 Subject: [PATCH 3/5] fix(menu): guard Keyboard.metrics call for react-native-web --- src/components/Menu/Menu.tsx | 1 - src/components/__tests__/Menu.test.tsx | 50 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx index d000db8367..0ecfa1e5b7 100644 --- a/src/components/Menu/Menu.tsx +++ b/src/components/Menu/Menu.tsx @@ -119,7 +119,6 @@ const WINDOW_LAYOUT = Dimensions.get('window'); const getKeyboardHeight = (fallback: number) => Keyboard.metrics?.()?.height ?? fallback; - const DEFAULT_ELEVATION: Elevation = 2; const DEFAULT_MODE = 'elevated'; diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index 8c0d12a520..26694431d6 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -301,3 +301,53 @@ it('accounts for a keyboard that is already open when the menu mounts', async () keyboardMetricsSpy.mockRestore(); dimensionsSpy.mockRestore(); }); +it('does not crash when Keyboard.metrics is unavailable (react-native-web)', async () => { + const testID = 'no-metrics-menu'; + const original = Keyboard.metrics; + // @ts-expect-error simulate react-native-web, where metrics doesn't exist + Keyboard.metrics = undefined; + + 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} + > + + + + ); + } + + try { + const { rerender, toJSON } = await render(makeMenu(false)); + + await act(async () => { + await rerender(makeMenu(true)); + await Promise.resolve(); + }); + + await waitFor(() => { + const json = JSON.stringify(toJSON()); + expect(json).toContain('"left":100'); + expect(json).toContain('"top":100'); + }); + } finally { + Keyboard.metrics = original; + measureSpy.mockRestore(); + dimensionsSpy.mockRestore(); + } +}); From 87b24857ecadcd107a9ddac7540b13b07e559a4c Mon Sep 17 00:00:00 2001 From: Manisha Choudhary <58603111+manchoudhary@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:14:06 +0530 Subject: [PATCH 4/5] Update Menu.test.tsx --- src/components/__tests__/Menu.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index 5b1f9f5207..275ac69c0b 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -1,7 +1,6 @@ import { Dimensions, Keyboard, StyleSheet, View } from 'react-native'; import { expect, it, jest } from '@jest/globals'; -import { Dimensions, Platform, StyleSheet, View } from 'react-native'; import { afterEach, From 2f21d8a4885ad76e45bd9f0ee2e3b1e54d36a95c Mon Sep 17 00:00:00 2001 From: Manisha Choudhary <58603111+manchoudhary@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:47:31 +0530 Subject: [PATCH 5/5] test(menu): fix duplicate jest import and restore Platform --- src/components/__tests__/Menu.test.tsx | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/components/__tests__/Menu.test.tsx b/src/components/__tests__/Menu.test.tsx index 275ac69c0b..053b1f6e0b 100644 --- a/src/components/__tests__/Menu.test.tsx +++ b/src/components/__tests__/Menu.test.tsx @@ -1,6 +1,4 @@ -import { Dimensions, Keyboard, StyleSheet, View } from 'react-native'; - -import { expect, it, jest } from '@jest/globals'; +import { Dimensions, Keyboard, Platform, StyleSheet, View } from 'react-native'; import { afterEach, @@ -94,7 +92,6 @@ describe('web focus', () => { it('does not move focus when mounted hidden', async () => { await render(makeMenu(false)); await act(() => jest.advanceTimersByTime(PAST_ANIMATION_MS)); - expect(focus).not.toHaveBeenCalled(); }); @@ -102,19 +99,15 @@ describe('web focus', () => { const { rerender } = await render(makeMenu(false)); await rerender(makeMenu(true)); await act(() => jest.advanceTimersByTime(PAST_ANIMATION_MS)); - expect(focus).toHaveBeenCalledTimes(1); - await rerender(makeMenu(false)); await act(() => jest.advanceTimersByTime(PAST_ANIMATION_MS)); - expect(focus).toHaveBeenCalledTimes(2); }); it('moves focus when mounted visible', async () => { await render(makeMenu(true)); await act(() => jest.advanceTimersByTime(PAST_ANIMATION_MS)); - expect(focus).toHaveBeenCalledTimes(1); }); });