From 544d8a7d72a134a20c62644cd1093576be92a0da Mon Sep 17 00:00:00 2001 From: Justin Wyne <1986068+wyne@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:18:55 -0400 Subject: [PATCH 1/2] Dismiss keyboard when leaving the edit-player screen The clear (x) button in the player editor focuses the input programmatically. If the screen is removed while that input is still the first responder, iOS keeps a dangling reference and re-shows the keyboard the next time a native view presents -- e.g. tapping the FAB to open the new-game player-count menu would pop up the keyboard too. Dismiss the keyboard on `beforeRemove` so the first responder resigns cleanly while the input is still mounted, matching the existing pattern in EditGame.tsx. Co-Authored-By: Claude Opus 4.8 --- src/screens/EditPlayerScreen.test.tsx | 40 +++++++++++++++++++++++++++ src/screens/EditPlayerScreen.tsx | 15 +++++++++- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/screens/EditPlayerScreen.test.tsx b/src/screens/EditPlayerScreen.test.tsx index 6da3070f..667650b0 100644 --- a/src/screens/EditPlayerScreen.test.tsx +++ b/src/screens/EditPlayerScreen.test.tsx @@ -3,6 +3,7 @@ import React from 'react'; import { configureStore } from '@reduxjs/toolkit'; import { act, fireEvent, render, waitFor } from '@testing-library/react-native'; +import { Keyboard } from 'react-native'; import { Provider } from 'react-redux'; import gamesReducer from '../../redux/GamesSlice'; @@ -463,6 +464,45 @@ describe('EditPlayerScreen', () => { expect(input).toBeTruthy(); }); + it('dismisses the keyboard when the screen is removed', () => { + // Regression: the clear button focuses the input programmatically. If the + // screen unmounts while it is still the first responder, iOS re-shows the + // keyboard the next time a native menu (the new-game player-count menu) + // presents. Leaving the screen must dismiss the keyboard. + const dismissSpy = jest.spyOn(Keyboard, 'dismiss').mockImplementation(() => { }); + + let beforeRemoveCallback: (() => void) | undefined; + mockNavigation.addListener.mockImplementation((event: string, cb: () => void) => { + if (event === 'beforeRemove') { + beforeRemoveCallback = cb; + } + return jest.fn(); + }); + + const mockRoute = { + params: { + index: 0, + playerId: 'player-1', + }, + }; + + render( + + + + ); + + expect(beforeRemoveCallback).toBeDefined(); + + act(() => { + beforeRemoveCallback?.(); + }); + + expect(dismissSpy).toHaveBeenCalled(); + + dismissSpy.mockRestore(); + }); + it('should limit input to 15 characters', () => { const store = createMockStore({ settings: { diff --git a/src/screens/EditPlayerScreen.tsx b/src/screens/EditPlayerScreen.tsx index d04ab210..e9c5c70a 100644 --- a/src/screens/EditPlayerScreen.tsx +++ b/src/screens/EditPlayerScreen.tsx @@ -1,8 +1,9 @@ -import React, { useLayoutEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ParamListBase, RouteProp } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { + Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, @@ -53,6 +54,18 @@ const EditPlayerScreen: React.FC = ({ ), }); }, [navigation, theme.tint]); + + // The clear button focuses the input programmatically. If the screen is + // removed while that input is still the first responder, iOS keeps a + // dangling reference and re-shows the keyboard the next time a native view + // (e.g. the new-game player-count menu) presents. Dismiss it on the way out. + useEffect(() => { + const unsubscribe = navigation.addListener('beforeRemove', () => { + Keyboard.dismiss(); + }); + return unsubscribe; + }, [navigation]); + const dispatch = useAppDispatch(); const currentGame = useAppSelector(selectCurrentGame); const { index, playerId } = route.params; From 64ec922cd72396ccf8a869e767a8f6d1f84297a3 Mon Sep 17 00:00:00 2001 From: Justin Wyne <1986068+wyne@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:28:17 -0400 Subject: [PATCH 2/2] Dismiss keyboard on FAB menu open and before edit-player back The beforeRemove handler alone fired too late during the back transition to reliably resign the first responder. Add two more direct dismissals: - FloatingActionButton: dismiss the keyboard in MenuView.onOpenMenu, so a dangling first responder can't be restored alongside the player-count menu (the visible symptom). - EditPlayerScreen: dismiss synchronously in the Back button before goBack(), while the input is still mounted. Co-Authored-By: Claude Opus 4.8 --- src/components/FloatingActionButton.tsx | 7 ++++++- src/screens/EditPlayerScreen.tsx | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/FloatingActionButton.tsx b/src/components/FloatingActionButton.tsx index e4943489..fdbb80a2 100644 --- a/src/components/FloatingActionButton.tsx +++ b/src/components/FloatingActionButton.tsx @@ -3,7 +3,7 @@ import React from 'react'; import { MenuAction, MenuView } from '@react-native-menu/menu'; import { ParamListBase } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { StyleSheet, View } from 'react-native'; +import { Keyboard, StyleSheet, View } from 'react-native'; import { Icon } from 'react-native-elements'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -51,6 +51,11 @@ const FloatingActionButton: React.FunctionComponent = ({ navigation }) => }]}> Keyboard.dismiss()} onPressAction={async ({ nativeEvent }) => { const playerNumber = parseInt(nativeEvent.event); addGameHandler(playerNumber); diff --git a/src/screens/EditPlayerScreen.tsx b/src/screens/EditPlayerScreen.tsx index e9c5c70a..29ff1c63 100644 --- a/src/screens/EditPlayerScreen.tsx +++ b/src/screens/EditPlayerScreen.tsx @@ -46,6 +46,10 @@ const EditPlayerScreen: React.FC = ({ navigation.setOptions({ headerLeft: ({ tintColor }) => ( { + // Resign the first responder before the back transition starts, + // while the input is still mounted, so iOS doesn't keep it around + // and re-show the keyboard later (see beforeRemove handler below). + Keyboard.dismiss(); navigation.goBack(); await logEvent('edit_player_back'); }}>