From 169c56fc6346976461111283ed987c7311fbebe4 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 11 Dec 2025 08:29:04 +0000 Subject: [PATCH 1/4] refactor(tokensList): replace props drilling with Jotai atom - Move token list view state to `tokenListViewAtom` using Jotai - Remove prop drilling through `SelectTokenModal` -> `TokensContent` -> `TokensVirtualList` - Extract `SelectTokenModalShell` to reduce LOC in `SelectTokenModal/index.tsx` - Implement `useHydrateAtoms` + `useLayoutEffect` for flicker-free state sync - Add `useResetTokenListViewState` for clean unmounts - Remove TODO from `types.ts` --- .../containers/TokenSearchResults/index.tsx | 17 +- .../hooks/useResetTokenListViewState.ts | 14 ++ .../tokensList/hooks/useTokenListViewState.ts | 7 + .../hooks/useUpdateTokenListViewState.ts | 10 ++ .../SelectTokenModalShell.tsx | 70 ++++++++ .../pure/SelectTokenModal/helpers.tsx | 35 +++- .../pure/SelectTokenModal/index.tsx | 167 ++++++------------ .../tokensList/pure/SelectTokenModal/types.ts | 1 - .../tokensList/pure/TokensContent/index.tsx | 100 +---------- .../pure/TokensVirtualList/index.tsx | 32 ++-- .../tokensList/state/tokenListViewAtom.ts | 63 +++++++ 11 files changed, 279 insertions(+), 237 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx create mode 100644 apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx index b43723b9f20..b3fb5817062 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/TokenSearchResults/index.tsx @@ -1,28 +1,17 @@ import { ReactNode, useCallback, useEffect, useMemo } from 'react' -import { TokenWithLogo } from '@cowprotocol/common-const' import { doesTokenMatchSymbolOrAddress } from '@cowprotocol/common-utils' import { getTokenSearchFilter, TokenSearchResponse, useSearchToken } from '@cowprotocol/tokens' import { useAddTokenImportCallback } from '../../hooks/useAddTokenImportCallback' +import { useTokenListViewState } from '../../hooks/useTokenListViewState' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' import { CommonListContainer } from '../../pure/commonElements' import { TokenSearchContent } from '../../pure/TokenSearchContent' -import { SelectTokenContext } from '../../types' -export interface TokenSearchResultsProps { - searchInput: string - selectTokenContext: SelectTokenContext - areTokensFromBridge: boolean - allTokens: TokenWithLogo[] -} +export function TokenSearchResults(): ReactNode { + const { searchInput, selectTokenContext, areTokensFromBridge, allTokens } = useTokenListViewState() -export function TokenSearchResults({ - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, -}: TokenSearchResultsProps): ReactNode { const { onSelectToken, onTokenListItemClick } = selectTokenContext // Do not make search when tokens are from bridge diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts new file mode 100644 index 00000000000..f65c1839c80 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useResetTokenListViewState.ts @@ -0,0 +1,14 @@ +import { useSetAtom } from 'jotai' +import { useCallback } from 'react' + +import { tokenListViewAtom, DEFAULT_TOKEN_LIST_VIEW_STATE } from '../state/tokenListViewAtom' + +type ResetTokenListViewState = () => void + +export function useResetTokenListViewState(): ResetTokenListViewState { + const setTokenListView = useSetAtom(tokenListViewAtom) + + return useCallback((): void => { + setTokenListView(DEFAULT_TOKEN_LIST_VIEW_STATE) // Full replacement, not partial merge + }, [setTokenListView]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts new file mode 100644 index 00000000000..f27f075a93e --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useTokenListViewState.ts @@ -0,0 +1,7 @@ +import { useAtomValue } from 'jotai' + +import { tokenListViewAtom, TokenListViewState } from '../state/tokenListViewAtom' + +export function useTokenListViewState(): TokenListViewState { + return useAtomValue(tokenListViewAtom) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts new file mode 100644 index 00000000000..2a90584119b --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useUpdateTokenListViewState.ts @@ -0,0 +1,10 @@ +import { useSetAtom } from 'jotai' +import { SetStateAction } from 'jotai/vanilla' + +import { updateTokenListViewAtom, TokenListViewState } from '../state/tokenListViewAtom' + +type UpdateTokenListViewState = (update: SetStateAction>) => void + +export function useUpdateTokenListViewState(): UpdateTokenListViewState { + return useSetAtom(updateTokenListViewAtom) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx new file mode 100644 index 00000000000..4892bbbe9b2 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/SelectTokenModalShell.tsx @@ -0,0 +1,70 @@ +import { ComponentProps, ReactNode } from 'react' + +import { SearchInput } from '@cowprotocol/ui' + +import { t } from '@lingui/core/macro' + +import { TitleBarActions } from './helpers' +import { MobileChainSelector } from './MobileChainSelector' +import * as styledEl from './styled' + +export interface SelectTokenModalShellProps { + children: ReactNode + hasChainPanel: boolean + isFullScreenMobile?: boolean + title: string + showManageButton: boolean + onDismiss(): void + onOpenManageWidget: () => void + searchValue: string + onSearchChange(value: string): void + onSearchEnter?: () => void + mobileChainSelector?: ComponentProps + sideContent?: ReactNode +} + +export function SelectTokenModalShell({ + children, + hasChainPanel, + isFullScreenMobile, + title, + showManageButton, + onDismiss, + onOpenManageWidget, + searchValue, + onSearchChange, + onSearchEnter, + mobileChainSelector, + sideContent, +}: SelectTokenModalShellProps): ReactNode { + return ( + + + + + { + if (event.key === 'Enter') { + onSearchEnter?.() + } + }} + onChange={(event) => onSearchChange(event.target.value)} + placeholder={t`Search name or paste address...`} + /> + + + {mobileChainSelector ? : null} + + {children} + {sideContent} + + + ) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx index cfa494aae59..5909e4e2c9c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useMemo, useState } from 'react' +import { ComponentProps, ReactNode, useMemo, useState } from 'react' import { BackButton } from '@cowprotocol/ui' @@ -6,6 +6,7 @@ import { t } from '@lingui/core/macro' import { SettingsIcon } from 'modules/trade/pure/Settings' +import { MobileChainSelector } from './MobileChainSelector' import * as styledEl from './styled' import { SelectTokenContext } from '../../types' @@ -54,6 +55,38 @@ export function useTokenSearchInput(defaultInputValue = ''): [string, (value: st return [inputValue, setInputValue, inputValue.trim()] } +export function getMobileChainSelectorConfig({ + showChainPanel, + mobileChainsState, + mobileChainsLabel, + onSelectChain, + onOpenMobileChainPanel, +}: { + showChainPanel: boolean + mobileChainsState: SelectTokenModalProps['mobileChainsState'] + mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] + onSelectChain?: SelectTokenModalProps['onSelectChain'] + onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel'] +}): ComponentProps | undefined { + const canRender = + !showChainPanel && + mobileChainsState && + onSelectChain && + onOpenMobileChainPanel && + (mobileChainsState.chains?.length ?? 0) > 0 + + if (!canRender) { + return undefined + } + + return { + chainsState: mobileChainsState, + label: mobileChainsLabel, + onSelectChain, + onOpenPanel: onOpenMobileChainPanel, + } +} + interface TitleBarActionsProps { showManageButton: boolean onDismiss(): void diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 6dd26daf27b..9d8f1df8d84 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,14 +1,15 @@ -import { ComponentProps, ReactNode, useMemo } from 'react' - -import { SearchInput } from '@cowprotocol/ui' +import { useHydrateAtoms } from 'jotai/utils' +import { ReactNode, useEffect, useLayoutEffect, useMemo } from 'react' import { t } from '@lingui/core/macro' -import { TitleBarActions, useSelectTokenContext, useTokenSearchInput } from './helpers' -import { MobileChainSelector } from './MobileChainSelector' -import * as styledEl from './styled' +import { getMobileChainSelectorConfig, useSelectTokenContext, useTokenSearchInput } from './helpers' +import { SelectTokenModalShell } from './SelectTokenModalShell' import { TokenColumnContent } from './TokenColumnContent' +import { useResetTokenListViewState } from '../../hooks/useResetTokenListViewState' +import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState' +import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom' import { ChainPanel } from '../ChainPanel' import { TokensContent } from '../TokensContent' @@ -64,6 +65,53 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { chainsPanelTitle, }) + // Compute the view state to hydrate the atom + const initialViewState: TokenListViewState = useMemo( + () => ({ + allTokens, + favoriteTokens, + recentTokens, + searchInput: trimmedInputValue, + areTokensLoading, + areTokensFromBridge, + hideFavoriteTokensTooltip: hideFavoriteTokensTooltip ?? false, + displayLpTokenLists: displayLpTokenLists ?? false, + selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + }), + [ + allTokens, + favoriteTokens, + recentTokens, + trimmedInputValue, + areTokensLoading, + areTokensFromBridge, + hideFavoriteTokensTooltip, + displayLpTokenLists, + selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + ], + ) + + // Hydrate atom SYNCHRONOUSLY on first render (no useEffect!) + useHydrateAtoms([[tokenListViewAtom, initialViewState]]) + + // Keep atom in sync when props change (after initial render) + // Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker + // Note: Always pass the full state object; partial updates may leave stale fields + const updateTokenListView = useUpdateTokenListViewState() + useLayoutEffect(() => { + updateTokenListView(initialViewState) + }, [initialViewState, updateTokenListView]) + + // Reset atom on unmount to avoid stale state on reopen (full replace, not partial merge) + const resetTokenListView = useResetTokenListViewState() + useEffect(() => { + return () => resetTokenListView() + }, [resetTokenListView]) + const mobileChainSelector = getMobileChainSelectorConfig({ showChainPanel, mobileChainsState, @@ -99,85 +147,12 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { chainsToSelect={chainsForTokenColumn} onSelectChain={onSelectChain} > - + ) } -interface SelectTokenModalShellProps { - children: ReactNode - hasChainPanel: boolean - isFullScreenMobile?: boolean - title: string - showManageButton: boolean - onDismiss(): void - onOpenManageWidget: () => void - searchValue: string - onSearchChange(value: string): void - onSearchEnter?: () => void - mobileChainSelector?: ComponentProps - sideContent?: ReactNode -} - -function SelectTokenModalShell({ - children, - hasChainPanel, - isFullScreenMobile, - title, - showManageButton, - onDismiss, - onOpenManageWidget, - searchValue, - onSearchChange, - onSearchEnter, - mobileChainSelector, - sideContent, -}: SelectTokenModalShellProps): ReactNode { - return ( - - - - - { - if (event.key === 'Enter') { - onSearchEnter?.() - } - }} - onChange={(event) => onSearchChange(event.target.value)} - placeholder={t`Search name or paste address...`} - /> - - - {mobileChainSelector ? : null} - - {children} - {sideContent} - - - ) -} - function useSelectTokenModalLayout(props: SelectTokenModalProps): { inputValue: string setInputValue: (value: string) => void @@ -223,35 +198,3 @@ function useSelectTokenModalLayout(props: SelectTokenModalProps): { resolvedModalTitle, } } - -function getMobileChainSelectorConfig({ - showChainPanel, - mobileChainsState, - mobileChainsLabel, - onSelectChain, - onOpenMobileChainPanel, -}: { - showChainPanel: boolean - mobileChainsState: SelectTokenModalProps['mobileChainsState'] - mobileChainsLabel: SelectTokenModalProps['mobileChainsLabel'] - onSelectChain?: SelectTokenModalProps['onSelectChain'] - onOpenMobileChainPanel?: SelectTokenModalProps['onOpenMobileChainPanel'] -}): ComponentProps | undefined { - const canRender = - !showChainPanel && - mobileChainsState && - onSelectChain && - onOpenMobileChainPanel && - (mobileChainsState.chains?.length ?? 0) > 0 - - if (!canRender) { - return undefined - } - - return { - chainsState: mobileChainsState, - label: mobileChainsLabel, - onSelectChain, - onOpenPanel: onOpenMobileChainPanel, - } -} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index 887d3b0696b..e2da53c4351 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -10,7 +10,6 @@ import { PermitCompatibleTokens } from 'modules/permit' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' -// TODO: Refactor to reduce prop count export interface TokenListContentProps { allTokens: TokenWithLogo[] favoriteTokens: TokenWithLogo[] diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx index 96b472d31aa..73c3520d902 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensContent/index.tsx @@ -1,41 +1,16 @@ -import React, { ReactNode, useMemo } from 'react' +import { ReactNode, useMemo } from 'react' -import { TokenWithLogo } from '@cowprotocol/common-const' import { Loader } from '@cowprotocol/ui' import { TokenSearchResults } from '../../containers/TokenSearchResults' -import { SelectTokenContext } from '../../types' +import { useTokenListViewState } from '../../hooks/useTokenListViewState' import { getTokenUniqueKey } from '../../utils/tokenKey' import * as styledEl from '../SelectTokenModal/styled' import { TokensVirtualList } from '../TokensVirtualList' -export interface TokensContentProps { - displayLpTokenLists?: boolean - selectTokenContext: SelectTokenContext - favoriteTokens: TokenWithLogo[] - recentTokens?: TokenWithLogo[] - areTokensLoading: boolean - allTokens: TokenWithLogo[] - searchInput: string - areTokensFromBridge: boolean - hideFavoriteTokensTooltip?: boolean - selectedTargetChainId?: number - onClearRecentTokens?: () => void -} +export function TokensContent(): ReactNode { + const { favoriteTokens, recentTokens, areTokensLoading, allTokens, searchInput } = useTokenListViewState() -export function TokensContent({ - selectTokenContext, - favoriteTokens, - recentTokens, - areTokensLoading, - allTokens, - displayLpTokenLists, - searchInput, - areTokensFromBridge, - hideFavoriteTokensTooltip, - selectedTargetChainId, - onClearRecentTokens, -}: TokensContentProps): ReactNode { const shouldShowFavoritesInline = !areTokensLoading && !searchInput && favoriteTokens.length > 0 const shouldShowRecentsInline = !areTokensLoading && !searchInput && (recentTokens?.length ?? 0) > 0 @@ -67,53 +42,6 @@ export function TokensContent({ const favoriteTokensInline = shouldShowFavoritesInline ? favoriteTokens : undefined const recentTokensInline = shouldShowRecentsInline ? recentTokens : undefined - return ( - - ) -} - -interface TokensViewProps { - areTokensLoading: boolean - searchInput: string - selectTokenContext: SelectTokenContext - areTokensFromBridge: boolean - allTokens: TokenWithLogo[] - tokensWithoutPinned: TokenWithLogo[] - displayLpTokenLists?: boolean - favoriteTokens?: TokenWithLogo[] - recentTokens?: TokenWithLogo[] - hideFavoriteTokensTooltip?: boolean - selectedTargetChainId?: number - onClearRecentTokens?: () => void -} - -function TokensView({ - areTokensLoading, - searchInput, - selectTokenContext, - areTokensFromBridge, - allTokens, - tokensWithoutPinned, - displayLpTokenLists, - favoriteTokens, - recentTokens, - hideFavoriteTokensTooltip, - selectedTargetChainId, - onClearRecentTokens, -}: TokensViewProps): ReactNode { if (areTokensLoading) { return ( @@ -123,26 +51,14 @@ function TokensView({ } if (searchInput) { - return ( - - ) + return } return ( ) } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx index b34320b9dd6..dbbd2937542 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/TokensVirtualList/index.tsx @@ -10,6 +10,7 @@ import { VirtualItem } from '@tanstack/react-virtual' import { CoWAmmBanner } from 'common/containers/CoWAmmBanner' import { VirtualList } from 'common/pure/VirtualList' +import { useTokenListViewState } from '../../hooks/useTokenListViewState' import { SelectTokenContext } from '../../types' import { tokensListSorter } from '../../utils/tokensListSorter' import { FavoriteTokensList } from '../FavoriteTokensList' @@ -17,14 +18,9 @@ import * as modalStyled from '../SelectTokenModal/styled' import { TokenListItemContainer } from '../TokenListItemContainer' export interface TokensVirtualListProps { - allTokens: TokenWithLogo[] - displayLpTokenLists?: boolean - selectTokenContext: SelectTokenContext + tokensToDisplay: TokenWithLogo[] // Pre-filtered by parent favoriteTokens?: TokenWithLogo[] recentTokens?: TokenWithLogo[] - hideFavoriteTokensTooltip?: boolean - scrollResetKey?: number - onClearRecentTokens?: () => void } type TokensVirtualRow = @@ -32,30 +28,32 @@ type TokensVirtualRow = | { type: 'title'; label: string; actionLabel?: string; onAction?: () => void } | { type: 'token'; token: TokenWithLogo } -export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { +export function TokensVirtualList({ + tokensToDisplay, + favoriteTokens, + recentTokens, +}: TokensVirtualListProps): ReactNode { const { - allTokens, selectTokenContext, displayLpTokenLists, - favoriteTokens, - recentTokens, hideFavoriteTokensTooltip, - scrollResetKey, + selectedTargetChainId, onClearRecentTokens, - } = props + } = useTokenListViewState() + const { values: balances } = selectTokenContext.balancesState const { isYieldEnabled } = useFeatureFlags() const sortedTokens = useMemo(() => { if (!balances) { - return allTokens + return tokensToDisplay } const prioritized: TokenWithLogo[] = [] const remainder: TokenWithLogo[] = [] - for (const token of allTokens) { + for (const token of tokensToDisplay) { const hasBalance = Boolean(balances[token.address.toLowerCase()]) if (hasBalance || getIsNativeToken(token)) { prioritized.push(token) @@ -67,7 +65,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { const sortedPrioritized = prioritized.length > 1 ? [...prioritized].sort(tokensListSorter(balances)) : prioritized return [...sortedPrioritized, ...remainder] - }, [allTokens, balances]) + }, [tokensToDisplay, balances]) const rows = useMemo(() => { const tokenRows = sortedTokens.map((token) => ({ type: 'token', token })) @@ -98,7 +96,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { return [...composedRows, ...tokenRows] }, [favoriteTokens, hideFavoriteTokensTooltip, onClearRecentTokens, recentTokens, sortedTokens]) - const virtualListKey = scrollResetKey ?? 'tokens-list' + const virtualListKey = selectedTargetChainId ?? 'tokens-list' const getItemView = useCallback( (virtualRows: TokensVirtualRow[], virtualItem: VirtualItem) => ( @@ -113,7 +111,7 @@ export function TokensVirtualList(props: TokensVirtualListProps): ReactNode { id="tokens-list" items={rows} getItemView={getItemView} - scrollResetKey={scrollResetKey} + scrollResetKey={selectedTargetChainId} > {displayLpTokenLists || !isYieldEnabled ? null : } diff --git a/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts new file mode 100644 index 00000000000..55926ed3631 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/state/tokenListViewAtom.ts @@ -0,0 +1,63 @@ +import { atom } from 'jotai' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { atomWithPartialUpdate } from '@cowprotocol/common-utils' + +import { SelectTokenContext } from '../types' + +export interface TokenListViewState { + // Token data + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + recentTokens: TokenWithLogo[] | undefined + + // UI state + searchInput: string + areTokensLoading: boolean + areTokensFromBridge: boolean + hideFavoriteTokensTooltip: boolean + displayLpTokenLists: boolean + selectedTargetChainId: number | undefined + + // Context - never null, use safe empty defaults + selectTokenContext: SelectTokenContext + + // Callbacks + onClearRecentTokens: (() => void) | undefined +} + +// Safe empty context that won't crash on access +// BalancesState requires: values, isLoading, chainId, fromCache +const EMPTY_SELECT_TOKEN_CONTEXT: SelectTokenContext = { + balancesState: { + values: {}, + isLoading: false, + chainId: null, + fromCache: false, + }, + selectedToken: undefined, + onSelectToken: () => {}, + onTokenListItemClick: undefined, + unsupportedTokens: {}, + permitCompatibleTokens: {}, + tokenListTags: {}, + isWalletConnected: false, +} + +export const DEFAULT_TOKEN_LIST_VIEW_STATE: TokenListViewState = { + allTokens: [], + favoriteTokens: [], + recentTokens: undefined, + searchInput: '', + areTokensLoading: true, // Default to loading to avoid flash of empty state + areTokensFromBridge: false, + hideFavoriteTokensTooltip: false, + displayLpTokenLists: false, + selectedTargetChainId: undefined, + selectTokenContext: EMPTY_SELECT_TOKEN_CONTEXT, + onClearRecentTokens: undefined, +} + +export const { atom: tokenListViewAtom, updateAtom: updateTokenListViewAtom } = atomWithPartialUpdate( + atom(DEFAULT_TOKEN_LIST_VIEW_STATE), +) From 01342a5491e00b23cacd962240ddfc2208aa9996 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:59:28 +0000 Subject: [PATCH 2/4] refactor(tokensList): move atom hydration to controller, slim modal props - Move atom hydration from SelectTokenModal to controllerAtomHydration hook - Split controllerState.ts into cohesive hooks (tokenData, tokenSelection, widgetUI) - Reduce SelectTokenModal props from 27 to ~17 (remove data props, keep UI callbacks) - Gate hydration by shouldRender to prevent unnecessary updates - Update Cosmos fixtures to use atom provider --- .../SelectTokenWidget/controller.ts | 19 +- .../controllerAtomHydration.ts | 112 ++++++ .../SelectTokenWidget/controllerModalProps.ts | 144 ++------ .../SelectTokenWidget/controllerProps.ts | 253 +++----------- .../SelectTokenWidget/controllerState.ts | 318 +----------------- .../SelectTokenWidget/controllerViewState.ts | 71 ++-- .../SelectTokenWidget/tokenDataHooks.ts | 128 +++++++ .../SelectTokenWidget/tokenSelectionHooks.ts | 143 ++++++++ .../SelectTokenWidget/widgetUIState.ts | 51 +++ .../hooks/useSelectTokenContextFromAtom.ts | 15 + .../pure/SelectTokenModal/helpers.tsx | 40 +-- .../pure/SelectTokenModal/index.cosmos.tsx | 172 ++++++---- .../pure/SelectTokenModal/index.tsx | 182 +++------- .../tokensList/pure/SelectTokenModal/types.ts | 65 ++-- 14 files changed, 801 insertions(+), 912 deletions(-) create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts create mode 100644 apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts index e064930038a..54236010236 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts @@ -1,3 +1,5 @@ +import { useEffect, useRef } from 'react' + import { useIsBridgingEnabled } from '@cowprotocol/common-hooks' import { useWalletInfo } from '@cowprotocol/wallet' @@ -18,6 +20,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect' import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget' import { useOnSelectChain } from '../../hooks/useOnSelectChain' import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError' +import { useResetTokenListViewState } from '../../hooks/useResetTokenListViewState' import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' @@ -75,8 +78,22 @@ export function useSelectTokenWidgetController({ isBridgeFeatureEnabled, }) + const shouldRender = Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)) + + // Reset atom when modal closes (shouldRender becomes false) + const resetTokenListView = useResetTokenListViewState() + const prevShouldRenderRef = useRef(shouldRender) + + useEffect(() => { + // Only reset when transitioning from true to false + if (prevShouldRenderRef.current && !shouldRender) { + resetTokenListView() + } + prevShouldRenderRef.current = shouldRender + }, [shouldRender, resetTokenListView]) + return { - shouldRender: Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)), + shouldRender, hasChainPanel: isChainPanelEnabled, viewProps, } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts new file mode 100644 index 00000000000..dd0123ce9c5 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts @@ -0,0 +1,112 @@ +import { useHydrateAtoms } from 'jotai/utils' +import { useLayoutEffect, useMemo } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { isInjectedWidget } from '@cowprotocol/common-utils' + +import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState' +import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom' +import { SelectTokenContext } from '../../types' + +import type { TokenDataSources } from './tokenDataHooks' +import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' + +interface HydrateTokenListViewAtomArgs { + shouldRender: boolean + tokenData: TokenDataSources + widgetState: ReturnType + favoriteTokens: TokenWithLogo[] + recentTokens: TokenWithLogo[] | undefined + onClearRecentTokens: (() => void) | undefined + onTokenListItemClick: ((token: TokenWithLogo) => void) | undefined + handleSelectToken: (token: TokenWithLogo) => Promise | void + account: string | undefined + displayLpTokenLists: boolean +} + +/** + * Hydrates the tokenListViewAtom at the controller level. + * This moves hydration responsibility from SelectTokenModal to the controller, + * allowing the modal to receive fewer props while children read from the atom. + * + * Only hydrates when shouldRender is true to avoid unnecessary atom writes + * when the modal isn't supposed to be displayed. + */ +export function useHydrateTokenListViewAtom({ + shouldRender, + tokenData, + widgetState, + favoriteTokens, + recentTokens, + onClearRecentTokens, + onTokenListItemClick, + handleSelectToken, + account, + displayLpTokenLists, +}: HydrateTokenListViewAtomArgs): void { + const updateTokenListView = useUpdateTokenListViewState() + + // Build the selectTokenContext object + const selectTokenContext: SelectTokenContext = useMemo( + () => ({ + balancesState: tokenData.balancesState, + selectedToken: widgetState.selectedToken, + onSelectToken: handleSelectToken, + onTokenListItemClick, + unsupportedTokens: tokenData.unsupportedTokens, + permitCompatibleTokens: tokenData.permitCompatibleTokens, + tokenListTags: tokenData.tokenListTags, + isWalletConnected: !!account, + }), + [ + tokenData.balancesState, + widgetState.selectedToken, + handleSelectToken, + onTokenListItemClick, + tokenData.unsupportedTokens, + tokenData.permitCompatibleTokens, + tokenData.tokenListTags, + account, + ], + ) + + // Compute the full view state to hydrate + // Note: searchInput is handled by the modal (local state + sync effect) + const viewState: Omit = useMemo( + () => ({ + allTokens: tokenData.allTokens, + favoriteTokens, + recentTokens, + areTokensLoading: tokenData.areTokensLoading, + areTokensFromBridge: tokenData.areTokensFromBridge, + hideFavoriteTokensTooltip: isInjectedWidget(), + selectedTargetChainId: widgetState.selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + displayLpTokenLists, + }), + [ + tokenData.allTokens, + favoriteTokens, + recentTokens, + tokenData.areTokensLoading, + tokenData.areTokensFromBridge, + widgetState.selectedTargetChainId, + selectTokenContext, + onClearRecentTokens, + displayLpTokenLists, + ], + ) + + // Hydrate atom SYNCHRONOUSLY on first render (only when modal should render) + useHydrateAtoms(shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : []) + + // Keep atom in sync when data changes (after initial render) + // Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker + // Skip when modal isn't rendered to avoid unnecessary atom writes + useLayoutEffect(() => { + if (shouldRender) { + updateTokenListView(viewState) + } + }, [shouldRender, viewState, updateTokenListView]) +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts index 2ed07f83c63..8cc9f07f9c2 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerModalProps.ts @@ -1,10 +1,7 @@ -import { TokenWithLogo } from '@cowprotocol/common-const' - -import { buildSelectTokenModalPropsInput, buildSelectTokenWidgetViewProps, useSelectTokenModalPropsMemo } from './controllerProps' +import { buildSelectTokenModalPropsInput, SelectTokenWidgetViewProps } from './controllerProps' import { useManageWidgetVisibility, usePoolPageHandlers, - useRecentTokenSection, useTokenDataSources, useTokenSelectionHandler, useWidgetMetadata, @@ -17,8 +14,6 @@ import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState import type { WidgetViewDependenciesResult } from './controllerDependencies' import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' -const EMPTY_FAV_TOKENS: TokenWithLogo[] = [] - interface WidgetModalPropsArgs { account: string | undefined chainsToSelect: ReturnType @@ -26,14 +21,16 @@ interface WidgetModalPropsArgs { widgetDeps: WidgetViewDependenciesResult hasChainPanel: boolean onSelectChain: ReturnType - recentTokens: ReturnType['recentTokens'] standalone?: boolean - tokenData: ReturnType widgetMetadata: ReturnType widgetState: ReturnType - isInjectedWidgetMode: boolean + isRouteAvailable: boolean | undefined } +/** + * Builds modal props. + * Token data and context are hydrated to atom by controller - no longer passed as props. + */ export function useWidgetModalProps({ account, chainsToSelect, @@ -41,40 +38,33 @@ export function useWidgetModalProps({ widgetDeps, hasChainPanel, onSelectChain, - recentTokens, standalone, - tokenData, widgetMetadata, widgetState, - isInjectedWidgetMode, + isRouteAvailable, }: WidgetModalPropsArgs): SelectTokenModalProps { - const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens - - return useSelectTokenModalPropsMemo( - createSelectTokenModalProps({ - account, - chainsPanelTitle: widgetMetadata.chainsPanelTitle, - chainsState: chainsToSelect, - disableErc20: widgetMetadata.disableErc20, - displayLpTokenLists, - favoriteTokens, - handleSelectToken: widgetDeps.handleSelectToken, - hasChainPanel, - isInjectedWidgetMode, - modalTitle: widgetMetadata.modalTitle, - onDismiss: widgetDeps.onDismiss, - onSelectChain, - onTokenListItemClick: widgetDeps.handleTokenListItemClick, - onClearRecentTokens: widgetDeps.clearRecentTokens, - onOpenManageWidget: widgetDeps.openManageWidget, - openPoolPage: widgetDeps.openPoolPage, - recentTokens, - standalone, - tokenData, - tokenListCategoryState: widgetMetadata.tokenListCategoryState, - widgetState, - }), - ) + return buildSelectTokenModalPropsInput({ + // Layout + standalone, + hasChainPanel, + modalTitle: widgetMetadata.modalTitle, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + // Chain panel + chainsState: chainsToSelect, + onSelectChain, + // Widget config + displayLpTokenLists, + tokenListCategoryState: widgetMetadata.tokenListCategoryState, + disableErc20: widgetMetadata.disableErc20, + isRouteAvailable, + account, + // Callbacks + handleSelectToken: widgetDeps.handleSelectToken, + onDismiss: widgetDeps.onDismiss, + onOpenManageWidget: widgetDeps.openManageWidget, + openPoolPage: widgetDeps.openPoolPage, + onInputPressEnter: widgetState.onInputPressEnter, + }) } interface BuildViewPropsArgs { @@ -87,7 +77,7 @@ interface BuildViewPropsArgs { isChainPanelEnabled: boolean onDismiss: () => void onSelectChain: ReturnType - selectTokenModalProps: ReturnType + selectTokenModalProps: SelectTokenModalProps selectedPoolAddress: ReturnType['selectedPoolAddress'] standalone: boolean | undefined tokenToImport: ReturnType['tokenToImport'] @@ -97,9 +87,7 @@ interface BuildViewPropsArgs { handleSelectToken: ReturnType } -type BuildViewPropsInput = Parameters[0] - -export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): BuildViewPropsInput { +export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): SelectTokenWidgetViewProps { const { standalone, tokenToImport, @@ -142,73 +130,3 @@ export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): Bui onSelectToken: handleSelectToken, } } - -function createSelectTokenModalProps({ - account, - chainsPanelTitle, - chainsState, - disableErc20, - displayLpTokenLists, - favoriteTokens, - handleSelectToken, - hasChainPanel, - isInjectedWidgetMode, - modalTitle, - onDismiss, - onSelectChain, - onTokenListItemClick, - onClearRecentTokens, - onOpenManageWidget, - openPoolPage, - recentTokens, - standalone, - tokenData, - tokenListCategoryState, - widgetState, -}: { - account: string | undefined - chainsPanelTitle: string - chainsState: ReturnType - disableErc20: boolean - displayLpTokenLists: boolean | undefined - favoriteTokens: TokenWithLogo[] - handleSelectToken: ReturnType - hasChainPanel: boolean - isInjectedWidgetMode: boolean - modalTitle: string - onDismiss: () => void - onSelectChain: ReturnType - onTokenListItemClick: ReturnType['handleTokenListItemClick'] - onClearRecentTokens: ReturnType['clearRecentTokens'] - onOpenManageWidget: ReturnType['openManageWidget'] - openPoolPage: ReturnType['openPoolPage'] - recentTokens: ReturnType['recentTokens'] - standalone: boolean | undefined - tokenData: ReturnType - tokenListCategoryState: ReturnType['tokenListCategoryState'] - widgetState: ReturnType -}): SelectTokenModalProps { - return buildSelectTokenModalPropsInput({ - standalone, - displayLpTokenLists, - tokenData, - widgetState, - favoriteTokens, - recentTokens, - handleSelectToken, - onTokenListItemClick, - onClearRecentTokens, - onDismiss, - onOpenManageWidget, - openPoolPage, - tokenListCategoryState, - disableErc20, - account, - hasChainPanel, - chainsState, - chainsPanelTitle, - onSelectChain, - isInjectedWidgetMode, - modalTitle, - }) -} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts index ea0c2771fc3..b8fba121d3c 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerProps.ts @@ -1,16 +1,12 @@ -import { useMemo } from 'react' - import { TokenWithLogo } from '@cowprotocol/common-const' import { ChainInfo } from '@cowprotocol/cow-sdk' import { ListState } from '@cowprotocol/tokens' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' -import type { TokenDataSources, TokenListCategoryState } from './controllerState' -import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import type { TokenListCategoryState } from './controllerState' import type { SelectTokenModalProps } from '../../pure/SelectTokenModal' -type WidgetState = ReturnType export interface SelectTokenWidgetViewProps { standalone?: boolean tokenToImport?: TokenWithLogo @@ -33,228 +29,83 @@ export interface SelectTokenWidgetViewProps { onSelectToken: TokenSelectionHandler } -interface BuildViewPropsArgs { - standalone?: boolean - tokenToImport?: TokenWithLogo - listToImport?: ListState - isManageWidgetOpen: boolean - selectedPoolAddress?: string - isChainPanelEnabled: boolean - chainsPanelTitle: string - chainsToSelect: ChainsToSelectState | undefined - onSelectChain(chain: ChainInfo): void - onDismiss(): void - onBackFromImport(): void - onImportTokens(tokens: TokenWithLogo[]): void - onImportList(list: ListState): void - allTokenLists: ListState[] - userAddedTokens: TokenWithLogo[] - onCloseManageWidget(): void - onClosePoolPage(): void - selectTokenModalProps: SelectTokenModalProps - onSelectToken: TokenSelectionHandler -} - +/** + * Arguments for building modal props. + * Token data, context, and atom-backed callbacks are hydrated to atom by controller. + */ interface BuildModalPropsArgs { + // Layout standalone?: boolean + hasChainPanel: boolean + modalTitle: string + chainsPanelTitle: string + // Chain panel + chainsState?: ChainsToSelectState + onSelectChain?(chain: ChainInfo): void + // Widget config displayLpTokenLists?: boolean - tokenData: TokenDataSources - widgetState: WidgetState - favoriteTokens: TokenWithLogo[] - recentTokens: TokenWithLogo[] + tokenListCategoryState: TokenListCategoryState + disableErc20: boolean + isRouteAvailable: boolean | undefined + account: string | undefined + // Callbacks handleSelectToken: TokenSelectionHandler - onTokenListItemClick(token: TokenWithLogo): void - onClearRecentTokens(): void onDismiss(): void onOpenManageWidget(): void openPoolPage(poolAddress: string): void - tokenListCategoryState: TokenListCategoryState - disableErc20: boolean - account: string | undefined - hasChainPanel: boolean - chainsState?: ChainsToSelectState - chainsPanelTitle: string - onSelectChain?(chain: ChainInfo): void - isInjectedWidgetMode: boolean - modalTitle: string + onInputPressEnter?(): void } -export function buildSelectTokenWidgetViewProps({ +/** + * Builds SelectTokenModalProps. + * Token data and context are now hydrated to atom by controller - modal doesn't receive them. + */ +export function buildSelectTokenModalPropsInput({ + // Layout standalone, - tokenToImport, - listToImport, - isManageWidgetOpen, - selectedPoolAddress, - isChainPanelEnabled, + hasChainPanel, + modalTitle, chainsPanelTitle, - chainsToSelect, + // Chain panel + chainsState, onSelectChain, - onDismiss, - onBackFromImport, - onImportTokens, - onImportList, - allTokenLists, - userAddedTokens, - onCloseManageWidget, - onClosePoolPage, - selectTokenModalProps, - onSelectToken, -}: BuildViewPropsArgs): SelectTokenWidgetViewProps { - return { - standalone, - tokenToImport, - listToImport, - isManageWidgetOpen, - selectedPoolAddress, - isChainPanelEnabled, - chainsPanelTitle, - chainsToSelect, - onSelectChain, - onDismiss, - onBackFromImport, - onImportTokens, - onImportList, - allTokenLists, - userAddedTokens, - onCloseManageWidget, - onClosePoolPage, - selectTokenModalProps, - onSelectToken, - } -} - -export function buildSelectTokenModalPropsInput({ - standalone, + // Widget config displayLpTokenLists, - tokenData, - widgetState, - favoriteTokens, - recentTokens, + tokenListCategoryState, + disableErc20, + isRouteAvailable, + account, + // Callbacks handleSelectToken, - onTokenListItemClick, - onClearRecentTokens, onDismiss, onOpenManageWidget, openPoolPage, - tokenListCategoryState, - disableErc20, - account, - hasChainPanel, - chainsState, - onSelectChain, - isInjectedWidgetMode, - chainsPanelTitle, - modalTitle, + onInputPressEnter, }: BuildModalPropsArgs): SelectTokenModalProps { const selectChainHandler: (chain: ChainInfo) => void = onSelectChain ?? (() => undefined) return { + // Layout standalone, + hasChainPanel, + modalTitle, + chainsPanelTitle, + // Chain panel + chainsToSelect: chainsState, + onSelectChain: selectChainHandler, + mobileChainsState: chainsState, + mobileChainsLabel: chainsPanelTitle, + // Widget config displayLpTokenLists, - unsupportedTokens: tokenData.unsupportedTokens, - selectedToken: widgetState.selectedToken, - allTokens: tokenData.allTokens, - favoriteTokens, - recentTokens, - balancesState: tokenData.balancesState, - permitCompatibleTokens: tokenData.permitCompatibleTokens, + tokenListCategoryState, + disableErc20, + isRouteAvailable, + account, + // Callbacks onSelectToken: handleSelectToken, - onTokenListItemClick, - onInputPressEnter: widgetState.onInputPressEnter, onDismiss, onOpenManageWidget, openPoolPage, - tokenListCategoryState, - disableErc20, - account, - areTokensLoading: tokenData.areTokensLoading, - tokenListTags: tokenData.tokenListTags, - areTokensFromBridge: tokenData.areTokensFromBridge, - isRouteAvailable: tokenData.isRouteAvailable, - modalTitle, - hasChainPanel, - chainsToSelect: chainsState, - chainsPanelTitle, - mobileChainsState: chainsState, - mobileChainsLabel: chainsPanelTitle, - hideFavoriteTokensTooltip: isInjectedWidgetMode, - selectedTargetChainId: widgetState.selectedTargetChainId, - onSelectChain: selectChainHandler, - onClearRecentTokens, + onInputPressEnter, } } - -export function useSelectTokenModalPropsMemo(props: SelectTokenModalProps): SelectTokenModalProps { - return useMemo( - () => ({ - standalone: props.standalone, - displayLpTokenLists: props.displayLpTokenLists, - unsupportedTokens: props.unsupportedTokens, - selectedToken: props.selectedToken, - allTokens: props.allTokens, - favoriteTokens: props.favoriteTokens, - recentTokens: props.recentTokens, - balancesState: props.balancesState, - permitCompatibleTokens: props.permitCompatibleTokens, - onSelectToken: props.onSelectToken, - onTokenListItemClick: props.onTokenListItemClick, - onInputPressEnter: props.onInputPressEnter, - onDismiss: props.onDismiss, - onOpenManageWidget: props.onOpenManageWidget, - openPoolPage: props.openPoolPage, - tokenListCategoryState: props.tokenListCategoryState, - disableErc20: props.disableErc20, - account: props.account, - areTokensLoading: props.areTokensLoading, - tokenListTags: props.tokenListTags, - areTokensFromBridge: props.areTokensFromBridge, - isRouteAvailable: props.isRouteAvailable, - modalTitle: props.modalTitle, - hasChainPanel: props.hasChainPanel, - chainsPanelTitle: props.chainsPanelTitle, - hideFavoriteTokensTooltip: props.hideFavoriteTokensTooltip, - selectedTargetChainId: props.selectedTargetChainId, - chainsToSelect: props.chainsToSelect, - mobileChainsState: props.mobileChainsState, - mobileChainsLabel: props.mobileChainsLabel, - onSelectChain: props.onSelectChain, - onOpenMobileChainPanel: props.onOpenMobileChainPanel, - onClearRecentTokens: props.onClearRecentTokens, - }), - [ - props.standalone, - props.displayLpTokenLists, - props.unsupportedTokens, - props.selectedToken, - props.allTokens, - props.favoriteTokens, - props.recentTokens, - props.balancesState, - props.permitCompatibleTokens, - props.onSelectToken, - props.onTokenListItemClick, - props.onInputPressEnter, - props.onDismiss, - props.onOpenManageWidget, - props.openPoolPage, - props.tokenListCategoryState, - props.disableErc20, - props.account, - props.areTokensLoading, - props.tokenListTags, - props.areTokensFromBridge, - props.isRouteAvailable, - props.modalTitle, - props.hasChainPanel, - props.chainsPanelTitle, - props.hideFavoriteTokensTooltip, - props.selectedTargetChainId, - props.chainsToSelect, - props.mobileChainsState, - props.mobileChainsLabel, - props.onSelectChain, - props.onOpenMobileChainPanel, - props.onClearRecentTokens, - ], - ) -} diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts index fd62f75f7b5..a460aa9ead8 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerState.ts @@ -1,304 +1,14 @@ -import { Dispatch, SetStateAction, useCallback, useState } from 'react' - -import { useCowAnalytics } from '@cowprotocol/analytics' -import { TokenWithLogo } from '@cowprotocol/common-const' -import { SupportedChainId } from '@cowprotocol/cow-sdk' -import { log } from '@cowprotocol/sdk-common' -import { - ListState, - TokenListCategory, - useAddList, - useAddUserToken, - useAllListsList, - useTokenListsTags, - useUnsupportedTokens, - useUserAddedTokens, -} from '@cowprotocol/tokens' -import { useWalletInfo } from '@cowprotocol/wallet' - -import { t } from '@lingui/core/macro' - -import { Field } from 'legacy/state/types' - -import { useTokensBalancesCombined } from 'modules/combinedBalances' -import { usePermitCompatibleTokens } from 'modules/permit' -import { TradeType } from 'modules/trade/types' - -import { CowSwapAnalyticsCategory } from 'common/analytics/types' -import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork' - -import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' - -import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens' -import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' -import { useTokensToSelect } from '../../hooks/useTokensToSelect' -import { ChainsToSelectState, TokenSelectionHandler } from '../../types' - -import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' - -type UpdateSelectTokenWidgetFn = ReturnType - -export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>] - -interface ManageWidgetVisibility { - isManageWidgetOpen: boolean - openManageWidget(): void - closeManageWidget(): void -} - -interface TokenAdminActions { - addCustomTokenLists(list: ListState): void - importTokenCallback(tokens: TokenWithLogo[]): void -} - -export interface TokenDataSources { - allTokens: TokenWithLogo[] - favoriteTokens: TokenWithLogo[] - areTokensLoading: boolean - areTokensFromBridge: boolean - isRouteAvailable: boolean | undefined - userAddedTokens: TokenWithLogo[] - allTokenLists: ListState[] - balancesState: ReturnType - unsupportedTokens: ReturnType - permitCompatibleTokens: ReturnType - tokenListTags: ReturnType -} - -interface WidgetMetadata { - disableErc20: boolean - tokenListCategoryState: TokenListCategoryState - modalTitle: string - chainsPanelTitle: string -} - -interface PoolPageHandlers { - openPoolPage(poolAddress: string): void - closePoolPage(): void -} - -interface ImportFlowCallbacks { - importTokenAndClose(tokens: TokenWithLogo[]): void - importListAndBack(list: ListState): void - resetTokenImport(): void -} - -interface RecentTokenSection { - recentTokens: TokenWithLogo[] - handleTokenListItemClick(token: TokenWithLogo): void - clearRecentTokens(): void -} - -export function useManageWidgetVisibility(): ManageWidgetVisibility { - const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) - - const openManageWidget = useCallback(() => setIsManageWidgetOpen(true), []) - const closeManageWidget = useCallback(() => setIsManageWidgetOpen(false), []) - - return { isManageWidgetOpen, openManageWidget, closeManageWidget } -} - -export function useTokenAdminActions(): TokenAdminActions { - const cowAnalytics = useCowAnalytics() - - const addCustomTokenLists = useAddList((source) => { - cowAnalytics.sendEvent({ - category: CowSwapAnalyticsCategory.LIST, - action: 'Add List Success', - label: source, - }) - }) - const importTokenCallback = useAddUserToken() - - return { addCustomTokenLists, importTokenCallback } -} - -export function useTokenDataSources(): TokenDataSources { - const tokensState = useTokensToSelect() - const userAddedTokens = useUserAddedTokens() - const allTokenLists = useAllListsList() - const balancesState = useTokensBalancesCombined() - const unsupportedTokens = useUnsupportedTokens() - const permitCompatibleTokens = usePermitCompatibleTokens() - const tokenListTags = useTokenListsTags() - - return { - allTokens: tokensState.tokens, - favoriteTokens: tokensState.favoriteTokens, - areTokensLoading: tokensState.isLoading, - areTokensFromBridge: tokensState.areTokensFromBridge, - isRouteAvailable: tokensState.isRouteAvailable, - userAddedTokens, - allTokenLists, - balancesState, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - } -} - -export function useWidgetMetadata( - field: Field, - tradeType: TradeType | undefined, - displayLpTokenLists: boolean | undefined, - oppositeToken: Parameters[1], - lpTokensWithBalancesCount: number, -): WidgetMetadata { - const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists - const tokenListCategoryState: TokenListCategoryState = useState( - getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), - ) - const modalTitle = resolveModalTitle(field, tradeType) - const chainsPanelTitle = - field === Field.INPUT ? t`From network` : field === Field.OUTPUT ? t`To network` : t`Select network` - - return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle } -} - -function resolveModalTitle(field: Field, tradeType: TradeType | undefined): string { - const isSwapTrade = !tradeType || tradeType === TradeType.SWAP - - if (field === Field.INPUT) { - return isSwapTrade ? t`Swap from` : t`Sell token` - } - - if (field === Field.OUTPUT) { - return isSwapTrade ? t`Swap to` : t`Buy token` - } - - return t`Select token` -} - -export function useDismissHandler( - closeManageWidget: () => void, - closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void, -): () => void { - return useCallback(() => { - closeManageWidget() - closeTokenSelectWidget({ overrideForceLock: true }) - }, [closeManageWidget, closeTokenSelectWidget]) -} - -export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers { - const openPoolPage = useCallback( - (selectedPoolAddress: string) => { - updateSelectTokenWidget({ selectedPoolAddress }) - }, - [updateSelectTokenWidget], - ) - - const closePoolPage = useCallback(() => { - updateSelectTokenWidget({ selectedPoolAddress: undefined }) - }, [updateSelectTokenWidget]) - - return { openPoolPage, closePoolPage } -} - -export function useImportFlowCallbacks( - importTokenCallback: ReturnType, - onSelectToken: TokenSelectionHandler | undefined, - onDismiss: () => void, - addCustomTokenLists: (list: ListState) => void, - onTokenListAddingError: (error: Error) => void, - updateSelectTokenWidget: UpdateSelectTokenWidgetFn, - favoriteTokens: TokenWithLogo[], -): ImportFlowCallbacks { - const importTokenAndClose = useCallback( - (tokens: TokenWithLogo[]) => { - importTokenCallback(tokens) - const [selectedToken] = tokens - - if (selectedToken) { - persistRecentTokenSelection(selectedToken, favoriteTokens) - onSelectToken?.(selectedToken) - } - - onDismiss() - }, - [importTokenCallback, onSelectToken, onDismiss, favoriteTokens], - ) - - const importListAndBack = useCallback( - (list: ListState) => { - try { - addCustomTokenLists(list) - } catch (error) { - onDismiss() - onTokenListAddingError(error as Error) - } - updateSelectTokenWidget({ listToImport: undefined }) - }, - [addCustomTokenLists, onDismiss, onTokenListAddingError, updateSelectTokenWidget], - ) - - const resetTokenImport = useCallback(() => { - updateSelectTokenWidget({ tokenToImport: undefined }) - }, [updateSelectTokenWidget]) - - return { importTokenAndClose, importListAndBack, resetTokenImport } -} - -export function useRecentTokenSection( - allTokens: TokenWithLogo[], - favoriteTokens: TokenWithLogo[], - activeChainId?: number, -): RecentTokenSection { - const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ - allTokens, - favoriteTokens, - activeChainId, - }) - - const handleTokenListItemClick = useCallback( - (token: TokenWithLogo) => { - addRecentToken(token) - }, - [addRecentToken], - ) - - return { recentTokens, handleTokenListItemClick, clearRecentTokens } -} - -export function useTokenSelectionHandler( - onSelectToken: TokenSelectionHandler | undefined, - widgetState: ReturnType, -): TokenSelectionHandler { - const { chainId: walletChainId } = useWalletInfo() - const onSelectNetwork = useOnSelectNetwork() - - return useCallback( - async (token: TokenWithLogo) => { - const targetChainId = widgetState.selectedTargetChainId - // SELL-side limit/TWAP orders must run on the picked network, - // so nudge the wallet onto that chain before finalizing selection. - const shouldSwitchWalletNetwork = - widgetState.field === Field.INPUT && - (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) && - typeof targetChainId === 'number' && - targetChainId !== walletChainId - - if (shouldSwitchWalletNetwork && targetChainId in SupportedChainId) { - try { - await onSelectNetwork(targetChainId as SupportedChainId, true) - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - log(`Failed to switch network after token selection: ${message}`) - } - } - - onSelectToken?.(token) - }, - [ - onSelectToken, - widgetState.field, - widgetState.tradeType, - widgetState.selectedTargetChainId, - walletChainId, - onSelectNetwork, - ], - ) -} - -export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean { - return Boolean(chainsToSelect) -} +// UI state hooks +export { useManageWidgetVisibility, useDismissHandler, usePoolPageHandlers } from './widgetUIState' + +// Token data hooks and types +export { useTokenAdminActions, useTokenDataSources, useWidgetMetadata } from './tokenDataHooks' +export type { TokenListCategoryState, TokenDataSources } from './tokenDataHooks' + +// Token selection hooks +export { + useImportFlowCallbacks, + useRecentTokenSection, + useTokenSelectionHandler, + hasAvailableChains, +} from './tokenSelectionHooks' diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts index 7c1e1a1d3a7..add67c519b9 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerViewState.ts @@ -1,10 +1,9 @@ -import { isInjectedWidget } from '@cowprotocol/common-utils' - import { TradeType } from 'modules/trade/types' +import { useHydrateTokenListViewAtom } from './controllerAtomHydration' import { useWidgetViewDependencies } from './controllerDependencies' import { getSelectTokenWidgetViewPropsArgs, useWidgetModalProps } from './controllerModalProps' -import { SelectTokenWidgetViewProps, buildSelectTokenWidgetViewProps } from './controllerProps' +import { SelectTokenWidgetViewProps } from './controllerProps' import { hasAvailableChains, useManageWidgetVisibility, @@ -76,6 +75,28 @@ export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateAr widgetState, activeChainId, }) + + // Determine if modal should render (same logic as controller.ts) + const shouldRender = Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)) + + // Determine favorite tokens (empty for standalone mode) + const favoriteTokens = standalone ? [] : tokenData.favoriteTokens + + // Hydrate the tokenListViewAtom at controller level (only when modal should render) + // This allows children to read from atom instead of receiving props + useHydrateTokenListViewAtom({ + shouldRender, + tokenData, + widgetState, + favoriteTokens, + recentTokens: widgetDeps.recentTokens, + onClearRecentTokens: widgetDeps.clearRecentTokens, + onTokenListItemClick: widgetDeps.handleTokenListItemClick, + handleSelectToken: widgetDeps.handleSelectToken, + account, + displayLpTokenLists: displayLpTokenLists ?? false, + }) + const shouldDisableChainPanelForYield = widgetState.tradeType === TradeType.YIELD && !ENABLE_YIELD_CHAIN_PANEL const isChainPanelEnabled = isBridgeFeatureEnabled && hasAvailableChains(chainsToSelect) && !shouldDisableChainPanelForYield @@ -86,35 +107,31 @@ export function useSelectTokenWidgetViewState(args: SelectTokenWidgetViewStateAr widgetDeps, hasChainPanel: isChainPanelEnabled, onSelectChain, - recentTokens: widgetDeps.recentTokens, standalone, - tokenData, widgetMetadata, widgetState, - isInjectedWidgetMode: isInjectedWidget(), + isRouteAvailable: tokenData.isRouteAvailable, }) - const viewProps = buildSelectTokenWidgetViewProps( - getSelectTokenWidgetViewPropsArgs({ - allTokenLists: tokenData.allTokenLists, - chainsPanelTitle: widgetMetadata.chainsPanelTitle, - chainsToSelect, - closeManageWidget: widgetDeps.closeManageWidget, - closePoolPage: widgetDeps.closePoolPage, - importFlows: widgetDeps.importFlows, - isChainPanelEnabled, - onDismiss: widgetDeps.onDismiss, - onSelectChain, - selectTokenModalProps, - selectedPoolAddress: widgetState.selectedPoolAddress, - standalone, - tokenToImport: widgetState.tokenToImport, - listToImport: widgetState.listToImport, - isManageWidgetOpen: widgetDeps.isManageWidgetOpen, - userAddedTokens: tokenData.userAddedTokens, - handleSelectToken: widgetDeps.handleSelectToken, - }), - ) + const viewProps = getSelectTokenWidgetViewPropsArgs({ + allTokenLists: tokenData.allTokenLists, + chainsPanelTitle: widgetMetadata.chainsPanelTitle, + chainsToSelect, + closeManageWidget: widgetDeps.closeManageWidget, + closePoolPage: widgetDeps.closePoolPage, + importFlows: widgetDeps.importFlows, + isChainPanelEnabled, + onDismiss: widgetDeps.onDismiss, + onSelectChain, + selectTokenModalProps, + selectedPoolAddress: widgetState.selectedPoolAddress, + standalone, + tokenToImport: widgetState.tokenToImport, + listToImport: widgetState.listToImport, + isManageWidgetOpen: widgetDeps.isManageWidgetOpen, + userAddedTokens: tokenData.userAddedTokens, + handleSelectToken: widgetDeps.handleSelectToken, + }) return { isChainPanelEnabled, viewProps } } diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts new file mode 100644 index 00000000000..66604902bda --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenDataHooks.ts @@ -0,0 +1,128 @@ +import { Dispatch, SetStateAction, useState } from 'react' + +import { useCowAnalytics } from '@cowprotocol/analytics' +import { TokenWithLogo } from '@cowprotocol/common-const' +import { + ListState, + TokenListCategory, + useAddList, + useAddUserToken, + useAllListsList, + useTokenListsTags, + useUnsupportedTokens, + useUserAddedTokens, +} from '@cowprotocol/tokens' + +import { t } from '@lingui/core/macro' + +import { Field } from 'legacy/state/types' + +import { useTokensBalancesCombined } from 'modules/combinedBalances' +import { usePermitCompatibleTokens } from 'modules/permit' +import { TradeType } from 'modules/trade/types' + +import { CowSwapAnalyticsCategory } from 'common/analytics/types' + +import { getDefaultTokenListCategories } from './getDefaultTokenListCategories' + +import { useTokensToSelect } from '../../hooks/useTokensToSelect' + +export type TokenListCategoryState = [TokenListCategory[] | null, Dispatch>] + +interface TokenAdminActions { + addCustomTokenLists(list: ListState): void + importTokenCallback(tokens: TokenWithLogo[]): void +} + +export interface TokenDataSources { + allTokens: TokenWithLogo[] + favoriteTokens: TokenWithLogo[] + areTokensLoading: boolean + areTokensFromBridge: boolean + isRouteAvailable: boolean | undefined + userAddedTokens: TokenWithLogo[] + allTokenLists: ListState[] + balancesState: ReturnType + unsupportedTokens: ReturnType + permitCompatibleTokens: ReturnType + tokenListTags: ReturnType +} + +interface WidgetMetadata { + disableErc20: boolean + tokenListCategoryState: TokenListCategoryState + modalTitle: string + chainsPanelTitle: string +} + +export function useTokenAdminActions(): TokenAdminActions { + const cowAnalytics = useCowAnalytics() + + const addCustomTokenLists = useAddList((source) => { + cowAnalytics.sendEvent({ + category: CowSwapAnalyticsCategory.LIST, + action: 'Add List Success', + label: source, + }) + }) + const importTokenCallback = useAddUserToken() + + return { addCustomTokenLists, importTokenCallback } +} + +export function useTokenDataSources(): TokenDataSources { + const tokensState = useTokensToSelect() + const userAddedTokens = useUserAddedTokens() + const allTokenLists = useAllListsList() + const balancesState = useTokensBalancesCombined() + const unsupportedTokens = useUnsupportedTokens() + const permitCompatibleTokens = usePermitCompatibleTokens() + const tokenListTags = useTokenListsTags() + + return { + allTokens: tokensState.tokens, + favoriteTokens: tokensState.favoriteTokens, + areTokensLoading: tokensState.isLoading, + areTokensFromBridge: tokensState.areTokensFromBridge, + isRouteAvailable: tokensState.isRouteAvailable, + userAddedTokens, + allTokenLists, + balancesState, + unsupportedTokens, + permitCompatibleTokens, + tokenListTags, + } +} + +export function useWidgetMetadata( + field: Field, + tradeType: TradeType | undefined, + displayLpTokenLists: boolean | undefined, + oppositeToken: Parameters[1], + lpTokensWithBalancesCount: number, +): WidgetMetadata { + const disableErc20 = field === Field.OUTPUT && !!displayLpTokenLists + const tokenListCategoryState: TokenListCategoryState = useState( + getDefaultTokenListCategories(field, oppositeToken, lpTokensWithBalancesCount), + ) + const modalTitle = resolveModalTitle(field, tradeType) + const chainsPanelTitle = + field === Field.INPUT ? t`From network` : field === Field.OUTPUT ? t`To network` : t`Select network` + + return { disableErc20, tokenListCategoryState, modalTitle, chainsPanelTitle } +} + +function resolveModalTitle(field: Field, tradeType: TradeType | undefined): string { + const isSwapTrade = !tradeType || tradeType === TradeType.SWAP + + if (field === Field.INPUT) { + return isSwapTrade ? t`Swap from` : t`Sell token` + } + + if (field === Field.OUTPUT) { + return isSwapTrade ? t`Swap to` : t`Buy token` + } + + return t`Select token` +} + diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts new file mode 100644 index 00000000000..3e057f1aeab --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/tokenSelectionHooks.ts @@ -0,0 +1,143 @@ +import { useCallback } from 'react' + +import { TokenWithLogo } from '@cowprotocol/common-const' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { log } from '@cowprotocol/sdk-common' +import { ListState, useAddUserToken } from '@cowprotocol/tokens' +import { useWalletInfo } from '@cowprotocol/wallet' + +import { Field } from 'legacy/state/types' + +import { TradeType } from 'modules/trade/types' + +import { useOnSelectNetwork } from 'common/hooks/useOnSelectNetwork' + +import { persistRecentTokenSelection, useRecentTokens } from '../../hooks/useRecentTokens' +import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState' +import { ChainsToSelectState, TokenSelectionHandler } from '../../types' + +import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +type UpdateSelectTokenWidgetFn = ReturnType + +interface ImportFlowCallbacks { + importTokenAndClose(tokens: TokenWithLogo[]): void + importListAndBack(list: ListState): void + resetTokenImport(): void +} + +interface RecentTokenSection { + recentTokens: TokenWithLogo[] + handleTokenListItemClick(token: TokenWithLogo): void + clearRecentTokens(): void +} + +export function useImportFlowCallbacks( + importTokenCallback: ReturnType, + onSelectToken: TokenSelectionHandler | undefined, + onDismiss: () => void, + addCustomTokenLists: (list: ListState) => void, + onTokenListAddingError: (error: Error) => void, + updateSelectTokenWidget: UpdateSelectTokenWidgetFn, + favoriteTokens: TokenWithLogo[], +): ImportFlowCallbacks { + const importTokenAndClose = useCallback( + (tokens: TokenWithLogo[]) => { + importTokenCallback(tokens) + const [selectedToken] = tokens + + if (selectedToken) { + persistRecentTokenSelection(selectedToken, favoriteTokens) + onSelectToken?.(selectedToken) + } + + onDismiss() + }, + [importTokenCallback, onSelectToken, onDismiss, favoriteTokens], + ) + + const importListAndBack = useCallback( + (list: ListState) => { + try { + addCustomTokenLists(list) + } catch (error) { + onDismiss() + onTokenListAddingError(error as Error) + } + updateSelectTokenWidget({ listToImport: undefined }) + }, + [addCustomTokenLists, onDismiss, onTokenListAddingError, updateSelectTokenWidget], + ) + + const resetTokenImport = useCallback(() => { + updateSelectTokenWidget({ tokenToImport: undefined }) + }, [updateSelectTokenWidget]) + + return { importTokenAndClose, importListAndBack, resetTokenImport } +} + +export function useRecentTokenSection( + allTokens: TokenWithLogo[], + favoriteTokens: TokenWithLogo[], + activeChainId?: number, +): RecentTokenSection { + const { recentTokens, addRecentToken, clearRecentTokens } = useRecentTokens({ + allTokens, + favoriteTokens, + activeChainId, + }) + + const handleTokenListItemClick = useCallback( + (token: TokenWithLogo) => { + addRecentToken(token) + }, + [addRecentToken], + ) + + return { recentTokens, handleTokenListItemClick, clearRecentTokens } +} + +export function useTokenSelectionHandler( + onSelectToken: TokenSelectionHandler | undefined, + widgetState: ReturnType, +): TokenSelectionHandler { + const { chainId: walletChainId } = useWalletInfo() + const onSelectNetwork = useOnSelectNetwork() + + return useCallback( + async (token: TokenWithLogo) => { + const targetChainId = widgetState.selectedTargetChainId + // SELL-side limit/TWAP orders must run on the picked network, + // so nudge the wallet onto that chain before finalizing selection. + const shouldSwitchWalletNetwork = + widgetState.field === Field.INPUT && + (widgetState.tradeType === TradeType.LIMIT_ORDER || widgetState.tradeType === TradeType.ADVANCED_ORDERS) && + typeof targetChainId === 'number' && + targetChainId !== walletChainId + + if (shouldSwitchWalletNetwork && targetChainId in SupportedChainId) { + try { + await onSelectNetwork(targetChainId as SupportedChainId, true) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(`Failed to switch network after token selection: ${message}`) + } + } + + onSelectToken?.(token) + }, + [ + onSelectToken, + widgetState.field, + widgetState.tradeType, + widgetState.selectedTargetChainId, + walletChainId, + onSelectNetwork, + ], + ) +} + +export function hasAvailableChains(chainsToSelect: ChainsToSelectState | undefined): boolean { + return Boolean(chainsToSelect) +} + diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts new file mode 100644 index 00000000000..cec28688c97 --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/widgetUIState.ts @@ -0,0 +1,51 @@ +import { useCallback, useState } from 'react' + +import type { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState' + +type UpdateSelectTokenWidgetFn = ReturnType + +interface ManageWidgetVisibility { + isManageWidgetOpen: boolean + openManageWidget(): void + closeManageWidget(): void +} + +interface PoolPageHandlers { + openPoolPage(poolAddress: string): void + closePoolPage(): void +} + +export function useManageWidgetVisibility(): ManageWidgetVisibility { + const [isManageWidgetOpen, setIsManageWidgetOpen] = useState(false) + + const openManageWidget = useCallback(() => setIsManageWidgetOpen(true), []) + const closeManageWidget = useCallback(() => setIsManageWidgetOpen(false), []) + + return { isManageWidgetOpen, openManageWidget, closeManageWidget } +} + +export function useDismissHandler( + closeManageWidget: () => void, + closeTokenSelectWidget: (options?: { overrideForceLock?: boolean }) => void, +): () => void { + return useCallback(() => { + closeManageWidget() + closeTokenSelectWidget({ overrideForceLock: true }) + }, [closeManageWidget, closeTokenSelectWidget]) +} + +export function usePoolPageHandlers(updateSelectTokenWidget: UpdateSelectTokenWidgetFn): PoolPageHandlers { + const openPoolPage = useCallback( + (selectedPoolAddress: string) => { + updateSelectTokenWidget({ selectedPoolAddress }) + }, + [updateSelectTokenWidget], + ) + + const closePoolPage = useCallback(() => { + updateSelectTokenWidget({ selectedPoolAddress: undefined }) + }, [updateSelectTokenWidget]) + + return { openPoolPage, closePoolPage } +} + diff --git a/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts new file mode 100644 index 00000000000..ead57c439ac --- /dev/null +++ b/apps/cowswap-frontend/src/modules/tokensList/hooks/useSelectTokenContextFromAtom.ts @@ -0,0 +1,15 @@ +import { useTokenListViewState } from './useTokenListViewState' + +import { SelectTokenContext } from '../types' + +/** + * Reads the SelectTokenContext from the tokenListViewAtom. + * + * This hook replaces the props-based useSelectTokenContext pattern. + * The controller now hydrates the context to the atom, and consumers + * read it directly via this hook. + */ +export function useSelectTokenContextFromAtom(): SelectTokenContext { + const { selectTokenContext } = useTokenListViewState() + return selectTokenContext +} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx index 5909e4e2c9c..b74d81cb5db 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/helpers.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, ReactNode, useMemo, useState } from 'react' +import { ComponentProps, ReactNode, useState } from 'react' import { BackButton } from '@cowprotocol/ui' @@ -9,46 +9,8 @@ import { SettingsIcon } from 'modules/trade/pure/Settings' import { MobileChainSelector } from './MobileChainSelector' import * as styledEl from './styled' -import { SelectTokenContext } from '../../types' - import type { SelectTokenModalProps } from './types' -export function useSelectTokenContext(props: SelectTokenModalProps): SelectTokenContext { - const { - selectedToken, - balancesState, - unsupportedTokens, - permitCompatibleTokens, - onSelectToken, - onTokenListItemClick, - account, - tokenListTags, - } = props - - return useMemo( - () => ({ - balancesState, - selectedToken, - onSelectToken, - onTokenListItemClick, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - isWalletConnected: !!account, - }), - [ - balancesState, - selectedToken, - onSelectToken, - onTokenListItemClick, - unsupportedTokens, - permitCompatibleTokens, - tokenListTags, - account, - ], - ) -} - export function useTokenSearchInput(defaultInputValue = ''): [string, (value: string) => void, string] { const [inputValue, setInputValue] = useState(defaultInputValue) diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx index 7a715d72407..86a64191e4f 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.cosmos.tsx @@ -1,5 +1,8 @@ +import { useHydrateAtoms } from 'jotai/utils' +import { ReactNode } from 'react' + import { BalancesState } from '@cowprotocol/balances-and-allowances' -import { CHAIN_INFO } from '@cowprotocol/common-const' +import { CHAIN_INFO, TokenWithLogo } from '@cowprotocol/common-const' import { getRandomInt } from '@cowprotocol/common-utils' import { SupportedChainId, ChainInfo } from '@cowprotocol/cow-sdk' import { BigNumber } from '@ethersproject/bignumber' @@ -7,6 +10,8 @@ import { BigNumber } from '@ethersproject/bignumber' import styled from 'styled-components/macro' import { allTokensMock, favoriteTokensMock } from '../../mocks' +import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom' +import { SelectTokenContext } from '../../types' import { mapChainInfo } from '../../utils/mapChainInfo' import { ChainPanel } from '../ChainPanel' @@ -23,16 +28,20 @@ const Wrapper = styled.div` border: 1px solid rgba(0, 0, 0, 0.05); ` -const unsupportedTokens = {} - const selectedToken = favoriteTokensMock[0] const balances = allTokensMock.reduce((acc, token) => { acc[token.address] = BigNumber.from(getRandomInt(20_000, 120_000_000) + '0'.repeat(token.decimals)) - return acc }, {}) +const balancesState: BalancesState = { + values: balances, + isLoading: false, + chainId: SupportedChainId.SEPOLIA, + fromCache: false, +} + const chainsMock: ChainInfo[] = [ SupportedChainId.MAINNET, SupportedChainId.BASE, @@ -42,53 +51,80 @@ const chainsMock: ChainInfo[] = [ SupportedChainId.GNOSIS_CHAIN, ].reduce((acc, id) => { const info = CHAIN_INFO[id] - if (info) { acc.push(mapChainInfo(id, info)) } - return acc }, []) const favoriteTokenAddresses = new Set(favoriteTokensMock.map((token) => token.address.toLowerCase())) -const recentTokensMock = allTokensMock.filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())).slice(0, 3) +const recentTokensMock = allTokensMock + .filter((token) => !favoriteTokenAddresses.has(token.address.toLowerCase())) + .slice(0, 3) -const defaultModalProps: SelectTokenModalProps = { - tokenListTags: {}, - account: undefined, +// Mock SelectTokenContext for atom hydration +const mockSelectTokenContext: SelectTokenContext = { + balancesState, + selectedToken, + onSelectToken: (token: TokenWithLogo) => { + console.log('onSelectToken', token.symbol) + }, + onTokenListItemClick: (token: TokenWithLogo) => { + console.log('onTokenListItemClick', token.symbol) + }, + unsupportedTokens: {}, permitCompatibleTokens: {}, - unsupportedTokens, + tokenListTags: {}, + isWalletConnected: false, +} + +// Mock atom state for Cosmos fixtures +const mockAtomState: TokenListViewState = { allTokens: allTokensMock, favoriteTokens: favoriteTokensMock, recentTokens: recentTokensMock, + searchInput: '', areTokensLoading: false, areTokensFromBridge: false, - tokenListCategoryState: [null, () => void 0], - balancesState: { - values: balances, - isLoading: false, - chainId: SupportedChainId.SEPOLIA, - fromCache: false, - }, - selectedToken, - isRouteAvailable: true, + hideFavoriteTokensTooltip: false, + displayLpTokenLists: false, + selectedTargetChainId: undefined, + selectTokenContext: mockSelectTokenContext, + onClearRecentTokens: () => console.log('onClearRecentTokens'), +} + +// Wrapper component that hydrates the atom for Cosmos fixtures +function CosmosAtomProvider({ + children, + atomState, +}: { + children: ReactNode + atomState: TokenListViewState +}): ReactNode { + useHydrateAtoms([[tokenListViewAtom, atomState]]) + return <>{children} +} + +// Slim modal props (new ~17 prop interface) +const defaultModalProps: SelectTokenModalProps = { + // Layout + standalone: false, + hasChainPanel: false, modalTitle: 'Swap from', + chainsPanelTitle: 'Cross chain swap', + // Chain panel onSelectChain: () => undefined, - onSelectToken() { - console.log('onSelectToken') - }, - onTokenListItemClick(token) { - console.log('onTokenListItemClick', token.symbol) - }, - onOpenManageWidget() { - console.log('onOpenManageWidget') - }, - onDismiss() { - console.log('onDismiss') - }, - openPoolPage() { - console.log('openPoolPage') - }, + // Widget config + tokenListCategoryState: [null, () => void 0], + disableErc20: false, + isRouteAvailable: true, + account: undefined, + displayLpTokenLists: false, + // Callbacks + onSelectToken: (token) => console.log('onSelectToken', token.symbol), + openPoolPage: () => console.log('openPoolPage'), + onOpenManageWidget: () => console.log('onOpenManageWidget'), + onDismiss: () => console.log('onDismiss'), } const defaultChainPanelProps = { @@ -105,44 +141,58 @@ const defaultChainPanelProps = { const Fixtures = { default: () => ( - - - - + + + + + + ), loadingSidebar: () => ( - - - - + + + + + + ), noSidebar: () => ( - - - + + + + + ), importByAddress: () => ( - - - + + + + + ), NoTokenFound: () => ( - - - + + + + + ), searchFromInactiveLists: () => ( - - - + + + + + ), searchFromExternalSources: () => ( - - - + + + + + ), } diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx index 9d8f1df8d84..a6347711c52 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/index.tsx @@ -1,15 +1,12 @@ -import { useHydrateAtoms } from 'jotai/utils' -import { ReactNode, useEffect, useLayoutEffect, useMemo } from 'react' +import { ReactNode, useLayoutEffect, useMemo } from 'react' import { t } from '@lingui/core/macro' -import { getMobileChainSelectorConfig, useSelectTokenContext, useTokenSearchInput } from './helpers' +import { getMobileChainSelectorConfig, useTokenSearchInput } from './helpers' import { SelectTokenModalShell } from './SelectTokenModalShell' import { TokenColumnContent } from './TokenColumnContent' -import { useResetTokenListViewState } from '../../hooks/useResetTokenListViewState' import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState' -import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom' import { ChainPanel } from '../ChainPanel' import { TokensContent } from '../TokensContent' @@ -17,101 +14,70 @@ import type { SelectTokenModalProps } from './types' export type { SelectTokenModalProps } -// eslint-disable-next-line max-lines-per-function +/** + * SelectTokenModal - Token selection modal component. + * + * Data flow: + * - Controller hydrates tokenListViewAtom with token data, context, and callbacks + * - This modal receives only UI/callback props + * - Children (TokensContent, TokensVirtualList, etc.) read from atom + * - searchInput is local state, synced to atom for children to read + */ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { const { - defaultInputValue = '', - onSelectToken, - onDismiss, - onInputPressEnter, - account, - displayLpTokenLists, - openPoolPage, - tokenListCategoryState, - disableErc20, - onSelectChain, - areTokensFromBridge, - isRouteAvailable, + // Layout standalone, - onOpenManageWidget, - favoriteTokens, - recentTokens, - onClearRecentTokens, - areTokensLoading, - allTokens, - hideFavoriteTokensTooltip, - selectedTargetChainId, hasChainPanel = false, + isFullScreenMobile, + modalTitle, chainsPanelTitle, + defaultInputValue = '', + // Chain panel + chainsToSelect, + onSelectChain, mobileChainsState, mobileChainsLabel, onOpenMobileChainPanel, - isFullScreenMobile, + // Widget config + tokenListCategoryState, + disableErc20, + isRouteAvailable, + account, + displayLpTokenLists, + // Callbacks + onSelectToken, + openPoolPage, + onInputPressEnter, + onOpenManageWidget, + onDismiss, } = props - const { - inputValue, - setInputValue, - trimmedInputValue, - selectTokenContext, - showChainPanel, - legacyChainsState, - chainPanel, - resolvedModalTitle, - } = useSelectTokenModalLayout({ - ...props, - defaultInputValue, - hasChainPanel, - chainsPanelTitle, - }) - - // Compute the view state to hydrate the atom - const initialViewState: TokenListViewState = useMemo( - () => ({ - allTokens, - favoriteTokens, - recentTokens, - searchInput: trimmedInputValue, - areTokensLoading, - areTokensFromBridge, - hideFavoriteTokensTooltip: hideFavoriteTokensTooltip ?? false, - displayLpTokenLists: displayLpTokenLists ?? false, - selectedTargetChainId, - selectTokenContext, - onClearRecentTokens, - }), - [ - allTokens, - favoriteTokens, - recentTokens, - trimmedInputValue, - areTokensLoading, - areTokensFromBridge, - hideFavoriteTokensTooltip, - displayLpTokenLists, - selectedTargetChainId, - selectTokenContext, - onClearRecentTokens, - ], - ) - - // Hydrate atom SYNCHRONOUSLY on first render (no useEffect!) - useHydrateAtoms([[tokenListViewAtom, initialViewState]]) + // Local search state - synced to atom for children to read + const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) - // Keep atom in sync when props change (after initial render) - // Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker - // Note: Always pass the full state object; partial updates may leave stale fields + // Sync ONLY searchInput to atom (controller handles all other hydration) const updateTokenListView = useUpdateTokenListViewState() useLayoutEffect(() => { - updateTokenListView(initialViewState) - }, [initialViewState, updateTokenListView]) + updateTokenListView({ searchInput: trimmedInputValue }) + }, [trimmedInputValue, updateTokenListView]) - // Reset atom on unmount to avoid stale state on reopen (full replace, not partial merge) - const resetTokenListView = useResetTokenListViewState() - useEffect(() => { - return () => resetTokenListView() - }, [resetTokenListView]) + // Resolve layout state + const showChainPanel = hasChainPanel + const resolvedModalTitle = modalTitle ?? t`Select token` + const legacyChainsState = + !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined + const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` + // Build chain panel component + const chainPanel = useMemo( + () => + showChainPanel && chainsToSelect ? ( + + ) : null, + [chainsToSelect, onSelectChain, resolvedChainPanelTitle, showChainPanel], + ) + + // Build mobile chain selector config const mobileChainSelector = getMobileChainSelectorConfig({ showChainPanel, mobileChainsState, @@ -152,49 +118,3 @@ export function SelectTokenModal(props: SelectTokenModalProps): ReactNode { ) } - -function useSelectTokenModalLayout(props: SelectTokenModalProps): { - inputValue: string - setInputValue: (value: string) => void - trimmedInputValue: string - selectTokenContext: ReturnType - showChainPanel: boolean - legacyChainsState: SelectTokenModalProps['chainsToSelect'] - chainPanel: ReactNode - resolvedModalTitle: string -} { - const { - defaultInputValue = '', - chainsToSelect, - onSelectChain, - modalTitle, - hasChainPanel = false, - chainsPanelTitle, - } = props - - const [inputValue, setInputValue, trimmedInputValue] = useTokenSearchInput(defaultInputValue) - const selectTokenContext = useSelectTokenContext(props) - const resolvedModalTitle = modalTitle ?? t`Select token` - const showChainPanel = hasChainPanel - const legacyChainsState = - !showChainPanel && chainsToSelect && (chainsToSelect.chains?.length ?? 0) > 0 ? chainsToSelect : undefined - const resolvedChainPanelTitle = chainsPanelTitle ?? t`Cross chain swap` - const chainPanel = useMemo( - () => - showChainPanel && chainsToSelect ? ( - - ) : null, - [chainsToSelect, onSelectChain, resolvedChainPanelTitle, showChainPanel], - ) - - return { - inputValue, - setInputValue, - trimmedInputValue, - selectTokenContext, - showChainPanel, - legacyChainsState, - chainPanel, - resolvedModalTitle, - } -} diff --git a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts index e2da53c4351..61a9c6731b1 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/pure/SelectTokenModal/types.ts @@ -1,59 +1,54 @@ -import { BalancesState } from '@cowprotocol/balances-and-allowances' -import { TokenWithLogo } from '@cowprotocol/common-const' import { ChainInfo } from '@cowprotocol/cow-sdk' -import { TokenListCategory, TokenListTags, UnsupportedTokensState } from '@cowprotocol/tokens' -import { Currency } from '@uniswap/sdk-core' - -import { Nullish } from 'types' - -import { PermitCompatibleTokens } from 'modules/permit' +import { TokenListCategory } from '@cowprotocol/tokens' import { ChainsToSelectState, TokenSelectionHandler } from '../../types' -export interface TokenListContentProps { - allTokens: TokenWithLogo[] - favoriteTokens: TokenWithLogo[] - recentTokens?: TokenWithLogo[] - balancesState: BalancesState - unsupportedTokens: UnsupportedTokensState - selectedToken?: Nullish - permitCompatibleTokens: PermitCompatibleTokens - hideFavoriteTokensTooltip?: boolean - displayLpTokenLists?: boolean - disableErc20?: boolean - account: string | undefined - tokenListCategoryState: [T, (category: T) => void] - defaultInputValue?: string - areTokensLoading: boolean - tokenListTags: TokenListTags +/** + * Props that remain on SelectTokenModal after moving data to atom. + * Children read token data, context, and callbacks from tokenListViewAtom. + * + * Props categories: + * - Callbacks: onDismiss, onOpenManageWidget, onInputPressEnter, onSelectToken, onSelectChain, openPoolPage + * - Layout: standalone, hasChainPanel, isFullScreenMobile + * - Strings: modalTitle, chainsPanelTitle, defaultInputValue + * - Chain panel: chainsToSelect, mobileChainsState, mobileChainsLabel, onOpenMobileChainPanel + * - Widget config: tokenListCategoryState, disableErc20, isRouteAvailable, account, displayLpTokenLists + */ + +export interface ModalLayoutProps { standalone?: boolean - areTokensFromBridge: boolean - isRouteAvailable: boolean | undefined - modalTitle?: string hasChainPanel?: boolean - chainsPanelTitle?: string isFullScreenMobile?: boolean - selectedTargetChainId?: number - mobileChainsState?: ChainsToSelectState - mobileChainsLabel?: string - onOpenMobileChainPanel?(): void + modalTitle?: string + chainsPanelTitle?: string + defaultInputValue?: string } export interface ChainSelectionProps { chainsToSelect?: ChainsToSelectState onSelectChain(chain: ChainInfo): void + mobileChainsState?: ChainsToSelectState + mobileChainsLabel?: string + onOpenMobileChainPanel?(): void +} + +export interface WidgetConfigProps { + tokenListCategoryState: [T, (category: T) => void] + disableErc20?: boolean + isRouteAvailable: boolean | undefined + account: string | undefined + displayLpTokenLists?: boolean } export interface ModalCallbackProps { onSelectToken: TokenSelectionHandler - onTokenListItemClick?(token: TokenWithLogo): void - onClearRecentTokens?(): void openPoolPage(poolAddress: string): void onInputPressEnter?(): void onOpenManageWidget(): void onDismiss(): void } -export type SelectTokenModalProps = TokenListContentProps & +export type SelectTokenModalProps = ModalLayoutProps & ChainSelectionProps & + WidgetConfigProps & ModalCallbackProps From 8285d307d885e4f71c8d88471e51f70230fb3051 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:21:01 +0000 Subject: [PATCH 3/4] refactor: memoize hydration values for stable type inference --- .../SelectTokenWidget/controllerAtomHydration.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts index dd0123ce9c5..bf30e386158 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts @@ -98,8 +98,16 @@ export function useHydrateTokenListViewAtom({ ], ) + // Memoize hydration values to ensure stable type inference + // Define concrete tuple type so TypeScript can pick the correct useHydrateAtoms overload + type HydrationEntry = readonly [typeof tokenListViewAtom, TokenListViewState] + const hydrationValues = useMemo( + () => (shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : []), + [shouldRender, viewState], + ) + // Hydrate atom SYNCHRONOUSLY on first render (only when modal should render) - useHydrateAtoms(shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : []) + useHydrateAtoms(hydrationValues) // Keep atom in sync when data changes (after initial render) // Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker From 5a72cb23b74e2db52db1ed313162480da5a40273 Mon Sep 17 00:00:00 2001 From: fairlighteth <31534717+fairlighteth@users.noreply.github.com> Date: Thu, 11 Dec 2025 12:24:18 +0000 Subject: [PATCH 4/4] refactor: define concrete tuple type for TypeScript overloads --- .../containers/SelectTokenWidget/controllerAtomHydration.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts index bf30e386158..ed3616f2a12 100644 --- a/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts +++ b/apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controllerAtomHydration.ts @@ -24,6 +24,9 @@ interface HydrateTokenListViewAtomArgs { displayLpTokenLists: boolean } +// Concrete tuple type for useHydrateAtoms to ensure TypeScript picks the correct overload +type HydrationEntry = readonly [typeof tokenListViewAtom, TokenListViewState] + /** * Hydrates the tokenListViewAtom at the controller level. * This moves hydration responsibility from SelectTokenModal to the controller, @@ -99,8 +102,6 @@ export function useHydrateTokenListViewAtom({ ) // Memoize hydration values to ensure stable type inference - // Define concrete tuple type so TypeScript can pick the correct useHydrateAtoms overload - type HydrationEntry = readonly [typeof tokenListViewAtom, TokenListViewState] const hydrationValues = useMemo( () => (shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : []), [shouldRender, viewState],