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;