diff --git a/app/.eslintrc.js b/app/.eslintrc.js index e003b17da..eb0a67537 100644 --- a/app/.eslintrc.js +++ b/app/.eslintrc.js @@ -18,7 +18,7 @@ module.exports = { 'react-hooks/exhaustive-deps': [ 'error', { - additionalHooks: '(useMyCustomHook|useMyOtherCustomHook)', + additionalHooks: '(useDeepMemo|useMyOtherCustomHook)', }, ], 'react-native/split-platform-components': 'error', diff --git a/app/src/app/(drawer)/[account]/(home)/index.tsx b/app/src/app/(drawer)/[account]/(home)/index.tsx index 2a11237e7..8040fc56a 100644 --- a/app/src/app/(drawer)/[account]/(home)/index.tsx +++ b/app/src/app/(drawer)/[account]/(home)/index.tsx @@ -6,6 +6,7 @@ import { withSuspense } from '#/skeleton/withSuspense'; import { TabScreenSkeleton } from '#/tab/TabScreenSkeleton'; import { StyleSheet } from 'react-native'; import { asChain } from 'lib'; +import { useMemo } from 'react'; import { gql } from '@api/generated'; import { useQuery, usePollQuery } from '~/gql'; import { AccountParams } from '~/app/(drawer)/[account]/(home)/_layout'; @@ -31,19 +32,19 @@ const TokensTabParams = AccountParams; function TokensTab() { const { account } = useLocalParams(TokensTabParams); - const { data, reexecute } = useQuery( - Query, - { account, chain: asChain(account) }, - { requestPolicy: 'cache-and-network' }, - ); - usePollQuery(reexecute, 15000); + const { data, reexecute } = useQuery(Query, { account, chain: asChain(account) }); + usePollQuery(reexecute, 30000); - const tokens = (data.tokens ?? []) - .map((t) => ({ - ...t, - value: new Decimal(t.balance).mul(new Decimal(t.price?.usd ?? 0)), - })) - .sort((a, b) => b.value.comparedTo(a.value)); + const tokens = useMemo( + () => + (data.tokens ?? []) + .map((t) => ({ + ...t, + value: new Decimal(t.balance).mul(new Decimal(t.price?.usd ?? 0)), + })) + .sort((a, b) => b.value.comparedTo(a.value)), + [data.tokens], + ); return ( item.__typename} keyExtractor={(item) => item.id} showsVerticalScrollIndicator={false} /> diff --git a/app/src/components/token/TokenIcon.tsx b/app/src/components/token/TokenIcon.tsx index 2a0044040..f9c6b3d97 100644 --- a/app/src/components/token/TokenIcon.tsx +++ b/app/src/components/token/TokenIcon.tsx @@ -7,6 +7,9 @@ import { ImageStyle, StyleProp } from 'react-native'; import { CircleSkeleton } from '#/skeleton/CircleSkeleton'; import { withSuspense } from '#/skeleton/withSuspense'; import { useQuery } from '~/gql'; +import { memo } from 'react'; +import deepEqual from 'fast-deep-equal'; +import _ from 'lodash'; export const ETH_ICON_URI = 'https://cloudfront-us-east-1.images.arcpublishing.com/coindesk/ZJZZK5B2ZNF25LYQHMUTBTOMLU.png'; @@ -29,6 +32,15 @@ const Token = gql(/* GraphQL */ ` } `); +/** + * @summary Trims the token to only the fragment fields to avoid unnecessary re-renders\\n + * @see https://github.com/urql-graphql/urql/issues/1408 + * @returns Token fields required by TokenIcon + */ +export function trimTokenIconTokenProp(token: any): any { + return _.pick(token, ['id', 'iconUri']); +} + export interface TokenIconProps extends Omit { token: FragmentType | UAddress | null | undefined; fallbackUri?: string; @@ -37,7 +49,7 @@ export interface TokenIconProps extends Omit { } function TokenIcon_({ - token: tokenFragment, + token: fragOrAddr, fallbackUri, size, style, @@ -47,13 +59,12 @@ function TokenIcon_({ const query = useQuery( Query, - { token: isUAddress(tokenFragment) ? tokenFragment : 'zksync:0x' }, - { pause: !isUAddress(tokenFragment) }, + { token: isUAddress(fragOrAddr) ? fragOrAddr : 'zksync:0x' }, + { pause: !isUAddress(fragOrAddr) }, ).data; const iconUri = - getFragment(Token, !isUAddress(tokenFragment) ? tokenFragment : query?.token)?.iconUri ?? - fallbackUri; + getFragment(Token, !isUAddress(fragOrAddr) ? fragOrAddr : query?.token)?.iconUri ?? fallbackUri; if (!iconUri) return ; @@ -62,7 +73,8 @@ function TokenIcon_({ ); } @@ -75,4 +87,17 @@ const stylesheet = createStyles(({ iconSize }) => ({ }), })); -export const TokenIcon = withSuspense(TokenIcon_, ({ size }) => ); +// export const TokenIcon = TokenIcon_; +export const TokenIcon = withSuspense( + memo(TokenIcon_, (prev, next) => deepEqual(normalizeProps(prev), normalizeProps(next))), + ({ size }) => , +); + +function normalizeProps(props: any) { + if (typeof props.token !== 'object') return props; + + return { + ...props, + token: trimTokenIconTokenProp(props.token), + }; +} diff --git a/app/src/components/token/TokenItem.tsx b/app/src/components/token/TokenItem.tsx index cac94c099..7a36e636b 100644 --- a/app/src/components/token/TokenItem.tsx +++ b/app/src/components/token/TokenItem.tsx @@ -6,10 +6,11 @@ import { withSuspense } from '../skeleton/withSuspense'; import { TokenAmount } from './TokenAmount'; import { Decimallike } from 'lib'; import { FragmentType, gql, useFragment } from '@api/generated'; -import { TokenIcon } from './TokenIcon'; +import { TokenIcon, trimTokenIconTokenProp } from './TokenIcon'; import { FC, memo } from 'react'; import deepEqual from 'fast-deep-equal'; import Decimal from 'decimal.js'; +import { useDeepCallback } from '~/hooks/useDeepMemo'; const Token = gql(/* GraphQL */ ` fragment TokenItem_Token on Token { @@ -51,9 +52,15 @@ const TokenItem_ = memo( ); + const t = trimTokenIconTokenProp(token); // Avoid re-rendering any time *any* field of the token changes + const Leading = useDeepCallback( + (props: ListIconElementProps) => , + [t], + ); + return ( } + leading={Leading} leadingSize="medium" headline={token.name} supporting={({ Text }) => ( diff --git a/app/src/hooks/useDeepMemo.ts b/app/src/hooks/useDeepMemo.ts new file mode 100644 index 000000000..38c65067a --- /dev/null +++ b/app/src/hooks/useDeepMemo.ts @@ -0,0 +1,29 @@ +import { useCallback, useMemo, useRef } from 'react'; +import deepEqual from 'fast-deep-equal'; + +export function useDeepMemo(factory: () => T, dependencies: React.DependencyList) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(factory, useDeepCompareMemoize(dependencies)); +} + +// eslint-disable-next-line @typescript-eslint/ban-types -- useCallback expects Function +export function useDeepCallback( + callback: T, + dependencies: React.DependencyList, +) { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useCallback(callback, useDeepCompareMemoize(dependencies)); +} + +function useDeepCompareMemoize(dependencies: React.DependencyList) { + const dependenciesRef = useRef(dependencies); + const signalRef = useRef(0); + + if (!deepEqual(dependencies, dependenciesRef.current)) { + dependenciesRef.current = dependencies; + signalRef.current += 1; + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => dependenciesRef.current, [signalRef.current]); +}