Skip to content

Commit d0795b6

Browse files
authored
perf(bridge): migrate token selector to FlashList (#33230)
## **Description** <!-- mms-check: type=text required=true --> SWAPS-4672 tracks a medium-severity performance audit finding in the bridge asset picker. Potentially large popular-token and paginated search-result lists were rendered with the gesture-handler `FlatList`, even though each token row contains avatars, badges, multiple views, and per-row hooks. Pagination was also driven by a manual scroll handler with `scrollEventThrottle={400}`, so the near-bottom check ran only a few times per second and could trigger late or feel janky. Network-filter changes compounded that cost by changing the list `key`, forcing the entire list to remount and discard its recycled cells. `extraData` only tracked the list length, while FlatList-specific windowing and clipping props added more manual tuning around a rich, variable dataset. This PR: - Migrates the token selector to FlashList v2 so rich rows are recycled across long popular-token and search-result lists. - Increases scroll sampling from 400 ms to 16 ms so the existing near-bottom pagination check responds promptly. - Removes the keyed network-change remount, length-only `extraData`, and FlatList-specific rendering/windowing props. - Keeps the list mounted across network filters, disables visible-content position maintenance for replacement datasets, and resets to row zero through FlashList's recycler-aware `scrollToIndex` after the new data commits. - Removes the redundant token-address key inside each recycled row. - Adds regression coverage for list configuration, retained mounting, and network-switch scroll resets. During manual validation, the migration exposed two recycler timing cases: All → Ethereum → All could show an empty list until scrolling, and switching networks after scrolling could leave the first asset blank. The final reset and visible-content-position configuration address both cases while preserving FlashList recycling. ## **Changelog** <!-- mms-check: type=changelog required=true blocking=true --> CHANGELOG entry: Improved bridge asset picker scrolling, pagination, and token rendering when switching networks ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: #31365 Refs: [SWAPS-4672](https://consensyssoftware.atlassian.net/browse/SWAPS-4672) ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Bridge asset picker list performance and network switching Background: Given I am logged into MetaMask Mobile And I open the bridge source or destination asset picker Scenario: scroll and paginate a long token search result list Given my search returns enough tokens to exceed the visible list When I scroll toward the bottom of the search results Then additional results should begin loading promptly And the list should scroll without blank or flashing token rows Scenario: switch from All to Ethereum and back to All Given the asset picker is showing the All network tab When I tap the Ethereum network tab And I tap the All network tab Then the All-network asset list should render immediately without additional scrolling Scenario: switch networks after scrolling the asset list Given the asset picker is showing the All network tab When I scroll down the asset list And I tap another network tab Then the list should reset to the top And the first asset for the selected network should render without additional scrolling When I switch to a different network tab Then the first asset for that network should render without additional scrolling ``` ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> <!-- If applicable, add screenshots and/or recordings to visualize the before and after of this change. --> ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [x] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [x] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [x] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- mms-check: type=checklist required=true --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- Generated with the help of the pr-description AI skill --> [SWAPS-4672]: https://consensyssoftware.atlassian.net/browse/SWAPS-4672?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
1 parent 687f807 commit d0795b6

3 files changed

Lines changed: 113 additions & 20 deletions

File tree

app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,10 @@ jest.mock('./NetworkPills', () => ({
476476
testID: 'select-polygon-network',
477477
onPress: () => onChainSelect(MOCK_CHAIN_IDS.polygon),
478478
}),
479+
createElement(TouchableOpacity, {
480+
testID: 'select-all-networks',
481+
onPress: () => onChainSelect(undefined),
482+
}),
479483
createElement(TouchableOpacity, {
480484
testID: 'open-network-modal',
481485
onPress: onMorePress,
@@ -580,6 +584,31 @@ jest.mock('react-native-gesture-handler', () => {
580584
return { FlatList, ScrollView };
581585
});
582586

587+
const mockFlashListScrollToIndex = jest.fn().mockResolvedValue(undefined);
588+
const mockFlashListMount = jest.fn();
589+
const mockFlashListUnmount = jest.fn();
590+
591+
jest.mock('@shopify/flash-list', () => {
592+
const ReactMock = jest.requireActual('react');
593+
const { FlatList } = jest.requireActual('react-native');
594+
595+
const MockFlashList = ReactMock.forwardRef(
596+
(props: Record<string, unknown>, ref: React.ForwardedRef<unknown>) => {
597+
ReactMock.useImperativeHandle(ref, () => ({
598+
scrollToIndex: mockFlashListScrollToIndex,
599+
}));
600+
ReactMock.useEffect(() => {
601+
mockFlashListMount();
602+
return () => mockFlashListUnmount();
603+
}, []);
604+
605+
return ReactMock.createElement(FlatList, props);
606+
},
607+
);
608+
609+
return { FlashList: MockFlashList };
610+
});
611+
583612
const resetMocks = () => {
584613
mockRouteParams = { type: 'source' };
585614
mockIsBatchSellEnabled = true;
@@ -965,6 +994,41 @@ describe('BridgeTokenSelector', () => {
965994
});
966995

967996
describe('chain selection', () => {
997+
it('resets the mounted list after scrolling and changing networks', async () => {
998+
const { getByTestId, UNSAFE_getByType } = renderWithReduxProvider(
999+
<BridgeTokenSelector />,
1000+
);
1001+
const initialMountCount = mockFlashListMount.mock.calls.length;
1002+
const { FlatList } = jest.requireActual('react-native');
1003+
1004+
await act(async () => {
1005+
UNSAFE_getByType(FlatList).props.onScroll({
1006+
nativeEvent: {
1007+
layoutMeasurement: { height: 500 },
1008+
contentOffset: { y: 500 },
1009+
contentSize: { height: 2000 },
1010+
},
1011+
});
1012+
});
1013+
1014+
await act(async () => {
1015+
fireEvent.press(getByTestId('select-eth-network'));
1016+
});
1017+
await act(async () => {
1018+
fireEvent.press(getByTestId('select-all-networks'));
1019+
});
1020+
1021+
await waitFor(() => {
1022+
expect(mockFlashListScrollToIndex).toHaveBeenCalledTimes(1);
1023+
expect(mockFlashListScrollToIndex).toHaveBeenCalledWith({
1024+
index: 0,
1025+
animated: false,
1026+
});
1027+
});
1028+
expect(mockFlashListMount).toHaveBeenCalledTimes(initialMountCount);
1029+
expect(mockFlashListUnmount).not.toHaveBeenCalled();
1030+
});
1031+
9681032
it('cancels search and resets when chain changes', async () => {
9691033
const { getByTestId } = renderWithReduxProvider(<BridgeTokenSelector />);
9701034
fireEvent.changeText(getByTestId('bridge-token-search-input'), 'ETH');
@@ -1053,6 +1117,26 @@ describe('BridgeTokenSelector', () => {
10531117
});
10541118

10551119
describe('pagination', () => {
1120+
it('disables visible-content position maintenance', () => {
1121+
const { UNSAFE_getByType } = renderWithReduxProvider(
1122+
<BridgeTokenSelector />,
1123+
);
1124+
const { FlatList } = jest.requireActual('react-native');
1125+
1126+
expect(
1127+
UNSAFE_getByType(FlatList).props.maintainVisibleContentPosition,
1128+
).toEqual({ disabled: true });
1129+
});
1130+
1131+
it('sets a frame-rate scroll event throttle', () => {
1132+
const { UNSAFE_getByType } = renderWithReduxProvider(
1133+
<BridgeTokenSelector />,
1134+
);
1135+
const { FlatList } = jest.requireActual('react-native');
1136+
1137+
expect(UNSAFE_getByType(FlatList).props.scrollEventThrottle).toBe(16);
1138+
});
1139+
10561140
it('triggers load more on scroll near bottom with cursor', async () => {
10571141
mockSearchTokensState = {
10581142
...mockSearchTokensState,

app/components/UI/Bridge/components/BridgeTokenSelector/BridgeTokenSelector.tsx

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,7 @@ import React, {
55
useEffect,
66
useRef,
77
} from 'react';
8-
import {
9-
NativeSyntheticEvent,
10-
NativeScrollEvent,
11-
ListRenderItemInfo,
12-
} from 'react-native';
8+
import { NativeSyntheticEvent, NativeScrollEvent } from 'react-native';
139
import { SafeAreaView } from 'react-native-safe-area-context';
1410
import {
1511
useNavigation,
@@ -19,7 +15,11 @@ import {
1915
} from '@react-navigation/native';
2016
import { useSelector, useDispatch } from 'react-redux';
2117
import { strings } from '../../../../../../locales/i18n';
22-
import { FlatList } from 'react-native-gesture-handler';
18+
import {
19+
FlashList,
20+
type FlashListRef,
21+
type ListRenderItem,
22+
} from '@shopify/flash-list';
2323
import { NetworkPills } from './NetworkPills';
2424
import Routes from '../../../../../constants/navigation/Routes';
2525
import { CaipChainId } from '@metamask/utils';
@@ -144,7 +144,7 @@ export const BridgeTokenSelector: React.FC = () => {
144144
useRoute<RouteProp<{ params: BridgeTokenSelectorRouteParams }, 'params'>>();
145145
const { styles } = useStyles(createStyles, {});
146146
const [searchString, setSearchString] = useState<string>('');
147-
const flatListRef = useRef<FlatList>(null);
147+
const flatListRef = useRef<FlashListRef<BridgeToken | null>>(null);
148148
const [flatListHeight, setFlatListHeight] = useState<number>(0);
149149

150150
// Set selecting token state to prevent quote expired modal from showing
@@ -231,7 +231,7 @@ export const BridgeTokenSelector: React.FC = () => {
231231

232232
// Track the last chain ID to detect changes
233233
const lastChainIdRef = useRef(selectedChainId);
234-
const [listKey, setListKey] = useState(0);
234+
const shouldResetListPositionRef = useRef(false);
235235

236236
const chainIdsToFetch = useMemo(() => {
237237
if (!enabledChainRanking || enabledChainRanking.length === 0) {
@@ -284,7 +284,7 @@ export const BridgeTokenSelector: React.FC = () => {
284284
useEffect(() => {
285285
if (lastChainIdRef.current !== selectedChainId) {
286286
lastChainIdRef.current = selectedChainId;
287-
setListKey((prev) => prev + 1);
287+
shouldResetListPositionRef.current = true;
288288

289289
// Cancel any pending debounced searches
290290
debouncedSearch.cancel();
@@ -348,6 +348,21 @@ export const BridgeTokenSelector: React.FC = () => {
348348
searchString,
349349
]);
350350

351+
// Reset only after the replacement dataset has been committed. scrollToIndex
352+
// engages and lays out the first recycled row before moving the native view.
353+
useEffect(() => {
354+
if (!shouldResetListPositionRef.current) {
355+
return undefined;
356+
}
357+
358+
const frameId = requestAnimationFrame(() => {
359+
void flatListRef.current?.scrollToIndex({ index: 0, animated: false });
360+
shouldResetListPositionRef.current = false;
361+
});
362+
363+
return () => cancelAnimationFrame(frameId);
364+
}, [displayData, selectedChainId]);
365+
351366
const getIsNoFeeAsset = useCallback(
352367
(token: BridgeToken) => {
353368
const routeNoFee =
@@ -474,8 +489,8 @@ export const BridgeTokenSelector: React.FC = () => {
474489
[navigation, enabledChainRanking],
475490
);
476491

477-
const renderToken = useCallback(
478-
({ item }: ListRenderItemInfo<BridgeToken | null>) => {
492+
const renderToken = useCallback<ListRenderItem<BridgeToken | null>>(
493+
({ item }) => {
479494
// This is to support a partial loading state for top tokens
480495
// We can show tokens with balance immediately, but we need to wait for the top tokens to load
481496
if (!item) {
@@ -644,28 +659,23 @@ export const BridgeTokenSelector: React.FC = () => {
644659
/>
645660
</Box>
646661

647-
<FlatList
662+
<FlashList
648663
ref={flatListRef}
649-
key={listKey}
650664
testID="bridge-token-list"
651665
style={styles.tokensList}
652666
contentContainerStyle={styles.tokensListContainer}
653667
data={displayData}
654668
renderItem={renderToken}
655669
keyExtractor={keyExtractor}
656-
extraData={displayData.length}
657670
showsVerticalScrollIndicator
658671
showsHorizontalScrollIndicator={false}
659672
onScroll={handleScroll}
660-
scrollEventThrottle={400}
673+
scrollEventThrottle={16}
661674
ListFooterComponent={renderFooter}
662675
ListHeaderComponent={renderListHeader}
663676
ListEmptyComponent={renderEmptyState}
664677
onLayout={handleFlatListLayout}
665-
initialNumToRender={8}
666-
maxToRenderPerBatch={5}
667-
windowSize={5}
668-
removeClippedSubviews
678+
maintainVisibleContentPosition={{ disabled: true }}
669679
/>
670680
</SafeAreaView>
671681
);

app/components/UI/Bridge/components/TokenSelectorItem.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,6 @@ const TokenSelectorItemInner: React.FC<TokenSelectorItemProps> = ({
359359
{shouldShowSelectedStyle && <View style={styles.selectedIndicator} />}
360360

361361
<TouchableOpacity
362-
key={token.address}
363362
onPress={handlePress}
364363
style={[
365364
styles.itemWrapper,

0 commit comments

Comments
 (0)