From 678cceb9a120a5cf7b22e59c3e29cbee093a2f14 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 6 Apr 2026 23:03:53 +0200 Subject: [PATCH 1/6] fix: animation stutter on editing --- .../Message/hooks/useMessageActionHandlers.ts | 50 +++++++++++++++++-- .../MessageInput/MessageComposer.tsx | 8 +-- 2 files changed, 48 insertions(+), 10 deletions(-) diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 66888ae58e..89a9b22636 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { Alert } from 'react-native'; +import { useEffect, useMemo, useRef } from 'react'; +import { Alert, EventSubscription, Keyboard, Platform } from 'react-native'; import { UserResponse } from 'stream-chat'; @@ -9,12 +9,15 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import { MessageComposerAPIContextValue } from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; +import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; import { useStableCallback } from '../../../hooks'; +import { useKeyboardVisibility } from '../../../hooks/useKeyboardVisibility'; import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; import { NativeHandlers } from '../../../native'; +import { KeyboardControllerPackage } from '../../KeyboardCompatibleView/KeyboardControllerAvoidingView'; export const useMessageActionHandlers = ({ channel, @@ -37,9 +40,19 @@ export const useMessageActionHandlers = ({ const { t } = useTranslationContext(); const handleResendMessage = useStableCallback(() => retrySendMessage(message)); const translatedMessage = useTranslatedMessage(message); + const { inputBoxRef } = useMessageInputContext(); + const isKeyboardVisible = useKeyboardVisibility(); + const keyboardDidShowSubscriptionRef = useRef(undefined); const isMuted = useUserMuteActive(message.user); + const clearKeyboardDidShowSubscription = useStableCallback(() => { + keyboardDidShowSubscriptionRef.current?.remove(); + keyboardDidShowSubscriptionRef.current = undefined; + }); + + useEffect(() => clearKeyboardDidShowSubscription, [clearKeyboardDidShowSubscription]); + const handleQuotedReplyMessage = useStableCallback(() => { setQuotedMessage(message); }); @@ -115,7 +128,38 @@ export const useMessageActionHandlers = ({ }); const handleEditMessage = useStableCallback(() => { - setEditingState(message); + requestAnimationFrame(() => + requestAnimationFrame(() => { + clearKeyboardDidShowSubscription(); + + const openEditingState = () => { + clearKeyboardDidShowSubscription(); + setEditingState(message); + }; + + if (!inputBoxRef.current) { + openEditingState(); + return; + } + + if (isKeyboardVisible) { + inputBoxRef.current?.focus(); + openEditingState(); + return; + } + + const event = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + + keyboardDidShowSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents + ? KeyboardControllerPackage.KeyboardEvents.addListener( + 'keyboardDidShow', + openEditingState, + ) + : Keyboard.addListener(event, openEditingState); + + inputBoxRef.current?.focus(); + }), + ); }); const handleFlagMessage = useStableCallback(() => { diff --git a/package/src/components/MessageInput/MessageComposer.tsx b/package/src/components/MessageInput/MessageComposer.tsx index d3cc6eecb6..b6e8dc1ead 100644 --- a/package/src/components/MessageInput/MessageComposer.tsx +++ b/package/src/components/MessageInput/MessageComposer.tsx @@ -219,7 +219,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { closePollCreationDialog, CreatePollContent, createPollOptionGap, - editing, InputView, MessageComposerLeadingView, MessageComposerTrailingView, @@ -272,12 +271,6 @@ const MessageComposerWithContext = (props: MessageComposerPropsWithContext) => { [closeAttachmentPicker], ); - useEffect(() => { - if (editing && inputBoxRef.current) { - inputBoxRef.current.focus(); - } - }, [editing, inputBoxRef]); - /** * Effect to get the draft data for legacy thread composer and set it to message composer. * TODO: This can be removed once we remove legacy thread composer. @@ -746,6 +739,7 @@ export const MessageComposer = (props: MessageComposerProps) => { closePollCreationDialog, compressImageQuality, CreatePollContent, + // TODO: probably not needed anymore, please check editing, Input, InputView, From 0949395c21bf16f908101a76f12b52114db36d6b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Mon, 6 Apr 2026 23:43:38 +0200 Subject: [PATCH 2/6] refactor: move PWC concern in a separate hook --- .../Message/hooks/useMessageActionHandlers.ts | 54 +----------- package/src/hooks/index.ts | 1 + .../usePortalClosingKeyboardSafeCallback.ts | 83 +++++++++++++++++++ 3 files changed, 88 insertions(+), 50 deletions(-) create mode 100644 package/src/hooks/usePortalClosingKeyboardSafeCallback.ts diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 89a9b22636..5c30deacc2 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -1,5 +1,5 @@ -import { useEffect, useMemo, useRef } from 'react'; -import { Alert, EventSubscription, Keyboard, Platform } from 'react-native'; +import { useMemo } from 'react'; +import { Alert } from 'react-native'; import { UserResponse } from 'stream-chat'; @@ -9,15 +9,12 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann import type { ChatContextValue } from '../../../contexts/chatContext/ChatContext'; import { MessageComposerAPIContextValue } from '../../../contexts/messageComposerContext/MessageComposerAPIContext'; import type { MessageContextValue } from '../../../contexts/messageContext/MessageContext'; -import { useMessageInputContext } from '../../../contexts/messageInputContext/MessageInputContext'; import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; -import { useStableCallback } from '../../../hooks'; -import { useKeyboardVisibility } from '../../../hooks/useKeyboardVisibility'; +import { usePortalClosingKeyboardSafeCallback, useStableCallback } from '../../../hooks'; import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; import { NativeHandlers } from '../../../native'; -import { KeyboardControllerPackage } from '../../KeyboardCompatibleView/KeyboardControllerAvoidingView'; export const useMessageActionHandlers = ({ channel, @@ -40,19 +37,9 @@ export const useMessageActionHandlers = ({ const { t } = useTranslationContext(); const handleResendMessage = useStableCallback(() => retrySendMessage(message)); const translatedMessage = useTranslatedMessage(message); - const { inputBoxRef } = useMessageInputContext(); - const isKeyboardVisible = useKeyboardVisibility(); - const keyboardDidShowSubscriptionRef = useRef(undefined); const isMuted = useUserMuteActive(message.user); - const clearKeyboardDidShowSubscription = useStableCallback(() => { - keyboardDidShowSubscriptionRef.current?.remove(); - keyboardDidShowSubscriptionRef.current = undefined; - }); - - useEffect(() => clearKeyboardDidShowSubscription, [clearKeyboardDidShowSubscription]); - const handleQuotedReplyMessage = useStableCallback(() => { setQuotedMessage(message); }); @@ -127,40 +114,7 @@ export const useMessageActionHandlers = ({ } }); - const handleEditMessage = useStableCallback(() => { - requestAnimationFrame(() => - requestAnimationFrame(() => { - clearKeyboardDidShowSubscription(); - - const openEditingState = () => { - clearKeyboardDidShowSubscription(); - setEditingState(message); - }; - - if (!inputBoxRef.current) { - openEditingState(); - return; - } - - if (isKeyboardVisible) { - inputBoxRef.current?.focus(); - openEditingState(); - return; - } - - const event = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - - keyboardDidShowSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents - ? KeyboardControllerPackage.KeyboardEvents.addListener( - 'keyboardDidShow', - openEditingState, - ) - : Keyboard.addListener(event, openEditingState); - - inputBoxRef.current?.focus(); - }), - ); - }); + const handleEditMessage = usePortalClosingKeyboardSafeCallback(() => setEditingState(message)); const handleFlagMessage = useStableCallback(() => { if (!message.id) { diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index 503cf5128b..d9d34831f5 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -9,6 +9,7 @@ export * from './useMessageReminder'; export * from './useQueryReminders'; export * from './useClientNotifications'; export * from './useInAppNotificationsState'; +export * from './usePortalClosingKeyboardSafeCallback'; export * from './useRAFCoalescedValue'; export * from './useAudioPlayerControl'; export * from './useAttachmentPickerState'; diff --git a/package/src/hooks/usePortalClosingKeyboardSafeCallback.ts b/package/src/hooks/usePortalClosingKeyboardSafeCallback.ts new file mode 100644 index 0000000000..b23be82ee9 --- /dev/null +++ b/package/src/hooks/usePortalClosingKeyboardSafeCallback.ts @@ -0,0 +1,83 @@ +import { useEffect, useRef } from 'react'; +import { EventSubscription, Keyboard, Platform } from 'react-native'; + +import { useKeyboardVisibility } from './useKeyboardVisibility'; + +import { useStableCallback } from './useStableCallback'; + +import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; +import { useMessageInputContext } from '../contexts/messageInputContext/MessageInputContext'; + +const SETTLE_FRAMES = Platform.OS === 'android' ? 2 : 0; + +const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: number[]) => { + if (frames <= 0) { + callback(); + return; + } + + const rafId = requestAnimationFrame(() => scheduleAfterFrames(callback, frames - 1, rafIds)); + rafIds.push(rafId); +}; + +export const usePortalClosingKeyboardSafeCallback = ( + callback: (...args: T) => void, +) => { + const isKeyboardVisible = useKeyboardVisibility(); + const { inputBoxRef } = useMessageInputContext(); + const keyboardSubscriptionRef = useRef(undefined); + const rafIdsRef = useRef([]); + const stableCallback = useStableCallback(callback); + + const clearKeyboardSubscription = useStableCallback(() => { + keyboardSubscriptionRef.current?.remove(); + keyboardSubscriptionRef.current = undefined; + }); + + const clearScheduledFrames = useStableCallback(() => { + rafIdsRef.current.forEach((rafId) => cancelAnimationFrame(rafId)); + rafIdsRef.current = []; + }); + + useEffect(() => { + return () => { + clearKeyboardSubscription(); + clearScheduledFrames(); + }; + }, [clearKeyboardSubscription, clearScheduledFrames]); + + return useStableCallback((...args: T) => { + clearKeyboardSubscription(); + clearScheduledFrames(); + + scheduleAfterFrames( + () => { + const runCallback = () => { + clearKeyboardSubscription(); + stableCallback(...args); + }; + + if (!inputBoxRef.current) { + runCallback(); + return; + } + + if (isKeyboardVisible) { + inputBoxRef.current.focus(); + runCallback(); + return; + } + + const keyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + + keyboardSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents + ? KeyboardControllerPackage.KeyboardEvents.addListener(keyboardEvent, runCallback) + : Keyboard.addListener(keyboardEvent, runCallback); + + inputBoxRef.current.focus(); + }, + SETTLE_FRAMES, + rafIdsRef.current, + ); + }); +}; From fa1546f3b415907811b4d45f447356f41e856952 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 7 Apr 2026 01:22:51 +0200 Subject: [PATCH 3/6] refactor: clean hooks up properly --- .../Message/hooks/useMessageActionHandlers.ts | 19 ++++- package/src/hooks/index.ts | 3 +- .../src/hooks/useAfterKeyboardOpenCallback.ts | 61 ++++++++++++++ .../usePortalClosingKeyboardSafeCallback.ts | 83 ------------------- package/src/hooks/usePortalSettledCallback.ts | 77 +++++++++++++++++ 5 files changed, 156 insertions(+), 87 deletions(-) create mode 100644 package/src/hooks/useAfterKeyboardOpenCallback.ts delete mode 100644 package/src/hooks/usePortalClosingKeyboardSafeCallback.ts create mode 100644 package/src/hooks/usePortalSettledCallback.ts diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 5c30deacc2..8efcdd90ca 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Alert } from 'react-native'; -import { UserResponse } from 'stream-chat'; +import { LocalMessage, UserResponse } from 'stream-chat'; import { useUserMuteActive } from './useUserMuteActive'; @@ -12,10 +12,20 @@ import type { MessageContextValue } from '../../../contexts/messageContext/Messa import type { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; import { useTranslationContext } from '../../../contexts/translationContext/TranslationContext'; -import { usePortalClosingKeyboardSafeCallback, useStableCallback } from '../../../hooks'; +import { + useAfterKeyboardOpenCallback, + usePortalSettledCallback, + useStableCallback, +} from '../../../hooks'; import { useTranslatedMessage } from '../../../hooks/useTranslatedMessage'; import { NativeHandlers } from '../../../native'; +const useWithPortalKeyboardSafety = (callback: (...args: T) => void) => { + const callbackAfterKeyboardOpen = useAfterKeyboardOpenCallback(callback); + + return usePortalSettledCallback(callbackAfterKeyboardOpen); +}; + export const useMessageActionHandlers = ({ channel, client, @@ -114,7 +124,10 @@ export const useMessageActionHandlers = ({ } }); - const handleEditMessage = usePortalClosingKeyboardSafeCallback(() => setEditingState(message)); + const setEditingMessage = useStableCallback((messageToEdit: LocalMessage = message) => { + setEditingState(messageToEdit); + }); + const handleEditMessage = useWithPortalKeyboardSafety(setEditingMessage); const handleFlagMessage = useStableCallback(() => { if (!message.id) { diff --git a/package/src/hooks/index.ts b/package/src/hooks/index.ts index d9d34831f5..094549af5b 100644 --- a/package/src/hooks/index.ts +++ b/package/src/hooks/index.ts @@ -7,9 +7,10 @@ export * from './useStableCallback'; export * from './useLoadingImage'; export * from './useMessageReminder'; export * from './useQueryReminders'; +export * from './useAfterKeyboardOpenCallback'; export * from './useClientNotifications'; export * from './useInAppNotificationsState'; -export * from './usePortalClosingKeyboardSafeCallback'; +export * from './usePortalSettledCallback'; export * from './useRAFCoalescedValue'; export * from './useAudioPlayerControl'; export * from './useAttachmentPickerState'; diff --git a/package/src/hooks/useAfterKeyboardOpenCallback.ts b/package/src/hooks/useAfterKeyboardOpenCallback.ts new file mode 100644 index 0000000000..b4e21ecacd --- /dev/null +++ b/package/src/hooks/useAfterKeyboardOpenCallback.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react'; +import { EventSubscription, Keyboard, Platform } from 'react-native'; + +import { useKeyboardVisibility } from './useKeyboardVisibility'; + +import { useStableCallback } from './useStableCallback'; + +import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; +import { useMessageInputContext } from '../contexts/messageInputContext/MessageInputContext'; + +/** + * A utility hook that returns a stable callback which focuses the message input + * and invokes the callback once the keyboard is open. + * + * @param callback - callback we want to run once the keyboard is ready + * @returns A stable callback that will wait for the keyboard to be open before executing. + */ +export const useAfterKeyboardOpenCallback = ( + callback: (...args: T) => void, +) => { + const isKeyboardVisible = useKeyboardVisibility(); + const { inputBoxRef } = useMessageInputContext(); + const keyboardSubscriptionRef = useRef(undefined); + const stableCallback = useStableCallback(callback); + + /** Clears the pending keyboard listener, if any. */ + const clearKeyboardSubscription = useStableCallback(() => { + keyboardSubscriptionRef.current?.remove(); + keyboardSubscriptionRef.current = undefined; + }); + + useEffect(() => clearKeyboardSubscription, [clearKeyboardSubscription]); + + return useStableCallback((...args: T) => { + clearKeyboardSubscription(); + + const runCallback = () => { + clearKeyboardSubscription(); + stableCallback(...args); + }; + + if (!inputBoxRef.current) { + runCallback(); + return; + } + + if (isKeyboardVisible) { + inputBoxRef.current.focus(); + runCallback(); + return; + } + + const keyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + + keyboardSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents + ? KeyboardControllerPackage.KeyboardEvents.addListener(keyboardEvent, runCallback) + : Keyboard.addListener(keyboardEvent, runCallback); + + inputBoxRef.current.focus(); + }); +}; diff --git a/package/src/hooks/usePortalClosingKeyboardSafeCallback.ts b/package/src/hooks/usePortalClosingKeyboardSafeCallback.ts deleted file mode 100644 index b23be82ee9..0000000000 --- a/package/src/hooks/usePortalClosingKeyboardSafeCallback.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { EventSubscription, Keyboard, Platform } from 'react-native'; - -import { useKeyboardVisibility } from './useKeyboardVisibility'; - -import { useStableCallback } from './useStableCallback'; - -import { KeyboardControllerPackage } from '../components/KeyboardCompatibleView/KeyboardControllerAvoidingView'; -import { useMessageInputContext } from '../contexts/messageInputContext/MessageInputContext'; - -const SETTLE_FRAMES = Platform.OS === 'android' ? 2 : 0; - -const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: number[]) => { - if (frames <= 0) { - callback(); - return; - } - - const rafId = requestAnimationFrame(() => scheduleAfterFrames(callback, frames - 1, rafIds)); - rafIds.push(rafId); -}; - -export const usePortalClosingKeyboardSafeCallback = ( - callback: (...args: T) => void, -) => { - const isKeyboardVisible = useKeyboardVisibility(); - const { inputBoxRef } = useMessageInputContext(); - const keyboardSubscriptionRef = useRef(undefined); - const rafIdsRef = useRef([]); - const stableCallback = useStableCallback(callback); - - const clearKeyboardSubscription = useStableCallback(() => { - keyboardSubscriptionRef.current?.remove(); - keyboardSubscriptionRef.current = undefined; - }); - - const clearScheduledFrames = useStableCallback(() => { - rafIdsRef.current.forEach((rafId) => cancelAnimationFrame(rafId)); - rafIdsRef.current = []; - }); - - useEffect(() => { - return () => { - clearKeyboardSubscription(); - clearScheduledFrames(); - }; - }, [clearKeyboardSubscription, clearScheduledFrames]); - - return useStableCallback((...args: T) => { - clearKeyboardSubscription(); - clearScheduledFrames(); - - scheduleAfterFrames( - () => { - const runCallback = () => { - clearKeyboardSubscription(); - stableCallback(...args); - }; - - if (!inputBoxRef.current) { - runCallback(); - return; - } - - if (isKeyboardVisible) { - inputBoxRef.current.focus(); - runCallback(); - return; - } - - const keyboardEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; - - keyboardSubscriptionRef.current = KeyboardControllerPackage?.KeyboardEvents - ? KeyboardControllerPackage.KeyboardEvents.addListener(keyboardEvent, runCallback) - : Keyboard.addListener(keyboardEvent, runCallback); - - inputBoxRef.current.focus(); - }, - SETTLE_FRAMES, - rafIdsRef.current, - ); - }); -}; diff --git a/package/src/hooks/usePortalSettledCallback.ts b/package/src/hooks/usePortalSettledCallback.ts new file mode 100644 index 0000000000..28873119c7 --- /dev/null +++ b/package/src/hooks/usePortalSettledCallback.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; + +import { useStableCallback } from './useStableCallback'; + +/** + * Number of frames we wait before invoking input focus sensitive work after the + * overlay closes. + */ +const SETTLE_FRAMES = Platform.OS === 'android' ? 2 : 0; + +/** + * Runs a callback after a fixed number of animation frames. + * + * We use RAFs here because the settling work we care about is tied to the next + * rendered frames after the overlay close transition. + * + * @param callback - callback to run once the frame budget has elapsed + * @param frames - number of frames to wait + * @param rafIds - accumulator used for later cancellation/cleanup + */ +const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: number[]) => { + if (frames <= 0) { + callback(); + return; + } + + const rafId = requestAnimationFrame(() => scheduleAfterFrames(callback, frames - 1, rafIds)); + rafIds.push(rafId); +}; + +/** + * Returns a stable callback that is safe to run after a `PortalWhileClosingView` + * has settled back into its original tree. + * + * Some followup actions are sensitive to that handoff window. If they run + * while a view is still being returned from a portal host to its in place host, + * they can target a node that is about to be reattached. On Android, that is + * especially noticeable with focus sensitive work, where the target can lose + * focus again mid keyboard animation. + * + * Two frames are intentional here: + * - frame 1 lets the portal retarget and React commit the component tree + * - frame 2 lets the native view hierarchy settle in its final host + * + * iOS does not currently need this settle window for this flow. + * + * A good example is the message composer edit action: after closing the message + * overlay, we wait for the portal handoff to settle before focusing the input + * and opening the keyboard. Doing this prematurely will result in the keyboard + * being immediately closed. + * + * Another good example would be having a button wrapped in a `PortalWhileClosingView`, + * that possibly renders (or morphs into) something when pressed. Handling `onPress` + * prematurely here may lead to the morphed button rendering into a completely different + * part of the UI hierarchy, causing unknown behaviour. This hook prevents that from + * happening. + * + * @param callback - callback we want to invoke once the portal handoff has settled + * @returns A stable callback gated behind the portal settle window. + */ +export const usePortalSettledCallback = (callback: (...args: T) => void) => { + const rafIdsRef = useRef([]); + const stableCallback = useStableCallback(callback); + + const clearScheduledFrames = useStableCallback(() => { + rafIdsRef.current.forEach((rafId) => cancelAnimationFrame(rafId)); + rafIdsRef.current = []; + }); + + useEffect(() => clearScheduledFrames, [clearScheduledFrames]); + + return useStableCallback((...args: T) => { + clearScheduledFrames(); + scheduleAfterFrames(() => stableCallback(...args), SETTLE_FRAMES, rafIdsRef.current); + }); +}; From 471c31a1cdd89ad55ea2af462659dbb70dfccc6e Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 7 Apr 2026 01:33:11 +0200 Subject: [PATCH 4/6] fix: cleanup --- .../src/components/Message/hooks/useMessageActionHandlers.ts | 5 ++--- package/src/hooks/useAfterKeyboardOpenCallback.ts | 1 + package/src/hooks/usePortalSettledCallback.ts | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 8efcdd90ca..f36306baf2 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -124,10 +124,9 @@ export const useMessageActionHandlers = ({ } }); - const setEditingMessage = useStableCallback((messageToEdit: LocalMessage = message) => { - setEditingState(messageToEdit); + const handleEditMessage = useWithPortalKeyboardSafety(() => { + setEditingState(message); }); - const handleEditMessage = useWithPortalKeyboardSafety(setEditingMessage); const handleFlagMessage = useStableCallback(() => { if (!message.id) { diff --git a/package/src/hooks/useAfterKeyboardOpenCallback.ts b/package/src/hooks/useAfterKeyboardOpenCallback.ts index b4e21ecacd..9201a6ee03 100644 --- a/package/src/hooks/useAfterKeyboardOpenCallback.ts +++ b/package/src/hooks/useAfterKeyboardOpenCallback.ts @@ -21,6 +21,7 @@ export const useAfterKeyboardOpenCallback = ( const isKeyboardVisible = useKeyboardVisibility(); const { inputBoxRef } = useMessageInputContext(); const keyboardSubscriptionRef = useRef(undefined); + // This callback runs from a keyboard event listener, so it must stay fresh across rerenders. const stableCallback = useStableCallback(callback); /** Clears the pending keyboard listener, if any. */ diff --git a/package/src/hooks/usePortalSettledCallback.ts b/package/src/hooks/usePortalSettledCallback.ts index 28873119c7..64847edb28 100644 --- a/package/src/hooks/usePortalSettledCallback.ts +++ b/package/src/hooks/usePortalSettledCallback.ts @@ -61,6 +61,7 @@ const scheduleAfterFrames = (callback: () => void, frames: number, rafIds: numbe */ export const usePortalSettledCallback = (callback: (...args: T) => void) => { const rafIdsRef = useRef([]); + // This callback runs from deferred RAF work, so it must stay fresh across rerenders. const stableCallback = useStableCallback(callback); const clearScheduledFrames = useStableCallback(() => { From fbcbb0cedd0e721f1f5be9327d43d29a36fff2be Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 7 Apr 2026 01:48:44 +0200 Subject: [PATCH 5/6] fix: lint and tests --- package/src/__tests__/offline-support/offline-feature.js | 2 +- .../src/components/Message/hooks/useMessageActionHandlers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package/src/__tests__/offline-support/offline-feature.js b/package/src/__tests__/offline-support/offline-feature.js index 9bfbab93f9..45b704ad3e 100644 --- a/package/src/__tests__/offline-support/offline-feature.js +++ b/package/src/__tests__/offline-support/offline-feature.js @@ -341,7 +341,7 @@ export const Generic = () => { await waitFor(async () => { expect(screen.getByTestId('channel-list')).toBeTruthy(); await expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText); - }); + }, { timeout: 5000 }); }); it('should fetch channels from the db correctly even if they are empty', async () => { diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index f36306baf2..d40beb85e8 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { Alert } from 'react-native'; -import { LocalMessage, UserResponse } from 'stream-chat'; +import { UserResponse } from 'stream-chat'; import { useUserMuteActive } from './useUserMuteActive'; From 78e6557d805b17d290506ddfd062d25ae73d929c Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 7 Apr 2026 09:29:17 +0200 Subject: [PATCH 6/6] fix: lint issue --- .../src/__tests__/offline-support/offline-feature.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/package/src/__tests__/offline-support/offline-feature.js b/package/src/__tests__/offline-support/offline-feature.js index 45b704ad3e..5cbbcae21f 100644 --- a/package/src/__tests__/offline-support/offline-feature.js +++ b/package/src/__tests__/offline-support/offline-feature.js @@ -338,10 +338,13 @@ export const Generic = () => { act(() => dispatchConnectionChangedEvent(chatClient)); await act(async () => await chatClient.offlineDb.syncManager.invokeSyncStatusListeners(true)); - await waitFor(async () => { - expect(screen.getByTestId('channel-list')).toBeTruthy(); - await expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText); - }, { timeout: 5000 }); + await waitFor( + async () => { + expect(screen.getByTestId('channel-list')).toBeTruthy(); + await expectAllChannelsWithStateToBeInDB(screen.queryAllByLabelText); + }, + { timeout: 5000 }, + ); }); it('should fetch channels from the db correctly even if they are empty', async () => {