Skip to content

Commit 01342a5

Browse files
committed
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
1 parent 169c56f commit 01342a5

File tree

14 files changed

+801
-912
lines changed

14 files changed

+801
-912
lines changed

apps/cowswap-frontend/src/modules/tokensList/containers/SelectTokenWidget/controller.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { useEffect, useRef } from 'react'
2+
13
import { useIsBridgingEnabled } from '@cowprotocol/common-hooks'
24
import { useWalletInfo } from '@cowprotocol/wallet'
35

@@ -18,6 +20,7 @@ import { useChainsToSelect } from '../../hooks/useChainsToSelect'
1820
import { useCloseTokenSelectWidget } from '../../hooks/useCloseTokenSelectWidget'
1921
import { useOnSelectChain } from '../../hooks/useOnSelectChain'
2022
import { useOnTokenListAddingError } from '../../hooks/useOnTokenListAddingError'
23+
import { useResetTokenListViewState } from '../../hooks/useResetTokenListViewState'
2124
import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
2225
import { useUpdateSelectTokenWidgetState } from '../../hooks/useUpdateSelectTokenWidgetState'
2326

@@ -75,8 +78,22 @@ export function useSelectTokenWidgetController({
7578
isBridgeFeatureEnabled,
7679
})
7780

81+
const shouldRender = Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen))
82+
83+
// Reset atom when modal closes (shouldRender becomes false)
84+
const resetTokenListView = useResetTokenListViewState()
85+
const prevShouldRenderRef = useRef(shouldRender)
86+
87+
useEffect(() => {
88+
// Only reset when transitioning from true to false
89+
if (prevShouldRenderRef.current && !shouldRender) {
90+
resetTokenListView()
91+
}
92+
prevShouldRenderRef.current = shouldRender
93+
}, [shouldRender, resetTokenListView])
94+
7895
return {
79-
shouldRender: Boolean(widgetState.onSelectToken && (widgetState.open || widgetState.forceOpen)),
96+
shouldRender,
8097
hasChainPanel: isChainPanelEnabled,
8198
viewProps,
8299
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useHydrateAtoms } from 'jotai/utils'
2+
import { useLayoutEffect, useMemo } from 'react'
3+
4+
import { TokenWithLogo } from '@cowprotocol/common-const'
5+
import { isInjectedWidget } from '@cowprotocol/common-utils'
6+
7+
import { useUpdateTokenListViewState } from '../../hooks/useUpdateTokenListViewState'
8+
import { tokenListViewAtom, TokenListViewState } from '../../state/tokenListViewAtom'
9+
import { SelectTokenContext } from '../../types'
10+
11+
import type { TokenDataSources } from './tokenDataHooks'
12+
import type { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState'
13+
14+
interface HydrateTokenListViewAtomArgs {
15+
shouldRender: boolean
16+
tokenData: TokenDataSources
17+
widgetState: ReturnType<typeof useSelectTokenWidgetState>
18+
favoriteTokens: TokenWithLogo[]
19+
recentTokens: TokenWithLogo[] | undefined
20+
onClearRecentTokens: (() => void) | undefined
21+
onTokenListItemClick: ((token: TokenWithLogo) => void) | undefined
22+
handleSelectToken: (token: TokenWithLogo) => Promise<void> | void
23+
account: string | undefined
24+
displayLpTokenLists: boolean
25+
}
26+
27+
/**
28+
* Hydrates the tokenListViewAtom at the controller level.
29+
* This moves hydration responsibility from SelectTokenModal to the controller,
30+
* allowing the modal to receive fewer props while children read from the atom.
31+
*
32+
* Only hydrates when shouldRender is true to avoid unnecessary atom writes
33+
* when the modal isn't supposed to be displayed.
34+
*/
35+
export function useHydrateTokenListViewAtom({
36+
shouldRender,
37+
tokenData,
38+
widgetState,
39+
favoriteTokens,
40+
recentTokens,
41+
onClearRecentTokens,
42+
onTokenListItemClick,
43+
handleSelectToken,
44+
account,
45+
displayLpTokenLists,
46+
}: HydrateTokenListViewAtomArgs): void {
47+
const updateTokenListView = useUpdateTokenListViewState()
48+
49+
// Build the selectTokenContext object
50+
const selectTokenContext: SelectTokenContext = useMemo(
51+
() => ({
52+
balancesState: tokenData.balancesState,
53+
selectedToken: widgetState.selectedToken,
54+
onSelectToken: handleSelectToken,
55+
onTokenListItemClick,
56+
unsupportedTokens: tokenData.unsupportedTokens,
57+
permitCompatibleTokens: tokenData.permitCompatibleTokens,
58+
tokenListTags: tokenData.tokenListTags,
59+
isWalletConnected: !!account,
60+
}),
61+
[
62+
tokenData.balancesState,
63+
widgetState.selectedToken,
64+
handleSelectToken,
65+
onTokenListItemClick,
66+
tokenData.unsupportedTokens,
67+
tokenData.permitCompatibleTokens,
68+
tokenData.tokenListTags,
69+
account,
70+
],
71+
)
72+
73+
// Compute the full view state to hydrate
74+
// Note: searchInput is handled by the modal (local state + sync effect)
75+
const viewState: Omit<TokenListViewState, 'searchInput'> = useMemo(
76+
() => ({
77+
allTokens: tokenData.allTokens,
78+
favoriteTokens,
79+
recentTokens,
80+
areTokensLoading: tokenData.areTokensLoading,
81+
areTokensFromBridge: tokenData.areTokensFromBridge,
82+
hideFavoriteTokensTooltip: isInjectedWidget(),
83+
selectedTargetChainId: widgetState.selectedTargetChainId,
84+
selectTokenContext,
85+
onClearRecentTokens,
86+
displayLpTokenLists,
87+
}),
88+
[
89+
tokenData.allTokens,
90+
favoriteTokens,
91+
recentTokens,
92+
tokenData.areTokensLoading,
93+
tokenData.areTokensFromBridge,
94+
widgetState.selectedTargetChainId,
95+
selectTokenContext,
96+
onClearRecentTokens,
97+
displayLpTokenLists,
98+
],
99+
)
100+
101+
// Hydrate atom SYNCHRONOUSLY on first render (only when modal should render)
102+
useHydrateAtoms(shouldRender ? [[tokenListViewAtom, { ...viewState, searchInput: '' }]] : [])
103+
104+
// Keep atom in sync when data changes (after initial render)
105+
// Using useLayoutEffect to ensure atom is updated before paint, avoiding flicker
106+
// Skip when modal isn't rendered to avoid unnecessary atom writes
107+
useLayoutEffect(() => {
108+
if (shouldRender) {
109+
updateTokenListView(viewState)
110+
}
111+
}, [shouldRender, viewState, updateTokenListView])
112+
}
Lines changed: 31 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
1-
import { TokenWithLogo } from '@cowprotocol/common-const'
2-
3-
import { buildSelectTokenModalPropsInput, buildSelectTokenWidgetViewProps, useSelectTokenModalPropsMemo } from './controllerProps'
1+
import { buildSelectTokenModalPropsInput, SelectTokenWidgetViewProps } from './controllerProps'
42
import {
53
useManageWidgetVisibility,
64
usePoolPageHandlers,
7-
useRecentTokenSection,
85
useTokenDataSources,
96
useTokenSelectionHandler,
107
useWidgetMetadata,
@@ -17,64 +14,57 @@ import { useSelectTokenWidgetState } from '../../hooks/useSelectTokenWidgetState
1714
import type { WidgetViewDependenciesResult } from './controllerDependencies'
1815
import type { SelectTokenModalProps } from '../../pure/SelectTokenModal'
1916

20-
const EMPTY_FAV_TOKENS: TokenWithLogo[] = []
21-
2217
interface WidgetModalPropsArgs {
2318
account: string | undefined
2419
chainsToSelect: ReturnType<typeof useChainsToSelect>
2520
displayLpTokenLists?: boolean
2621
widgetDeps: WidgetViewDependenciesResult
2722
hasChainPanel: boolean
2823
onSelectChain: ReturnType<typeof useOnSelectChain>
29-
recentTokens: ReturnType<typeof useRecentTokenSection>['recentTokens']
3024
standalone?: boolean
31-
tokenData: ReturnType<typeof useTokenDataSources>
3225
widgetMetadata: ReturnType<typeof useWidgetMetadata>
3326
widgetState: ReturnType<typeof useSelectTokenWidgetState>
34-
isInjectedWidgetMode: boolean
27+
isRouteAvailable: boolean | undefined
3528
}
3629

30+
/**
31+
* Builds modal props.
32+
* Token data and context are hydrated to atom by controller - no longer passed as props.
33+
*/
3734
export function useWidgetModalProps({
3835
account,
3936
chainsToSelect,
4037
displayLpTokenLists,
4138
widgetDeps,
4239
hasChainPanel,
4340
onSelectChain,
44-
recentTokens,
4541
standalone,
46-
tokenData,
4742
widgetMetadata,
4843
widgetState,
49-
isInjectedWidgetMode,
44+
isRouteAvailable,
5045
}: WidgetModalPropsArgs): SelectTokenModalProps {
51-
const favoriteTokens = standalone ? EMPTY_FAV_TOKENS : tokenData.favoriteTokens
52-
53-
return useSelectTokenModalPropsMemo(
54-
createSelectTokenModalProps({
55-
account,
56-
chainsPanelTitle: widgetMetadata.chainsPanelTitle,
57-
chainsState: chainsToSelect,
58-
disableErc20: widgetMetadata.disableErc20,
59-
displayLpTokenLists,
60-
favoriteTokens,
61-
handleSelectToken: widgetDeps.handleSelectToken,
62-
hasChainPanel,
63-
isInjectedWidgetMode,
64-
modalTitle: widgetMetadata.modalTitle,
65-
onDismiss: widgetDeps.onDismiss,
66-
onSelectChain,
67-
onTokenListItemClick: widgetDeps.handleTokenListItemClick,
68-
onClearRecentTokens: widgetDeps.clearRecentTokens,
69-
onOpenManageWidget: widgetDeps.openManageWidget,
70-
openPoolPage: widgetDeps.openPoolPage,
71-
recentTokens,
72-
standalone,
73-
tokenData,
74-
tokenListCategoryState: widgetMetadata.tokenListCategoryState,
75-
widgetState,
76-
}),
77-
)
46+
return buildSelectTokenModalPropsInput({
47+
// Layout
48+
standalone,
49+
hasChainPanel,
50+
modalTitle: widgetMetadata.modalTitle,
51+
chainsPanelTitle: widgetMetadata.chainsPanelTitle,
52+
// Chain panel
53+
chainsState: chainsToSelect,
54+
onSelectChain,
55+
// Widget config
56+
displayLpTokenLists,
57+
tokenListCategoryState: widgetMetadata.tokenListCategoryState,
58+
disableErc20: widgetMetadata.disableErc20,
59+
isRouteAvailable,
60+
account,
61+
// Callbacks
62+
handleSelectToken: widgetDeps.handleSelectToken,
63+
onDismiss: widgetDeps.onDismiss,
64+
onOpenManageWidget: widgetDeps.openManageWidget,
65+
openPoolPage: widgetDeps.openPoolPage,
66+
onInputPressEnter: widgetState.onInputPressEnter,
67+
})
7868
}
7969

8070
interface BuildViewPropsArgs {
@@ -87,7 +77,7 @@ interface BuildViewPropsArgs {
8777
isChainPanelEnabled: boolean
8878
onDismiss: () => void
8979
onSelectChain: ReturnType<typeof useOnSelectChain>
90-
selectTokenModalProps: ReturnType<typeof useSelectTokenModalPropsMemo>
80+
selectTokenModalProps: SelectTokenModalProps
9181
selectedPoolAddress: ReturnType<typeof useSelectTokenWidgetState>['selectedPoolAddress']
9282
standalone: boolean | undefined
9383
tokenToImport: ReturnType<typeof useSelectTokenWidgetState>['tokenToImport']
@@ -97,9 +87,7 @@ interface BuildViewPropsArgs {
9787
handleSelectToken: ReturnType<typeof useTokenSelectionHandler>
9888
}
9989

100-
type BuildViewPropsInput = Parameters<typeof buildSelectTokenWidgetViewProps>[0]
101-
102-
export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): BuildViewPropsInput {
90+
export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): SelectTokenWidgetViewProps {
10391
const {
10492
standalone,
10593
tokenToImport,
@@ -142,73 +130,3 @@ export function getSelectTokenWidgetViewPropsArgs(args: BuildViewPropsArgs): Bui
142130
onSelectToken: handleSelectToken,
143131
}
144132
}
145-
146-
function createSelectTokenModalProps({
147-
account,
148-
chainsPanelTitle,
149-
chainsState,
150-
disableErc20,
151-
displayLpTokenLists,
152-
favoriteTokens,
153-
handleSelectToken,
154-
hasChainPanel,
155-
isInjectedWidgetMode,
156-
modalTitle,
157-
onDismiss,
158-
onSelectChain,
159-
onTokenListItemClick,
160-
onClearRecentTokens,
161-
onOpenManageWidget,
162-
openPoolPage,
163-
recentTokens,
164-
standalone,
165-
tokenData,
166-
tokenListCategoryState,
167-
widgetState,
168-
}: {
169-
account: string | undefined
170-
chainsPanelTitle: string
171-
chainsState: ReturnType<typeof useChainsToSelect>
172-
disableErc20: boolean
173-
displayLpTokenLists: boolean | undefined
174-
favoriteTokens: TokenWithLogo[]
175-
handleSelectToken: ReturnType<typeof useTokenSelectionHandler>
176-
hasChainPanel: boolean
177-
isInjectedWidgetMode: boolean
178-
modalTitle: string
179-
onDismiss: () => void
180-
onSelectChain: ReturnType<typeof useOnSelectChain>
181-
onTokenListItemClick: ReturnType<typeof useRecentTokenSection>['handleTokenListItemClick']
182-
onClearRecentTokens: ReturnType<typeof useRecentTokenSection>['clearRecentTokens']
183-
onOpenManageWidget: ReturnType<typeof useManageWidgetVisibility>['openManageWidget']
184-
openPoolPage: ReturnType<typeof usePoolPageHandlers>['openPoolPage']
185-
recentTokens: ReturnType<typeof useRecentTokenSection>['recentTokens']
186-
standalone: boolean | undefined
187-
tokenData: ReturnType<typeof useTokenDataSources>
188-
tokenListCategoryState: ReturnType<typeof useWidgetMetadata>['tokenListCategoryState']
189-
widgetState: ReturnType<typeof useSelectTokenWidgetState>
190-
}): SelectTokenModalProps {
191-
return buildSelectTokenModalPropsInput({
192-
standalone,
193-
displayLpTokenLists,
194-
tokenData,
195-
widgetState,
196-
favoriteTokens,
197-
recentTokens,
198-
handleSelectToken,
199-
onTokenListItemClick,
200-
onClearRecentTokens,
201-
onDismiss,
202-
onOpenManageWidget,
203-
openPoolPage,
204-
tokenListCategoryState,
205-
disableErc20,
206-
account,
207-
hasChainPanel,
208-
chainsState,
209-
chainsPanelTitle,
210-
onSelectChain,
211-
isInjectedWidgetMode,
212-
modalTitle,
213-
})
214-
}

0 commit comments

Comments
 (0)