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..81a591324 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 !== undefined ? [] : [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 !== undefined) { + 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 (!sourceAmount) 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 a8f9828fb..aa24617a8 100644 --- a/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx +++ b/apps/mobile/src/modules/accounts/components/AccountAssetList/AccountAssetList.tsx @@ -92,6 +92,9 @@ export const AccountAssetList = ({ @@ -117,6 +120,8 @@ export const AccountAssetList = ({ renderItem={renderItem} scrollEnabled={scrollEnabled} keyExtractor={item => item.assetId} + estimatedItemSize={72} + recycleItems automaticallyAdjustKeyboardInsets keyboardDismissMode='interactive' contentContainerStyle={styles.rootContainer} @@ -179,7 +184,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 922f84ce3..0180f95e6 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( @@ -181,10 +187,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() + }) }) 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',