From f44e82bd58d3fd8636ea1a992ebc723a64bd8e5b Mon Sep 17 00:00:00 2001 From: Ludwig <62896243+ludwig-pro@users.noreply.github.com> Date: Fri, 22 May 2026 18:17:55 +0200 Subject: [PATCH] fix: anchor keyboard chat after dismiss --- __tests__/__mocks__/react-native.ts | 8 + .../integrations/keyboard.behavior.test.tsx | 230 +++++++++++++++++- example/screens/examples/AiChatExample.tsx | 29 ++- src/integrations/keyboard.tsx | 66 ++++- 4 files changed, 316 insertions(+), 17 deletions(-) diff --git a/__tests__/__mocks__/react-native.ts b/__tests__/__mocks__/react-native.ts index 23358e21..50fa87c4 100644 --- a/__tests__/__mocks__/react-native.ts +++ b/__tests__/__mocks__/react-native.ts @@ -27,6 +27,14 @@ export const I18nManager = { swapLeftAndRightInRTL: (_value: boolean) => {}, }; +export const Keyboard = { + addListener(_eventName: string, _listener: AnyFunction) { + return { + remove: () => {}, + }; + }, +}; + export const Dimensions = { get(_what: "window" | "screen") { return { fontScale: 2, height: 667, scale: 2, width: 375 }; diff --git a/__tests__/integrations/keyboard.behavior.test.tsx b/__tests__/integrations/keyboard.behavior.test.tsx index 7b3591d9..2b910596 100644 --- a/__tests__/integrations/keyboard.behavior.test.tsx +++ b/__tests__/integrations/keyboard.behavior.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, mock } from "bun:test"; import "../setup"; import * as React from "react"; -import type { LayoutChangeEvent } from "react-native"; +import { Keyboard, type LayoutChangeEvent, Platform } from "react-native"; import { useCombinedRef } from "../../src/hooks/useCombinedRef"; import { typedForwardRef } from "../../src/types.internal"; @@ -12,6 +12,30 @@ let lastAnimatedLegendListProps: any; const reportContentInsetMock = mock( (_insets: Partial<{ bottom: number; left: number; right: number; top: number }>) => {}, ); +const keyboardDismissMock = mock(() => Promise.resolve()); +const keyboardListeners = new Map void>>(); +const keyboardAddListenerMock = mock((eventName: string, listener: () => void) => { + let listeners = keyboardListeners.get(eventName); + + if (!listeners) { + listeners = new Set(); + keyboardListeners.set(eventName, listeners); + } + + listeners.add(listener); + + return { + remove: mock(() => { + listeners?.delete(listener); + }), + }; +}); + +const emitKeyboardEvent = (eventName: string) => { + for (const listener of keyboardListeners.get(eventName) ?? []) { + listener(); + } +}; const createSharedValue = (initial: T) => { let current = initial; @@ -40,7 +64,7 @@ const createSharedValue = (initial: T) => { mock.module("react-native-keyboard-controller", () => ({ KeyboardChatScrollView: (props: any) => React.createElement("keyboard-chat-scroll-view", props), KeyboardController: { - dismiss: () => Promise.resolve(), + dismiss: keyboardDismissMock, }, useKeyboardHandler: () => {}, })); @@ -142,8 +166,35 @@ function ComposerInsetProbe({ return null; } +function ScrollToEndProbe({ + freeze, + listRef, + onResult, + useKeyboardScrollToEnd, +}: { + freeze: ReturnType>; + listRef: React.RefObject<{ scrollToEnd(params?: { animated?: boolean }): Promise } | null>; + onResult: (result: ReturnType) => void; + useKeyboardScrollToEnd: typeof import("../../src/integrations/keyboard").useKeyboardScrollToEnd; +}) { + const result = useKeyboardScrollToEnd({ freeze, listRef }); + + React.useEffect(() => { + onResult(result); + }, [onResult, result]); + + return null; +} + describe("KeyboardAwareLegendList", () => { + const originalPlatform = Platform.OS; + beforeEach(() => { + Platform.OS = originalPlatform; + Keyboard.addListener = keyboardAddListenerMock as typeof Keyboard.addListener; + keyboardAddListenerMock.mockClear(); + keyboardDismissMock.mockClear(); + keyboardListeners.clear(); lastAnimatedLegendListProps = undefined; reportContentInsetMock.mockClear(); }); @@ -267,4 +318,179 @@ describe("KeyboardAwareLegendList", () => { expect(reportContentInsetMock).toHaveBeenCalledTimes(1); expect(reportContentInsetMock).toHaveBeenNthCalledWith(1, { bottom: 42 }); }); + + it("keeps non-Android keyboard dismissal and scroll in the existing parallel path", async () => { + const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?ios-scroll-to-end-test"); + const freeze = createSharedValue(false); + const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {}); + let hookResult: ReturnType | undefined; + + Platform.OS = "ios"; + + act(() => { + TestRenderer.create( + { + hookResult = result; + }} + useKeyboardScrollToEnd={useKeyboardScrollToEnd} + />, + ); + }); + + await act(async () => { + await hookResult?.scrollMessageToEnd({ animated: true, closeKeyboard: true }); + }); + + expect(keyboardDismissMock).toHaveBeenCalledTimes(1); + expect(keyboardAddListenerMock).not.toHaveBeenCalled(); + expect(scrollToEnd).toHaveBeenCalledTimes(1); + expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true }); + expect(freeze.value).toBe(false); + }); + + it("runs Android scroll after keyboard hide and layout settle", async () => { + const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?android-scroll-to-end-test"); + const freeze = createSharedValue(false); + const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {}); + let hookResult: ReturnType | undefined; + + Platform.OS = "android"; + + act(() => { + TestRenderer.create( + { + hookResult = result; + }} + useKeyboardScrollToEnd={useKeyboardScrollToEnd} + />, + ); + }); + + const scrollPromise = hookResult!.scrollMessageToEnd({ animated: true, closeKeyboard: true }); + + await Promise.resolve(); + + expect(keyboardDismissMock).toHaveBeenCalledTimes(1); + expect(keyboardAddListenerMock).toHaveBeenCalledWith("keyboardDidHide", expect.any(Function)); + expect(scrollToEnd).not.toHaveBeenCalled(); + + emitKeyboardEvent("keyboardDidHide"); + + await act(async () => { + await scrollPromise; + }); + + expect(scrollToEnd).toHaveBeenCalledTimes(1); + expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true }); + expect(freeze.value).toBe(false); + }); + + it("uses the Android keyboard hide fallback before the post-dismiss scroll", async () => { + const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?android-fallback-scroll-test"); + const freeze = createSharedValue(false); + const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {}); + let hookResult: ReturnType | undefined; + + Platform.OS = "android"; + + act(() => { + TestRenderer.create( + { + hookResult = result; + }} + useKeyboardScrollToEnd={useKeyboardScrollToEnd} + />, + ); + }); + + await act(async () => { + await hookResult?.scrollMessageToEnd({ animated: true, closeKeyboard: true }); + }); + + expect(keyboardDismissMock).toHaveBeenCalledTimes(1); + expect(keyboardAddListenerMock).toHaveBeenCalledWith("keyboardDidHide", expect.any(Function)); + expect(scrollToEnd).toHaveBeenCalledTimes(1); + expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true }); + expect(freeze.value).toBe(false); + }); + + it("ignores stale Android scrolls when a newer send starts", async () => { + const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?stale-android-scroll-test"); + const freeze = createSharedValue(false); + const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {}); + let hookResult: ReturnType | undefined; + + Platform.OS = "android"; + + act(() => { + TestRenderer.create( + { + hookResult = result; + }} + useKeyboardScrollToEnd={useKeyboardScrollToEnd} + />, + ); + }); + + const firstScrollPromise = hookResult!.scrollMessageToEnd({ animated: true, closeKeyboard: true }); + + await Promise.resolve(); + + const secondScrollPromise = hookResult!.scrollMessageToEnd({ animated: true, closeKeyboard: true }); + + await Promise.resolve(); + emitKeyboardEvent("keyboardDidHide"); + + await act(async () => { + await Promise.all([firstScrollPromise, secondScrollPromise]); + }); + + expect(scrollToEnd).toHaveBeenCalledTimes(1); + expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true }); + expect(freeze.value).toBe(false); + }); + + it("keeps Android closeKeyboard false on the direct scroll path", async () => { + const { useKeyboardScrollToEnd } = await import("../../src/integrations/keyboard?android-no-close-scroll-test"); + const freeze = createSharedValue(false); + const scrollToEnd = mock(async (_params?: { animated?: boolean }) => {}); + let hookResult: ReturnType | undefined; + + Platform.OS = "android"; + + act(() => { + TestRenderer.create( + { + hookResult = result; + }} + useKeyboardScrollToEnd={useKeyboardScrollToEnd} + />, + ); + }); + + await act(async () => { + await hookResult?.scrollMessageToEnd({ animated: true, closeKeyboard: false }); + }); + + expect(keyboardDismissMock).not.toHaveBeenCalled(); + expect(keyboardAddListenerMock).not.toHaveBeenCalled(); + expect(scrollToEnd).toHaveBeenCalledTimes(1); + expect(scrollToEnd).toHaveBeenNthCalledWith(1, { animated: true }); + expect(freeze.value).toBe(false); + }); }); diff --git a/example/screens/examples/AiChatExample.tsx b/example/screens/examples/AiChatExample.tsx index 3eb73cea..0bae39ef 100644 --- a/example/screens/examples/AiChatExample.tsx +++ b/example/screens/examples/AiChatExample.tsx @@ -1,23 +1,30 @@ import { MaterialIcons } from "@expo/vector-icons"; import { useCallback, useRef } from "react"; -import { Pressable, StyleSheet, type ViewProps } from "react-native"; +import { Pressable, StyleSheet, View, type ViewProps } from "react-native"; import { KeyboardGestureArea, KeyboardProvider, KeyboardStickyView } from "react-native-keyboard-controller"; import Animated, { useAnimatedProps, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; -import { KeyboardAwareLegendList, useKeyboardScrollToEnd } from "@legendapp/list/keyboard"; +import { + KeyboardAwareLegendList, + useKeyboardChatComposerInset, + useKeyboardScrollToEnd, +} from "@legendapp/list/keyboard"; import type { LegendListRef } from "@legendapp/list/react-native"; import { ChatComposer, getAiChatListProps, useAiChatExample } from "./chatShared"; import { SafeAreaShell } from "./shared"; export function AiChatExample() { const listRef = useRef(null); + const composerRef = useRef(null); const insets = useSafeAreaInsets(); const isNearEnd = useSharedValue(true); - const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); + const { contentInsetEndAdjustment, onComposerLayout } = useKeyboardChatComposerInset(listRef, composerRef); + const { scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); const scrollMessageToEndCallback = useCallback(() => { scrollMessageToEnd({ animated: true, closeKeyboard: true }); }, [scrollMessageToEnd]); + const { anchorIndex, input, messages, sendPrompt, setInput } = useAiChatExample({ scrollMessageToEnd: scrollMessageToEndCallback, streamIntervalMs: 5, @@ -38,7 +45,7 @@ export function AiChatExample() { - sendPrompt(input)} - placeholder="Ask about list behavior" - /> + + sendPrompt(input)} + placeholder="Ask about list behavior" + /> + diff --git a/src/integrations/keyboard.tsx b/src/integrations/keyboard.tsx index 190c4a75..84dd2801 100644 --- a/src/integrations/keyboard.tsx +++ b/src/integrations/keyboard.tsx @@ -1,7 +1,7 @@ // biome-ignore lint/style/useImportType: Leaving this out makes it crash in some environments import * as React from "react"; import { type ForwardedRef, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react"; -import type { LayoutChangeEvent, ScrollViewProps, View } from "react-native"; +import { Keyboard, type LayoutChangeEvent, Platform, type ScrollViewProps, type View } from "react-native"; import { KeyboardChatScrollView, type KeyboardChatScrollViewProps, @@ -16,6 +16,9 @@ import { AnimatedLegendList, type AnimatedLegendListProps } from "@legendapp/lis const { typedForwardRef, useCombinedRef } = internal; +const ANDROID_KEYBOARD_HIDE_FALLBACK_MS = 300; +const ANDROID_KEYBOARD_LAYOUT_SETTLE_MS = 60; + if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { console.warn( "[legend-list] KeyboardAwareLegendList requires a recent react-native-keyboard-controller with KeyboardChatScrollView. Please upgrade react-native-keyboard-controller to at least 1.21.7.", @@ -71,6 +74,35 @@ type KeyboardChatComposerRef = { current: Pick | null; }; +function wait(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} +/** + * Waits for Android to report that the keyboard is hidden, with a timeout fallback for + * devices or controller paths that complete dismissal without emitting keyboardDidHide. + */ +function waitForKeyboardDidHide() { + return new Promise((resolve) => { + let didResolve = false; + + const finish = () => { + if (didResolve) { + return; + } + + didResolve = true; + clearTimeout(timeoutId); + subscription.remove(); + resolve(); + }; + + const subscription = Keyboard.addListener("keyboardDidHide", finish); + const timeoutId = setTimeout(finish, ANDROID_KEYBOARD_HIDE_FALLBACK_MS); + }); +} + export function useKeyboardChatComposerInset( listRef: KeyboardChatComposerInsetListRef, composerRef: KeyboardChatComposerRef, @@ -109,19 +141,43 @@ export function useKeyboardChatComposerInset( export function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyboardScrollToEndOptions) { const internalFreeze = useSharedValue(false); const freeze = freezeProp ?? internalFreeze; + const scrollSequenceRef = useRef(0); const scrollMessageToEnd = useCallback( async ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => { const listRefCurrent = listRef.current; if (listRefCurrent) { + const scrollSequence = scrollSequenceRef.current + 1; + + scrollSequenceRef.current = scrollSequence; freeze.set(true); - const dismissPromise = closeKeyboard && KeyboardController.dismiss(); - const scrollPromise = listRefCurrent.scrollToEnd({ animated }); + try { + if (Platform.OS === "android" && closeKeyboard) { + // Android can resize the list viewport after dismiss starts, so the scroll target + // is only computed once the keyboard hide event and a short layout settle have passed. + const keyboardDidHidePromise = waitForKeyboardDidHide(); + const dismissPromise = KeyboardController.dismiss(); + + await Promise.all([dismissPromise, keyboardDidHidePromise]); + await wait(ANDROID_KEYBOARD_LAYOUT_SETTLE_MS); + + // A newer send supersedes this sequence; avoid completing an old scroll over it. + if (scrollSequenceRef.current === scrollSequence) { + await listRef.current?.scrollToEnd({ animated }); + } + return; + } - await Promise.all([scrollPromise, dismissPromise]); + const dismissPromise = closeKeyboard && KeyboardController.dismiss(); + const scrollPromise = listRefCurrent.scrollToEnd({ animated }); - freeze.set(false); + await Promise.all([scrollPromise, dismissPromise]); + } finally { + if (scrollSequenceRef.current === scrollSequence) { + freeze.set(false); + } + } } }, [freeze, listRef],