From 9c6b80e65d64458de6c3220b6ae57b99c2220cae Mon Sep 17 00:00:00 2001 From: Tejas Rahane <17571323+trahane@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:11:37 -0700 Subject: [PATCH 1/2] test(mobile): cover Android keyboard inset recovery --- .../keyboard/keyboardControllerState.test.tsx | 125 ++++++++++++++++++ apps/mobile/vite.config.ts | 1 + 2 files changed, 126 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 000000000000..74d53ff3b25c --- /dev/null +++ b/apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx @@ -0,0 +1,125 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; +import type { KeyboardEventData } from "react-native-keyboard-controller"; + +const native = vi.hoisted(() => ({ + listeners: new Map void>>(), + dismiss: vi.fn(), +})); + +// Metro uses the source entry. Exercise its real cache and hook with native +// events and commands replaced so the regression runs without a device. +vi.mock("../../../node_modules/react-native-keyboard-controller/src/bindings", () => ({ + KeyboardEvents: { + addListener: (name: string, listener: (event: KeyboardEventData) => void) => { + const listeners = native.listeners.get(name) ?? new Set(); + listeners.add(listener); + native.listeners.set(name, listeners); + return { remove: () => listeners.delete(listener) }; + }, + }, + KeyboardControllerNative: { + setDefaultMode: vi.fn(), + setInputMode: vi.fn(), + setFocusTo: vi.fn(), + preload: vi.fn(), + setTranslucent: vi.fn(), + dismiss: native.dismiss, + }, +})); + +import { KeyboardController } from "../../../node_modules/react-native-keyboard-controller/src/module"; +import { useKeyboardState } from "../../../node_modules/react-native-keyboard-controller/src/hooks/useKeyboardState"; + +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 native.listeners.get(name) ?? []) listener(event); + }); +} + +function setup() { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + native.dismiss.mockReset(); + emit("keyboardDidHide", 0); + const container = document.createElement("div"); + const root = createRoot(container); + + function KeyboardState() { + const state = useKeyboardState(); + return {JSON.stringify({ visible: state.isVisible, height: state.height })}; + } + + act(() => root.render()); + + return { + container, + [Symbol.dispose]() { + act(() => root.unmount()); + container.remove(); + emit("keyboardDidHide", 0); + vi.unstubAllGlobals(); + }, + }; +} + +describe("keyboard-controller nonanimated inset recovery", () => { + it("updates the imperative cache and mounted hook after didShow without willShow", () => { + using screen = setup(); + expect(screen.container.textContent).toBe('{"visible":false,"height":0}'); + + // Android syncKeyboardPosition can restore IME insets without willShow. + emit("keyboardDidShow", 376); + + expect({ + visible: KeyboardController.isVisible(), + height: KeyboardController.state().height, + mounted: screen.container.textContent, + }).toEqual({ visible: true, height: 376, mounted: '{"visible":true,"height":376}' }); + }); + + it("waits for native dismissal after a didShow-only recovery", async () => { + using screen = setup(); + emit("keyboardDidShow", 376); + let settled = false; + const dismissal = KeyboardController.dismiss().then(() => { + settled = true; + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(native.dismiss).toHaveBeenCalledWith(false, true); + const settledBeforeHide = settled; + emit("keyboardDidHide", 0); + await dismissal; + + expect(settledBeforeHide).toBe(false); + expect(settled).toBe(true); + expect(KeyboardController.isVisible()).toBe(false); + expect(screen.container.textContent).toBe('{"visible":false,"height":0}'); + }); + + it("still publishes willShow immediately and accepts the completed keyboard height", () => { + using screen = setup(); + emit("keyboardWillShow", 320); + expect(KeyboardController.isVisible()).toBe(true); + expect(screen.container.textContent).toBe('{"visible":true,"height":320}'); + + emit("keyboardDidShow", 376); + expect(KeyboardController.state().height).toBe(376); + expect(screen.container.textContent).toBe('{"visible":true,"height":376}'); + + emit("keyboardDidHide", 0); + expect(KeyboardController.isVisible()).toBe(false); + expect(screen.container.textContent).toBe('{"visible":false,"height":0}'); + }); +}); diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 7fe24034f1e6..e09d22d2a50d 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import "vite-plus/test/config"; const reactTests = [ "src/components/ui/Dialog.android.test.tsx", "src/features/home/home-route-visibility.test.tsx", + "src/features/keyboard/keyboardControllerState.test.tsx", "src/features/settings/SettingsServerControlsRouteScreen.test.tsx", "src/features/settings/components/BranchNamingSettings.test.tsx", "src/features/threads/use-visible-second-clock.test.tsx", From b2c877784eee065dad61d478b6ca3ebe55137719 Mon Sep 17 00:00:00 2001 From: Tejas Rahane <17571323+trahane@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:11:47 -0700 Subject: [PATCH 2/2] fix(mobile): update keyboard state after nonanimated inset recovery --- ...ct-native-keyboard-controller@1.22.7.patch | 100 ++++++++++++++++++ pnpm-lock.yaml | 6 +- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/patches/react-native-keyboard-controller@1.22.7.patch b/patches/react-native-keyboard-controller@1.22.7.patch index 7fde383a42a7..47447c5adb0b 100644 --- a/patches/react-native-keyboard-controller@1.22.7.patch +++ b/patches/react-native-keyboard-controller@1.22.7.patch @@ -229,6 +229,39 @@ index 827436c..fe9f371 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..fc1da8b8c843968ade0a7033a70136166a80b674 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..9f64d066a2e647759046a7e8bda2c1f7c29a6585 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 @@ -473,6 +506,39 @@ index 3c90439..9e7ebb6 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..813b8ec3471c3695f76cf911a4a9dc3853f8e23b 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..f4a87b3f0b7b39d819bc4ee7a2e1795211a8cf75 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 @@ -902,3 +968,37 @@ index e7584ed..dc653b0 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 3cd3fc1b2bae..22052b0e69e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -191,7 +191,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.7: f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb + react-native-keyboard-controller@1.22.7: 683facba63cf6cba36880b1a90824bad94879edb982728615869e4b9dbb8dc74 react-native-pdf-jsi@5.0.0: 6f52854210f61bef499850ebd498229a111bc34ec5b2a4925e644f2f48f42760 react-native-webview@14.0.1: 81caa279ec3dc1f81209062133b66e8f824e13f106cca57f1fa732a72c2ab378 @@ -578,7 +578,7 @@ importers: version: 0.2.2(patch_hash=419f35ee91eac88ced9438058281db5eb09d9c799d8974c1172926068c9448cf)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-keyboard-controller: specifier: 1.22.7 - version: 1.22.7(patch_hash=f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb)(react-native-reanimated@4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 1.22.7(patch_hash=683facba63cf6cba36880b1a90824bad94879edb982728615869e4b9dbb8dc74)(react-native-reanimated@4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react-native-nitro-modules: specifier: 0.37.1 version: 0.37.1(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) @@ -22614,7 +22614,7 @@ snapshots: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.22.7(patch_hash=f28d08f1233e3cce8b9324b4357d2b10f1ee040b8051bc87235b0fb95069b9bb)(react-native-reanimated@4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + react-native-keyboard-controller@1.22.7(patch_hash=683facba63cf6cba36880b1a90824bad94879edb982728615869e4b9dbb8dc74)(react-native-reanimated@4.7.1(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: react: 19.3.0 react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)