Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,11 +250,13 @@ const [showModal, setShowModal] = useState(false);

**Pattern 2: Global State (Zustand)**
```tsx
const { open } = useModal();
const { open } = useModalStore();
<Button onClick={() => open('supply', { market, position })}>Supply</Button>
```

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.

---

Expand Down
250 changes: 127 additions & 123 deletions src/features/positions/components/rebalance/rebalance-modal.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand All @@ -30,16 +29,22 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch,
const [selectedFromMarketUniqueKey, setSelectedFromMarketUniqueKey] = useState('');
const [selectedToMarketUniqueKey, setSelectedToMarketUniqueKey] = useState('');
const [amount, setAmount] = useState<string>('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(() => {
Expand Down Expand Up @@ -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
Expand All @@ -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(() => {
Expand All @@ -223,125 +243,109 @@ export function RebalanceModal({ groupedPosition, isOpen, onOpenChange, refetch,
};

return (
<>
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
isDismissable={false}
flexibleWidth
>
<ModalHeader
title={
<div className="flex items-center gap-2">
<span className="text-2xl">Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position</span>
{isRefetching && <Spinner size={20} />}
</div>
}
description={`Click on your existing position to rebalance ${
groupedPosition.loanAssetSymbol ?? groupedPosition.loanAsset ?? 'this token'
} to a new market. You can batch actions.`}
mainIcon={
<TokenIcon
address={groupedPosition.loanAssetAddress as `0x${string}`}
chainId={groupedPosition.chainId}
symbol={groupedPosition.loanAssetSymbol}
width={28}
height={28}
/>
}
onClose={() => onOpenChange(false)}
auxiliaryAction={{
icon: <ReloadIcon className={`h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`} />,
onClick: () => {
if (!isRefetching) {
handleManualRefresh();
}
},
ariaLabel: 'Refresh position data',
}}
/>
<ModalBody className="gap-4">
<FromMarketsTable
positions={groupedPosition.markets
.filter((p) => BigInt(p.state.supplyShares) > 0)
.map((market) => ({
...market,
pendingDelta: getPendingDelta(market.market.uniqueKey),
}))}
selectedMarketUniqueKey={selectedFromMarketUniqueKey}
onSelectMarket={setSelectedFromMarketUniqueKey}
onSelectMax={handleMaxSelect}
<Modal
isOpen={isOpen}
onOpenChange={onOpenChange}
isDismissable={false}
flexibleWidth
>
<ModalHeader
title={
<div className="flex items-center gap-2">
<span className="text-2xl">Rebalance {groupedPosition.loanAsset ?? 'Unknown'} Position</span>
{isRefetching && <Spinner size={20} />}
</div>
}
description={`Click on your existing position to rebalance ${
groupedPosition.loanAssetSymbol ?? groupedPosition.loanAsset ?? 'this token'
} to a new market. You can batch actions.`}
mainIcon={
<TokenIcon
address={groupedPosition.loanAssetAddress as `0x${string}`}
chainId={groupedPosition.chainId}
symbol={groupedPosition.loanAssetSymbol}
width={28}
height={28}
/>
}
onClose={() => onOpenChange(false)}
auxiliaryAction={{
icon: <ReloadIcon className={`h-4 w-4 ${isRefetching ? 'animate-spin' : ''}`} />,
onClick: () => {
if (!isRefetching) {
handleManualRefresh();
}
},
ariaLabel: 'Refresh position data',
}}
/>
<ModalBody className="gap-4">
<FromMarketsTable
positions={groupedPosition.markets
.filter((p) => BigInt(p.state.supplyShares) > 0)
.map((market) => ({
...market,
pendingDelta: getPendingDelta(market.market.uniqueKey),
}))}
selectedMarketUniqueKey={selectedFromMarketUniqueKey}
onSelectMarket={setSelectedFromMarketUniqueKey}
onSelectMax={handleMaxSelect}
/>

<RebalanceActionInput
amount={amount}
setAmount={setAmount}
selectedFromMarketUniqueKey={selectedFromMarketUniqueKey}
selectedToMarketUniqueKey={selectedToMarketUniqueKey}
groupedPosition={groupedPosition}
eligibleMarkets={eligibleMarkets}
token={{
address: groupedPosition.loanAssetAddress,
<RebalanceActionInput
amount={amount}
setAmount={setAmount}
selectedFromMarketUniqueKey={selectedFromMarketUniqueKey}
selectedToMarketUniqueKey={selectedToMarketUniqueKey}
groupedPosition={groupedPosition}
eligibleMarkets={eligibleMarkets}
token={{
address: groupedPosition.loanAssetAddress,
chainId: groupedPosition.chainId,
}}
onAddAction={handleAddAction}
onToMarketClick={() =>
openModal('rebalanceMarketSelection', {
vaultAsset: groupedPosition.loanAssetAddress as `0x${string}`,
chainId: groupedPosition.chainId,
}}
onAddAction={handleAddAction}
onToMarketClick={() => setShowToModal(true)}
onClearToMarket={() => setSelectedToMarketUniqueKey('')}
/>

<RebalanceCart
rebalanceActions={rebalanceActions}
groupedPosition={groupedPosition}
eligibleMarkets={eligibleMarkets}
removeRebalanceAction={removeRebalanceAction}
/>
</ModalBody>
<ModalFooter className="mx-2">
<Button
variant="default"
onClick={() => onOpenChange(false)}
className="rounded-sm p-4 px-10 font-zen text-secondary duration-200 ease-in-out hover:scale-105"
>
Cancel
</Button>
<ExecuteTransactionButton
targetChainId={groupedPosition.chainId}
onClick={handleExecuteRebalance}
disabled={rebalanceActions.length === 0}
isLoading={isProcessing}
variant="primary"
className="rounded-sm p-4 px-10 font-zen text-white duration-200 ease-in-out hover:scale-105 disabled:opacity-50"
>
Execute Rebalance
</ExecuteTransactionButton>
</ModalFooter>
</Modal>
{showProcessModal && (
<RebalanceProcessModal
currentStep={currentStep}
isPermit2Flow={usePermit2Setting}
onOpenChange={setShowProcessModal}
tokenSymbol={groupedPosition.loanAsset}
actionsCount={rebalanceActions.length}
multiSelect: false,
onSelect: (_markets) => {
if (_markets.length > 0) {
setSelectedToMarketUniqueKey(_markets[0].uniqueKey);
}
closeModal('rebalanceMarketSelection');
},
})
}
Comment on lines +307 to +319
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the types related to SupportedNetworks
rg -t ts "type SupportedNetworks\s*=" --no-heading -A 3

Repository: antoncoding/monarch

Length of output: 45


🏁 Script executed:

# Find ModalProps definition
rg -t ts "ModalProps" --no-heading -B 2 -A 5 | head -50

Repository: antoncoding/monarch

Length of output: 2576


🏁 Script executed:

# Look at the rebalance-modal.tsx file around the lines in question
sed -n '300,330p' src/features/positions/components/rebalance/rebalance-modal.tsx

Repository: antoncoding/monarch

Length of output: 1198


🏁 Script executed:

# Find groupedPosition type definition
rg -t ts "groupedPosition" src/features/positions/components/rebalance/rebalance-modal.tsx -B 2 -A 2 | head -30

Repository: antoncoding/monarch

Length of output: 1222


🏁 Script executed:

# Get the full ModalProps definition
rg -t ts "export type ModalProps" -A 100 src/stores/useModalStore.ts | head -150

Repository: antoncoding/monarch

Length of output: 2594


🏁 Script executed:

# Find GroupedPosition type definition
rg -t ts "type GroupedPosition|interface GroupedPosition" -A 10

Repository: antoncoding/monarch

Length of output: 1978


🏁 Script executed:

# Search for SupportedNetworks - try different patterns
rg -t ts "SupportedNetworks" --no-heading | head -20

Repository: antoncoding/monarch

Length of output: 1847


🏁 Script executed:

# Find the SupportedNetworks definition
rg -t ts "type SupportedNetworks|enum SupportedNetworks" -A 10 src/utils/networks.ts

Repository: antoncoding/monarch

Length of output: 256


🏁 Script executed:

# Also check if there's a compilation happening and what errors exist
fd -t f 'package.json' | head -1 | xargs cat | grep -A 3 "scripts"

Repository: antoncoding/monarch

Length of output: 498


🏁 Script executed:

# Check if there's any type casting or assertion around the chainId usage
sed -n '305,325p' src/features/positions/components/rebalance/rebalance-modal.tsx

Repository: antoncoding/monarch

Length of output: 823


🏁 Script executed:

# Also check TypeScript strictness settings
cat tsconfig.json | grep -A 5 -B 5 "strict"

Repository: antoncoding/monarch

Length of output: 480


Cast chainId to SupportedNetworks or validate it first.

groupedPosition.chainId is typed as number, but ModalProps['rebalanceMarketSelection'].chainId expects the SupportedNetworks enum. With strict TypeScript enabled, add chainId: groupedPosition.chainId as SupportedNetworks or validate the value explicitly before passing it.

🤖 Prompt for AI Agents
In src/features/positions/components/rebalance/rebalance-modal.tsx around lines
307 to 319, groupedPosition.chainId is a number but the modal prop expects
SupportedNetworks; either cast it to SupportedNetworks when passing (chainId:
groupedPosition.chainId as SupportedNetworks) or validate/convert it beforehand
(e.g., check it exists in SupportedNetworks and throw/log or map it) and then
pass the validated/typed value to openModal to satisfy strict TypeScript.

onClearToMarket={() => setSelectedToMarketUniqueKey('')}
/>
)}

{showToModal && (
<MarketSelectionModal
title="Select Destination Market"
description="Choose a market to rebalance funds to"
vaultAsset={groupedPosition.loanAssetAddress as `0x${string}`}
chainId={groupedPosition.chainId}
multiSelect={false}
onOpenChange={setShowToModal}
onSelect={(selectedMarkets) => {
if (selectedMarkets.length > 0) {
setSelectedToMarketUniqueKey(selectedMarkets[0].uniqueKey);
}
}}
confirmButtonText="Select Market"

<RebalanceCart
rebalanceActions={rebalanceActions}
groupedPosition={groupedPosition}
eligibleMarkets={eligibleMarkets}
removeRebalanceAction={removeRebalanceAction}
/>
)}
</>
</ModalBody>
<ModalFooter className="mx-2">
<Button
variant="default"
onClick={() => onOpenChange(false)}
className="rounded-sm p-4 px-10 font-zen text-secondary duration-200 ease-in-out hover:scale-105"
>
Cancel
</Button>
<ExecuteTransactionButton
targetChainId={groupedPosition.chainId}
onClick={handleExecuteRebalance}
disabled={rebalanceActions.length === 0}
isLoading={isProcessing}
variant="primary"
className="rounded-sm p-4 px-10 font-zen text-white duration-200 ease-in-out hover:scale-105 disabled:opacity-50"
>
Execute Rebalance
</ExecuteTransactionButton>
</ModalFooter>
</Modal>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,6 +17,7 @@ type RebalanceProcessModalProps = {
export function RebalanceProcessModal({
currentStep,
isPermit2Flow,
isOpen,
onOpenChange,
tokenSymbol,
actionsCount,
Expand Down Expand Up @@ -85,7 +87,7 @@ export function RebalanceProcessModal({

return (
<Modal
isOpen
isOpen={isOpen}
onOpenChange={onOpenChange}
size="lg"
isDismissable={false}
Expand Down
Loading