Skip to content
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect, useRef } from 'react'

import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
import { useWalletInfo } from '@cowprotocol/wallet'

Expand All @@ -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'

Expand Down Expand Up @@ -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,
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
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<typeof useSelectTokenWidgetState>
favoriteTokens: TokenWithLogo[]
recentTokens: TokenWithLogo[] | undefined
onClearRecentTokens: (() => void) | undefined
onTokenListItemClick: ((token: TokenWithLogo) => void) | undefined
handleSelectToken: (token: TokenWithLogo) => Promise<void> | void
account: string | undefined
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,
* 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<TokenListViewState, 'searchInput'> = 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,
],
)

// Memoize hydration values to ensure stable type inference
const hydrationValues = useMemo<HydrationEntry[]>(
() => (shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : []),
[shouldRender, viewState],
)

// Hydrate atom SYNCHRONOUSLY on first render (only when modal should render)
useHydrateAtoms(hydrationValues)

// 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])
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,64 +14,57 @@ 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<typeof useChainsToSelect>
displayLpTokenLists?: boolean
widgetDeps: WidgetViewDependenciesResult
hasChainPanel: boolean
onSelectChain: ReturnType<typeof useOnSelectChain>
recentTokens: ReturnType<typeof useRecentTokenSection>['recentTokens']
standalone?: boolean
tokenData: ReturnType<typeof useTokenDataSources>
widgetMetadata: ReturnType<typeof useWidgetMetadata>
widgetState: ReturnType<typeof useSelectTokenWidgetState>
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,
displayLpTokenLists,
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 {
Expand All @@ -87,7 +77,7 @@ interface BuildViewPropsArgs {
isChainPanelEnabled: boolean
onDismiss: () => void
onSelectChain: ReturnType<typeof useOnSelectChain>
selectTokenModalProps: ReturnType<typeof useSelectTokenModalPropsMemo>
selectTokenModalProps: SelectTokenModalProps
selectedPoolAddress: ReturnType<typeof useSelectTokenWidgetState>['selectedPoolAddress']
standalone: boolean | undefined
tokenToImport: ReturnType<typeof useSelectTokenWidgetState>['tokenToImport']
Expand All @@ -97,9 +87,7 @@ interface BuildViewPropsArgs {
handleSelectToken: ReturnType<typeof useTokenSelectionHandler>
}

type BuildViewPropsInput = Parameters<typeof buildSelectTokenWidgetViewProps>[0]

export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): BuildViewPropsInput {
export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): SelectTokenWidgetViewProps {
const {
standalone,
tokenToImport,
Expand Down Expand Up @@ -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<typeof useChainsToSelect>
disableErc20: boolean
displayLpTokenLists: boolean | undefined
favoriteTokens: TokenWithLogo[]
handleSelectToken: ReturnType<typeof useTokenSelectionHandler>
hasChainPanel: boolean
isInjectedWidgetMode: boolean
modalTitle: string
onDismiss: () => void
onSelectChain: ReturnType<typeof useOnSelectChain>
onTokenListItemClick: ReturnType<typeof useRecentTokenSection>['handleTokenListItemClick']
onClearRecentTokens: ReturnType<typeof useRecentTokenSection>['clearRecentTokens']
onOpenManageWidget: ReturnType<typeof useManageWidgetVisibility>['openManageWidget']
openPoolPage: ReturnType<typeof usePoolPageHandlers>['openPoolPage']
recentTokens: ReturnType<typeof useRecentTokenSection>['recentTokens']
standalone: boolean | undefined
tokenData: ReturnType<typeof useTokenDataSources>
tokenListCategoryState: ReturnType<typeof useWidgetMetadata>['tokenListCategoryState']
widgetState: ReturnType<typeof useSelectTokenWidgetState>
}): SelectTokenModalProps {
return buildSelectTokenModalPropsInput({
standalone,
displayLpTokenLists,
tokenData,
widgetState,
favoriteTokens,
recentTokens,
handleSelectToken,
onTokenListItemClick,
onClearRecentTokens,
onDismiss,
onOpenManageWidget,
openPoolPage,
tokenListCategoryState,
disableErc20,
account,
hasChainPanel,
chainsState,
chainsPanelTitle,
onSelectChain,
isInjectedWidgetMode,
modalTitle,
})
}
Loading