diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index eec593656..d11857e79 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -106,6 +106,10 @@ {"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"} {"ts":"2026-07-07T13:45:43.994Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:31:34.162Z","tool":"Write","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-07-07T15:31:49.364Z","tool":"Edit","file":"src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} @@ -119,6 +123,8 @@ {"ts":"2026-07-08T16:29:42.858Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-08T16:29:55.658Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialog.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":11,"adapter":"claude"} {"ts":"2026-07-08T16:30:49.523Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-07-14T13:48:20.570Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-07-14T13:49:10.198Z","tool":"Edit","file":".agents/shared/skills/rules/dialog-conventions.md","rule":"rule-authoring","bytes":2672,"elapsed_ms":4,"adapter":"claude"} {"ts":"2026-07-09T11:51:56.300Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-09T12:00:44.020Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-07-13T10:09:35.950Z","tool":"Edit","file":"apps/app/src/modules/governance/dialogs/selectPluginDialog/selectPluginDialogProcessListItem.tsx","rule":"plugin-visibility","bytes":2379,"elapsed_ms":3,"adapter":"claude"} diff --git a/.agents/shared/skills/rules/dialog-conventions.md b/.agents/shared/skills/rules/dialog-conventions.md index 7e31a9848..097b910fa 100644 --- a/.agents/shared/skills/rules/dialog-conventions.md +++ b/.agents/shared/skills/rules/dialog-conventions.md @@ -1,7 +1,7 @@ --- name: dialog-conventions description: Dialog barrel + definitions wiring — dynamic-import the component, static-export the types, register by id through the dialogProvider. -globs: apps/app/src/**/dialogs/*/index.ts, apps/app/src/**/constants/*DialogsDefinitions.ts +globs: apps/app/src/**/dialogs/*/index.ts, apps/app/src/**/*DialogsDefinitions.ts, apps/app/src/**/*DialogDefinitions.ts kind: rule --- @@ -49,6 +49,7 @@ export const financeDialogsDefinitions: Record', () => { useWalletAccountSpy.mockReturnValue({ address: WALLET, chainId: 1, + isConnecting: false, isReconnecting: false, }); useEnsAvatarSpy.mockReturnValue({ diff --git a/apps/app/src/modules/application/dialogs/aragonProfileRenameDialog/aragonProfileRenameDialog.test.tsx b/apps/app/src/modules/application/dialogs/aragonProfileRenameDialog/aragonProfileRenameDialog.test.tsx index d38fd8b99..bf3ba0897 100644 --- a/apps/app/src/modules/application/dialogs/aragonProfileRenameDialog/aragonProfileRenameDialog.test.tsx +++ b/apps/app/src/modules/application/dialogs/aragonProfileRenameDialog/aragonProfileRenameDialog.test.tsx @@ -87,6 +87,7 @@ describe('', () => { useWalletAccountSpy.mockReturnValue({ address: '0x1111111111111111111111111111111111111111', chainId: 1, + isConnecting: false, isReconnecting: false, }); useEnsSubdomainFieldSpy.mockReturnValue({ diff --git a/apps/app/src/modules/application/dialogs/aragonProfileRenameTransactionDialog/aragonProfileRenameTransactionDialog.test.tsx b/apps/app/src/modules/application/dialogs/aragonProfileRenameTransactionDialog/aragonProfileRenameTransactionDialog.test.tsx index a95835080..9efb3f5ef 100644 --- a/apps/app/src/modules/application/dialogs/aragonProfileRenameTransactionDialog/aragonProfileRenameTransactionDialog.test.tsx +++ b/apps/app/src/modules/application/dialogs/aragonProfileRenameTransactionDialog/aragonProfileRenameTransactionDialog.test.tsx @@ -61,6 +61,7 @@ describe('', () => { useWalletAccountSpy.mockReturnValue({ address: WALLET, chainId: 1, + isConnecting: false, isReconnecting: false, }); }); diff --git a/apps/app/src/modules/application/hooks/useWalletAccount/useWalletAccount.ts b/apps/app/src/modules/application/hooks/useWalletAccount/useWalletAccount.ts index 9e7d4c405..18b38bfd0 100644 --- a/apps/app/src/modules/application/hooks/useWalletAccount/useWalletAccount.ts +++ b/apps/app/src/modules/application/hooks/useWalletAccount/useWalletAccount.ts @@ -3,13 +3,13 @@ import { useConnection } from 'wagmi'; /** * Returns the wagmi-sourced account fields the app actually needs: - * `address`, `chainId`, `isReconnecting`. Intentionally omits `isConnected` — - * use `useWalletConnected` (AppKit-sourced) for any UI gate that must agree - * with the connect modal. `DesyncWatcher` is the only sanctioned consumer of - * wagmi's raw `useConnection`. + * `address`, `chainId`, `isConnecting`, `isReconnecting`. Intentionally omits + * `isConnected` — use `useWalletConnected` (AppKit-sourced) for any UI gate that + * must agree with the connect modal. `DesyncWatcher` is the only sanctioned + * consumer of wagmi's raw `useConnection`. */ export const useWalletAccount = () => { - const { address, chainId, isReconnecting } = useConnection(); + const { address, chainId, isConnecting, isReconnecting } = useConnection(); - return { address, chainId, isReconnecting }; + return { address, chainId, isConnecting, isReconnecting }; }; diff --git a/apps/app/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts b/apps/app/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts index 9418991f3..47f99ca8a 100644 --- a/apps/app/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts +++ b/apps/app/src/modules/capitalFlow/constants/capitalFlowDialogsDefinitions.ts @@ -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', @@ -36,6 +39,7 @@ export const capitalFlowDialogsDefinitions: Record< }, [CapitalFlowDialogId.DISPATCH_TRANSACTION]: { Component: DispatchTransactionDialog, + requiresWallet: true, size: 'lg', }, }; diff --git a/apps/app/src/modules/createDao/constants/createDaoDialogsDefinitions.ts b/apps/app/src/modules/createDao/constants/createDaoDialogsDefinitions.ts index 76b6bd93b..f2f622b0d 100644 --- a/apps/app/src/modules/createDao/constants/createDaoDialogsDefinitions.ts +++ b/apps/app/src/modules/createDao/constants/createDaoDialogsDefinitions.ts @@ -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', @@ -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', diff --git a/apps/app/src/modules/governance/constants/governanceDialogsDefinitions.ts b/apps/app/src/modules/governance/constants/governanceDialogsDefinitions.ts index 3b512f4f9..33f753863 100644 --- a/apps/app/src/modules/governance/constants/governanceDialogsDefinitions.ts +++ b/apps/app/src/modules/governance/constants/governanceDialogsDefinitions.ts @@ -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', @@ -26,9 +29,18 @@ export const governanceDialogsDefinitions: Record< hiddenDescription: 'app.governance.duplicateProposalAlertDialog.a11y.description', }, - [GovernanceDialogId.EXECUTE_ACTIONS]: { Component: ExecuteActionsDialog }, - [GovernanceDialogId.EXECUTE]: { Component: ExecuteDialog }, - [GovernanceDialogId.VOTE]: { Component: VoteDialog }, + [GovernanceDialogId.EXECUTE_ACTIONS]: { + Component: ExecuteActionsDialog, + requiresWallet: true, + }, + [GovernanceDialogId.EXECUTE]: { + Component: ExecuteDialog, + requiresWallet: true, + }, + [GovernanceDialogId.VOTE]: { + Component: VoteDialog, + requiresWallet: true, + }, [GovernanceDialogId.SELECT_PLUGIN]: { Component: SelectPluginDialog }, [GovernanceDialogId.VERIFY_SMART_CONTRACT]: { Component: VerifySmartContractDialog, @@ -56,5 +68,6 @@ export const governanceDialogsDefinitions: Record< }, [GovernanceDialogId.DELEGATE_STATEMENT_TRANSACTION]: { Component: DelegateStatementTransactionDialog, + requiresWallet: true, }, }; diff --git a/apps/app/src/modules/governance/hooks/useExecutePermissionCheckGuard/useExecutePermissionCheckGuard.test.tsx b/apps/app/src/modules/governance/hooks/useExecutePermissionCheckGuard/useExecutePermissionCheckGuard.test.tsx index bc8c079ae..41ca743ec 100644 --- a/apps/app/src/modules/governance/hooks/useExecutePermissionCheckGuard/useExecutePermissionCheckGuard.test.tsx +++ b/apps/app/src/modules/governance/hooks/useExecutePermissionCheckGuard/useExecutePermissionCheckGuard.test.tsx @@ -41,6 +41,7 @@ describe('useExecutePermissionCheckGuard hook', () => { useWalletAccountSpy.mockReturnValue({ address: '0xabc', chainId: 1, + isConnecting: false, isReconnecting: false, }); useConnectedWalletGuardSpy.mockImplementation((params) => ({ diff --git a/apps/app/src/modules/governance/hooks/useSimulateProposal/useSimulateProposalCreation.test.ts b/apps/app/src/modules/governance/hooks/useSimulateProposal/useSimulateProposalCreation.test.ts index e016cb322..d657aba15 100644 --- a/apps/app/src/modules/governance/hooks/useSimulateProposal/useSimulateProposalCreation.test.ts +++ b/apps/app/src/modules/governance/hooks/useSimulateProposal/useSimulateProposalCreation.test.ts @@ -35,6 +35,7 @@ describe('useSimulateProposalCreation hook', () => { useWalletAccountSpy.mockReturnValue({ address: walletAddress, chainId: 1, + isConnecting: false, isReconnecting: false, }); buildTransactionSpy.mockReturnValue({ @@ -114,6 +115,7 @@ describe('useSimulateProposalCreation hook', () => { useWalletAccountSpy.mockReturnValue({ address: undefined, chainId: undefined, + isConnecting: false, isReconnecting: false, }); diff --git a/apps/app/src/modules/governance/pages/createExecuteActionsPage/createExecuteActionsPageClientSteps.test.tsx b/apps/app/src/modules/governance/pages/createExecuteActionsPage/createExecuteActionsPageClientSteps.test.tsx index a35616ab0..1767edf71 100644 --- a/apps/app/src/modules/governance/pages/createExecuteActionsPage/createExecuteActionsPageClientSteps.test.tsx +++ b/apps/app/src/modules/governance/pages/createExecuteActionsPage/createExecuteActionsPageClientSteps.test.tsx @@ -54,6 +54,7 @@ describe(' component', () => { useWalletAccountSpy.mockReturnValue({ address: '0xwallet', chainId: 1, + isConnecting: false, isReconnecting: false, }); }); diff --git a/apps/app/src/modules/settings/constants/settingsDialogDefinitions.ts b/apps/app/src/modules/settings/constants/settingsDialogDefinitions.ts index 0411211c8..b68fbe2aa 100644 --- a/apps/app/src/modules/settings/constants/settingsDialogDefinitions.ts +++ b/apps/app/src/modules/settings/constants/settingsDialogDefinitions.ts @@ -16,6 +16,7 @@ export const settingsDialogDefinitions: Record< }, [SettingsDialogId.PREPARE_DAO_CONTRACTS_UPDATE]: { Component: PrepareDaoContractsUpdateDialog, + requiresWallet: true, }, [SettingsDialogId.GOVERNANCE_PROCESS_REQUIRED]: { Component: GovernanceProcessRequiredDialog, @@ -32,5 +33,6 @@ export const settingsDialogDefinitions: Record< }, [SettingsDialogId.PREPARE_PLUGIN_UNINSTALLATION]: { Component: PreparePluginUninstallationDialog, + requiresWallet: true, }, }; diff --git a/apps/app/src/plugins/capitalDistributorPlugin/constants/capitalDistributorPluginDialogsDefinitions.ts b/apps/app/src/plugins/capitalDistributorPlugin/constants/capitalDistributorPluginDialogsDefinitions.ts index 6dc9aa86c..f5c4ea416 100644 --- a/apps/app/src/plugins/capitalDistributorPlugin/constants/capitalDistributorPluginDialogsDefinitions.ts +++ b/apps/app/src/plugins/capitalDistributorPlugin/constants/capitalDistributorPluginDialogsDefinitions.ts @@ -12,5 +12,6 @@ export const capitalDistributorPluginDialogsDefinitions: Record< }, [CapitalDistributorPluginDialogId.CLAIM_TRANSACTION]: { Component: CapitalDistributorClaimTransactionDialog, + requiresWallet: true, }, }; diff --git a/apps/app/src/plugins/gaugeVoterPlugin/constants/gaugeVoterPluginDialogsDefinitions.ts b/apps/app/src/plugins/gaugeVoterPlugin/constants/gaugeVoterPluginDialogsDefinitions.ts index dbd95aafb..b3cf1f0da 100644 --- a/apps/app/src/plugins/gaugeVoterPlugin/constants/gaugeVoterPluginDialogsDefinitions.ts +++ b/apps/app/src/plugins/gaugeVoterPlugin/constants/gaugeVoterPluginDialogsDefinitions.ts @@ -30,9 +30,11 @@ export const gaugeVoterPluginDialogsDefinitions: Record< [GaugeVoterPluginDialogId.VOTE_GAUGES_TRANSACTION]: { Component: GaugeVoterVoteTransactionDialog, size: 'md', + requiresWallet: true, }, [GaugeVoterPluginDialogId.LOCK_UNLOCK]: { Component: GaugeVoterLockUnlockDialog, + requiresWallet: true, }, [GaugeVoterPluginDialogId.VIEW_LOCKS]: { Component: GaugeVoterLocksDialog, @@ -42,6 +44,7 @@ export const gaugeVoterPluginDialogsDefinitions: Record< }, [GaugeVoterPluginDialogId.EXIT_QUEUE_WITHDRAW_TRANSACTION]: { Component: GaugeVoterExitQueueWithdrawTransactionDialog, + requiresWallet: true, }, [GaugeVoterPluginDialogId.LOCK_ONBOARDING_INTRO]: { Component: GaugeVoterLockOnboardingIntroDialog, diff --git a/apps/app/src/plugins/lockToVotePlugin/constants/lockToVotePluginDialogsDefinitions.ts b/apps/app/src/plugins/lockToVotePlugin/constants/lockToVotePluginDialogsDefinitions.ts index 5c9763586..efe905072 100644 --- a/apps/app/src/plugins/lockToVotePlugin/constants/lockToVotePluginDialogsDefinitions.ts +++ b/apps/app/src/plugins/lockToVotePlugin/constants/lockToVotePluginDialogsDefinitions.ts @@ -25,6 +25,7 @@ export const lockToVotePluginDialogsDefinitions: Record< }, [LockToVotePluginDialogId.LOCK_UNLOCK_L2V]: { Component: LockToVoteLockUnlockDialog, + requiresWallet: true, }, [LockToVotePluginDialogId.UNLOCK_BLOCKED_INFO]: { Component: UnlockBlockedInfoDialog, diff --git a/apps/app/src/plugins/sppPlugin/constants/sppPluginDialogsDefinitions.ts b/apps/app/src/plugins/sppPlugin/constants/sppPluginDialogsDefinitions.ts index fe3e4cf0f..097b55d27 100644 --- a/apps/app/src/plugins/sppPlugin/constants/sppPluginDialogsDefinitions.ts +++ b/apps/app/src/plugins/sppPlugin/constants/sppPluginDialogsDefinitions.ts @@ -8,9 +8,13 @@ export const sppPluginDialogsDefinitions: Record< SppPluginDialogId, IDialogComponentDefinitions > = { - [SppPluginDialogId.ADVANCE_STAGE]: { Component: SppAdvanceStageDialog }, + [SppPluginDialogId.ADVANCE_STAGE]: { + Component: SppAdvanceStageDialog, + requiresWallet: true, + }, [SppPluginDialogId.REPORT_PROPOSAL_RESULT]: { Component: SppReportProposalResultDialog, + requiresWallet: true, }, [SppPluginDialogId.INVALID_ADDRESS_CONNECTED]: { Component: SppInvalidAddressConnectedDialog, diff --git a/apps/app/src/plugins/tokenPlugin/constants/tokenPluginDialogsDefinitions.ts b/apps/app/src/plugins/tokenPlugin/constants/tokenPluginDialogsDefinitions.ts index 40e692fcf..310ed215e 100644 --- a/apps/app/src/plugins/tokenPlugin/constants/tokenPluginDialogsDefinitions.ts +++ b/apps/app/src/plugins/tokenPlugin/constants/tokenPluginDialogsDefinitions.ts @@ -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: diff --git a/apps/app/src/shared/components/dialogProvider/dialogProvider.api.ts b/apps/app/src/shared/components/dialogProvider/dialogProvider.api.ts index 39715fb56..fa64d7afe 100644 --- a/apps/app/src/shared/components/dialogProvider/dialogProvider.api.ts +++ b/apps/app/src/shared/components/dialogProvider/dialogProvider.api.ts @@ -103,4 +103,11 @@ export interface IDialogComponentDefinitions< * Component to be rendered. */ Component: ComponentType>; + /** + * Marks a dialog that requires a connected wallet to be meaningful: DialogRoot unmounts it + * and closes it on wallet disconnect, before a dialog reading the address can throw. + * Dialogs stacked on top of a flagged dialog should be flagged too, otherwise the child is + * left orphaned on the stack once its parent closes. + */ + requiresWallet?: boolean; } diff --git a/apps/app/src/shared/components/dialogRoot/dialogRoot.test.tsx b/apps/app/src/shared/components/dialogRoot/dialogRoot.test.tsx index dc9824fd1..9649cb2e8 100644 --- a/apps/app/src/shared/components/dialogRoot/dialogRoot.test.tsx +++ b/apps/app/src/shared/components/dialogRoot/dialogRoot.test.tsx @@ -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'; @@ -11,12 +14,33 @@ describe(' component', () => { 'useDialogContext', ); + const useWalletAccountSpy = jest.spyOn( + useWalletAccountHook, + 'useWalletAccount', + ); + + const connectedAccount = { + address: '0x1234567890123456789012345678901234567890', + chainId: 1, + isConnecting: false, + isReconnecting: false, + } as const; + + const disconnectedAccount = { + address: undefined, + chainId: 1, + isConnecting: false, + isReconnecting: false, + } as const; + beforeEach(() => { useDialogContextSpy.mockReturnValue(generateDialogContext()); + useWalletAccountSpy.mockReturnValue({ ...connectedAccount }); }); afterEach(() => { useDialogContextSpy.mockReset(); + useWalletAccountSpy.mockReset(); }); const createTestComponent = (props?: Partial) => { @@ -95,4 +119,163 @@ describe(' 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('unmounts but does not close a wallet-requiring dialog while connecting on a connector switch', () => { + 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, + isConnecting: true, + }); + render(createTestComponent({ dialogs })); + expect(screen.queryByText(dialogContent)).not.toBeInTheDocument(); + expect(close).not.toHaveBeenCalled(); + }); + + it('restores a wallet-requiring dialog once the connector switch completes', () => { + 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, + isConnecting: true, + }); + const { rerender } = render(createTestComponent({ dialogs })); + expect(screen.queryByText(dialogContent)).not.toBeInTheDocument(); + + useWalletAccountSpy.mockReturnValue({ ...connectedAccount }); + rerender(createTestComponent({ dialogs })); + + expect(screen.getByText(dialogContent)).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); + }); }); diff --git a/apps/app/src/shared/components/dialogRoot/dialogRoot.tsx b/apps/app/src/shared/components/dialogRoot/dialogRoot.tsx index 370f3c07c..f683f4bc5 100644 --- a/apps/app/src/shared/components/dialogRoot/dialogRoot.tsx +++ b/apps/app/src/shared/components/dialogRoot/dialogRoot.tsx @@ -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, @@ -23,6 +25,29 @@ export const DialogRoot: React.FC = (props) => { const { t } = useTranslations(); const { locations, close } = useDialogContext(); + const { address, isConnecting, isReconnecting } = useWalletAccount(); + + // Wallet-requiring dialogs read the address during render, so they must be unmounted from + // here before a disconnect makes them throw. + // Only close once wagmi settles: both 'connecting' (connector switch) and 'reconnecting' + // (mount) blank the address for a moment without the wallet actually being gone. + + const missingAddress = address == null; + const walletSettling = isConnecting || isReconnecting; + const walletDisconnected = missingAddress && !walletSettling; + + 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. @@ -40,9 +65,16 @@ export const DialogRoot: React.FC = (props) => { Component: ActiveDialogComponent, hiddenTitle, hiddenDescription, + requiresWallet, ...otherDialogProps } = dialogDefinition; + // Unmount before the dialog renders without an address. While the wallet is + // settling this only hides it; a real disconnect also pops it via the effect above. + if (missingAddress && requiresWallet) { + return null; + } + const isAlertDialog = 'variant' in otherDialogProps; const { disableOutsideClick, modal, onClose } = location; diff --git a/apps/app/src/shared/hooks/useDaoExecutePermission/useDaoExecutePermission.test.ts b/apps/app/src/shared/hooks/useDaoExecutePermission/useDaoExecutePermission.test.ts index 5002a8e1c..eafe5bb8b 100644 --- a/apps/app/src/shared/hooks/useDaoExecutePermission/useDaoExecutePermission.test.ts +++ b/apps/app/src/shared/hooks/useDaoExecutePermission/useDaoExecutePermission.test.ts @@ -20,6 +20,7 @@ describe('useDaoExecutePermission hook', () => { useWalletAccountSpy.mockReturnValue({ address: walletAddress, chainId: 1, + isConnecting: false, isReconnecting: false, }); useReadContractSpy.mockReturnValue({ @@ -75,6 +76,7 @@ describe('useDaoExecutePermission hook', () => { useWalletAccountSpy.mockReturnValue({ address: undefined, chainId: undefined, + isConnecting: false, isReconnecting: false, }); diff --git a/apps/app/src/shared/hooks/useNetworkSwitch/useNetworkSwitch.test.ts b/apps/app/src/shared/hooks/useNetworkSwitch/useNetworkSwitch.test.ts index 617877e98..68c354a86 100644 --- a/apps/app/src/shared/hooks/useNetworkSwitch/useNetworkSwitch.test.ts +++ b/apps/app/src/shared/hooks/useNetworkSwitch/useNetworkSwitch.test.ts @@ -23,6 +23,7 @@ describe('useNetworkSwitch hook', () => { useWalletAccountSpy.mockReturnValue({ address: '0x123', chainId: ethereumChainId, + isConnecting: false, isReconnecting: false, }); }); @@ -54,6 +55,7 @@ describe('useNetworkSwitch hook', () => { useWalletAccountSpy.mockReturnValue({ address: undefined, chainId: undefined, + isConnecting: false, isReconnecting: false, }); @@ -94,6 +96,7 @@ describe('useNetworkSwitch hook', () => { useWalletAccountSpy.mockReturnValue({ address: undefined, chainId: undefined, + isConnecting: false, isReconnecting: false, });