diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx index bbc02441901..aed2f82a3a3 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.test.tsx @@ -1,23 +1,57 @@ 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', () => ({ - QuoteRow: ({ provider, quoteRequestId }: QuoteRowProps) => { +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 ( - {provider.name} - {quoteRequestId} + {providerName} - {quoteRequestId} ); - }, + } + + return { + QuoteRowView: ReactActual.memo(MockQuoteRowView), + }; +}); + +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(); @@ -33,9 +67,23 @@ describe('QuoteList', () => { beforeEach(() => { jest.clearAllMocks(); + mockQuoteRowRenderCounts.clear(); }); 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(); @@ -96,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({ diff --git a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx index 73c2ca6c91d..bc402842adb 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteList.tsx @@ -1,9 +1,51 @@ 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(({ provider, ...quote }) => ( + + )); +}; 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 759272b2c4a..be8b8f45190 100644 --- a/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx +++ b/app/components/UI/Bridge/components/QuoteSelectorView/QuoteRow.tsx @@ -38,108 +38,133 @@ export interface QuoteRowProps { receiveAmount?: string; } -export const QuoteRow = ({ - selected, - provider, - isLowestCost, - formattedTotalCost, - onPress, - quoteRequestId, - loading, - receiveAmount, -}: QuoteRowProps) => { - const theme = useTheme(); - const destToken = useSelector(selectDestToken); - const formattedReceiveAmountFiat = useDisplayCurrencyValue( +export interface QuoteRowViewProps extends Omit { + providerName: string; + formattedReceiveAmountFiat: string; + destTokenSymbol?: string; +} + +export const QuoteRowView = React.memo( + ({ + selected, + providerName, + isLowestCost, + formattedTotalCost, + formattedReceiveAmountFiat, + destTokenSymbol, + onPress, + quoteRequestId, + loading, receiveAmount, - destToken, - ); - const formattedReceiveAmount = useMemo( - () => - receiveAmount && receiveAmount !== '0' && destToken - ? formatAmountWithLocaleSeparators( - limitToMaximumDecimalPlaces(parseFloat(receiveAmount)), - ) - : receiveAmount, - [receiveAmount, destToken], - ); + }: QuoteRowViewProps) => { + const theme = useTheme(); + const formattedReceiveAmount = useMemo( + () => + receiveAmount && receiveAmount !== '0' && destTokenSymbol + ? formatAmountWithLocaleSeparators( + limitToMaximumDecimalPlaces(parseFloat(receiveAmount)), + ) + : receiveAmount, + [receiveAmount, destTokenSymbol], + ); - return ( - onPress(quoteRequestId)}> - - + return ( + onPress(quoteRequestId)}> + + + + + + + {providerName} + + + + + {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} {destToken?.symbol} - - - - - ~ {formattedReceiveAmountFiat} - - - - + + ); + }, +); + +export const QuoteRow = ({ provider, ...props }: QuoteRowProps) => { + const destToken = useSelector(selectDestToken); + const formattedReceiveAmountFiat = useDisplayCurrencyValue( + props.receiveAmount, + destToken, + ); + + return ( + ); };