From c4dc6ebc9a0c34c9bb745afe7e80e60b0994ce10 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:29:19 +0000 Subject: [PATCH 01/10] test(mobile): reproduce keyboard state lost after Android IME inset repair These tests reproduce #22484. Android's syncKeyboardPosition repairs a nonanimated IME inset change with keyboardDidHide then keyboardDidShow and no keyboardWillShow. react-native-keyboard-controller's imperative cache and useKeyboardState ignore keyboardDidShow, so both stay hidden at height 0 and KeyboardController.dismiss() resolves before the native keyboard hides. Co-authored-by: Tejas Rahane <17571323+trahane@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../keyboard/keyboardControllerState.test.tsx | 84 +++++++++++++++++++ apps/mobile/vite.config.ts | 1 + 2 files changed, 85 insertions(+) create mode 100644 apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx diff --git a/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx new file mode 100644 index 0000000000000..1d781859f272c --- /dev/null +++ b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx @@ -0,0 +1,84 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import type { KeyboardEventData } from "react-native-keyboard-controller"; +import { expect, it, vi } from "vite-plus/test"; + +const listeners = vi.hoisted(() => new Map void>>()); + +// Exercises the patched library source with only its native bindings replaced. +vi.mock("react-native-keyboard-controller/src/bindings", () => ({ + KeyboardEvents: { + addListener: (name: string, listener: (event: KeyboardEventData) => void) => { + const named = listeners.get(name) ?? new Set(); + named.add(listener); + listeners.set(name, named); + return { remove: () => named.delete(listener) }; + }, + }, + KeyboardControllerNative: { dismiss: () => {} }, +})); + +import { useKeyboardState } from "react-native-keyboard-controller/src/hooks/useKeyboardState"; +import { KeyboardController } from "react-native-keyboard-controller/src/module"; + +function emit(name: string, height: number) { + const event: KeyboardEventData = { + height, + duration: 0, + timestamp: 1, + target: 42, + type: "default", + appearance: "light", + }; + act(() => { + for (const listener of listeners.get(name) ?? []) listener(event); + }); +} + +function KeyboardState() { + const { isVisible, height } = useKeyboardState(); + return {JSON.stringify({ isVisible, height })}; +} + +function setup() { + emit("keyboardDidHide", 0); + const container = document.createElement("div"); + const root = createRoot(container); + act(() => root.render()); + return { + container, + [Symbol.dispose]() { + act(() => root.unmount()); + emit("keyboardDidHide", 0); + }, + }; +} + +// Android's syncKeyboardPosition repairs a nonanimated IME inset change with +// keyboardDidHide then keyboardDidShow, and no keyboardWillShow. +it("reports the keyboard shown after Android repairs the IME inset without keyboardWillShow", () => { + using screen = setup(); + + emit("keyboardDidShow", 376); + + expect({ + isVisible: KeyboardController.isVisible(), + height: KeyboardController.state().height, + hook: JSON.parse(screen.container.textContent ?? ""), + }).toEqual({ isVisible: true, height: 376, hook: { isVisible: true, height: 376 } }); +}); + +it("waits for the native hide before dismiss resolves after a didShow-only repair", async () => { + using _screen = setup(); + emit("keyboardDidShow", 376); + + let dismissed = false; + const dismissal = KeyboardController.dismiss().then(() => { + dismissed = true; + }); + await act(async () => {}); + + expect({ dismissedBeforeNativeHide: dismissed }).toEqual({ dismissedBeforeNativeHide: false }); + emit("keyboardDidHide", 0); + await dismissal; +}); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index c341dec4e2b9a..eb2a3806e82cf 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -23,6 +23,7 @@ const reactTests = [ "src/state/queries.debounce.test.tsx", "src/state/use-selected-thread-git-actions.test.tsx", "src/features/terminal/useTerminalExitNavigation.test.tsx", + "src/features/keyboard/keyboardControllerState.test.tsx", ]; const sourceRoot = `${normalizePath(NodeURL.fileURLToPath(new URL("./src", import.meta.url)))}/`; From fe3591b299a43acdca19402982fe3dd13916310d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 08:41:05 +0000 Subject: [PATCH 02/10] fix(mobile): keyboard state follows Android's nonanimated IME show repair react-native-keyboard-controller's Android desync repair emits keyboardDidShow without keyboardWillShow, but the library's imperative cache and useKeyboardState only listened to keyboardWillShow, so they stayed hidden/0 and KeyboardController.dismiss() resolved before the native hide. Consume keyboardDidShow in both, across src and lib outputs. Co-authored-by: Tejas Rahane <17571323+trahane@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../terminal/ThreadTerminalRouteScreen.tsx | 2 +- ...ct-native-keyboard-controller@1.22.8.patch | 100 ++++++++++++++++++ pnpm-lock.yaml | 6 +- 3 files changed, 104 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index de0c090f93994..51607baa3b7d3 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -354,7 +354,7 @@ function readLiveTerminalSessions( })); } -// `useKeyboardState` only flips on willShow and didHide; the accessory must +// `useKeyboardState` only flips to hidden on didHide; the accessory must // leave as soon as the keyboard starts hiding, so it follows the will-events. let keyboardShowing = KeyboardController.isVisible(); function subscribeKeyboardShowing(onChange: () => void) { diff --git a/patches/react-native-keyboard-controller@1.22.8.patch b/patches/react-native-keyboard-controller@1.22.8.patch index 3f307d3202cd8..737494cb12e9d 100644 --- a/patches/react-native-keyboard-controller@1.22.8.patch +++ b/patches/react-native-keyboard-controller@1.22.8.patch @@ -233,6 +233,39 @@ index 11e81a9..de39eac 100644 } //# sourceMappingURL=index.js.map \ No newline at end of file +diff --git a/lib/commonjs/hooks/useKeyboardState/index.js b/lib/commonjs/hooks/useKeyboardState/index.js +index a4b287006a47011a58c8a16f19b31a479087f44e..4084025d644c8555b269f9d496266e2cb3234024 100644 +--- a/lib/commonjs/hooks/useKeyboardState/index.js ++++ b/lib/commonjs/hooks/useKeyboardState/index.js +@@ -7,7 +7,7 @@ exports.useKeyboardState = useKeyboardState; + var _react = require("react"); + var _bindings = require("../../bindings"); + var _module = require("../../module"); +-const EVENTS = ["keyboardWillShow", "keyboardDidHide"]; ++const EVENTS = ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"]; + const getLatestState = () => ({ + ..._module.KeyboardController.state(), + isVisible: _module.KeyboardController.isVisible() +diff --git a/lib/commonjs/module.js b/lib/commonjs/module.js +index f27c5a095129cf60831088a73ce94d44f3ba52cd..9b9477515cb4f1213bb2769e095efabe9e7ba58c 100644 +--- a/lib/commonjs/module.js ++++ b/lib/commonjs/module.js +@@ -18,10 +18,13 @@ _bindings.KeyboardEvents.addListener("keyboardDidHide", e => { + isClosed = true; + lastState = e; + }); +-_bindings.KeyboardEvents.addListener("keyboardWillShow", e => { ++const updateShownState = e => { + isClosed = false; + lastState = e; +-}); ++}; ++_bindings.KeyboardEvents.addListener("keyboardWillShow", updateShownState); ++// Android can restore IME insets without an animation and emit only didShow. ++_bindings.KeyboardEvents.addListener("keyboardDidShow", updateShownState); + const dismiss = async options => { + const keepFocus = (options === null || options === void 0 ? void 0 : options.keepFocus) ?? false; + const animated = (options === null || options === void 0 ? void 0 : options.animated) ?? true; diff --git a/lib/module/components/KeyboardChatScrollView/index.js b/lib/module/components/KeyboardChatScrollView/index.js index e0c8391..8df802a 100644 --- a/lib/module/components/KeyboardChatScrollView/index.js @@ -481,6 +514,39 @@ index cdca47b..3ea3a7f 100644 export { useExtraContentPadding }; //# sourceMappingURL=index.js.map \ No newline at end of file +diff --git a/lib/module/hooks/useKeyboardState/index.js b/lib/module/hooks/useKeyboardState/index.js +index 8da7c09fbb398253420d7bfcd9a85abdb981065b..31b1ff4370d8328a0569e559e3bbfa4b201f5a9d 100644 +--- a/lib/module/hooks/useKeyboardState/index.js ++++ b/lib/module/hooks/useKeyboardState/index.js +@@ -1,7 +1,7 @@ + import { useEffect, useState } from "react"; + import { KeyboardEvents } from "../../bindings"; + import { KeyboardController } from "../../module"; +-const EVENTS = ["keyboardWillShow", "keyboardDidHide"]; ++const EVENTS = ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"]; + const getLatestState = () => ({ + ...KeyboardController.state(), + isVisible: KeyboardController.isVisible() +diff --git a/lib/module/module.js b/lib/module/module.js +index 01aad2effee498a8daec55e1d4644a5fbc723765..36d08242a766a47cd254538f518e50a897012d94 100644 +--- a/lib/module/module.js ++++ b/lib/module/module.js +@@ -12,10 +12,13 @@ KeyboardEvents.addListener("keyboardDidHide", e => { + isClosed = true; + lastState = e; + }); +-KeyboardEvents.addListener("keyboardWillShow", e => { ++const updateShownState = e => { + isClosed = false; + lastState = e; +-}); ++}; ++KeyboardEvents.addListener("keyboardWillShow", updateShownState); ++// Android can restore IME insets without an animation and emit only didShow. ++KeyboardEvents.addListener("keyboardDidShow", updateShownState); + const dismiss = async options => { + const keepFocus = (options === null || options === void 0 ? void 0 : options.keepFocus) ?? false; + const animated = (options === null || options === void 0 ? void 0 : options.animated) ?? true; diff --git a/lib/typescript/components/KeyboardChatScrollView/types.d.ts b/lib/typescript/components/KeyboardChatScrollView/types.d.ts index eba1514..6597d80 100644 --- a/lib/typescript/components/KeyboardChatScrollView/types.d.ts @@ -908,3 +974,37 @@ index 99cc6c6..d883e76 100644 ); } +diff --git a/src/hooks/useKeyboardState/index.ts b/src/hooks/useKeyboardState/index.ts +index e5fe38dd8e7b8370bfb575296259933094857ca5..d0b98f60a6d7ad7f72ebd70b22e1ce6b7fb56874 100644 +--- a/src/hooks/useKeyboardState/index.ts ++++ b/src/hooks/useKeyboardState/index.ts +@@ -5,7 +5,7 @@ import { KeyboardController } from "../../module"; + + import type { IKeyboardState } from "../../types"; + +-const EVENTS = ["keyboardWillShow", "keyboardDidHide"] as const; ++const EVENTS = ["keyboardWillShow", "keyboardDidShow", "keyboardDidHide"] as const; + + const getLatestState = () => ({ + ...KeyboardController.state(), +diff --git a/src/module.ts b/src/module.ts +index f74941914c9ecebbd0a6c2ba55c5436378de79d4..317f9908d88cd2812567c079611d4884807b713d 100644 +--- a/src/module.ts ++++ b/src/module.ts +@@ -21,10 +21,14 @@ KeyboardEvents.addListener("keyboardDidHide", (e) => { + lastState = e; + }); + +-KeyboardEvents.addListener("keyboardWillShow", (e) => { ++const updateShownState = (e: KeyboardEventData) => { + isClosed = false; + lastState = e; +-}); ++}; ++ ++KeyboardEvents.addListener("keyboardWillShow", updateShownState); ++// Android can restore IME insets without an animation and emit only didShow. ++KeyboardEvents.addListener("keyboardDidShow", updateShownState); + + const dismiss = async (options?: Partial): Promise => { + const keepFocus = options?.keepFocus ?? false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8400ddc45278b..087b6b5ad5a0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -373,7 +373,7 @@ patchedDependencies: node-pty@1.2.0-beta.15: f1e680afec85f650699ea756f91f469844ec4c976f4f168492a45c6b3f67d651 react-native-gesture-handler@3.3.0: abe20e1d1c23549297cbbc818e14fe479b41455930d9663135d6b40fe359b626 react-native-image-viewing@0.2.2: 419f35ee91eac88ced9438058281db5eb09d9c799d8974c1172926068c9448cf - react-native-keyboard-controller@1.22.8: 6eb6c847ac693b636ec97c83575acd44b77e92ad0d2c776de0cb4e78e8fb9d4f + react-native-keyboard-controller@1.22.8: f5d53dbdc6bd6082849181d6c39ea49482c11da4bf43b89dd6a0563fa39227a1 react-native-pdf-jsi@5.0.0: 6f52854210f61bef499850ebd498229a111bc34ec5b2a4925e644f2f48f42760 react-native-reanimated@4.7.1: 95ee26e5fcab5f50bc4b4c47b3f7e6278a0b20850205efb9f6ee7e70a22d2b59 react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 @@ -749,7 +749,7 @@ importers: version: 0.2.2(patch_hash=419f35ee91eac88ced9438058281db5eb09d9c799d8974c1172926068c9448cf)(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) react-native-keyboard-controller: specifier: 1.22.8 - version: 1.22.8(patch_hash=6eb6c847ac693b636ec97c83575acd44b77e92ad0d2c776de0cb4e78e8fb9d4f)(e2a826d844fe10cdcbda558127861778) + version: 1.22.8(patch_hash=f5d53dbdc6bd6082849181d6c39ea49482c11da4bf43b89dd6a0563fa39227a1)(e2a826d844fe10cdcbda558127861778) react-native-nitro-modules: specifier: 0.37.1 version: 0.37.1(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -21410,7 +21410,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.22.8(patch_hash=6eb6c847ac693b636ec97c83575acd44b77e92ad0d2c776de0cb4e78e8fb9d4f)(e2a826d844fe10cdcbda558127861778): + react-native-keyboard-controller@1.22.8(patch_hash=f5d53dbdc6bd6082849181d6c39ea49482c11da4bf43b89dd6a0563fa39227a1)(e2a826d844fe10cdcbda558127861778): dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6) From df3a160c87414b67ee31839262ca7e8cb0639e87 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 21:47:01 +0000 Subject: [PATCH 03/10] test(mobile): takeover tests for keyboard didShow-only state The takeover's tests for #25847. They keep the PR's two tests, one per behaviour a user notices after Android's nonanimated IME repair: the keyboard reads as shown (module and useKeyboardState), and dismiss() waits for the native keyboardDidHide. The dismiss test no longer renders the hook it never reads. Co-Authored-By: Claude Opus 5.5 --- .../keyboard/keyboardControllerState.test.tsx | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx index 1d781859f272c..f628e495f9e29 100644 --- a/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx +++ b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx @@ -40,24 +40,24 @@ function KeyboardState() { return {JSON.stringify({ isVisible, height })}; } +// The library keeps keyboard state at module scope; every test starts and ends hidden. function setup() { emit("keyboardDidHide", 0); + return { [Symbol.dispose]: () => emit("keyboardDidHide", 0) }; +} + +function renderKeyboardState() { const container = document.createElement("div"); const root = createRoot(container); act(() => root.render()); - return { - container, - [Symbol.dispose]() { - act(() => root.unmount()); - emit("keyboardDidHide", 0); - }, - }; + return { container, [Symbol.dispose]: () => act(() => root.unmount()) }; } // Android's syncKeyboardPosition repairs a nonanimated IME inset change with // keyboardDidHide then keyboardDidShow, and no keyboardWillShow. it("reports the keyboard shown after Android repairs the IME inset without keyboardWillShow", () => { - using screen = setup(); + using _keyboard = setup(); + using screen = renderKeyboardState(); emit("keyboardDidShow", 376); @@ -69,7 +69,7 @@ it("reports the keyboard shown after Android repairs the IME inset without keybo }); it("waits for the native hide before dismiss resolves after a didShow-only repair", async () => { - using _screen = setup(); + using _keyboard = setup(); emit("keyboardDidShow", 376); let dismissed = false; @@ -78,7 +78,7 @@ it("waits for the native hide before dismiss resolves after a didShow-only repai }); await act(async () => {}); - expect({ dismissedBeforeNativeHide: dismissed }).toEqual({ dismissedBeforeNativeHide: false }); + expect(dismissed).toBe(false); emit("keyboardDidHide", 0); await dismissal; }); From 29d68e8651a8f2ce1b9c353f1ff074456086e209 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 21:53:12 +0000 Subject: [PATCH 04/10] fix(mobile): keyboard didShow patch matches upstream; terminal skips extra render Mirror kirillzyusko/react-native-keyboard-controller#1698: add a separate keyboardDidShow listener instead of rewriting the keyboardWillShow one, so the patch hunk can be dropped when that PR ships. Split the terminal's object selector into primitives so the added didShow update bails out instead of re-rendering the terminal screen on every keyboard show. Co-Authored-By: Claude Opus 5.5 --- .../terminal/ThreadTerminalRouteScreen.tsx | 20 +++--- ...ct-native-keyboard-controller@1.22.8.patch | 63 ++++++++----------- pnpm-lock.yaml | 6 +- 3 files changed, 38 insertions(+), 51 deletions(-) diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 51607baa3b7d3..8adbdbde18033 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -590,10 +590,8 @@ function ThreadTerminal(props: { { kind: "send", key: "dash", label: "-", data: "-" }, ]; }, [hostPlatform]); - const keyboardState = useKeyboardState((state) => ({ - height: state.height, - isVisible: state.isVisible, - })); + const isKeyboardVisible = useKeyboardState((state) => state.isVisible); + const keyboardHeight = useKeyboardState((state) => state.height); const keyboardShowing = useSyncExternalStore(subscribeKeyboardShowing, getKeyboardShowing); // The accessory follows the keyboard, except that the dismiss and show // buttons flip it ahead of the keyboard animation. @@ -602,16 +600,15 @@ function ThreadTerminal(props: { setAccessory({ keyboardShowing, dismissed: !keyboardShowing }); } const isAccessoryDismissed = accessory.dismissed; - const isAccessoryVisible = canOperateTerminal && keyboardState.isVisible && !isAccessoryDismissed; + const isAccessoryVisible = canOperateTerminal && isKeyboardVisible && !isAccessoryDismissed; // Android's terminal owns an EditText; turning off autoFocus also clears its native focus. const terminalAutoFocus = Platform.OS === "android" ? !isAccessoryDismissed && - (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || keyboardState.isVisible) + (!SHOWCASE_ENABLED || keyboardFocusRequest > 0 || isKeyboardVisible) : !SHOWCASE_ENABLED; const terminalBottomInset = - (keyboardState.isVisible ? keyboardState.height : 0) + - (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); + (isKeyboardVisible ? keyboardHeight : 0) + (isAccessoryVisible ? TERMINAL_ACCESSORY_HEIGHT : 0); const terminalMenuSessions = useMemo>( () => @@ -883,8 +880,7 @@ function ThreadTerminal(props: { // Measured top reservation is zero when UIKit already inset the body or moved the bar. paddingTop: Platform.OS === "ios" ? (nativeColumnMetrics?.safeArea.top ?? insets.top) : 0, - paddingBottom: - Platform.OS === "android" && !keyboardState.isVisible ? insets.bottom : 0, + paddingBottom: Platform.OS === "android" && !isKeyboardVisible ? insets.bottom : 0, }} > {!isEnvironmentReady ? ( @@ -978,7 +974,7 @@ function ThreadTerminal(props: { /> - {Platform.OS === "android" && !keyboardState.isVisible ? ( + {Platform.OS === "android" && !isKeyboardVisible ? ( {hasNativeTerminalSurface() ? (