Skip to content

Commit 5e28681

Browse files
committed
fix(mobile): dismiss keyboard before Android workspace Back
1 parent d1aeed2 commit 5e28681

8 files changed

Lines changed: 71 additions & 10 deletions

‎apps/mobile/src/components/ui/AppBar.android.test.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@ import { act, type ReactNode } from "react";
22
import { createRoot, type Root } from "react-dom/client";
33
import { afterEach, expect, it, vi } from "vite-plus/test";
44

5+
vi.mock("../../features/keyboard/dismissOpenKeyboard", () => ({
6+
dismissOpenKeyboard: () => false,
7+
}));
8+
59
vi.mock("react-native", () => ({
610
View: (props: { readonly children?: ReactNode }) => <div>{props.children}</div>,
711
Keyboard: { dismiss: () => {} },

‎apps/mobile/src/components/ui/AppBar.android.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { dismissOpenKeyboard } from "../../features/keyboard/dismissOpenKeyboard";
12
import { type RefObject, use, useRef, useState } from "react";
23
import { Keyboard, type TextInputInstance, View } from "react-native";
34
import { useHardwareBack } from "../../lib/backHandler";
@@ -68,7 +69,7 @@ function SearchableAppBar(props: AppBarProps & { readonly search: AppBarSearch }
6869
search.onClose?.();
6970
};
7071
useHardwareBack(searching && !inline && !controlled, () => {
71-
close();
72+
if (!dismissOpenKeyboard()) close();
7273
return true;
7374
});
7475
return <AppBarView {...props} searchState={{ searching, open, close, inputRef }} />;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { KeyboardController, KeyboardEvents } from "react-native-keyboard-controller";
2+
3+
let dismissPending = false;
4+
for (const event of ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"] as const) {
5+
KeyboardEvents.addListener(event, () => {
6+
dismissPending = false;
7+
});
8+
}
9+
10+
export function dismissOpenKeyboard(): boolean {
11+
// The custom composer is a native EditText outside React Native's focus tracking.
12+
if (!KeyboardController.isVisible()) return false;
13+
// A missed hide event must not leave Back trapped behind a stale visible cache.
14+
if (dismissPending) return false;
15+
dismissPending = true;
16+
void KeyboardController.dismiss();
17+
return true;
18+
}

‎apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,20 @@ vi.mock("react-native-keyboard-controller/src/bindings", () => ({
1515
return { remove: () => named.delete(listener) };
1616
},
1717
},
18-
KeyboardControllerNative: { dismiss: () => {} },
18+
KeyboardControllerNative: { dismiss: vi.fn() },
1919
}));
2020

21+
vi.mock("react-native", () => ({
22+
Keyboard: { isVisible: () => false, dismiss: vi.fn() },
23+
}));
24+
vi.mock("react-native-keyboard-controller", async () => {
25+
const { KeyboardController } = await import("react-native-keyboard-controller/src/module");
26+
const { KeyboardEvents } = await import("react-native-keyboard-controller/src/bindings");
27+
return { KeyboardController, KeyboardEvents };
28+
});
29+
30+
import { dismissOpenKeyboard } from "./dismissOpenKeyboard";
31+
import { KeyboardControllerNative } from "react-native-keyboard-controller/src/bindings";
2132
import { useKeyboardState } from "react-native-keyboard-controller/src/hooks/useKeyboardState";
2233
import { KeyboardController } from "react-native-keyboard-controller/src/module";
2334

@@ -82,3 +93,29 @@ it("waits for the native hide before dismiss resolves after a didShow-only repai
8293
emit("keyboardDidHide", 0);
8394
await dismissal;
8495
});
96+
97+
it("dismisses the custom composer before Back navigates, including after resume", () => {
98+
using _keyboard = setup();
99+
const dismiss = vi.mocked(KeyboardControllerNative.dismiss);
100+
dismiss.mockClear();
101+
102+
emit("keyboardDidShow", 376);
103+
expect(dismissOpenKeyboard()).toBe(true);
104+
expect(dismiss).toHaveBeenCalledTimes(1);
105+
emit("keyboardDidHide", 0);
106+
expect(dismissOpenKeyboard()).toBe(false);
107+
108+
emit("keyboardDidShow", 376);
109+
expect(dismissOpenKeyboard()).toBe(true);
110+
// A dropped hide event must not trap later Back presses in a stale visible cache.
111+
expect(dismissOpenKeyboard()).toBe(false);
112+
expect(dismiss).toHaveBeenCalledTimes(2);
113+
114+
emit("keyboardWillShow", 376);
115+
expect(dismissOpenKeyboard()).toBe(true);
116+
expect(dismissOpenKeyboard()).toBe(false);
117+
emit("keyboardDidShow", 376);
118+
expect(dismissOpenKeyboard()).toBe(true);
119+
emit("keyboardDidHide", 0);
120+
expect(dismissOpenKeyboard()).toBe(false);
121+
});

