From 68be302952553e4f61ad07ee8ee8544febbca0be Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Thu, 19 Feb 2026 21:53:28 +0530 Subject: [PATCH 1/4] feat: migrate FlatList to FlashList for rooms and message lists - Add @shopify/flash-list dependency - Replace FlatList with FlashList in RoomsListView - Replace Animated.FlatList with AnimatedFlashList in RoomView message list - Update list ref/types to FlashListRef and FlashListProps - Preserve a11y props and add List.test.tsx for accessibility parity - Adjust scroll/viewability usage for FlashList API --- .../RoomView/List/components/List.test.tsx | 48 +++++++++++++++++++ app/views/RoomView/List/components/List.tsx | 13 ++--- app/views/RoomView/List/definitions.ts | 7 ++- app/views/RoomView/List/hooks/useScroll.ts | 16 +++---- app/views/RoomView/List/index.tsx | 4 +- app/views/RoomsListView/index.tsx | 15 ++---- package.json | 1 + yarn.lock | 5 ++ 8 files changed, 74 insertions(+), 35 deletions(-) create mode 100644 app/views/RoomView/List/components/List.test.tsx diff --git a/app/views/RoomView/List/components/List.test.tsx b/app/views/RoomView/List/components/List.test.tsx new file mode 100644 index 00000000000..0687435d11e --- /dev/null +++ b/app/views/RoomView/List/components/List.test.tsx @@ -0,0 +1,48 @@ +import React, { createRef } from 'react'; +import { render } from '@testing-library/react-native'; + +import List from './List'; +import { RoomContext } from '../../context'; +import { type TAnyMessageModel } from '../../../../definitions'; + +jest.mock('@shopify/flash-list', () => { + const React = require('react'); + const { FlatList } = require('react-native'); + + return { + FlashList: React.forwardRef((props: any, ref: any) => ) + }; +}); + +const messages = [{ id: '1' }, { id: '2' }] as TAnyMessageModel[]; + +const renderList = (isAutocompleteVisible: boolean) => + render( + + item.id} + renderItem={({ item }) => null} + /> + + ); + +describe('RoomView List accessibility', () => { + it('hides message list from accessibility tree while autocomplete is visible', () => { + const { UNSAFE_getByProps } = renderList(true); + const list = UNSAFE_getByProps({ testID: 'room-view-messages' }); + + expect(list.props.accessibilityElementsHidden).toBe(true); + expect(list.props.importantForAccessibility).toBe('no-hide-descendants'); + }); + + it('keeps message list visible to accessibility tree while autocomplete is hidden', () => { + const { UNSAFE_getByProps } = renderList(false); + const list = UNSAFE_getByProps({ testID: 'room-view-messages' }); + + expect(list.props.accessibilityElementsHidden).toBe(false); + expect(list.props.importantForAccessibility).toBe('yes'); + }); +}); diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 2d8b0c081e7..6963e457a51 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; +import { FlashList } from '@shopify/flash-list'; import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'; -import { isIOS } from '../../../../lib/methods/helpers'; import scrollPersistTaps from '../../../../lib/methods/helpers/scrollPersistTaps'; import NavBottomFAB from './NavBottomFAB'; import { type IListProps } from '../definitions'; @@ -18,6 +18,8 @@ const styles = StyleSheet.create({ } }); +const AnimatedFlashList = Animated.createAnimatedComponent(FlashList); + const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { const [visible, setVisible] = useState(false); const { isAutocompleteVisible } = useRoomContext(); @@ -34,20 +36,15 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { return ( {/* @ts-ignore */} - item.id} + ref={listRef as any} contentContainerStyle={styles.contentContainer} style={styles.list} inverted - removeClippedSubviews={isIOS} - initialNumToRender={7} onEndReachedThreshold={0.5} - maxToRenderPerBatch={5} - windowSize={10} scrollEventThrottle={16} onScroll={scrollHandler} {...props} diff --git a/app/views/RoomView/List/definitions.ts b/app/views/RoomView/List/definitions.ts index 7ed5c4d929e..38f5abcf56d 100644 --- a/app/views/RoomView/List/definitions.ts +++ b/app/views/RoomView/List/definitions.ts @@ -1,14 +1,13 @@ import { type RefObject } from 'react'; -import { type FlatListProps } from 'react-native'; -import { type FlatList } from 'react-native-gesture-handler'; +import { type FlashListProps, type FlashListRef } from '@shopify/flash-list'; import { type TAnyMessageModel } from '../../../definitions'; -export type TListRef = RefObject | null>; +export type TListRef = RefObject | null>; export type TMessagesIdsRef = RefObject; -export interface IListProps extends FlatListProps { +export interface IListProps extends FlashListProps { listRef: TListRef; jumpToBottom: () => void; } diff --git a/app/views/RoomView/List/hooks/useScroll.ts b/app/views/RoomView/List/hooks/useScroll.ts index b83fe5cdb32..6704395731a 100644 --- a/app/views/RoomView/List/hooks/useScroll.ts +++ b/app/views/RoomView/List/hooks/useScroll.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { type ViewToken, type ViewabilityConfigCallbackPairs } from 'react-native'; +import { type ViewToken } from '@shopify/flash-list'; +import { type TAnyMessageModel } from '../../../../definitions'; import { type IListContainerRef, type IListProps, type TListRef, type TMessagesIdsRef } from '../definitions'; import { VIEWABILITY_CONFIG } from '../constants'; @@ -8,7 +9,7 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message const [highlightedMessageId, setHighlightedMessageId] = useState(null); const cancelJump = useRef(false); const jumping = useRef(false); - const viewableItems = useRef(null); + const viewableItems = useRef[] | null>(null); const highlightTimeout = useRef | null>(null); useEffect( @@ -28,14 +29,10 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message viewableItems.current = vi; }; - const viewabilityConfigCallbackPairs = useRef([ + const viewabilityConfigCallbackPairs = useRef>([ { onViewableItemsChanged, viewabilityConfig: VIEWABILITY_CONFIG } ]); - const handleScrollToIndexFailed: IListProps['onScrollToIndexFailed'] = params => { - listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false }); - }; - const setHighlightTimeout = () => { if (highlightTimeout.current) { clearTimeout(highlightTimeout.current); @@ -59,7 +56,9 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message // if found message, scroll to it if (index !== -1) { - listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }); + listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }).catch(() => { + listRef.current?.scrollToEnd(); + }); // wait for scroll animation to finish await new Promise(res => setTimeout(res, 300)); @@ -99,7 +98,6 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message jumpToMessage, cancelJumpToMessage, viewabilityConfigCallbackPairs, - handleScrollToIndexFailed, highlightedMessageId }; }; diff --git a/app/views/RoomView/List/index.tsx b/app/views/RoomView/List/index.tsx index 2b7c3839ee9..be2f88f3a4c 100644 --- a/app/views/RoomView/List/index.tsx +++ b/app/views/RoomView/List/index.tsx @@ -20,7 +20,6 @@ const ListContainer = forwardRef( jumpToMessage, cancelJumpToMessage, viewabilityConfigCallbackPairs, - handleScrollToIndexFailed, highlightedMessageId } = useScroll({ listRef, messagesIds }); @@ -41,13 +40,12 @@ const ListContainer = forwardRef( item.id} renderItem={renderItem} onEndReached={onEndReached} - onScrollToIndexFailed={handleScrollToIndexFailed} viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} jumpToBottom={jumpToBottom} maintainVisibleContentPosition={{ - minIndexForVisible: 0, autoscrollToTopThreshold: 0 }} /> diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index 0ec091befb3..258c1d65104 100644 --- a/app/views/RoomsListView/index.tsx +++ b/app/views/RoomsListView/index.tsx @@ -1,8 +1,9 @@ import { useNavigation } from '@react-navigation/native'; import React, { memo, useContext, useEffect } from 'react'; -import { BackHandler, FlatList, RefreshControl } from 'react-native'; +import { BackHandler, RefreshControl } from 'react-native'; import { useSafeAreaFrame } from 'react-native-safe-area-context'; import { shallowEqual } from 'react-redux'; +import { FlashList } from '@shopify/flash-list'; import ActivityIndicator from '../../containers/ActivityIndicator'; import BackgroundContainer from '../../containers/BackgroundContainer'; @@ -13,7 +14,7 @@ import { SupportedVersionsExpired } from '../../containers/SupportedVersions'; import i18n from '../../i18n'; import { MAX_SIDEBAR_WIDTH } from '../../lib/constants/tablet'; import { useAppSelector } from '../../lib/hooks/useAppSelector'; -import { getRoomAvatar, getRoomTitle, getUidDirectMessage, isIOS, isRead, isTablet } from '../../lib/methods/helpers'; +import { getRoomAvatar, getRoomTitle, getUidDirectMessage, isIOS, isRead } from '../../lib/methods/helpers'; import { goRoom } from '../../lib/methods/helpers/goRoom'; import { events, logEvent } from '../../lib/methods/helpers/log'; import { getUserSelector } from '../../selectors/login'; @@ -22,14 +23,11 @@ import Container from './components/Container'; import ListHeader from './components/ListHeader'; import SectionHeader from './components/SectionHeader'; import RoomsSearchProvider, { RoomsSearchContext } from './contexts/RoomsSearchProvider'; -import { useGetItemLayout } from './hooks/useGetItemLayout'; import { useHeader } from './hooks/useHeader'; import { useRefresh } from './hooks/useRefresh'; import { useSubscriptions } from './hooks/useSubscriptions'; import styles from './styles'; -const INITIAL_NUM_TO_RENDER = isTablet ? 20 : 12; - const RoomsListView = memo(function RoomsListView() { 'use memo'; @@ -44,7 +42,6 @@ const RoomsListView = memo(function RoomsListView() { const isMasterDetail = useAppSelector(state => state.app.isMasterDetail); const navigation = useNavigation(); const { width } = useSafeAreaFrame(); - const getItemLayout = useGetItemLayout(); const { subscriptions, loading } = useSubscriptions(); const subscribedRoom = useAppSelector(state => state.room.subscribedRoom); const changingServer = useAppSelector(state => state.server.changingServer); @@ -128,19 +125,15 @@ const RoomsListView = memo(function RoomsListView() { } return ( - `${item.rid}-${searchEnabled}`} style={[styles.list, { backgroundColor: colors.surfaceRoom }]} renderItem={renderItem} ListHeaderComponent={ListHeader} - getItemLayout={getItemLayout} - removeClippedSubviews={isIOS} keyboardShouldPersistTaps='always' - initialNumToRender={INITIAL_NUM_TO_RENDER} refreshControl={} - windowSize={9} onEndReachedThreshold={0.5} keyboardDismissMode={isIOS ? 'on-drag' : 'none'} /> diff --git a/package.json b/package.json index 45c7b5e50e1..098cdfefeb4 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@rocket.chat/mobile-crypto": "RocketChat/rocket.chat-mobile-crypto", "@rocket.chat/sdk": "RocketChat/Rocket.Chat.js.SDK#mobile", "@rocket.chat/ui-kit": "0.31.19", + "@shopify/flash-list": "^2.2.2", "bytebuffer": "5.0.1", "color2k": "1.2.4", "dayjs": "^1.11.18", diff --git a/yarn.lock b/yarn.lock index 4a19e871830..e537e04410b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5042,6 +5042,11 @@ resolved "https://registry.yarnpkg.com/@rocket.chat/ui-kit/-/ui-kit-0.31.19.tgz#737103123bc7e635382217eef75965b7e0f44703" integrity sha512-8zRKQ5CoC4hIuYHVheO0d7etX9oizmM18fu99r5s/deciL/0MRWocdb4H/QsmbsNrkKCO6Z6wr7f9zzJCNTRHg== +"@shopify/flash-list@^2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@shopify/flash-list/-/flash-list-2.2.2.tgz#552c4959f2ab3bc5cc69fac1e3fb514b5502e6ad" + integrity sha512-YrvLBK5FCpvuX+d9QvJvjVqyi4eBUaEamkyfh9CjPdF6c+AukP0RSBh97qHyTwOEaVq21A5ukwgyWMDIbmxpmQ== + "@sideway/address@^4.1.5": version "4.1.5" resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.5.tgz#4bc149a0076623ced99ca8208ba780d65a99b9d5" From a3fd229227a8b631e17eeff9f9578e3c8ff19e86 Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Wed, 25 Feb 2026 03:24:29 +0530 Subject: [PATCH 2/4] feat: migrate RoomsListView FlatList to FlashList - Replace FlatList with FlashList in RoomsListView for better view recycling on Android - Wrap renderItem and onPressItem in useCallback to prevent unnecessary re-renders - Remove getItemLayout, removeClippedSubviews, initialNumToRender, windowSize (unneeded with FlashList recycler) - Add drawDistance={300} to pre-render cells beyond the viewport - Revert RoomView message list to Animated.FlatList; FlashList v2 lacks inverted prop support required by the chat list - Add accessibility regression tests for RoomView List component - Add FLASHLIST_MIGRATION.md with Android performance measurement guide --- .../RoomView/List/components/List.test.tsx | 11 +-- app/views/RoomView/List/components/List.tsx | 13 +-- app/views/RoomView/List/definitions.ts | 7 +- app/views/RoomView/List/hooks/useScroll.ts | 16 ++-- app/views/RoomView/List/index.tsx | 4 +- app/views/RoomsListView/index.tsx | 87 ++++++++++--------- docs/FLASHLIST_MIGRATION.md | 26 ++++++ 7 files changed, 98 insertions(+), 66 deletions(-) create mode 100644 docs/FLASHLIST_MIGRATION.md diff --git a/app/views/RoomView/List/components/List.test.tsx b/app/views/RoomView/List/components/List.test.tsx index 0687435d11e..d06e15a0255 100644 --- a/app/views/RoomView/List/components/List.test.tsx +++ b/app/views/RoomView/List/components/List.test.tsx @@ -5,15 +5,6 @@ import List from './List'; import { RoomContext } from '../../context'; import { type TAnyMessageModel } from '../../../../definitions'; -jest.mock('@shopify/flash-list', () => { - const React = require('react'); - const { FlatList } = require('react-native'); - - return { - FlashList: React.forwardRef((props: any, ref: any) => ) - }; -}); - const messages = [{ id: '1' }, { id: '2' }] as TAnyMessageModel[]; const renderList = (isAutocompleteVisible: boolean) => @@ -24,7 +15,7 @@ const renderList = (isAutocompleteVisible: boolean) => jumpToBottom={jest.fn()} data={messages} keyExtractor={item => item.id} - renderItem={({ item }) => null} + renderItem={() => null} /> ); diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 6963e457a51..2d8b0c081e7 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; -import { FlashList } from '@shopify/flash-list'; import Animated, { runOnJS, useAnimatedScrollHandler } from 'react-native-reanimated'; +import { isIOS } from '../../../../lib/methods/helpers'; import scrollPersistTaps from '../../../../lib/methods/helpers/scrollPersistTaps'; import NavBottomFAB from './NavBottomFAB'; import { type IListProps } from '../definitions'; @@ -18,8 +18,6 @@ const styles = StyleSheet.create({ } }); -const AnimatedFlashList = Animated.createAnimatedComponent(FlashList); - const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { const [visible, setVisible] = useState(false); const { isAutocompleteVisible } = useRoomContext(); @@ -36,15 +34,20 @@ const List = ({ listRef, jumpToBottom, ...props }: IListProps) => { return ( {/* @ts-ignore */} - item.id} contentContainerStyle={styles.contentContainer} style={styles.list} inverted + removeClippedSubviews={isIOS} + initialNumToRender={7} onEndReachedThreshold={0.5} + maxToRenderPerBatch={5} + windowSize={10} scrollEventThrottle={16} onScroll={scrollHandler} {...props} diff --git a/app/views/RoomView/List/definitions.ts b/app/views/RoomView/List/definitions.ts index 38f5abcf56d..7ed5c4d929e 100644 --- a/app/views/RoomView/List/definitions.ts +++ b/app/views/RoomView/List/definitions.ts @@ -1,13 +1,14 @@ import { type RefObject } from 'react'; -import { type FlashListProps, type FlashListRef } from '@shopify/flash-list'; +import { type FlatListProps } from 'react-native'; +import { type FlatList } from 'react-native-gesture-handler'; import { type TAnyMessageModel } from '../../../definitions'; -export type TListRef = RefObject | null>; +export type TListRef = RefObject | null>; export type TMessagesIdsRef = RefObject; -export interface IListProps extends FlashListProps { +export interface IListProps extends FlatListProps { listRef: TListRef; jumpToBottom: () => void; } diff --git a/app/views/RoomView/List/hooks/useScroll.ts b/app/views/RoomView/List/hooks/useScroll.ts index 6704395731a..b83fe5cdb32 100644 --- a/app/views/RoomView/List/hooks/useScroll.ts +++ b/app/views/RoomView/List/hooks/useScroll.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { type ViewToken } from '@shopify/flash-list'; +import { type ViewToken, type ViewabilityConfigCallbackPairs } from 'react-native'; -import { type TAnyMessageModel } from '../../../../definitions'; import { type IListContainerRef, type IListProps, type TListRef, type TMessagesIdsRef } from '../definitions'; import { VIEWABILITY_CONFIG } from '../constants'; @@ -9,7 +8,7 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message const [highlightedMessageId, setHighlightedMessageId] = useState(null); const cancelJump = useRef(false); const jumping = useRef(false); - const viewableItems = useRef[] | null>(null); + const viewableItems = useRef(null); const highlightTimeout = useRef | null>(null); useEffect( @@ -29,10 +28,14 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message viewableItems.current = vi; }; - const viewabilityConfigCallbackPairs = useRef>([ + const viewabilityConfigCallbackPairs = useRef([ { onViewableItemsChanged, viewabilityConfig: VIEWABILITY_CONFIG } ]); + const handleScrollToIndexFailed: IListProps['onScrollToIndexFailed'] = params => { + listRef.current?.scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false }); + }; + const setHighlightTimeout = () => { if (highlightTimeout.current) { clearTimeout(highlightTimeout.current); @@ -56,9 +59,7 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message // if found message, scroll to it if (index !== -1) { - listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }).catch(() => { - listRef.current?.scrollToEnd(); - }); + listRef.current?.scrollToIndex({ index, viewPosition: 0.5, viewOffset: 100 }); // wait for scroll animation to finish await new Promise(res => setTimeout(res, 300)); @@ -98,6 +99,7 @@ export const useScroll = ({ listRef, messagesIds }: { listRef: TListRef; message jumpToMessage, cancelJumpToMessage, viewabilityConfigCallbackPairs, + handleScrollToIndexFailed, highlightedMessageId }; }; diff --git a/app/views/RoomView/List/index.tsx b/app/views/RoomView/List/index.tsx index be2f88f3a4c..2b7c3839ee9 100644 --- a/app/views/RoomView/List/index.tsx +++ b/app/views/RoomView/List/index.tsx @@ -20,6 +20,7 @@ const ListContainer = forwardRef( jumpToMessage, cancelJumpToMessage, viewabilityConfigCallbackPairs, + handleScrollToIndexFailed, highlightedMessageId } = useScroll({ listRef, messagesIds }); @@ -40,12 +41,13 @@ const ListContainer = forwardRef( item.id} renderItem={renderItem} onEndReached={onEndReached} + onScrollToIndexFailed={handleScrollToIndexFailed} viewabilityConfigCallbackPairs={viewabilityConfigCallbackPairs.current} jumpToBottom={jumpToBottom} maintainVisibleContentPosition={{ + minIndexForVisible: 0, autoscrollToTopThreshold: 0 }} /> diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index 258c1d65104..f6574947367 100644 --- a/app/views/RoomsListView/index.tsx +++ b/app/views/RoomsListView/index.tsx @@ -1,5 +1,5 @@ import { useNavigation } from '@react-navigation/native'; -import React, { memo, useContext, useEffect } from 'react'; +import React, { memo, useCallback, useContext, useEffect } from 'react'; import { BackHandler, RefreshControl } from 'react-native'; import { useSafeAreaFrame } from 'react-native-safe-area-context'; import { shallowEqual } from 'react-redux'; @@ -60,48 +60,54 @@ const RoomsListView = memo(function RoomsListView() { return () => subscription.remove(); }, [searchEnabled]); - const onPressItem = (item = {} as IRoomItem) => { - if (!navigation.isFocused()) { - return; - } - if (item.rid === subscribedRoom) { - return; - } + const onPressItem = useCallback( + (item = {} as IRoomItem) => { + if (!navigation.isFocused()) { + return; + } + if (item.rid === subscribedRoom) { + return; + } - logEvent(events.RL_GO_ROOM); - stopSearch(); - goRoom({ item, isMasterDetail }); - }; + logEvent(events.RL_GO_ROOM); + stopSearch(); + goRoom({ item, isMasterDetail }); + }, + [navigation, subscribedRoom, stopSearch, isMasterDetail] + ); - const renderItem = ({ item }: { item: IRoomItem }) => { - if (item.separator) { - return ; - } + const renderItem = useCallback( + ({ item }: { item: IRoomItem }) => { + if (item.separator) { + return ; + } - const id = item.search && item.t === 'd' ? item._id : getUidDirectMessage(item); - // TODO: move to RoomItem - const swipeEnabled = !(item?.search || item?.joinCodeRequired || item?.outside); - - return ( - - ); - }; + const id = item.search && item.t === 'd' ? item._id : getUidDirectMessage(item); + // TODO: move to RoomItem + const swipeEnabled = !(item?.search || item?.joinCodeRequired || item?.outside); + + return ( + + ); + }, + [username, showLastMessage, onPressItem, isMasterDetail, width, useRealName, subscribedRoom, showAvatar, displayMode] + ); if (searchEnabled) { if (searching) { @@ -136,6 +142,7 @@ const RoomsListView = memo(function RoomsListView() { refreshControl={} onEndReachedThreshold={0.5} keyboardDismissMode={isIOS ? 'on-drag' : 'none'} + drawDistance={300} /> ); }); diff --git a/docs/FLASHLIST_MIGRATION.md b/docs/FLASHLIST_MIGRATION.md new file mode 100644 index 00000000000..55a64a5db60 --- /dev/null +++ b/docs/FLASHLIST_MIGRATION.md @@ -0,0 +1,26 @@ +# FlashList migration + +The rooms list (`RoomsListView`) uses `@shopify/flash-list` instead of `FlatList` for better performance on Android. + +The room message list (`RoomView`) continues to use `Animated.FlatList` because it relies on the `inverted` prop and `onScrollToIndexFailed` which are not supported by FlashList v2. + +## Comparing CPU usage (Android) + +On a physical device with the app installed: + +1. Reset and capture baseline (optional, if comparing two builds): + ```bash + adb shell dumpsys gfxinfo chat.rocket.reactnative reset + ``` +2. Use the app (scroll rooms list, open a room, scroll messages, receive messages, jump to message, scroll to bottom). +3. Dump CPU and graphics stats: + ```bash + adb shell dumpsys cpuinfo | grep -E "chat.rocket|Total" + adb shell dumpsys gfxinfo chat.rocket.reactnative + ``` +4. From `gfxinfo` compare: + - **Janky frames** (lower is better) + - **50th / 90th / 95th / 99th percentile** frame times (lower is better) + - **Number Slow UI thread** (lower is better) + +Run the same flow on the same device for a pre-migration build and the FlashList build, then compare the numbers. From 2968649c4f873ed5acf3a379a1bfada820a48b6b Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Wed, 25 Feb 2026 04:53:21 +0530 Subject: [PATCH 3/4] fix: add getItemType to FlashList for separate recycling pools Section headers and room items use distinct view types; getItemType ensures FlashList recycles them in separate pools to avoid layout glitches and incorrect renders when scrolling. --- app/views/RoomsListView/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index f6574947367..5c338cd3df0 100644 --- a/app/views/RoomsListView/index.tsx +++ b/app/views/RoomsListView/index.tsx @@ -28,6 +28,9 @@ import { useRefresh } from './hooks/useRefresh'; import { useSubscriptions } from './hooks/useSubscriptions'; import styles from './styles'; +const ITEM_TYPE_SECTION = 'section'; +const ITEM_TYPE_ROOM = 'room'; + const RoomsListView = memo(function RoomsListView() { 'use memo'; @@ -76,6 +79,8 @@ const RoomsListView = memo(function RoomsListView() { [navigation, subscribedRoom, stopSearch, isMasterDetail] ); + const getItemType = useCallback((item: IRoomItem) => (item.separator ? ITEM_TYPE_SECTION : ITEM_TYPE_ROOM), []); + const renderItem = useCallback( ({ item }: { item: IRoomItem }) => { if (item.separator) { @@ -134,6 +139,7 @@ const RoomsListView = memo(function RoomsListView() { `${item.rid}-${searchEnabled}`} style={[styles.list, { backgroundColor: colors.surfaceRoom }]} renderItem={renderItem} From 9a1d3a56a51f0a46ed37a711325b34d691ff7262 Mon Sep 17 00:00:00 2001 From: Deepak Bhagat Date: Wed, 25 Feb 2026 05:01:43 +0530 Subject: [PATCH 4/4] fix: include subscribedRoom in FlashList extraData for correct isFocused updates --- app/views/RoomsListView/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index 5c338cd3df0..a7b8df3b019 100644 --- a/app/views/RoomsListView/index.tsx +++ b/app/views/RoomsListView/index.tsx @@ -138,7 +138,7 @@ const RoomsListView = memo(function RoomsListView() { return ( `${item.rid}-${searchEnabled}`} style={[styles.list, { backgroundColor: colors.surfaceRoom }]}