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?.()
}