Skip to content

Commit

Permalink
fix(app): excessive TokenIcon re-rendering
Browse files Browse the repository at this point in the history
This is due to the whole token being passed as a prop (causing a re-render) not just the required fragment fields. This should hopefully be resolved with urql-graphql/urql#1408
  • Loading branch information
hbriese committed Feb 10, 2024
1 parent d86b4f2 commit 81548fb
Show file tree
Hide file tree
Showing 5 changed files with 84 additions and 23 deletions.
2 changes: 1 addition & 1 deletion app/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ module.exports = {
'react-hooks/exhaustive-deps': [
'error',
{
additionalHooks: '(useMyCustomHook|useMyOtherCustomHook)',
additionalHooks: '(useDeepMemo|useMyOtherCustomHook)',
},
],
'react-native/split-platform-components': 'error',
Expand Down
26 changes: 13 additions & 13 deletions app/src/app/(drawer)/[account]/(home)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 (
<FlashList
Expand All @@ -57,7 +58,6 @@ function TokensTab() {
}
contentContainerStyle={styles.contentContainer}
estimatedItemSize={ListItemHeight.DOUBLE_LINE}
getItemType={(item) => item.__typename}
keyExtractor={(item) => item.id}
showsVerticalScrollIndicator={false}
/>
Expand Down
39 changes: 32 additions & 7 deletions app/src/components/token/TokenIcon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {

Check warning on line 40 in app/src/components/token/TokenIcon.tsx

View workflow job for this annotation

GitHub Actions / app-tests

Unexpected any. Specify a different type

Check warning on line 40 in app/src/components/token/TokenIcon.tsx

View workflow job for this annotation

GitHub Actions / app-tests

Unexpected any. Specify a different type

Check warning on line 40 in app/src/components/token/TokenIcon.tsx

View workflow job for this annotation

GitHub Actions / app-tests

Unexpected any. Specify a different type

Check warning on line 40 in app/src/components/token/TokenIcon.tsx

View workflow job for this annotation

GitHub Actions / app-tests

Unexpected any. Specify a different type
return _.pick(token, ['id', 'iconUri']);
}

export interface TokenIconProps extends Omit<ImageProps, 'source' | 'style'> {
token: FragmentType<typeof Token> | UAddress | null | undefined;
fallbackUri?: string;
Expand All @@ -37,7 +49,7 @@ export interface TokenIconProps extends Omit<ImageProps, 'source' | 'style'> {
}

function TokenIcon_({
token: tokenFragment,
token: fragOrAddr,
fallbackUri,
size,
style,
Expand All @@ -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 <UnknownTokenIcon {...imageProps} size={size} style={[style, styles.icon(size)]} />;
Expand All @@ -62,7 +73,8 @@ function TokenIcon_({
<Image
{...imageProps}
source={{ uri: iconUri }}
style={[style, styles.icon(size)].filter(Boolean)}
style={[style, styles.icon(size)]}
cachePolicy="memory-disk"
/>
);
}
Expand All @@ -75,4 +87,17 @@ const stylesheet = createStyles(({ iconSize }) => ({
}),
}));

export const TokenIcon = withSuspense(TokenIcon_, ({ size }) => <CircleSkeleton size={size} />);
// export const TokenIcon = TokenIcon_;
export const TokenIcon = withSuspense(
memo(TokenIcon_, (prev, next) => deepEqual(normalizeProps(prev), normalizeProps(next))),
({ size }) => <CircleSkeleton size={size} />,
);

function normalizeProps(props: any) {
if (typeof props.token !== 'object') return props;

return {
...props,
token: trimTokenIconTokenProp(props.token),
};
}
11 changes: 9 additions & 2 deletions app/src/components/token/TokenItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -51,9 +52,15 @@ const TokenItem_ = memo(
</Text>
);

const t = trimTokenIconTokenProp(token); // Avoid re-rendering any time *any* field of the token changes
const Leading = useDeepCallback(
(props: ListIconElementProps) => <TokenIcon token={t} {...props} />,
[t],
);

return (
<ListItem
leading={(props) => <TokenIcon token={token} {...props} />}
leading={Leading}
leadingSize="medium"
headline={token.name}
supporting={({ Text }) => (
Expand Down
29 changes: 29 additions & 0 deletions app/src/hooks/useDeepMemo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCallback, useMemo, useRef } from 'react';
import deepEqual from 'fast-deep-equal';

export function useDeepMemo<T>(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<T extends Function>(
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<React.DependencyList>(dependencies);
const signalRef = useRef<number>(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]);
}

0 comments on commit 81548fb

Please sign in to comment.