Skip to content

Commit 0dd1382

Browse files
committed
Cancel deferred focus when screen unfocuses before window-ready resolves
1 parent 0ac8e59 commit 0dd1382

2 files changed

Lines changed: 35 additions & 2 deletions

File tree

src/components/ValidateCodeActionModal/ValidateCodeForm/BaseValidateCodeForm.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,14 +181,20 @@ function BaseValidateCodeForm({
181181

182182
// Android only opens the soft keyboard once the app window has focus, so we
183183
// chain: wait for the screen transition to finish, then for the window-focus
184-
// signal (a no-op on iOS and web), then focus the input.
184+
// signal (a no-op on iOS and web), then focus the input. isCancelled guards
185+
// against a late isWindowReadyToFocus resolution stealing focus after the
186+
// screen has unfocused (e.g. window was blurred when transitionEnd fired).
185187
let didFocus = false;
188+
let isCancelled = false;
186189
const focusOnce = () => {
187190
if (didFocus) {
188191
return;
189192
}
190193
didFocus = true;
191194
isWindowReadyToFocus().then(() => {
195+
if (isCancelled) {
196+
return;
197+
}
192198
inputValidateCodeRef.current?.focusLastSelected();
193199
});
194200
};
@@ -205,6 +211,7 @@ function BaseValidateCodeForm({
205211
focusTimeoutRef.current = setTimeout(focusOnce, CONST.SCREEN_TRANSITION_END_TIMEOUT);
206212

207213
return () => {
214+
isCancelled = true;
208215
unsubscribeTransitionEnd?.();
209216
if (focusTimeoutRef.current) {
210217
clearTimeout(focusTimeoutRef.current);

tests/unit/BaseValidateCodeFormTest.tsx

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const mockEnsureState = (): MockStateShape => {
5454
};
5555

5656
jest.mock('@react-navigation/native', () => {
57-
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
57+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- jest.requireActual returns unknown; this is the standard pattern for spreading the actual module in jest.mock factories.
5858
const actualNav = jest.requireActual('@react-navigation/native');
5959
const ReactActual = jest.requireActual<typeof React>('react');
6060
// Stable navigation object so useCallback([navigation]) doesn't change across renders.
@@ -287,4 +287,30 @@ describe('BaseValidateCodeForm focus behavior on screen focus', () => {
287287
// ref.focus is part of the imperative handle — calling it should not throw.
288288
expect(() => ref.current?.focus()).not.toThrow();
289289
});
290+
291+
it('does not steal focus if the screen unfocuses before isWindowReadyToFocus resolves', async () => {
292+
const state = mockEnsureState();
293+
state.isMobileSafariReturn = false;
294+
295+
const {unmount} = renderForm();
296+
await waitForBatchedUpdatesWithAct();
297+
298+
// transitionEnd fires while the window is still blurred; focusOnce kicks off the pending promise.
299+
await act(async () => {
300+
for (const handler of state.transitionEndHandlers) {
301+
handler({data: {closing: false}});
302+
}
303+
});
304+
305+
// Screen unmounts (or blurs) before isWindowReadyToFocus settles — cleanup must mark the effect cancelled.
306+
unmount();
307+
308+
// Now resolve the window-ready promise. The deferred focus should NOT happen.
309+
await act(async () => {
310+
state.windowReady.resolve();
311+
await state.windowReady.promise;
312+
});
313+
314+
expect(state.focusLastSelected).not.toHaveBeenCalled();
315+
});
290316
});

0 commit comments

Comments
 (0)