Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions apps/mobile/src/features/keyboard/keyboardControllerState.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Set<(event: KeyboardEventData) => 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 <output>{JSON.stringify({ visible: state.isVisible, height: state.height })}</output>;
}

act(() => root.render(<KeyboardState />));

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}');
});
});
1 change: 1 addition & 0 deletions apps/mobile/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
100 changes: 100 additions & 0 deletions patches/react-native-keyboard-controller@1.22.7.patch
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<DismissOptions>): Promise<void> => {
const keepFocus = options?.keepFocus ?? false;
6 changes: 3 additions & 3 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading