Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
4 changes: 4 additions & 0 deletions .agents/shared/metrics/hits.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,7 @@
{"ts":"2026-07-07T08:25:58.831Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":2,"adapter":"claude"}
{"ts":"2026-07-07T08:26:08.144Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"}
{"ts":"2026-07-07T08:27:04.958Z","tool":"Edit","file":"src/shared/components/transactionDialog/transactionDialog.test.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":1,"adapter":"claude"}
{"ts":"2026-07-08T11:05:20.251Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":29,"adapter":"claude"}
{"ts":"2026-07-08T11:05:45.029Z","tool":"Edit","file":"src/modules/application/components/errorBoundary/errorBoundaryClass.tsx","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":6,"adapter":"claude"}
{"ts":"2026-07-08T11:06:24.791Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":3,"adapter":"claude"}
{"ts":"2026-07-08T11:06:45.503Z","tool":"Edit","file":"src/shared/utils/monitoringUtils/monitoringUtils.test.ts","rule":"error-and-monitoring","bytes":2525,"elapsed_ms":4,"adapter":"claude"}
5 changes: 5 additions & 0 deletions .changeset/dialog-wallet-disconnect-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@aragon/app": patch
---

Fix the application crashing when the wallet disconnects while a transaction dialog is open. Wallet-requiring dialogs assert a connected address during render, so a disconnect made them throw with no error boundary above the dialog layer, unmounting the whole application. Dialog definitions now carry a `requiresWallet` flag and `DialogRoot` unmounts and closes flagged dialogs on disconnect before they can re-render without an address. Connector switches are unaffected: while reconnecting, dialogs are only hidden and restored once the address returns.
Comment thread
harryburger marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ export const capitalFlowDialogsDefinitions: Record<
hiddenDescription:
'app.capitalFlow.setupStrategyDialog.a11y.description',
},
[CapitalFlowDialogId.PREPARE_POLICY]: { Component: PreparePolicyDialog },
[CapitalFlowDialogId.PREPARE_POLICY]: {
Component: PreparePolicyDialog,
requiresWallet: true,
},
[CapitalFlowDialogId.ROUTER_SELECTOR]: {
Component: RouterSelectorDialog,
size: 'lg',
Expand All @@ -36,6 +39,7 @@ export const capitalFlowDialogsDefinitions: Record<
},
[CapitalFlowDialogId.DISPATCH_TRANSACTION]: {
Component: DispatchTransactionDialog,
requiresWallet: true,
size: 'lg',
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export const createDaoDialogsDefinitions: Record<
CreateDaoDialogId,
IDialogComponentDefinitions
> = {
[CreateDaoDialogId.PUBLISH_DAO]: { Component: PublishDaoDialog },
[CreateDaoDialogId.PUBLISH_DAO]: {
Component: PublishDaoDialog,
requiresWallet: true,
},
[CreateDaoDialogId.CREATE_DAO_DETAILS]: {
Component: CreateDaoDetailsDialog,
size: 'lg',
Expand All @@ -20,7 +23,10 @@ export const createDaoDialogsDefinitions: Record<
Component: CreateProcessDetailsDialog,
size: 'lg',
},
[CreateDaoDialogId.PREPARE_PROCESS]: { Component: PrepareProcessDialog },
[CreateDaoDialogId.PREPARE_PROCESS]: {
Component: PrepareProcessDialog,
requiresWallet: true,
},
[CreateDaoDialogId.SETUP_BODY]: {
Component: SetupBodyDialog,
size: 'lg',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export const governanceDialogsDefinitions: Record<
GovernanceDialogId,
IDialogComponentDefinitions
> = {
[GovernanceDialogId.PUBLISH_PROPOSAL]: { Component: PublishProposalDialog },
[GovernanceDialogId.PUBLISH_PROPOSAL]: {
Component: PublishProposalDialog,
requiresWallet: true,
},
[GovernanceDialogId.DUPLICATE_PROPOSAL_WARNING]: {
Component: DuplicateProposalAlertDialog,
variant: 'warning',
Expand All @@ -27,8 +30,14 @@ export const governanceDialogsDefinitions: Record<
'app.governance.duplicateProposalAlertDialog.a11y.description',
},
[GovernanceDialogId.EXECUTE_ACTIONS]: { Component: ExecuteActionsDialog },
[GovernanceDialogId.EXECUTE]: { Component: ExecuteDialog },
[GovernanceDialogId.VOTE]: { Component: VoteDialog },
[GovernanceDialogId.EXECUTE]: {
Component: ExecuteDialog,
requiresWallet: true,
},
[GovernanceDialogId.VOTE]: {
Component: VoteDialog,
requiresWallet: true,
},
[GovernanceDialogId.SELECT_PLUGIN]: { Component: SelectPluginDialog },
[GovernanceDialogId.VERIFY_SMART_CONTRACT]: {
Component: VerifySmartContractDialog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,6 @@ export const settingsDialogDefinitions: Record<
},
[SettingsDialogId.PREPARE_PLUGIN_UNINSTALLATION]: {
Component: PreparePluginUninstallationDialog,
requiresWallet: true,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export const gaugeVoterPluginDialogsDefinitions: Record<
},
[GaugeVoterPluginDialogId.LOCK_UNLOCK]: {
Component: GaugeVoterLockUnlockDialog,
requiresWallet: true,
},
[GaugeVoterPluginDialogId.VIEW_LOCKS]: {
Component: GaugeVoterLocksDialog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const lockToVotePluginDialogsDefinitions: Record<
},
[LockToVotePluginDialogId.LOCK_UNLOCK_L2V]: {
Component: LockToVoteLockUnlockDialog,
requiresWallet: true,
},
[LockToVotePluginDialogId.UNLOCK_BLOCKED_INFO]: {
Component: UnlockBlockedInfoDialog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const sppPluginDialogsDefinitions: Record<
[SppPluginDialogId.ADVANCE_STAGE]: { Component: SppAdvanceStageDialog },
[SppPluginDialogId.REPORT_PROPOSAL_RESULT]: {
Component: SppReportProposalResultDialog,
requiresWallet: true,
},
[SppPluginDialogId.INVALID_ADDRESS_CONNECTED]: {
Component: SppInvalidAddressConnectedDialog,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,22 @@ export const tokenPluginDialogsDefinitions: Record<
TokenPluginDialogId,
IDialogComponentDefinitions
> = {
[TokenPluginDialogId.DELEGATE]: { Component: TokenDelegationDialog },
[TokenPluginDialogId.DELEGATE]: {
Component: TokenDelegationDialog,
requiresWallet: true,
},
[TokenPluginDialogId.APPROVE_TOKENS]: {
Component: TokenApproveTokensDialog,
requiresWallet: true,
},
[TokenPluginDialogId.APPROVE_NFT]: {
Component: TokenApproveNftDialog,
requiresWallet: true,
},
[TokenPluginDialogId.WRAP_UNWRAP]: {
Component: TokenWrapUnwrapDialog,
requiresWallet: true,
},
[TokenPluginDialogId.APPROVE_NFT]: { Component: TokenApproveNftDialog },
[TokenPluginDialogId.WRAP_UNWRAP]: { Component: TokenWrapUnwrapDialog },
[TokenPluginDialogId.DELEGATION_ONBOARDING_INTRO]: {
Component: TokenDelegationOnboardingIntroDialog,
hiddenTitle:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,9 @@ export interface IDialogComponentDefinitions<
* Component to be rendered.
*/
Component: ComponentType<IDialogComponentProps<TParams>>;
/**
* Marks a dialog that asserts a connected wallet during render. DialogRoot unmounts and
* closes it on wallet disconnect, before the assertion can throw and crash the application.
*/
requiresWallet?: boolean;
Comment thread
thekidnamedkd marked this conversation as resolved.
}
129 changes: 129 additions & 0 deletions apps/app/src/shared/components/dialogRoot/dialogRoot.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { invariant } from '@aragon/gov-ui-kit';
import { render, screen } from '@testing-library/react';
import { userEvent } from '@testing-library/user-event';
import { useState } from 'react';
import * as useWalletAccountHook from '@/modules/application/hooks/useWalletAccount';
import { generateDialogContext } from '@/shared/testUtils';
import { testLogger } from '@/test/utils';
import * as useDialogContext from '../dialogProvider';
Expand All @@ -11,12 +14,31 @@ describe('<DialogRoot /> component', () => {
'useDialogContext',
);

const useWalletAccountSpy = jest.spyOn(
useWalletAccountHook,
'useWalletAccount',
);

const connectedAccount = {
address: '0x1234567890123456789012345678901234567890',
chainId: 1,
isReconnecting: false,
} as const;

const disconnectedAccount = {
address: undefined,
chainId: 1,
isReconnecting: false,
} as const;

beforeEach(() => {
useDialogContextSpy.mockReturnValue(generateDialogContext());
useWalletAccountSpy.mockReturnValue({ ...connectedAccount });
});

afterEach(() => {
useDialogContextSpy.mockReset();
useWalletAccountSpy.mockReset();
});

const createTestComponent = (props?: Partial<IDialogRootProps>) => {
Expand Down Expand Up @@ -95,4 +117,111 @@ describe('<DialogRoot /> component', () => {
render(createTestComponent({ dialogs }));
expect(screen.getByRole('alertdialog')).toBeInTheDocument();
});

it('renders a wallet-requiring dialog while the wallet is connected', () => {
const dialogId = 'vote';
const dialogContent = 'vote-content';
const dialogs = {
[dialogId]: {
Component: () => dialogContent,
requiresWallet: true,
},
};
const locations = [{ id: dialogId }];
useDialogContextSpy.mockReturnValue(
generateDialogContext({ locations }),
);
render(createTestComponent({ dialogs }));
expect(screen.getByText(dialogContent)).toBeInTheDocument();
});

it('unmounts and closes a wallet-requiring dialog when the wallet disconnects', () => {
const dialogId = 'vote';
const dialogContent = 'vote-content';
const dialogs = {
[dialogId]: {
Component: () => dialogContent,
requiresWallet: true,
},
};
const locations = [{ id: dialogId }];
const close = jest.fn();
useDialogContextSpy.mockReturnValue(
generateDialogContext({ locations, close }),
);
useWalletAccountSpy.mockReturnValue({ ...disconnectedAccount });
render(createTestComponent({ dialogs }));
expect(screen.queryByText(dialogContent)).not.toBeInTheDocument();
expect(close).toHaveBeenCalledWith(dialogId);
});

it('keeps dialogs without the requires-wallet flag open when the wallet disconnects', () => {
const dialogId = 'connect-wallet';
const dialogContent = 'connect-wallet-content';
const dialogs = { [dialogId]: { Component: () => dialogContent } };
const locations = [{ id: dialogId }];
const close = jest.fn();
useDialogContextSpy.mockReturnValue(
generateDialogContext({ locations, close }),
);
useWalletAccountSpy.mockReturnValue({ ...disconnectedAccount });
render(createTestComponent({ dialogs }));
expect(screen.getByText(dialogContent)).toBeInTheDocument();
expect(close).not.toHaveBeenCalled();
});

it('unmounts but does not close a wallet-requiring dialog while reconnecting', () => {
const dialogId = 'vote';
const dialogContent = 'vote-content';
const dialogs = {
[dialogId]: {
Component: () => dialogContent,
requiresWallet: true,
},
};
const locations = [{ id: dialogId }];
const close = jest.fn();
useDialogContextSpy.mockReturnValue(
generateDialogContext({ locations, close }),
);
useWalletAccountSpy.mockReturnValue({
...disconnectedAccount,
isReconnecting: true,
});
render(createTestComponent({ dialogs }));
expect(screen.queryByText(dialogContent)).not.toBeInTheDocument();
expect(close).not.toHaveBeenCalled();
});

it('does not throw when the wallet disconnects while a wallet-requiring dialog is open', () => {
const dialogId = 'vote';
const dialogContent = 'vote-content';

// Mirrors the real wallet-requiring dialogs: address assertion followed by more hooks.
const VoteDialog: React.FC = () => {
const { address } = useWalletAccountHook.useWalletAccount();
invariant(address != null, 'VoteDialog: user must be connected.');
const [state] = useState(dialogContent);

return state;
};

const dialogs = {
[dialogId]: { Component: VoteDialog, requiresWallet: true },
};
const locations = [{ id: dialogId }];
const close = jest.fn();
useDialogContextSpy.mockReturnValue(
generateDialogContext({ locations, close }),
);

const { rerender } = render(createTestComponent({ dialogs }));
expect(screen.getByText(dialogContent)).toBeInTheDocument();

useWalletAccountSpy.mockReturnValue({ ...disconnectedAccount });

expect(() => rerender(createTestComponent({ dialogs }))).not.toThrow();
expect(screen.queryByText(dialogContent)).not.toBeInTheDocument();
expect(close).toHaveBeenCalledWith(dialogId);
});
});
30 changes: 30 additions & 0 deletions apps/app/src/shared/components/dialogRoot/dialogRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
DialogAlert,
type IDialogRootProps as IGukDialogRootProps,
} from '@aragon/gov-ui-kit';
import { useEffect } from 'react';
import { useWalletAccount } from '@/modules/application/hooks/useWalletAccount';
import {
type IDialogComponentDefinitions,
useDialogContext,
Expand All @@ -23,6 +25,27 @@ export const DialogRoot: React.FC<IDialogRootProps> = (props) => {

const { t } = useTranslations();
const { locations, close } = useDialogContext();
const { address, isReconnecting } = useWalletAccount();

// Wallet-requiring dialogs assert the address during render, so they must be unmounted from
// here before a disconnect makes them throw.
// Closing waits out reconnects, as connector switches blank the address for a moment.

const missingAddress = address == null;
const walletDisconnected = missingAddress && !isReconnecting;
Comment thread
harryburger marked this conversation as resolved.
Outdated

useEffect(() => {
if (!walletDisconnected) {
return;
}

// Use the context close directly as custom onClose handlers might not pop the stack.
for (const location of locations) {
if (dialogs[location.id]?.requiresWallet) {
close(location.id);
}
}
}, [walletDisconnected, locations, dialogs, close]);

// Render each dialog in the stack but only top one should be visible.
// Non-visible dialogs should still be rendered in order to keep the state. Useful in parent-child dialog relationships.
Expand All @@ -40,9 +63,16 @@ export const DialogRoot: React.FC<IDialogRootProps> = (props) => {
Component: ActiveDialogComponent,
hiddenTitle,
hiddenDescription,
requiresWallet,
...otherDialogProps
} = dialogDefinition;

// Unmount before the dialog renders without an address; the effect above then
// removes it from the stack.
if (missingAddress && requiresWallet) {
return null;
}

const isAlertDialog = 'variant' in otherDialogProps;
const { disableOutsideClick, modal, onClose } = location;

Expand Down
Loading