Skip to content

Commit ca5f3b6

Browse files
authored
Merge pull request Expensify#64570 from margelo/@perunt/expenses-list-perf-on-web
FlashList for Expenses list
2 parents 9fcecdf + 65ee01b commit ca5f3b6

9 files changed

Lines changed: 322 additions & 38 deletions

File tree

cspell.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -688,7 +688,8 @@
688688
"zxcv",
689689
"zxldvw",
690690
"مثال",
691-
"pnrs"
691+
"pnrs",
692+
"POLICYCHANGELOG_ADD_EMPLOYEE"
692693
],
693694
"ignorePaths": [
694695
"src/languages/de.ts",

ios/Podfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2794,7 +2794,7 @@ PODS:
27942794
- Firebase/Performance (= 8.8.0)
27952795
- React-Core
27962796
- RNFBApp
2797-
- RNFlashList (1.7.6):
2797+
- RNFlashList (1.8.2):
27982798
- DoubleConversion
27992799
- glog
28002800
- hermes-engine
@@ -3863,7 +3863,7 @@ SPEC CHECKSUMS:
38633863
RNFBApp: 729c0666395b1953198dc4a1ec6deb8fbe1c302e
38643864
RNFBCrashlytics: 2061ca863e8e2fa1aae9b12477d7dfa8e88ca0f9
38653865
RNFBPerf: 389914cda4000fe0d996a752532a591132cbf3f9
3866-
RNFlashList: 30b68f572400383347ce7df3777985af0d089624
3866+
RNFlashList: 8d83cb64dc02d78e391b0a9fcb0392a00d7fe407
38673867
RNFS: 4ac0f0ea233904cb798630b3c077808c06931688
38683868
RNGestureHandler: 5d8431415d4b8518e86e289e9ad5bb9be78f6dba
38693869
RNGoogleSignin: ccaa4a81582cf713eea562c5dd9dc1961a715fd0

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@
118118
"@react-navigation/stack": "7.3.3",
119119
"@react-ng/bounds-observer": "^0.2.1",
120120
"@rnmapbox/maps": "10.1.33",
121-
"@shopify/flash-list": "1.7.6",
121+
"@shopify/flash-list": "1.8.2",
122122
"@ua/react-native-airship": "~24.4.0",
123123
"awesome-phonenumber": "^5.4.0",
124124
"babel-polyfill": "^6.26.0",

src/components/Search/SearchList.tsx

Lines changed: 93 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {useIsFocused} from '@react-navigation/native';
2-
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
2+
import {FlashList} from '@shopify/flash-list';
3+
import type {FlashListProps, ViewToken} from '@shopify/flash-list';
4+
import React, {forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
35
import type {ForwardedRef} from 'react';
46
import {View} from 'react-native';
5-
import type {FlatList, ListRenderItemInfo, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native';
7+
import type {NativeSyntheticEvent, StyleProp, ViewStyle} from 'react-native';
68
import Animated from 'react-native-reanimated';
7-
import type {FlatListPropsWithLayout} from 'react-native-reanimated';
89
import Checkbox from '@components/Checkbox';
910
import * as Expensicons from '@components/Icon/Expensicons';
1011
import MenuItem from '@components/MenuItem';
@@ -17,6 +18,7 @@ import type TransactionListItem from '@components/SelectionList/Search/Transacti
1718
import type {ExtendedTargetedEvent, ReportActionListItemType, TaskListItemType, TransactionGroupListItemType, TransactionListItemType} from '@components/SelectionList/types';
1819
import Text from '@components/Text';
1920
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
21+
import useInitialWindowDimensions from '@hooks/useInitialWindowDimensions';
2022
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
2123
import useKeyboardState from '@hooks/useKeyboardState';
2224
import useLocalize from '@hooks/useLocalize';
@@ -30,8 +32,12 @@ import {addKeyDownPressListener, removeKeyDownPressListener} from '@libs/Keyboar
3032
import variables from '@styles/variables';
3133
import CONST from '@src/CONST';
3234
import ONYXKEYS from '@src/ONYXKEYS';
35+
import {createItemHeightCalculator} from './itemHeightCalculator';
36+
import ITEM_HEIGHTS from './itemHeights';
3337
import type {SearchQueryJSON} from './types';
3438

39+
const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList<SearchListItem>);
40+
3541
type SearchListItem = TransactionListItemType | TransactionGroupListItemType | ReportActionListItemType | TaskListItemType;
3642
type SearchListItemComponentType = typeof TransactionListItem | typeof ChatListItem | typeof TransactionGroupListItem | typeof TaskListItem;
3743

@@ -40,7 +46,7 @@ type SearchListHandle = {
4046
scrollToIndex: (index: number, animated?: boolean) => void;
4147
};
4248

43-
type SearchListProps = Pick<FlatListPropsWithLayout<SearchListItem>, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent'> & {
49+
type SearchListProps = Pick<FlashListProps<SearchListItem>, 'onScroll' | 'contentContainerStyle' | 'onEndReached' | 'onEndReachedThreshold' | 'ListFooterComponent'> & {
4450
data: SearchListItem[];
4551

4652
/** Default renderer for every item in the list */
@@ -78,15 +84,17 @@ type SearchListProps = Pick<FlatListPropsWithLayout<SearchListItem>, 'onScroll'
7884
/** Invoked on mount and layout changes */
7985
onLayout?: () => void;
8086

87+
/** Styles to apply to the content container */
88+
contentContainerStyle?: StyleProp<ViewStyle>;
89+
90+
/** The estimated height of an item in the list */
91+
estimatedItemSize?: number;
92+
8193
/** Whether mobile selection mode is enabled */
8294
isMobileSelectionModeEnabled: boolean;
8395
};
8496

85-
const keyExtractor = (item: SearchListItem, index: number) => {
86-
return item.keyForList ?? `${index}`;
87-
};
88-
89-
const onScrollToIndexFailed = () => {};
97+
const keyExtractor = (item: SearchListItem, index: number) => item.keyForList ?? `${index}`;
9098

9199
function SearchList(
92100
{
@@ -96,7 +104,7 @@ function SearchList(
96104
onSelectRow,
97105
onCheckboxPress,
98106
canSelectMultiple,
99-
onScroll,
107+
onScroll = () => {},
100108
onAllCheckboxPress,
101109
contentContainerStyle,
102110
onEndReachedThreshold,
@@ -108,20 +116,33 @@ function SearchList(
108116
queryJSON,
109117
onViewableItemsChanged,
110118
onLayout,
119+
estimatedItemSize = ITEM_HEIGHTS.NARROW_WITHOUT_DRAWER.STANDARD,
111120
isMobileSelectionModeEnabled,
112121
}: SearchListProps,
113122
ref: ForwardedRef<SearchListHandle>,
114123
) {
115124
const styles = useThemeStyles();
116-
const {hash, groupBy} = queryJSON;
125+
126+
const {initialHeight, initialWidth} = useInitialWindowDimensions();
127+
const {hash, groupBy, type} = queryJSON;
117128
const flattenedTransactions = groupBy ? (data as TransactionGroupListItemType[]).flatMap((item) => item.transactions) : data;
118-
const flattenedTransactionWithoutPendingDelete = flattenedTransactions.filter((t) => t.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
119-
const selectedItemsLength = flattenedTransactions.reduce((acc, item) => {
120-
return item?.isSelected ? acc + 1 : acc;
121-
}, 0);
129+
130+
const flattenedTransactionWithoutPendingDelete = useMemo(
131+
() => flattenedTransactions.filter((t) => t?.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE),
132+
[flattenedTransactions],
133+
);
134+
135+
const selectedItemsLength = useMemo(
136+
() =>
137+
flattenedTransactions.reduce((acc, item) => {
138+
return item?.isSelected ? acc + 1 : acc;
139+
}, 0),
140+
[flattenedTransactions],
141+
);
142+
122143
const {translate} = useLocalize();
123144
const isFocused = useIsFocused();
124-
const listRef = useRef<FlatList<SearchListItem>>(null);
145+
const listRef = useRef<FlashList<SearchListItem>>(null);
125146
const hasKeyBeenPressed = useRef(false);
126147
const [itemsToHighlight, setItemsToHighlight] = useState<Set<string> | null>(null);
127148
const itemFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -130,7 +151,7 @@ function SearchList(
130151
// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout here because there is a race condition that causes shouldUseNarrowLayout to change indefinitely in this component
131152
// See https://github.com/Expensify/App/issues/48675 for more details
132153
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
133-
const {isSmallScreenWidth} = useResponsiveLayout();
154+
const {isSmallScreenWidth, isLargeScreenWidth, shouldUseNarrowLayout} = useResponsiveLayout();
134155

135156
const [isModalVisible, setIsModalVisible] = useState(false);
136157
const [longPressedItem, setLongPressedItem] = useState<SearchListItem>();
@@ -271,7 +292,8 @@ function SearchList(
271292
useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex}), [scrollAndHighlightItem, scrollToIndex]);
272293

273294
const renderItem = useCallback(
274-
({item, index}: ListRenderItemInfo<SearchListItem>) => {
295+
// eslint-disable-next-line react/no-unused-prop-types
296+
({item, index}: {item: SearchListItem; index: number}) => {
275297
const isItemFocused = focusedIndex === index;
276298
const isItemHighlighted = !!itemsToHighlight?.has(item.keyForList ?? '');
277299
const isDisabled = item.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
@@ -332,6 +354,51 @@ function SearchList(
332354
const selectAllButtonVisible = canSelectMultiple && !SearchTableHeader;
333355
const isSelectAllChecked = selectedItemsLength > 0 && selectedItemsLength === flattenedTransactionWithoutPendingDelete.length;
334356

357+
const getItemHeight = useMemo(
358+
() =>
359+
createItemHeightCalculator({
360+
isLargeScreenWidth,
361+
shouldUseNarrowLayout,
362+
type,
363+
}),
364+
[isLargeScreenWidth, shouldUseNarrowLayout, type],
365+
);
366+
367+
const overrideItemLayout = useCallback(
368+
(layout: {span?: number; size?: number}, item: SearchListItem) => {
369+
if (!layout) {
370+
return;
371+
}
372+
const height = getItemHeight(item);
373+
// eslint-disable-next-line no-param-reassign
374+
return (layout.size = height > 0 ? height : estimatedItemSize);
375+
},
376+
[getItemHeight, estimatedItemSize],
377+
);
378+
379+
const calculatedListHeight = useMemo(() => {
380+
return initialHeight - variables.contentHeaderHeight;
381+
}, [initialHeight]);
382+
383+
const calculatedListWidth = useMemo(() => {
384+
if (shouldUseNarrowLayout) {
385+
return initialWidth;
386+
}
387+
388+
if (isLargeScreenWidth) {
389+
return initialWidth - variables.navigationTabBarSize - variables.sideBarWithLHBWidth;
390+
}
391+
392+
return initialWidth;
393+
}, [initialWidth, shouldUseNarrowLayout, isLargeScreenWidth]);
394+
395+
const estimatedListSize = useMemo(() => {
396+
return {
397+
height: calculatedListHeight,
398+
width: calculatedListWidth,
399+
};
400+
}, [calculatedListHeight, calculatedListWidth]);
401+
335402
return (
336403
<View style={[styles.flex1, !isKeyboardShown && safeAreaPaddingBottomStyle, containerStyle]}>
337404
{tableHeaderVisible && (
@@ -365,22 +432,26 @@ function SearchList(
365432
</View>
366433
)}
367434

368-
<Animated.FlatList
435+
<AnimatedFlashListComponent
369436
data={data}
370437
renderItem={renderItem}
371438
keyExtractor={keyExtractor}
372439
onScroll={onScroll}
373-
contentContainerStyle={contentContainerStyle}
374440
showsVerticalScrollIndicator={false}
375441
ref={listRef}
376442
extraData={focusedIndex}
377443
onEndReached={onEndReached}
378444
onEndReachedThreshold={onEndReachedThreshold}
379445
ListFooterComponent={ListFooterComponent}
380-
removeClippedSubviews
381446
onViewableItemsChanged={onViewableItemsChanged}
382-
onScrollToIndexFailed={onScrollToIndexFailed}
383447
onLayout={onLayout}
448+
removeClippedSubviews
449+
drawDistance={1000}
450+
estimatedItemSize={estimatedItemSize}
451+
overrideItemLayout={overrideItemLayout}
452+
estimatedListSize={estimatedListSize}
453+
contentContainerStyle={contentContainerStyle}
454+
overrideProps={{estimatedHeightSize: calculatedListHeight}}
384455
/>
385456
<Modal
386457
isVisible={isModalVisible}

src/components/Search/index.tsx

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {useFocusEffect, useIsFocused, useNavigation} from '@react-navigation/native';
2+
import type {ContentStyle} from '@shopify/flash-list';
23
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
3-
import type {NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native';
4+
import type {NativeScrollEvent, NativeSyntheticEvent, ViewToken} from 'react-native';
45
import {View} from 'react-native';
56
import FullPageErrorView from '@components/BlockingViews/FullPageErrorView';
67
import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
@@ -57,7 +58,8 @@ import type {SearchColumnType, SearchParams, SearchQueryJSON, SelectedTransactio
5758
type SearchProps = {
5859
queryJSON: SearchQueryJSON;
5960
onSearchListScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
60-
contentContainerStyle?: StyleProp<ViewStyle>;
61+
contentContainerStyle?: ContentStyle;
62+
// contentContainerStyle?: StyleProp<ViewStyle>; // TODO: remove this
6163
searchResults?: SearchResults;
6264
handleSearch: (value: SearchParams) => void;
6365
isMobileSelectionModeEnabled: boolean;
@@ -200,6 +202,10 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
200202
}, [selectedTransactions, isMobileSelectionModeEnabled, shouldTurnOffSelectionMode]);
201203

202204
useEffect(() => {
205+
if (!isFocused) {
206+
return;
207+
}
208+
203209
const selectedKeys = Object.keys(selectedTransactions).filter((key) => selectedTransactions[key]);
204210
if (!isSmallScreenWidth) {
205211
if (selectedKeys.length === 0 && isMobileSelectionModeEnabled) {
@@ -215,11 +221,12 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
215221
}, [isSmallScreenWidth, selectedTransactions, isMobileSelectionModeEnabled]);
216222

217223
useEffect(() => {
218-
if (isOffline) {
224+
if (!isFocused || isOffline) {
219225
return;
220226
}
221227

222228
handleSearch({queryJSON, offset});
229+
// We don't need to run the effect on change of isFocused.
223230
}, [handleSearch, isOffline, offset, queryJSON]);
224231

225232
useEffect(() => {
@@ -260,6 +267,10 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
260267
const isRefreshingSelection = useRef(false);
261268

262269
useEffect(() => {
270+
if (!isFocused) {
271+
return;
272+
}
273+
263274
if (type === CONST.SEARCH.DATA_TYPES.CHAT) {
264275
return;
265276
}
@@ -319,7 +330,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
319330

320331
isRefreshingSelection.current = true;
321332
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
322-
}, [data, setSelectedTransactions, isExportMode]);
333+
}, [data, setSelectedTransactions, isExportMode, isFocused]);
323334

324335
useEffect(() => {
325336
if (!isSearchResultsEmpty || prevIsSearchResultEmpty) {
@@ -345,7 +356,11 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
345356
}, [selectedTransactions]);
346357

347358
useEffect(() => {
348-
if (!data.length || isRefreshingSelection.current || !isFocused) {
359+
if (!isFocused) {
360+
return;
361+
}
362+
363+
if (!data.length || isRefreshingSelection.current) {
349364
return;
350365
}
351366
const areItemsGrouped = !!groupBy;
@@ -458,6 +473,10 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
458473

459474
const onViewableItemsChanged = useCallback(
460475
({viewableItems}: {viewableItems: ViewToken[]}) => {
476+
if (!isFocused) {
477+
return;
478+
}
479+
461480
const isFirstItemVisible = viewableItems.at(0)?.index === 1;
462481
// If the user is still loading the search results, or if they are scrolling down, don't refresh the search results
463482
if (shouldShowLoadingState || !isFirstItemVisible) {
@@ -470,7 +489,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
470489
// Therefore, when the user scrolls to the top, we need to refresh the search results.
471490
setOffset(0);
472491
},
473-
[shouldShowLoadingState],
492+
[shouldShowLoadingState, isFocused],
474493
);
475494

476495
const isChat = type === CONST.SEARCH.DATA_TYPES.CHAT;
@@ -506,7 +525,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
506525
const hasErrors = Object.keys(searchResults?.errors ?? {}).length > 0 && !isOffline;
507526

508527
const fetchMoreResults = useCallback(() => {
509-
if (!searchResults?.search?.hasMoreResults || shouldShowLoadingState || shouldShowLoadingMoreItems) {
528+
if (!isFocused || !searchResults?.search?.hasMoreResults || shouldShowLoadingState || shouldShowLoadingMoreItems) {
510529
return;
511530
}
512531
setOffset(offset + CONST.SEARCH.RESULTS_PAGE_SIZE);
@@ -624,7 +643,7 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
624643
/>
625644
)
626645
}
627-
contentContainerStyle={[contentContainerStyle, styles.pb3]}
646+
contentContainerStyle={{...contentContainerStyle, ...styles.pb3}}
628647
containerStyle={[styles.pv0, type === CONST.SEARCH.DATA_TYPES.CHAT && !isSmallScreenWidth && styles.pt3]}
629648
shouldPreventDefaultFocusOnSelectRow={!canUseTouchScreen()}
630649
onScroll={onSearchListScroll}

0 commit comments

Comments
 (0)