‎apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { dismissOpenKeyboard } from "../keyboard/dismissOpenKeyboard";
12
import {
23
WorkspaceColumnsRendererContext,
34
WorkspacePresentationHostContext,
@@ -812,7 +813,7 @@ function AdaptiveWorkspaceLayoutContent(
812813
if (state && workspaceRoutesOf(state.routes.slice(0, state.index + 1)).overlays.length > 0) {
813814
return false;
814815
}
815-
setPrimarySidebarVisible(false);
816+
if (!dismissOpenKeyboard()) setPrimarySidebarVisible(false);
816817
return true;
817818
});
818819
return () => subscription.remove();

‎apps/mobile/src/features/layout/workspace/PullRequestToolbar.android.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { dismissOpenKeyboard } from "../../keyboard/dismissOpenKeyboard";
12
import { useNavigation } from "@react-navigation/native";
23
import { useEffect } from "react";
34
import { BackHandler, Keyboard } from "react-native";
@@ -46,8 +47,7 @@ export function PullRequestToolbar(props: {
4647
const state = navigation.getState();
4748
if (!state || state.routes[state.index]?.name !== "Home" || !isTabActive("pullRequests"))
4849
return false;
49-
if (Keyboard.isVisible()) Keyboard.dismiss();
50-
else closeSearch();
50+
if (!dismissOpenKeyboard()) closeSearch();
5151
return true;
5252
});
5353
return () => subscription.remove();

‎apps/mobile/src/features/layout/workspace/WorkspaceListHeader.android.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { dismissOpenKeyboard } from "../../keyboard/dismissOpenKeyboard";
12
import { useEffect, useState } from "react";
23
import { useNavigation, useNavigationState } from "@react-navigation/native";
34
import { BackHandler, Keyboard } from "react-native";
@@ -49,8 +50,7 @@ export function WorkspaceListHeader(props: WorkspaceListHeaderProps) {
4950
isTabActive?.("threads") === false
5051
)
5152
return false;
52-
if (Keyboard.isVisible()) Keyboard.dismiss();
53-
else closeSearch();
53+
if (!dismissOpenKeyboard()) closeSearch();
5454
return true;
5555
});
5656
return () => subscription.remove();

‎apps/mobile/src/features/layout/workspace/useAndroidWorkspaceNavigation.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { dismissOpenKeyboard } from "../../keyboard/dismissOpenKeyboard";
12
import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native";
23
import { useCallback } from "react";
3-
import { BackHandler, Keyboard, Platform } from "react-native";
4+
import { BackHandler, Platform } from "react-native";
45
import {
56
resetAndroidWorkspace,
67
returnToAndroidWorkspaceList,
@@ -39,8 +40,7 @@ export function useAndroidWorkspaceBack(tab: WorkspaceTab) {
3940
if (Platform.OS !== "android") return;
4041
dispatch?.({ type: "select", tab });
4142
const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
42-
if (Keyboard.isVisible()) Keyboard.dismiss();
43-
else goBack();
43+
if (!dismissOpenKeyboard()) goBack();
4444
return true;
4545
});
4646
return () => subscription.remove();

0 commit comments

Comments
 (0)