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..d06e15a0255 --- /dev/null +++ b/app/views/RoomView/List/components/List.test.tsx @@ -0,0 +1,39 @@ +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'; + +const messages = [{ id: '1' }, { id: '2' }] as TAnyMessageModel[]; + +const renderList = (isAutocompleteVisible: boolean) => + render( + + item.id} + renderItem={() => 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/RoomsListView/index.tsx b/app/views/RoomsListView/index.tsx index 0ec091befb3..a7b8df3b019 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 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'; +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,13 +23,13 @@ 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 ITEM_TYPE_SECTION = 'section'; +const ITEM_TYPE_ROOM = 'room'; const RoomsListView = memo(function RoomsListView() { 'use memo'; @@ -44,7 +45,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); @@ -63,48 +63,56 @@ 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 getItemType = useCallback((item: IRoomItem) => (item.separator ? ITEM_TYPE_SECTION : ITEM_TYPE_ROOM), []); + + 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) { @@ -128,21 +136,19 @@ 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'} + 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. 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"