diff --git a/src/components/account/Wallets/SendManaModal/SendManaContent.integration.spec.tsx b/src/components/account/Wallets/SendManaModal/SendManaContent.integration.spec.tsx new file mode 100644 index 00000000..8e2eb6f6 --- /dev/null +++ b/src/components/account/Wallets/SendManaModal/SendManaContent.integration.spec.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import type { ReactNode } from 'react' +import { render, screen } from '@testing-library/react' +import { useWalletTransactions } from '../../../../hooks/useWalletTransactions' +import { SendManaContent } from './SendManaContent' + +// Crash repro (React #185 white page): uses the REAL tx store, with a parent that subscribes to it +// and passes an unstable onSuccess — the wiring that made the confirm effect loop until React aborted. + +const mockWriteContract = jest.fn() + +jest.mock('@dcl/core-web3', () => ({ + useWallet: () => ({ isConnected: true, connect: jest.fn(), connectors: [] }) +})) + +jest.mock('wagmi', () => ({ + useAccount: () => ({ chainId: 137 }), + useSwitchChain: () => ({ switchChain: jest.fn(), isPending: false }), + useWriteContract: () => ({ writeContract: mockWriteContract, data: '0xLOOPHASH', isPending: false, error: null }), + useWaitForTransactionReceipt: () => ({ isLoading: false, isSuccess: true }) +})) + +jest.mock('viem', () => ({ + parseEther: (value: string) => `parsed:${value}` +})) + +jest.mock('../manaContract', () => ({ + getManaAddress: () => '0xMANA', + getNetworkChainId: () => 137, + ERC20_TRANSFER_ABI: ['abi'] +})) + +jest.mock('../../../../hooks/adapters/useFormatMessage', () => ({ + useFormatMessage: () => (id: string) => id +})) + +jest.mock('decentraland-ui2', () => ({ + Button: ({ children, onClick }: { children?: ReactNode; onClick?: () => void }) => ( + + ), + TextField: () => +})) + +jest.mock('./SendManaModal.styled', () => ({ + Body: ({ children }: { children?: ReactNode }) =>
{children}
, + Centered: ({ children }: { children?: ReactNode }) =>
{children}
, + ConnectList: ({ children }: { children?: ReactNode }) =>
{children}
, + Description: ({ children }: { children?: ReactNode }) =>

{children}

, + StateText: ({ children }: { children?: ReactNode }) => {children} +})) + +const Harness = ({ address, onSuccessSpy }: { address: string; onSuccessSpy: jest.Mock }) => { + const { transactions } = useWalletTransactions(address) + const [refreshes, setRefreshes] = useState(0) + return ( +
+ { + onSuccessSpy() + setRefreshes(current => current + 1) + }} + /> +
+ ) +} + +describe('SendManaContent wired to the real transaction store', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + describe('when the receipt confirms while the parent re-renders on store notifications', () => { + it('should settle after a single confirmation instead of looping to a crash', () => { + const onSuccessSpy = jest.fn() + const address = '0xB00000000000000000000000000000000000C0DE' + + render() + + expect(screen.getByText('account.wallets.send.success')).toBeInTheDocument() + expect(onSuccessSpy).toHaveBeenCalledTimes(1) + expect(screen.getByTestId('harness')).toHaveAttribute('data-tx-count', '1') + + const stored: unknown = JSON.parse(window.localStorage.getItem(`dcl-account-wallet-txs-${address.toLowerCase()}`) ?? '[]') + expect(stored).toEqual([expect.objectContaining({ hash: '0xLOOPHASH', type: 'send', network: 'polygon', status: 'confirmed' })]) + }) + }) +}) diff --git a/src/components/account/Wallets/SendManaModal/SendManaContent.spec.tsx b/src/components/account/Wallets/SendManaModal/SendManaContent.spec.tsx index f43e53c6..cc3e4b4c 100644 --- a/src/components/account/Wallets/SendManaModal/SendManaContent.spec.tsx +++ b/src/components/account/Wallets/SendManaModal/SendManaContent.spec.tsx @@ -285,5 +285,19 @@ describe('SendManaContent', () => { expect(mockUpdateTransactionStatus).toHaveBeenCalledWith('0xHASH', 'confirmed') expect(onSuccess).toHaveBeenCalledTimes(1) }) + + it('should confirm only once even when onSuccess changes identity across re-renders', () => { + // Regression: an unstable onSuccess used to re-arm the confirm effect into an infinite loop (React #185). + mockWalletReturn = { isConnected: true, connect: mockConnect, connectors: [] } + mockAccountReturn = { chainId: 137 } + mockWriteReturn = { writeContract: mockWriteContract, data: '0xHASH', isPending: false, error: null } + mockReceiptReturn = { isLoading: false, isSuccess: true } + + const { rerender } = render() + rerender() + rerender() + + expect(mockUpdateTransactionStatus).toHaveBeenCalledTimes(1) + }) }) }) diff --git a/src/components/account/Wallets/SendManaModal/SendManaContent.tsx b/src/components/account/Wallets/SendManaModal/SendManaContent.tsx index 788b66f1..77201a22 100644 --- a/src/components/account/Wallets/SendManaModal/SendManaContent.tsx +++ b/src/components/account/Wallets/SendManaModal/SendManaContent.tsx @@ -32,6 +32,7 @@ const SendManaContent = ({ network, address, balance, onClose, onSuccess }: Send const [to, setTo] = useState('') const [amount, setAmount] = useState('') const recordedHash = useRef(null) + const confirmedHash = useRef(null) // Record the transfer the moment it has a hash (pending), then flip to confirmed when mined. useEffect(() => { @@ -41,8 +42,11 @@ const SendManaContent = ({ network, address, balance, onClose, onSuccess }: Send } }, [hash, network, amount, addTransaction]) + // Confirm once per hash: `onSuccess` is unstable across parent renders and would otherwise + // re-fire this effect on every tx-store notification (infinite update loop, React #185). useEffect(() => { - if (isSuccess && hash) { + if (isSuccess && hash && confirmedHash.current !== hash) { + confirmedHash.current = hash updateTransactionStatus(hash, 'confirmed') onSuccess?.() }