Skip to content

Commit ba41d5d

Browse files
authored
fix: KeyboardAwareScrollView re-focus after hardware keyboard dismissal (#1403)
## 📜 Description Fixed a problem, when re-focusing field (after dismiss via system button) pushes the field significantly higher. ## 💡 Motivation and Context The `onEnd` handler nulls `lastSelection.value` when the keyboard hides (`e.height === 0`). This was added intentionally in #1234 to solve an iOS 15 problem: without nulling, `onStart` couldn't distinguish a fresh selection (that arrived before `onStart` in the current session) from a stale selection (leftover from the previous session). On iOS 15, selection sometimes arrives after `onStart`, so the `pendingSelectionForFocus` mechanism was introduced to defer layout setup until the selection arrives. However, nulling `lastSelection` causes two regressions: ### 1️⃣ Android — refocus same input Android doesn't re-emit `onSelectionChange` when refocusing the same input at the same cursor position. After the `null`, `onStart` sees l`astSelection.value?.target !== e.target` (because `null?.target` is `undefined`), falls back to `layout.value = input.value` (full input height, e.g. `180px` instead of caret height `43px`), and sets `pendingSelectionForFocus = true`. But the selection event never arrives - so `onMove` runs the entire animation with the **wrong** height. ### 2️⃣ iOS 15 — refocus same input with new cursor Similar to Android - `lastSelection` is `null`, so `onStart` can't use the stale selection as a reasonable fallback. It uses the full input height instead. The fix consist of several coordinated changes ### 1️⃣ Replace lastSelection.value = null with a flag Instead of destroying selection data, introduce `selectionUpdatedSinceHide`: - Set to `false` in `onEnd` when keyboard **hides** - Set to `true` in `onSelectionChange` when any selection arrives - In `onStart`, the "fresh selection" check becomes: ```ts lastSelection.value?.target === e.target && selectionUpdatedSinceHide.value ``` This preserves the iOS 15 detection (stale selection has `selectionUpdatedSinceHide = false`, so `onStart` correctly enters the pending path) while keeping the data available as a fallback. ### 2️⃣ Use stale selection as best-effort fallback in `onStart` When the selection is stale (not fresh) but targets the same input, use `updateLayoutFromSelection()` instead of falling back to `input.value`. The stale caret position (e.g. `y=43`) is much closer to correct than the full input height (e.g. `180`). If a fresh selection arrives later (iOS 15), it will overwrite this — but on Android where it never arrives, the stale value is already correct. ```ts if (lastSelection.value?.target === e.target) { updateLayoutFromSelection(); // stale but same target — use as fallback } else if (input.value) { layout.value = input.value; // different target or no selection at all } pendingSelectionForFocus.value = true; ``` ### 3️⃣ Handle same-target refocus in onSelectionChange Since `lastSelection` is no longer nulled, when iOS 15 refocuses the same input, `onSelectionChange` arrives with `e.target === lastTarget`. The existing check if (`e.target !== lastTarget`) won't enter the deferred-setup block. Fix by also checking the pending flag: ```ts if (e.target !== lastTarget || pendingSelectionForFocus.value) { ``` This ensures the deferred selection setup runs regardless of whether the target changed, as long as `onStart` flagged it as pending. ### 4️⃣ Conditional cleanup of pendingSelectionForFocus in onEnd To prevent the pending flag from leaking into the next focus session (Android case where selection never arrives), clear it in `onEnd` - but only when the keyboard was actually appearing (`keyboardWillAppear.value`), not during a focus switch with the same keyboard height. Otherwise, toolbar focus switching breaks: `onEnd` fires immediately (no animation), clearing the flag before `onSelectionChange` has a chance to process the deferred scroll. ```ts if (e.height === 0) { selectionUpdatedSinceHide.value = false; } else if (keyboardWillAppear.value) { pendingSelectionForFocus.value = false; } ``` Closes #1394 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### JS - create separate `types.ts` file; - created `KeyboardAwareScrollView` tests (70% test coverage, texting 5 most critical/hard to catch bugs); - fixed a problem with re-focus on Android; ## 🤔 How Has This Been Tested? Tested manually and via this PR. ## 📸 Screenshots (if appropriate): |Before|After| |-------|-----| |<video src="https://github.com/user-attachments/assets/c6eb29cf-6756-4bda-8f4f-ba7d83e15d4e">|<video src="https://github.com/user-attachments/assets/7ecc2b46-77cc-4203-8264-63138d325ad5">| |iOS 15|iOS 26|Android| |------|-------|--------| |<video src="https://github.com/user-attachments/assets/7f867783-ab5d-4a8e-9232-a7a7ad68cdaf">|<video src="https://github.com/user-attachments/assets/f054efa7-9774-450c-93ab-7a4d90e6c66b">|<video src="https://github.com/user-attachments/assets/b49bbb15-63f7-4143-ba5c-dcd159159723">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
1 parent af5da35 commit ba41d5d

10 files changed

Lines changed: 582 additions & 31 deletions

File tree

.prettierignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,6 @@ android/build/
2222
android/.cxx/
2323

2424
tea.yaml
25+
26+
coverage/
27+
e2e/kit/assets/
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* Shared `jest.mock()` registrations for KeyboardAwareScrollView tests.
3+
*
4+
* Import this file in each test file. Because `jest.mock()` calls are hoisted
5+
* by babel, they will always run before any other imports.
6+
*
7+
* @example import "../__fixtures__/mocks";
8+
*/
9+
10+
// ---------------------------------------------------------------------------
11+
// Inline constants & helpers — defined here to avoid a circular dependency
12+
// chain (testUtils → useChatKeyboard/testUtils → reanimated → this factory).
13+
// ---------------------------------------------------------------------------
14+
const MOCK_SCREEN_H = 928;
15+
const MOCK_SV = 1469;
16+
17+
const mockInterpolateFn = (
18+
value: number,
19+
input: number[],
20+
output: number[],
21+
): number => {
22+
"worklet";
23+
24+
if (input[1] === 0) {
25+
return 0;
26+
}
27+
28+
const progress = (value - input[0]) / (input[1] - input[0]);
29+
30+
return output[0] + progress * (output[1] - output[0]);
31+
};
32+
33+
const mockState = () => require("./testUtils");
34+
35+
// ---------------------------------------------------------------------------
36+
// jest.mock registrations
37+
// ---------------------------------------------------------------------------
38+
39+
jest.mock("react-native-reanimated", () => ({
40+
...require("react-native-reanimated/mock"),
41+
scrollTo: (...args: unknown[]) => mockState().mockScrollTo(...args),
42+
interpolate: mockInterpolateFn,
43+
clamp: (value: number, min: number, max: number) =>
44+
Math.min(Math.max(value, min), max),
45+
}));
46+
47+
jest.mock("../useSmoothKeyboardHandler", () => ({
48+
useSmoothKeyboardHandler: jest.fn(
49+
(h: {
50+
onStart: (e: unknown) => void;
51+
onMove: (e: unknown) => void;
52+
onEnd: (e: unknown) => void;
53+
}) => {
54+
mockState().mockKeyboardHandlers.current = h;
55+
},
56+
),
57+
}));
58+
59+
jest.mock("../../../hooks", () => ({
60+
useFocusedInputHandler: jest.fn(
61+
(h: { onSelectionChange: (...args: unknown[]) => void }) => {
62+
mockState().mockSelectionHandler.current = h.onSelectionChange;
63+
},
64+
),
65+
useReanimatedFocusedInput: jest.fn(() => ({
66+
input: mockState().mockInput,
67+
update: jest.fn().mockResolvedValue(undefined),
68+
})),
69+
useWindowDimensions: jest.fn(() => ({ height: MOCK_SCREEN_H })),
70+
}));
71+
72+
jest.mock("../../hooks/useScrollState", () => ({
73+
__esModule: true,
74+
default: jest.fn(() => ({
75+
offset: mockState().mockOffset,
76+
layout: mockState().mockLayout,
77+
size: mockState().mockSize,
78+
})),
79+
}));
80+
81+
jest.mock("../../hooks/useCombinedRef", () => ({
82+
__esModule: true,
83+
default: jest.fn(() => jest.fn()),
84+
}));
85+
86+
jest.mock("../../../utils/findNodeHandle", () => ({
87+
findNodeHandle: jest.fn(() => MOCK_SV),
88+
}));
89+
90+
jest.mock("../../../bindings", () => ({
91+
KeyboardControllerNative: {
92+
viewPositionInWindow: jest.fn().mockResolvedValue({ y: 0 }),
93+
},
94+
}));
95+
96+
jest.mock("../../ScrollViewWithBottomPadding", () => {
97+
const { forwardRef, createElement } = require("react");
98+
const { View: MockView } = require("react-native");
99+
100+
return {
101+
__esModule: true,
102+
default: forwardRef(
103+
(
104+
props: { onLayout?: (e: never) => void; children?: unknown },
105+
ref: unknown,
106+
) => {
107+
mockState().mockCapturedOnLayout.current = props.onLayout ?? null;
108+
109+
return createElement(MockView, { ref }, props.children);
110+
},
111+
),
112+
};
113+
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { act, render } from "@testing-library/react-native";
2+
import React from "react";
3+
import { View } from "react-native";
4+
5+
import type { LayoutChangeEvent } from "react-native";
6+
import type {
7+
FocusedInputLayoutChangedEvent,
8+
FocusedInputSelectionChangedEvent,
9+
NativeEvent,
10+
} from "react-native-keyboard-controller";
11+
import type { SharedValue } from "react-native-reanimated";
12+
13+
// ---------------------------------------------------------------------------
14+
// Constants (derived from real device logs)
15+
// ---------------------------------------------------------------------------
16+
export const MOCK_SCREEN_HEIGHT = 928;
17+
export const KEYBOARD_HEIGHT = 312;
18+
export const BOTTOM_OFFSET = 62;
19+
export const MOCK_SV_TARGET = 1469;
20+
export const INPUT_TARGET_A = 1373;
21+
export const INPUT_TARGET_B = 1395;
22+
23+
export const INPUT_LAYOUT_A = {
24+
absoluteY: 281.67,
25+
height: 60.33,
26+
y: 165.67,
27+
x: 0,
28+
absoluteX: 16,
29+
width: 394.67,
30+
};
31+
32+
export const INPUT_LAYOUT_B = {
33+
absoluteY: 695.67,
34+
height: 60.33,
35+
y: 579.67,
36+
x: 0,
37+
absoluteX: 16,
38+
width: 121,
39+
};
40+
41+
// ---------------------------------------------------------------------------
42+
// Types
43+
// ---------------------------------------------------------------------------
44+
export type KeyboardHandlers = {
45+
onStart: (e: NativeEvent) => void;
46+
onMove: (e: NativeEvent) => void;
47+
onEnd: (e: NativeEvent) => void;
48+
};
49+
50+
export type SelectionHandler = (e: FocusedInputSelectionChangedEvent) => void;
51+
52+
// ---------------------------------------------------------------------------
53+
// Mock state
54+
// ---------------------------------------------------------------------------
55+
export const mockScrollTo = jest.fn();
56+
export const mockInput = {
57+
value: null,
58+
} as SharedValue<FocusedInputLayoutChangedEvent | null>;
59+
export const mockOffset = { value: 0 } as SharedValue<number>;
60+
export const mockLayout = {
61+
value: { width: 390, height: 812 },
62+
} as SharedValue<{ width: number; height: number }>;
63+
export const mockSize = {
64+
value: { width: 390, height: 2000 },
65+
} as SharedValue<{ width: number; height: number }>;
66+
67+
// ---------------------------------------------------------------------------
68+
// Captured handlers (populated on each render)
69+
// ---------------------------------------------------------------------------
70+
export const mockKeyboardHandlers: { current: KeyboardHandlers } = {
71+
current: undefined as unknown as KeyboardHandlers,
72+
};
73+
export const mockSelectionHandler: { current: SelectionHandler } = {
74+
current: undefined as unknown as SelectionHandler,
75+
};
76+
export const mockCapturedOnLayout: {
77+
current: ((e: LayoutChangeEvent) => void) | null;
78+
} = { current: null };
79+
80+
// ---------------------------------------------------------------------------
81+
// Re-export shared utilities
82+
// ---------------------------------------------------------------------------
83+
84+
// ---------------------------------------------------------------------------
85+
// Helpers
86+
// ---------------------------------------------------------------------------
87+
export const reset = () => {
88+
mockScrollTo.mockClear();
89+
mockOffset.value = 0;
90+
mockLayout.value = { width: 390, height: 812 };
91+
mockSize.value = { width: 390, height: 2000 };
92+
mockInput.value = null;
93+
mockCapturedOnLayout.current = null;
94+
};
95+
96+
export const inputEvent = (
97+
target: number,
98+
layout: typeof INPUT_LAYOUT_A,
99+
): FocusedInputLayoutChangedEvent => ({
100+
target,
101+
parentScrollViewTarget: MOCK_SV_TARGET,
102+
layout: { ...layout },
103+
});
104+
105+
export const selectionEvent = (
106+
target: number,
107+
endY = 47,
108+
endPosition = 0,
109+
): FocusedInputSelectionChangedEvent => ({
110+
target,
111+
selection: {
112+
start: { x: 0, y: endY, position: endPosition },
113+
end: { x: 0, y: endY, position: endPosition },
114+
},
115+
});
116+
117+
export const kbEvent = (height: number, target: number): NativeEvent => ({
118+
height,
119+
target,
120+
duration: 285,
121+
progress: KEYBOARD_HEIGHT > 0 ? height / KEYBOARD_HEIGHT : 0,
122+
});
123+
124+
export const renderKeyboardAwareScrollView = async (
125+
bottomOffset = BOTTOM_OFFSET,
126+
) => {
127+
const KeyboardAwareScrollView = require("../index").default;
128+
129+
render(
130+
<KeyboardAwareScrollView bottomOffset={bottomOffset}>
131+
<View />
132+
</KeyboardAwareScrollView>,
133+
);
134+
135+
await act(async () => {
136+
mockCapturedOnLayout.current?.({
137+
nativeEvent: { layout: { x: 0, y: 0, width: 390, height: 812 } },
138+
} as LayoutChangeEvent);
139+
});
140+
};
141+
142+
export const lastScrollToY = (): number | undefined => {
143+
const calls = mockScrollTo.mock.calls;
144+
145+
if (calls.length === 0) {
146+
return undefined;
147+
}
148+
149+
// scrollTo(ref, x, y, animated)
150+
return calls[calls.length - 1][2] as number;
151+
};
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import "../__fixtures__/mocks";
2+
3+
import {
4+
INPUT_LAYOUT_B,
5+
INPUT_TARGET_B,
6+
KEYBOARD_HEIGHT,
7+
inputEvent,
8+
kbEvent,
9+
lastScrollToY,
10+
mockInput,
11+
mockKeyboardHandlers,
12+
mockOffset,
13+
mockScrollTo,
14+
mockSelectionHandler,
15+
renderKeyboardAwareScrollView,
16+
reset,
17+
selectionEvent,
18+
} from "../__fixtures__/testUtils";
19+
20+
beforeEach(() => {
21+
reset();
22+
});
23+
24+
describe("KeyboardAwareScrollView — refocus same input", () => {
25+
// Input B: point = 695.67 + 47 = 742.67
26+
// relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67
27+
28+
describe("iOS 15: refocus after keyboard hide", () => {
29+
it("should use fresh selection data on refocus", async () => {
30+
await renderKeyboardAwareScrollView();
31+
mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B);
32+
33+
// ---- First focus (cursor at y=47) ----
34+
mockKeyboardHandlers.current.onStart(
35+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
36+
);
37+
mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 47, 0));
38+
mockKeyboardHandlers.current.onMove(
39+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
40+
);
41+
mockKeyboardHandlers.current.onEnd(
42+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
43+
);
44+
mockOffset.value = 189;
45+
46+
// ---- Keyboard hide ----
47+
mockKeyboardHandlers.current.onStart(kbEvent(0, INPUT_TARGET_B));
48+
mockKeyboardHandlers.current.onEnd(kbEvent(0, INPUT_TARGET_B));
49+
mockOffset.value = 0;
50+
51+
// ---- Second focus (cursor moved to y=20) ----
52+
mockScrollTo.mockClear();
53+
54+
mockKeyboardHandlers.current.onStart(
55+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
56+
);
57+
mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 20, 5));
58+
mockKeyboardHandlers.current.onMove(
59+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
60+
);
61+
62+
// point = 695.67 + 20 = 715.67
63+
// relativeScrollTo = 312 - (928 - 715.67) + 62 = 161.67
64+
expect(lastScrollToY()).toBeCloseTo(161.67, 0);
65+
66+
mockKeyboardHandlers.current.onEnd(
67+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
68+
);
69+
});
70+
});
71+
72+
describe("Android: selection not re-emitted on refocus", () => {
73+
it("should use stale selection as fallback", async () => {
74+
await renderKeyboardAwareScrollView();
75+
mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B);
76+
77+
// ---- First focus (selection arrives) ----
78+
mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 47, 0));
79+
mockKeyboardHandlers.current.onStart(
80+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
81+
);
82+
mockKeyboardHandlers.current.onMove(
83+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
84+
);
85+
mockKeyboardHandlers.current.onEnd(
86+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
87+
);
88+
mockOffset.value = 189;
89+
90+
// ---- Keyboard hide ----
91+
mockKeyboardHandlers.current.onStart(kbEvent(0, INPUT_TARGET_B));
92+
mockKeyboardHandlers.current.onEnd(kbEvent(0, INPUT_TARGET_B));
93+
mockOffset.value = 0;
94+
95+
// ---- Second focus (NO selection event — Android behavior) ----
96+
mockScrollTo.mockClear();
97+
98+
mockKeyboardHandlers.current.onStart(
99+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
100+
);
101+
// No selectionHandler call — Android doesn't re-emit for same cursor
102+
mockKeyboardHandlers.current.onMove(
103+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
104+
);
105+
106+
// Must use stale selection (height=47), NOT full input height (60.33)
107+
expect(lastScrollToY()).toBeCloseTo(188.67, 0);
108+
109+
mockKeyboardHandlers.current.onEnd(
110+
kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B),
111+
);
112+
});
113+
});
114+
});

0 commit comments

Comments
 (0)