From 77f44af16fb98cf734619256f20434b45bee60cd Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 13 Nov 2025 19:22:45 +0000 Subject: [PATCH] feat(tokenselector): add useDeferredVisibility hook for optimized rendering --- .../tokensList/hooks/useDeferredVisibility.ts | 68 ++++++++++++++++ .../tokensList/pure/TokenInfo/index.tsx | 5 +- .../tokensList/pure/TokenListItem/index.tsx | 78 ++++++++++++++----- 3 files changed, 131 insertions(+), 20 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts new file mode 100644 index 0000000000..34e330b60f --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useDeferredVisibility.ts @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react' + +interface DeferredVisibilityOptions { + /** + * Expands the observed viewport so we hydrate content slightly before it + * scrolls into view. + */ + rootMargin?: string + /** + * When this key changes we reset the visibility state. Helpful when the same + * virtualized row instance renders different data. + */ + resetKey?: string | number +} + +interface DeferredVisibilityResult { + ref: (element: T | null) => void + isVisible: boolean +} + +const DEFAULT_ROOT_MARGIN = '120px' + +// Lightweight helper to delay hydration of expensive UI until the row is close to the viewport. +export function useDeferredVisibility( + options: DeferredVisibilityOptions = {}, +): DeferredVisibilityResult { + const { rootMargin = DEFAULT_ROOT_MARGIN, resetKey } = options + const [element, setElement] = useState(null) + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (resetKey === undefined) { + return + } + + setIsVisible(false) + }, [resetKey]) + + useEffect(() => { + if (isVisible || !element) { + return undefined + } + + if (typeof IntersectionObserver === 'undefined') { + setIsVisible(true) + return undefined + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) { + setIsVisible(true) + } + }, + { rootMargin }, + ) + + observer.observe(element) + + return () => observer.disconnect() + }, [element, isVisible, rootMargin]) + + const ref = useCallback((node: T | null) => { + setElement(node) + }, []) + + return { ref, isVisible } +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx index 95d0f4ad77..8129de48de 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenInfo/index.tsx @@ -12,10 +12,11 @@ export interface TokenInfoProps { token: TokenWithLogo className?: string tags?: ReactNode + showAddress?: boolean } export function TokenInfo(props: TokenInfoProps): ReactNode { - const { token, className, tags } = props + const { token, className, tags, showAddress = true } = props return ( @@ -23,7 +24,7 @@ export function TokenInfo(props: TokenInfoProps): ReactNode { - + {showAddress ? : null} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx index 5594741567..9e141417f3 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokenListItem/index.tsx @@ -12,6 +12,7 @@ import { Nullish } from 'types' import * as styledEl from './styled' +import { useDeferredVisibility } from '../../hooks/useDeferredVisibility' import { TokenInfo } from '../TokenInfo' import { TokenTags } from '../TokenTags' @@ -58,6 +59,13 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { className, } = props + const tokenKey = `${token.chainId}:${token.address.toLowerCase()}` + // Defer heavyweight UI (tooltips, formatted numbers) until the row is about to enter the viewport. + const { ref: visibilityRef, isVisible: hasIntersected } = useDeferredVisibility({ + resetKey: tokenKey, + rootMargin: '200px', + }) + const handleClick: MouseEventHandler = (e) => { if (isTokenSelected) { e.preventDefault() @@ -74,11 +82,15 @@ export function TokenListItem(props: TokenListItemProps): ReactNode { ) const isSupportedChain = token.chainId in SupportedChainId - - const balanceAmount = balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined + const shouldShowBalances = isWalletConnected && isSupportedChain + // Formatting balances (BigNumber -> CurrencyAmount -> Fiat) is expensive; delay until the row is visible. + const shouldFormatBalances = shouldShowBalances && hasIntersected + const balanceAmount = + shouldFormatBalances && balance ? CurrencyAmount.fromRawAmount(token, balance.toHexString()) : undefined return ( + hasIntersected ? ( + + ) : null } /> - {isWalletConnected && ( - - {isSupportedChain ? ( - <> - {balanceAmount ? : LoadingElement} - {usdAmount ? : null} - - ) : null} - - )} + {children} ) } + +interface TokenBalanceColumnProps { + shouldShow: boolean + shouldFormat: boolean + balanceAmount?: CurrencyAmount + usdAmount?: CurrencyAmount | null +} + +function TokenBalanceColumn({ + shouldShow, + shouldFormat, + balanceAmount, + usdAmount, +}: TokenBalanceColumnProps): ReactNode { + if (!shouldShow) { + return null + } + + return ( + + {shouldFormat ? ( + <> + {balanceAmount ? : LoadingElement} + {usdAmount ? : null} + + ) : ( + LoadingElement + )} + + ) +}