From 5e6766bab7cb4b8dbf65a56432e10b5f44223d2b Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:07:37 -0400 Subject: [PATCH 1/4] chore: lift selectDestToken and useDisplayCurrencyValue out of each quote row --- .../QuoteSelectorView/QuoteList.test.tsx | 32 ++++++++++++- .../QuoteSelectorView/QuoteList.tsx | 47 +++++++++++++++++-- .../components/QuoteSelectorView/QuoteRow.tsx | 38 +++++++++++---- 3 files changed, 103 insertions(+), 14 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx index bbc02441901..ca6cffdf816 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx @@ -2,9 +2,11 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import { QuoteList } from './QuoteList'; import { QuoteRowProps } from './QuoteRow'; +import { useSelector } from 'react-redux'; +import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; jest.mock('./QuoteRow', () => ({ - QuoteRow: ({ provider, quoteRequestId }: QuoteRowProps) => { + QuoteRowView: ({ provider, quoteRequestId }: QuoteRowProps) => { const { Text } = jest.requireActual('react-native'); return ( @@ -14,10 +16,25 @@ jest.mock('./QuoteRow', () => ({ }, })); +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => undefined), +})); + +jest.mock('../../utils/exchange-rates', () => ({ + getDisplayCurrencyValue: jest.fn(() => '$0.00'), +})); + jest.mock('../../hooks/useShouldRenderGasSponsoredBanner', () => ({ useShouldRenderGasSponsoredBanner: jest.fn(), })); +const mockUseSelector = useSelector as jest.MockedFunction; +const mockGetDisplayCurrencyValue = + getDisplayCurrencyValue as jest.MockedFunction< + typeof getDisplayCurrencyValue + >; + describe('QuoteList', () => { const mockOnPress = jest.fn(); @@ -36,6 +53,19 @@ describe('QuoteList', () => { }); describe('rendering', () => { + it('selects currency data once for multiple rows', () => { + const quotes = [ + createMockQuote({ quoteRequestId: 'quote-1' }), + createMockQuote({ quoteRequestId: 'quote-2' }), + createMockQuote({ quoteRequestId: 'quote-3' }), + ]; + + render(); + + expect(mockUseSelector).toHaveBeenCalledTimes(6); + expect(mockGetDisplayCurrencyValue).toHaveBeenCalledTimes(3); + }); + it('renders empty list when data is empty array', () => { const { queryByTestId } = render(); diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx index 73c2ca6c91d..114cbf369a1 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx @@ -1,9 +1,50 @@ import React from 'react'; -import { QuoteRow, QuoteRowProps } from './QuoteRow'; +import { useSelector } from 'react-redux'; +import { QuoteRowProps, QuoteRowView } from './QuoteRow'; +import { selectDestToken } from '../../../../../core/redux/slices/bridge'; +import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController'; +import { + selectCurrencyRates, + selectCurrentCurrency, +} from '../../../../../selectors/currencyRateController'; +import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) +import { selectMultichainAssetsRates } from '../../../../../selectors/multichain'; +///: END:ONLY_INCLUDE_IF(keyring-snaps) +import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; interface Props { data: QuoteRowProps[]; } -export const QuoteList = ({ data }: Props) => - data.map((quote) => ); +export const QuoteList = ({ data }: Props) => { + const destToken = useSelector(selectDestToken); + const evmMultiChainMarketData = useSelector(selectTokenMarketData); + const evmMultiChainCurrencyRates = useSelector(selectCurrencyRates); + const networkConfigurationsByChainId = useSelector( + selectNetworkConfigurations, + ); + const currentCurrency = useSelector(selectCurrentCurrency); + + let nonEvmMultichainAssetRates = {}; + ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) + nonEvmMultichainAssetRates = useSelector(selectMultichainAssetsRates); + ///: END:ONLY_INCLUDE_IF(keyring-snaps) + + return data.map((quote) => ( + + )); +}; diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx index 759272b2c4a..5e9e8603de3 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx @@ -38,30 +38,32 @@ export interface QuoteRowProps { receiveAmount?: string; } -export const QuoteRow = ({ +export interface QuoteRowViewProps extends QuoteRowProps { + formattedReceiveAmountFiat: string; + destTokenSymbol?: string; +} + +export const QuoteRowView = ({ selected, provider, isLowestCost, formattedTotalCost, + formattedReceiveAmountFiat, + destTokenSymbol, onPress, quoteRequestId, loading, receiveAmount, -}: QuoteRowProps) => { +}: QuoteRowViewProps) => { const theme = useTheme(); - const destToken = useSelector(selectDestToken); - const formattedReceiveAmountFiat = useDisplayCurrencyValue( - receiveAmount, - destToken, - ); const formattedReceiveAmount = useMemo( () => - receiveAmount && receiveAmount !== '0' && destToken + receiveAmount && receiveAmount !== '0' && destTokenSymbol ? formatAmountWithLocaleSeparators( limitToMaximumDecimalPlaces(parseFloat(receiveAmount)), ) : receiveAmount, - [receiveAmount, destToken], + [receiveAmount, destTokenSymbol], ); return ( @@ -127,7 +129,7 @@ export const QuoteRow = ({ > - ~ {formattedReceiveAmount} {destToken?.symbol} + ~ {formattedReceiveAmount} {destTokenSymbol} @@ -143,3 +145,19 @@ export const QuoteRow = ({ ); }; + +export const QuoteRow = (props: QuoteRowProps) => { + const destToken = useSelector(selectDestToken); + const formattedReceiveAmountFiat = useDisplayCurrencyValue( + props.receiveAmount, + destToken, + ); + + return ( + + ); +}; From 3cc7495e57e3d713fc96ac61f70114d9b1a7f0cd Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:10:31 -0400 Subject: [PATCH 2/4] chore: memoize quote row view --- .../QuoteSelectorView/QuoteRow.test.tsx | 8 +- .../components/QuoteSelectorView/QuoteRow.tsx | 193 +++++++++--------- 2 files changed, 106 insertions(+), 95 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx index 2b958b99f57..c2ec4602b69 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; -import { QuoteRow, QuoteRowProps } from './QuoteRow'; +import { QuoteRow, QuoteRowProps, QuoteRowView } from './QuoteRow'; import { strings } from '../../../../../../locales/i18n'; import { BridgeToken } from '../../types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; @@ -77,6 +77,12 @@ describe('QuoteRow', () => { }); }); + describe('memoization', () => { + it('memoizes QuoteRowView', () => { + expect(QuoteRowView).toHaveProperty('$$typeof', Symbol.for('react.memo')); + }); + }); + describe('rendering', () => { it('renders provider name', () => { // Act diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx index 5e9e8603de3..da8c420ae0c 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx @@ -43,108 +43,113 @@ export interface QuoteRowViewProps extends QuoteRowProps { destTokenSymbol?: string; } -export const QuoteRowView = ({ - selected, - provider, - isLowestCost, - formattedTotalCost, - formattedReceiveAmountFiat, - destTokenSymbol, - onPress, - quoteRequestId, - loading, - receiveAmount, -}: QuoteRowViewProps) => { - const theme = useTheme(); - const formattedReceiveAmount = useMemo( - () => - receiveAmount && receiveAmount !== '0' && destTokenSymbol - ? formatAmountWithLocaleSeparators( - limitToMaximumDecimalPlaces(parseFloat(receiveAmount)), - ) - : receiveAmount, - [receiveAmount, destTokenSymbol], - ); +export const QuoteRowView = React.memo( + ({ + selected, + provider, + isLowestCost, + formattedTotalCost, + formattedReceiveAmountFiat, + destTokenSymbol, + onPress, + quoteRequestId, + loading, + receiveAmount, + }: QuoteRowViewProps) => { + const theme = useTheme(); + const formattedReceiveAmount = useMemo( + () => + receiveAmount && receiveAmount !== '0' && destTokenSymbol + ? formatAmountWithLocaleSeparators( + limitToMaximumDecimalPlaces(parseFloat(receiveAmount)), + ) + : receiveAmount, + [receiveAmount, destTokenSymbol], + ); - return ( - onPress(quoteRequestId)}> - - + return ( + onPress(quoteRequestId)}> + + + + + + + {provider.name} + + + + + {isLowestCost && !loading && ( + + {strings('bridge.lowest_cost')} + + )} + + + + + {strings('bridge.total_cost')}: {formattedTotalCost} + + + - - - {provider.name} - - + + ~ {formattedReceiveAmount} {destTokenSymbol} + + + + + ~ {formattedReceiveAmountFiat} + - - {isLowestCost && !loading && ( - - {strings('bridge.lowest_cost')} - - )} - - - - {strings('bridge.total_cost')}: {formattedTotalCost} - - - - - - - ~ {formattedReceiveAmount} {destTokenSymbol} - - - - - ~ {formattedReceiveAmountFiat} - - - - - ); -}; + + ); + }, +); export const QuoteRow = (props: QuoteRowProps) => { const destToken = useSelector(selectDestToken); From fbcc70969da6fb33bc484951a415890eee881f72 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:20:55 -0400 Subject: [PATCH 3/4] refactor: update QuoteRow to use primitive providerName prop --- .../components/QuoteSelectorView/QuoteList.test.tsx | 6 +++--- .../Bridge/components/QuoteSelectorView/QuoteList.tsx | 3 ++- .../Bridge/components/QuoteSelectorView/QuoteRow.tsx | 10 ++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx index ca6cffdf816..dfc315aa86b 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx @@ -1,16 +1,16 @@ import React from 'react'; import { render } from '@testing-library/react-native'; import { QuoteList } from './QuoteList'; -import { QuoteRowProps } from './QuoteRow'; +import { QuoteRowProps, QuoteRowViewProps } from './QuoteRow'; import { useSelector } from 'react-redux'; import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; jest.mock('./QuoteRow', () => ({ - QuoteRowView: ({ provider, quoteRequestId }: QuoteRowProps) => { + QuoteRowView: ({ providerName, quoteRequestId }: QuoteRowViewProps) => { const { Text } = jest.requireActual('react-native'); return ( - {provider.name} - {quoteRequestId} + {providerName} - {quoteRequestId} ); }, diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx index 114cbf369a1..bc402842adb 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx @@ -31,10 +31,11 @@ export const QuoteList = ({ data }: Props) => { nonEvmMultichainAssetRates = useSelector(selectMultichainAssetsRates); ///: END:ONLY_INCLUDE_IF(keyring-snaps) - return data.map((quote) => ( + return data.map(({ provider, ...quote }) => ( { + providerName: string; formattedReceiveAmountFiat: string; destTokenSymbol?: string; } @@ -46,7 +47,7 @@ export interface QuoteRowViewProps extends QuoteRowProps { export const QuoteRowView = React.memo( ({ selected, - provider, + providerName, isLowestCost, formattedTotalCost, formattedReceiveAmountFiat, @@ -94,7 +95,7 @@ export const QuoteRowView = React.memo( fontWeight={FontWeight.Medium} variant={TextVariantDS.BodyMd} > - {provider.name} + {providerName} @@ -151,7 +152,7 @@ export const QuoteRowView = React.memo( }, ); -export const QuoteRow = (props: QuoteRowProps) => { +export const QuoteRow = ({ provider, ...props }: QuoteRowProps) => { const destToken = useSelector(selectDestToken); const formattedReceiveAmountFiat = useDisplayCurrencyValue( props.receiveAmount, @@ -161,6 +162,7 @@ export const QuoteRow = (props: QuoteRowProps) => { return ( From ad7bb4696fe6858cc6daf1050c32e4588e13fab1 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:46:36 -0400 Subject: [PATCH 4/4] chore: tests --- .../QuoteSelectorView/QuoteList.test.tsx | 70 +++++++++++++++++-- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx index dfc315aa86b..aed2f82a3a3 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx @@ -5,16 +5,33 @@ import { QuoteRowProps, QuoteRowViewProps } from './QuoteRow'; import { useSelector } from 'react-redux'; import { getDisplayCurrencyValue } from '../../utils/exchange-rates'; -jest.mock('./QuoteRow', () => ({ - QuoteRowView: ({ providerName, quoteRequestId }: QuoteRowViewProps) => { +const mockQuoteRowRenderCounts = new Map(); + +jest.mock('./QuoteRow', () => { + const ReactActual = jest.requireActual('react'); + + function MockQuoteRowView({ + providerName, + quoteRequestId, + }: QuoteRowViewProps) { const { Text } = jest.requireActual('react-native'); + + mockQuoteRowRenderCounts.set( + quoteRequestId, + (mockQuoteRowRenderCounts.get(quoteRequestId) ?? 0) + 1, + ); + return ( {providerName} - {quoteRequestId} ); - }, -})); + } + + return { + QuoteRowView: ReactActual.memo(MockQuoteRowView), + }; +}); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -50,6 +67,7 @@ describe('QuoteList', () => { beforeEach(() => { jest.clearAllMocks(); + mockQuoteRowRenderCounts.clear(); }); describe('rendering', () => { @@ -126,6 +144,50 @@ describe('QuoteList', () => { }); }); + describe('memoization', () => { + it('does not re-render unchanged rows when one quote changes', () => { + // Arrange + const quotes = [ + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + }), + createMockQuote({ + provider: { name: 'Socket' }, + quoteRequestId: 'quote-2', + }), + createMockQuote({ + provider: { name: 'Hop' }, + quoteRequestId: 'quote-3', + }), + ]; + const { rerender } = render(); + const updatedQuotes = [ + createMockQuote({ + provider: { name: 'Lifi' }, + quoteRequestId: 'quote-1', + }), + createMockQuote({ + provider: { name: 'Socket' }, + quoteRequestId: 'quote-2', + formattedTotalCost: '$101.00', + }), + createMockQuote({ + provider: { name: 'Hop' }, + quoteRequestId: 'quote-3', + }), + ]; + + // Act + rerender(); + + // Assert + expect(mockQuoteRowRenderCounts.get('quote-1')).toBe(1); + expect(mockQuoteRowRenderCounts.get('quote-2')).toBe(2); + expect(mockQuoteRowRenderCounts.get('quote-3')).toBe(1); + }); + }); + describe('prop passing', () => { it('passes all props to QuoteRow components', () => { const quote = createMockQuote({