diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index f7a69b27..d27f5847 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -250,11 +250,13 @@ const [showModal, setShowModal] = useState(false); **Pattern 2: Global State (Zustand)** ```tsx -const { open } = useModal(); +const { open } = useModalStore(); ``` -Use Pattern 2 only when: multi-trigger (2+ places), props drilling pain, or modal chaining. +Use Pattern 2 when: multi-trigger (2+ places), props drilling pain, modal chaining, or **nested modals**. + +⚠️ **Nested modals must use Pattern 2.** Radix-UI crashes with "Maximum update depth exceeded" when multiple Dialogs are mounted simultaneously ([#3675](https://github.com/radix-ui/primitives/issues/3675)). The modal store avoids this by only mounting visible modals. --- diff --git a/src/features/positions/components/rebalance/rebalance-modal.tsx b/src/features/positions/components/rebalance/rebalance-modal.tsx index 4d236133..2cdf683f 100644 --- a/src/features/positions/components/rebalance/rebalance-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-modal.tsx @@ -1,13 +1,13 @@ -import { useState, useMemo, useCallback } from 'react'; +import { useState, useMemo, useCallback, useEffect } from 'react'; import { ReloadIcon } from '@radix-ui/react-icons'; import { parseUnits, formatUnits } from 'viem'; import { Button } from '@/components/ui/button'; -import { MarketSelectionModal } from '@/features/markets/components/market-selection-modal'; import { Modal, ModalHeader, ModalBody, ModalFooter } from '@/components/common/Modal'; import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { useAppSettings } from '@/stores/useAppSettings'; +import { useModalStore } from '@/stores/useModalStore'; import { useProcessedMarkets } from '@/hooks/useProcessedMarkets'; import { useRebalance } from '@/hooks/useRebalance'; import { useStyledToast } from '@/hooks/useStyledToast'; @@ -16,7 +16,6 @@ import type { GroupedPosition, RebalanceAction } from '@/utils/types'; import { FromMarketsTable } from '../from-markets-table'; import { RebalanceActionInput } from './rebalance-action-input'; import { RebalanceCart } from './rebalance-cart'; -import { RebalanceProcessModal } from './rebalance-process-modal'; type RebalanceModalProps = { groupedPosition: GroupedPosition; @@ -30,16 +29,22 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState(''); const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState(''); const [amount, setAmount] = useState('0'); - const [showProcessModal, setShowProcessModal] = useState(false); - const [showToModal, setShowToModal] = useState(false); const toast = useStyledToast(); const { usePermit2: usePermit2Setting } = useAppSettings(); + const { open: openModal, close: closeModal, update: updateModal, isOpen: isModalOpen } = useModalStore(); // Use computed markets based on user setting const { markets } = useProcessedMarkets(); const { rebalanceActions, addRebalanceAction, removeRebalanceAction, executeRebalance, isProcessing, currentStep } = useRebalance(groupedPosition); + // Sync currentStep to rebalanceProcess modal when it changes + useEffect(() => { + if (isModalOpen('rebalanceProcess')) { + updateModal('rebalanceProcess', { currentStep }); + } + }, [currentStep, isModalOpen, updateModal]); + // Filter eligible markets (same loan asset and chain) // Fresh state is fetched by MarketsTableWithSameLoanAsset component const eligibleMarkets = useMemo(() => { @@ -196,7 +201,12 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, const handleExecuteRebalance = useCallback(() => { void (async () => { - setShowProcessModal(true); + openModal('rebalanceProcess', { + currentStep, + isPermit2Flow: usePermit2Setting, + tokenSymbol: groupedPosition.loanAsset, + actionsCount: rebalanceActions.length, + }); try { const result = await executeRebalance(); // Explicitly refetch AFTER successful execution @@ -209,10 +219,20 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, } catch (error) { console.error('Error during rebalance:', error); } finally { - setShowProcessModal(false); + closeModal('rebalanceProcess'); } })(); - }, [executeRebalance, toast, refetch]); + }, [ + executeRebalance, + toast, + refetch, + openModal, + closeModal, + currentStep, + usePermit2Setting, + groupedPosition.loanAsset, + rebalanceActions.length, + ]); const handleManualRefresh = () => { refetch(() => { @@ -223,125 +243,109 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch, }; return ( - <> - - - Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position - {isRefetching && } - - } - description={`Click on your existing position to rebalance ${ - groupedPosition.loanAssetSymbol ?? groupedPosition.loanAsset ?? 'this token' - } to a new market. You can batch actions.`} - mainIcon={ - - } - onClose={() => onOpenChange(false)} - auxiliaryAction={{ - icon: , - onClick: () => { - if (!isRefetching) { - handleManualRefresh(); - } - }, - ariaLabel: 'Refresh position data', - }} - /> - - BigInt(p.state.supplyShares) > 0) - .map((market) => ({ - ...market, - pendingDelta: getPendingDelta(market.market.uniqueKey), - }))} - selectedMarketUniqueKey={selectedFromMarketUniqueKey} - onSelectMarket={setSelectedFromMarketUniqueKey} - onSelectMax={handleMaxSelect} + + + Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position + {isRefetching && } + + } + description={`Click on your existing position to rebalance ${ + groupedPosition.loanAssetSymbol ?? groupedPosition.loanAsset ?? 'this token' + } to a new market. You can batch actions.`} + mainIcon={ + + } + onClose={() => onOpenChange(false)} + auxiliaryAction={{ + icon: , + onClick: () => { + if (!isRefetching) { + handleManualRefresh(); + } + }, + ariaLabel: 'Refresh position data', + }} + /> + + BigInt(p.state.supplyShares) > 0) + .map((market) => ({ + ...market, + pendingDelta: getPendingDelta(market.market.uniqueKey), + }))} + selectedMarketUniqueKey={selectedFromMarketUniqueKey} + onSelectMarket={setSelectedFromMarketUniqueKey} + onSelectMax={handleMaxSelect} + /> - + openModal('rebalanceMarketSelection', { + vaultAsset: groupedPosition.loanAssetAddress as `0x${string}`, chainId: groupedPosition.chainId, - }} - onAddAction={handleAddAction} - onToMarketClick={() => setShowToModal(true)} - onClearToMarket={() => setSelectedToMarketUniqueKey('')} - /> - - - - - - - Execute Rebalance - - - - {showProcessModal && ( - { + if (_markets.length > 0) { + setSelectedToMarketUniqueKey(_markets[0].uniqueKey); + } + closeModal('rebalanceMarketSelection'); + }, + }) + } + onClearToMarket={() => setSelectedToMarketUniqueKey('')} /> - )} - - {showToModal && ( - { - if (selectedMarkets.length > 0) { - setSelectedToMarketUniqueKey(selectedMarkets[0].uniqueKey); - } - }} - confirmButtonText="Select Market" + + - )} - + + + + + Execute Rebalance + + + ); } diff --git a/src/features/positions/components/rebalance/rebalance-process-modal.tsx b/src/features/positions/components/rebalance/rebalance-process-modal.tsx index b174756e..9b8bbaf4 100644 --- a/src/features/positions/components/rebalance/rebalance-process-modal.tsx +++ b/src/features/positions/components/rebalance/rebalance-process-modal.tsx @@ -8,6 +8,7 @@ import type { RebalanceStepType } from '@/hooks/useRebalance'; type RebalanceProcessModalProps = { currentStep: RebalanceStepType; isPermit2Flow: boolean; + isOpen: boolean; onOpenChange: (open: boolean) => void; tokenSymbol: string; actionsCount: number; @@ -16,6 +17,7 @@ type RebalanceProcessModalProps = { export function RebalanceProcessModal({ currentStep, isPermit2Flow, + isOpen, onOpenChange, tokenSymbol, actionsCount, @@ -85,7 +87,7 @@ export function RebalanceProcessModal({ return ( >(new Set()); - const [showRebalanceModal, setShowRebalanceModal] = useState(false); - const [selectedGroupedPosition, setSelectedGroupedPosition] = useState(null); const { showCollateralExposure, setShowCollateralExposure } = usePositionsPreferences(); const { isOpen: isSettingsOpen, onOpen: onSettingsOpen, onOpenChange: onSettingsOpenChange } = useDisclosure(); const { address } = useConnection(); const { isAprDisplay } = useAppSettings(); const { short: rateLabel } = useRateLabel(); + const { open: openModal } = useModalStore(); const toast = useStyledToast(); @@ -135,18 +134,6 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr const processedPositions = useMemo(() => processCollaterals(groupedPositions), [groupedPositions]); - useEffect(() => { - if (selectedGroupedPosition) { - const updatedPosition = processedPositions.find( - (position) => - position.loanAssetAddress === selectedGroupedPosition.loanAssetAddress && position.chainId === selectedGroupedPosition.chainId, - ); - if (updatedPosition) { - setSelectedGroupedPosition(updatedPosition); - } - } - }, [processedPositions]); - const toggleRow = (rowKey: string) => { setExpandedRows((prev) => { const newSet = new Set(prev); @@ -338,8 +325,11 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr toast.error('No authorization', 'You can only rebalance your own positions'); return; } - setSelectedGroupedPosition(groupedPosition); - setShowRebalanceModal(true); + openModal('rebalance', { + groupedPosition, + refetch, + isRefetching, + }); }} /> @@ -374,15 +364,6 @@ export function SuppliedMorphoBlueGroupedTable({ account }: SuppliedMorphoBlueGr - {showRebalanceModal && selectedGroupedPosition && ( - - )} import('@/features/swap/components/BridgeSwap // Supply & Withdraw const SupplyModalV2 = lazy(() => import('@/modals/supply/supply-modal').then((m) => ({ default: m.SupplyModalV2 }))); +// Rebalance +const RebalanceModal = lazy(() => + import('@/features/positions/components/rebalance/rebalance-modal').then((m) => ({ default: m.RebalanceModal })), +); + +const RebalanceProcessModal = lazy(() => + import('@/features/positions/components/rebalance/rebalance-process-modal').then((m) => ({ default: m.RebalanceProcessModal })), +); + +const RebalanceMarketSelectionModal = lazy(() => + import('@/features/markets/components/market-selection-modal').then((m) => ({ default: m.MarketSelectionModal })), +); + // Settings & Configuration const BlacklistedMarketsModal = lazy(() => import('@/modals/settings/blacklisted-markets-modal').then((m) => ({ @@ -38,6 +51,9 @@ export const MODAL_REGISTRY: { } = { bridgeSwap: BridgeSwapModal, supply: SupplyModalV2, + rebalance: RebalanceModal, + rebalanceProcess: RebalanceProcessModal, + rebalanceMarketSelection: RebalanceMarketSelectionModal, marketSettings: MarketSettingsModal, trustedVaults: TrustedVaultsModal, blacklistedMarkets: BlacklistedMarketsModal, diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 2c445a7f..70fe3615 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -1,6 +1,8 @@ import { create } from 'zustand'; -import type { Market, MarketPosition } from '@/utils/types'; +import type { Market, MarketPosition, GroupedPosition } from '@/utils/types'; import type { SwapToken } from '@/features/swap/types'; +import type { RebalanceStepType } from '@/hooks/useRebalance'; +import type { SupportedNetworks } from '@/utils/networks'; /** * Registry of Zustand-managed modals (Pattern 2). @@ -22,6 +24,27 @@ export type ModalProps = { refetch?: () => void; }; + // Rebalance + rebalance: { + groupedPosition: GroupedPosition; + refetch: (onSuccess?: () => void) => void; + isRefetching: boolean; + }; + + rebalanceProcess: { + currentStep: RebalanceStepType; + isPermit2Flow: boolean; + tokenSymbol: string; + actionsCount: number; + }; + + rebalanceMarketSelection: { + vaultAsset: `0x${string}`; + chainId: SupportedNetworks; + multiSelect?: boolean; + onSelect: (markets: Market[]) => void; + }; + // Settings & Configuration marketSettings: Record; // No props needed - uses useMarketPreferences() store @@ -59,6 +82,12 @@ type ModalActions = { */ closeAll: () => void; + /** + * Update props for an existing modal by type. + * Useful for modals that need dynamic prop updates while open. + */ + update: (type: T, props: Partial) => void; + /** * Get props for a specific modal type (useful for modal components). */ @@ -122,6 +151,17 @@ export const useModalStore = create((set, get) => ({ set({ stack: [] }); }, + update: (type, props) => { + set((state) => ({ + stack: state.stack.map((modal) => { + if (modal.type === type) { + return { ...modal, props: { ...modal.props, ...props } }; + } + return modal; + }), + })); + }, + getModalProps: (type) => { const modal = get().stack.find((m) => m.type === type); return modal?.props as ModalProps[typeof type] | undefined;