From b1d2a5fd6b94f5689e401bf1efef1187c7a2bcb3 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:20:54 +0000 Subject: [PATCH 1/6] fix: improve large lists of assets --- .../PreferredCurrencyDisplay.tsx | 17 +++++-- .../usePreferredCurrencyDisplay.spec.tsx | 31 +++++++++++ .../usePreferredCurrencyDisplay.ts | 27 +++++++--- .../AccountAssetList/AccountAssetList.tsx | 7 ++- .../SwipeableAssetItem/SwipeableAssetItem.tsx | 13 ++++- .../AccountAssetList/useAccountAssetList.ts | 11 +++- .../AssetItem/AccountAssetItemView.tsx | 32 ++++++++++-- .../__tests__/AccountAssetItemView.spec.tsx | 51 ++++++++++++++++++- 8 files changed, 169 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/components/PreferredCurrencyDisplay/PreferredCurrencyDisplay.tsx b/apps/mobile/src/components/PreferredCurrencyDisplay/PreferredCurrencyDisplay.tsx index 87cbaec45..ea2845fdc 100644 --- a/apps/mobile/src/components/PreferredCurrencyDisplay/PreferredCurrencyDisplay.tsx +++ b/apps/mobile/src/components/PreferredCurrencyDisplay/PreferredCurrencyDisplay.tsx @@ -21,15 +21,26 @@ export type PreferredCurrencyDisplayProps = { sourceAmount: Decimal | null | undefined sourceAssetId: string forceFallback?: boolean + usdPrice?: Decimal } & Omit export const PreferredCurrencyDisplay = ( props: PreferredCurrencyDisplayProps, ) => { - const { sourceAmount, sourceAssetId, forceFallback, ...displayProps } = - props + const { + sourceAmount, + sourceAssetId, + forceFallback, + usdPrice, + ...displayProps + } = props const { displayCurrency, convertedValue, isPending } = - usePreferredCurrencyDisplay(sourceAmount, sourceAssetId, forceFallback) + usePreferredCurrencyDisplay( + sourceAmount, + sourceAssetId, + forceFallback, + usdPrice, + ) return ( { expect(result.current.isPending).toBe(true) expect(result.current.convertedValue).toBeNull() }) + + it('uses pre-fetched usdPrice and skips asset price query', () => { + mockUseCurrency.mockReturnValue({ + preferredCurrency: 'EUR', + fallbackCurrency: 'USD', + usdToPreferred: (v: Decimal) => v.mul(Decimal('0.85')), + }) + + // Return empty map — should not matter since preFetchedUsdPrice is provided + mockUseAssetPricesQuery.mockReturnValue({ + data: new Map(), + isPending: false, + }) + + const { result } = renderHook(() => + usePreferredCurrencyDisplay( + Decimal(10), + '123', + false, + Decimal('1.50'), + ), + ) + + expect(result.current.displayCurrency).toBe('EUR') + // 10 * 1.50 = 15 USD, usdToPreferred(15) = 15 * 0.85 = 12.75 + expect(result.current.convertedValue).toEqual(Decimal(12.75)) + expect(result.current.isPending).toBe(false) + + // Verify it was called with empty array (no per-item fetch) + expect(mockUseAssetPricesQuery).toHaveBeenCalledWith([]) + }) }) diff --git a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts index 924504d87..330d33307 100644 --- a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts +++ b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts @@ -32,6 +32,7 @@ export const usePreferredCurrencyDisplay = ( sourceAmount: Decimal | null | undefined, sourceAssetId: string, forceFallback?: boolean, + preFetchedUsdPrice?: Decimal, ): UsePreferredCurrencyDisplayResult => { const { preferredCurrency, fallbackCurrency, usdToPreferred } = useCurrency() @@ -45,8 +46,11 @@ export const usePreferredCurrencyDisplay = ( return preferredCurrency }, [needsFallback, fallbackCurrency, preferredCurrency]) - // Asset prices in USD - const priceIDs = useMemo(() => [sourceAssetId], [sourceAssetId]) + // Skip per-item price fetch when a pre-fetched price is provided (bulk query optimization) + const priceIDs = useMemo( + () => (preFetchedUsdPrice != null ? [] : [sourceAssetId]), + [preFetchedUsdPrice, sourceAssetId], + ) const { data: usdPrices, isPending: usdPricesPending } = useAssetPricesQuery(priceIDs) @@ -55,19 +59,29 @@ export const usePreferredCurrencyDisplay = ( usePreferredCurrencyPriceQuery(fallbackCurrency, needsFallback) const isPending = useMemo(() => { + if (preFetchedUsdPrice != null) { + return needsFallback ? fallbackRatePending : false + } if (needsFallback) { return usdPricesPending || fallbackRatePending } return usdPricesPending - }, [needsFallback, fallbackRatePending, usdPricesPending]) + }, [ + preFetchedUsdPrice, + needsFallback, + fallbackRatePending, + usdPricesPending, + ]) const convertedValue = useMemo(() => { if (sourceAmount == null) return null if (isPending) return null - const assetPrice = usdPrices?.get(sourceAssetId) - const usdPrice = assetPrice?.usdPrice ?? Decimal(0) - const usdValue = sourceAmount.mul(usdPrice) + const resolvedUsdPrice = + preFetchedUsdPrice ?? + usdPrices?.get(sourceAssetId)?.usdPrice ?? + Decimal(0) + const usdValue = sourceAmount.mul(resolvedUsdPrice) if (!needsFallback) { return usdToPreferred(usdValue) @@ -79,6 +93,7 @@ export const usePreferredCurrencyDisplay = ( sourceAmount, isPending, needsFallback, + preFetchedUsdPrice, usdPrices, sourceAssetId, usdToPreferred, diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx b/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx index 0921692ed..6ab9be271 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx @@ -85,6 +85,9 @@ export const AccountAssetList = ({ @@ -109,6 +112,8 @@ export const AccountAssetList = ({ renderItem={renderItem} scrollEnabled={scrollEnabled} keyExtractor={item => item.assetId} + estimatedItemSize={72} + recycleItems automaticallyAdjustKeyboardInsets keyboardDismissMode='interactive' contentContainerStyle={styles.rootContainer} @@ -171,7 +176,7 @@ export const AccountAssetList = ({ ) : ( diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/SwipeableAssetItem/SwipeableAssetItem.tsx b/apps/mobile/src/modules/accounts/components/AccountAssetList/SwipeableAssetItem/SwipeableAssetItem.tsx index d5af9faf4..4212409af 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/SwipeableAssetItem/SwipeableAssetItem.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/SwipeableAssetItem/SwipeableAssetItem.tsx @@ -21,11 +21,13 @@ import { } from '@components/core' import { AccountAssetItemView } from '@modules/assets/components/AssetItem/AccountAssetItemView' import { AssetWithAccountBalance } from '@perawallet/wallet-core-accounts' +import { Decimal } from 'decimal.js' import { useStyles } from './styles' export type SwipeableAssetItemProps = { item: AssetWithAccountBalance isSwipeEnabled: boolean + usdPrice?: Decimal onPress: (item: AssetWithAccountBalance) => void onOptOut: (item: AssetWithAccountBalance) => void } @@ -33,6 +35,7 @@ export type SwipeableAssetItemProps = { const SwipeableAssetItemInner = ({ item, isSwipeEnabled, + usdPrice, onPress, onOptOut, }: SwipeableAssetItemProps) => { @@ -69,7 +72,10 @@ const SwipeableAssetItemInner = ({ style={styles.itemContainer} onPress={handlePress} > - + ) } @@ -86,7 +92,10 @@ const SwipeableAssetItemInner = ({ style={styles.itemContainer} onPress={handlePress} > - + diff --git a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts index 187a43473..2026037ef 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/useAccountAssetList.ts @@ -19,7 +19,11 @@ import { WalletAccount, AssetWithAccountBalance, } from '@perawallet/wallet-core-accounts' -import { useAssetsQuery } from '@perawallet/wallet-core-assets' +import { + useAssetsQuery, + useAssetPricesQuery, + type AssetPrices, +} from '@perawallet/wallet-core-assets' import { useModalState, ModalState } from '@hooks/useModalState' import { useDebouncedValue } from '@hooks/useDebouncedValue' import { useSortedAssetBalances } from './useSortedAssetBalances' @@ -48,6 +52,7 @@ type UseAccountAssetListResult = { getEmptyBody: () => string renderItemProps: { isWatch: boolean + assetPrices: AssetPrices goToAssetScreen: (asset: AssetWithAccountBalance) => void handleOptOut: (item: AssetWithAccountBalance) => void } @@ -80,6 +85,7 @@ export const useAccountAssetList = ({ [balanceData], ) const { data: assets } = useAssetsQuery(assetIDs) + const { data: assetPrices } = useAssetPricesQuery(assetIDs) const navigation = useNavigation>() const { sortedBalances, hideZeroBalance } = useSortedAssetBalances( @@ -177,10 +183,11 @@ export const useAccountAssetList = ({ const renderItemProps = useMemo( () => ({ isWatch, + assetPrices, goToAssetScreen, handleOptOut, }), - [isWatch, goToAssetScreen, handleOptOut], + [isWatch, assetPrices, goToAssetScreen, handleOptOut], ) return { diff --git a/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx b/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx index 8679d2227..f3d14773b 100644 --- a/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx +++ b/apps/mobile/src/modules/assets/components/AssetItem/AccountAssetItemView.tsx @@ -10,12 +10,14 @@ limitations under the License */ +import { Decimal } from 'decimal.js' import { AssetIcon } from '../AssetIcon' import { CurrencyDisplay } from '@components/CurrencyDisplay' import { PreferredCurrencyDisplay } from '@components/PreferredCurrencyDisplay' import { PWIcon, PWIconSize, + PWSkeleton, PWText, PWView, PWViewProps, @@ -27,21 +29,31 @@ import { useMemo } from 'react' export type AccountAssetItemViewProps = { accountBalance: AssetWithAccountBalance + usdPrice?: Decimal iconSize?: PWIconSize } & PWViewProps export const AccountAssetItemView = ({ accountBalance, + usdPrice, iconSize, ...rest }: AccountAssetItemViewProps) => { const styles = useStyles() - const { data: assets } = useAssetsQuery([accountBalance.assetId]) + // Use pre-fetched asset data when available to avoid N+1 queries. + // Falls back to individual fetch for callers that don't populate accountBalance.asset. + const assetIds = useMemo( + () => (accountBalance.asset ? [] : [accountBalance.assetId]), + [accountBalance.asset, accountBalance.assetId], + ) + const { data: fetchedAssets } = useAssetsQuery(assetIds) const asset = useMemo(() => { - return assets?.get(accountBalance.assetId) - }, [assets, accountBalance.assetId]) + return ( + accountBalance.asset ?? fetchedAssets?.get(accountBalance.assetId) + ) + }, [accountBalance.asset, fetchedAssets, accountBalance.assetId]) const isAlgo = useMemo( () => asset?.assetId === ALGO_ASSET_ID, @@ -77,7 +89,18 @@ export const AccountAssetItemView = ({ }, [asset, accountBalance.assetId]) if (!asset?.unitName) { - return <> + return ( + + + + + + + ) } return ( @@ -121,6 +144,7 @@ export const AccountAssetItemView = ({ ({ })) describe('AccountAssetItemView', () => { - it('renders asset info for Algo', () => { + it('renders asset info for Algo using pre-fetched asset data', () => { const accountBalance = { assetId: 0, + asset: { + assetId: 0, + unitName: 'ALGO', + name: 'Algorand', + decimals: 6, + }, amount: '1000000', } as unknown as AssetWithAccountBalance @@ -65,7 +71,25 @@ describe('AccountAssetItemView', () => { expect(screen.getByText('ALGO')).toBeTruthy() }) - it('renders asset units and IDs for non-algo assets', async () => { + it('renders asset units and IDs for non-algo assets using pre-fetched data', () => { + const accountBalance = { + assetId: 123, + asset: { + assetId: 123, + unitName: 'TEST', + name: 'Test Asset', + decimals: 2, + }, + amount: '500', + } as unknown as AssetWithAccountBalance + + render() + + expect(screen.getByText('Test Asset')).toBeTruthy() + expect(screen.getByText('TEST - 123')).toBeTruthy() + }) + + it('falls back to useAssetsQuery when asset is not pre-fetched', async () => { const { useAssetsQuery } = await import('@perawallet/wallet-core-assets') vi.mocked(useAssetsQuery).mockReturnValue({ @@ -93,4 +117,27 @@ describe('AccountAssetItemView', () => { expect(screen.getByText('Test Asset')).toBeTruthy() expect(screen.getByText('TEST - 123')).toBeTruthy() }) + + it('renders skeleton placeholder when asset data is not available', async () => { + const { useAssetsQuery } = + await import('@perawallet/wallet-core-assets') + vi.mocked(useAssetsQuery).mockReturnValue({ + data: new Map(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any) + + const accountBalance = { + assetId: 999, + amount: '0', + } as unknown as AssetWithAccountBalance + + const { container } = render( + , + ) + + // Should not render asset name since data isn't available + expect(screen.queryByText('Algo')).toBeNull() + // Should render something (the skeleton placeholder) + expect(container).toBeTruthy() + }) }) From 49f90c1c9963bf898264ca6740de96ac4b7b2415 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:32:04 +0000 Subject: [PATCH 2/6] fix: fixing second line rendering --- apps/mobile/src/modules/assets/components/AssetItem/styles.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/mobile/src/modules/assets/components/AssetItem/styles.ts b/apps/mobile/src/modules/assets/components/AssetItem/styles.ts index bcf4e50d7..ef34ecaed 100644 --- a/apps/mobile/src/modules/assets/components/AssetItem/styles.ts +++ b/apps/mobile/src/modules/assets/components/AssetItem/styles.ts @@ -38,7 +38,6 @@ export const useStyles = makeStyles(theme => { }, secondaryUnit: { color: theme.colors.textGrayLighter, - lineHeight: theme.spacing.md, }, primaryAmount: { textAlign: 'right', @@ -47,7 +46,6 @@ export const useStyles = makeStyles(theme => { textAlign: 'right', color: theme.colors.textGray, alignSelf: 'flex-end', - lineHeight: theme.spacing.md, }, row: { flexDirection: 'row', From d139e80ea79c860e807d4ea825dbf954d4b8cdf3 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 23 Mar 2026 11:35:04 +0000 Subject: [PATCH 3/6] Apply suggestion from @fmsouza --- .../PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts index 330d33307..1958cafb8 100644 --- a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts +++ b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts @@ -59,7 +59,7 @@ export const usePreferredCurrencyDisplay = ( usePreferredCurrencyPriceQuery(fallbackCurrency, needsFallback) const isPending = useMemo(() => { - if (preFetchedUsdPrice != null) { + if (preFetchedUsdPrice !== null) { return needsFallback ? fallbackRatePending : false } if (needsFallback) { From 471cf83991a27d30075cb05523a300275ae1c333 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:00:04 +0000 Subject: [PATCH 4/6] Update apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts --- .../PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts index 1958cafb8..10e08e466 100644 --- a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts +++ b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts @@ -48,7 +48,7 @@ export const usePreferredCurrencyDisplay = ( // Skip per-item price fetch when a pre-fetched price is provided (bulk query optimization) const priceIDs = useMemo( - () => (preFetchedUsdPrice != null ? [] : [sourceAssetId]), + () => (preFetchedUsdPrice !== null ? [] : [sourceAssetId]), [preFetchedUsdPrice, sourceAssetId], ) const { data: usdPrices, isPending: usdPricesPending } = From 9a5b109f0022823bf9a2a87c3a93f618f72b71b0 Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 23 Mar 2026 12:00:38 +0000 Subject: [PATCH 5/6] Update apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Yasin Çalışkan --- .../PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts index 10e08e466..e0d85f9b7 100644 --- a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts +++ b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts @@ -74,7 +74,7 @@ export const usePreferredCurrencyDisplay = ( ]) const convertedValue = useMemo(() => { - if (sourceAmount == null) return null + if (!sourceAmount) return null if (isPending) return null const resolvedUsdPrice = From f413adda296145d2115ee05fd7e17a8db1ec905c Mon Sep 17 00:00:00 2001 From: Fred Souza <1396615+fmsouza@users.noreply.github.com> Date: Mon, 23 Mar 2026 13:18:05 +0000 Subject: [PATCH 6/6] chore: fixing failing tests --- .../PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts index e0d85f9b7..81a591324 100644 --- a/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts +++ b/apps/mobile/src/components/PreferredCurrencyDisplay/usePreferredCurrencyDisplay.ts @@ -48,7 +48,7 @@ export const usePreferredCurrencyDisplay = ( // Skip per-item price fetch when a pre-fetched price is provided (bulk query optimization) const priceIDs = useMemo( - () => (preFetchedUsdPrice !== null ? [] : [sourceAssetId]), + () => (preFetchedUsdPrice !== undefined ? [] : [sourceAssetId]), [preFetchedUsdPrice, sourceAssetId], ) const { data: usdPrices, isPending: usdPricesPending } = @@ -59,7 +59,7 @@ export const usePreferredCurrencyDisplay = ( usePreferredCurrencyPriceQuery(fallbackCurrency, needsFallback) const isPending = useMemo(() => { - if (preFetchedUsdPrice !== null) { + if (preFetchedUsdPrice !== undefined) { return needsFallback ? fallbackRatePending : false } if (needsFallback) {