Skip to content
8 changes: 1 addition & 7 deletions .madgerc
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,5 @@
"skipAsyncImports": true
}
},
"allowedCircularGlob": [
"ui/ducks/**",
"ui/selectors/**",
"ui/store/**",
"ui/components/app/**",
"shared/lib/selectors/**"
]
"allowedCircularGlob": []
}
53 changes: 1 addition & 52 deletions development/circular-deps.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -6,55 +6,4 @@
// - To prevent new circular dependencies, ensure your changes don't add new cycles
// - For more information contact the Extension Platform team.

[
[
"shared/lib/selectors/account.ts",
"shared/lib/selectors/index.ts",
"ui/ducks/metamask/metamask.js",
"ui/selectors/selectors.js",
"ui/store/actions.ts"
],
[
"ui/components/app/metamask-template-renderer/index.js",
"ui/components/app/metamask-template-renderer/metamask-template-renderer.js",
"ui/components/app/metamask-template-renderer/safe-component-list.js",
"ui/components/app/metamask-translation/index.js",
"ui/components/app/metamask-translation/metamask-translation.js"
],
[
"ui/ducks/metamask/metamask.js",
"ui/selectors/confirm-transaction.js",
"ui/selectors/custom-gas.js",
"ui/selectors/index.js",
"ui/selectors/selectors.js",
"ui/store/actions.ts"
],
[
"ui/ducks/metamask/metamask.js",
"ui/selectors/confirm-transaction.js",
"ui/selectors/custom-gas.js",
"ui/selectors/index.js",
"ui/store/actions.ts"
],
[
"ui/ducks/metamask/metamask.js",
"ui/selectors/confirm-transaction.js",
"ui/selectors/index.js",
"ui/selectors/selectors.js",
"ui/store/actions.ts"
],
[
"ui/ducks/metamask/metamask.js",
"ui/selectors/confirm-transaction.js",
"ui/selectors/index.js",
"ui/store/actions.ts"
],
[
"ui/ducks/metamask/metamask.js",
"ui/selectors/index.js",
"ui/selectors/selectors.js",
"ui/store/actions.ts"
],
["ui/ducks/metamask/metamask.js", "ui/selectors/selectors.js"],
["ui/ducks/metamask/metamask.js", "ui/store/actions.ts"]
]
[]
24 changes: 24 additions & 0 deletions scripts/count-circular-deps.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/bash
# Count circular dependency cycles from development/circular-deps.jsonc
# Usage: ./scripts/count-circular-deps.sh (run from mm-extension root)
# Run after: yarn circular-deps:update
# Outputs: single integer (cycle count)

set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"

FILE="development/circular-deps.jsonc"
if [[ ! -f "$FILE" ]]; then
echo "Error: $FILE not found. Run yarn circular-deps:update first." >&2
exit 1
fi

node -e "
const fs = require('fs');
const content = fs.readFileSync('$FILE', 'utf8');
const json = content.split('\n').filter(l => !l.trim().startsWith('//')).join('\n');
const arr = JSON.parse(json);
console.log(Array.isArray(arr) ? arr.length : 0);
"
43 changes: 39 additions & 4 deletions shared/lib/selectors/account.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { isHardwareWallet } from '../../../ui/selectors/selectors';
import { getCurrentKeyringFromState } from './metamask-keyring';
import {
isHardwareWalletFromKeyring,
getHardwareWalletTypeFromKeyring,
} from './keyring-utils';

export { isHardwareWallet };
type MetamaskAccountsState = {
metamask?: {
internalAccounts?: {
selectedAccount?: string;
accounts?: Record<string, { metadata?: { keyring?: { type?: string } } }>;
};
};
};

/**
* Returns true if the current wallet is a hardware wallet.
* Implemented in shared to avoid circular dependency on ui/selectors.
*
* @param state - Redux state
* @returns true if hardware wallet
*/
export function isHardwareWallet(state: MetamaskAccountsState): boolean {
const keyring = getCurrentKeyringFromState(state);
return isHardwareWalletFromKeyring(keyring);
}

/**
* Returns the hardware wallet type string (e.g. "Ledger Hardware"), or undefined.
* Implemented in shared to avoid circular dependency on ui/selectors.
*
* @param state - Redux state
* @returns type string or undefined
*/
export function getHardwareWalletType(
state: MetamaskAccountsState,
): string | undefined {
const keyring = getCurrentKeyringFromState(state);
return getHardwareWalletTypeFromKeyring(keyring);
}
6 changes: 0 additions & 6 deletions shared/lib/selectors/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,2 @@
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { getHardwareWalletType } from '../../../ui/selectors/selectors';

export * from './smart-transactions';
export * from './account';

export { getHardwareWalletType };
31 changes: 31 additions & 0 deletions shared/lib/selectors/keyring-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Pure keyring helpers. No UI or state dependencies.
* Used by shared selectors to avoid circular dependency on ui/selectors.
*/

/**
* Keyring-like object with optional type string.
*/
type KeyringLike = { type?: string } | null | undefined;

/**
* Returns true if the keyring is a hardware wallet (type includes 'Hardware').
*
* @param keyring - Keyring object from state
* @returns true if hardware wallet
*/
export function isHardwareWalletFromKeyring(keyring: KeyringLike): boolean {
return Boolean(keyring?.type?.includes('Hardware'));
}

/**
* Returns the hardware wallet type string, or undefined if not a hardware wallet.
*
* @param keyring - Keyring object from state
* @returns type string e.g. "Ledger Hardware" or undefined
*/
export function getHardwareWalletTypeFromKeyring(
keyring: KeyringLike,
): string | undefined {
return isHardwareWalletFromKeyring(keyring) ? keyring?.type : undefined;
}
51 changes: 51 additions & 0 deletions shared/lib/selectors/metamask-keyring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { KeyringTypes } from '@metamask/keyring-controller';

/**
* Minimal accessors for MetaMask state.
* No UI imports - only reads state shape to break shared→ui cycle.
*/

type InternalAccountsState = {
metamask?: {
internalAccounts?: {
selectedAccount?: string;
accounts?: Record<string, { metadata?: { keyring?: { type?: string } } }>;
};
preferences?: Record<string, unknown>;
};
};

/**
* Returns the keyring for the currently selected internal account.
* Uses minimal state shape to avoid depending on ui/selectors.
*
* @param state - Redux state with metamask.internalAccounts
* @returns keyring object or null
*/
export function getCurrentKeyringFromState(
state: InternalAccountsState,
): { type?: string } | null {
const selectedAccount = state.metamask?.internalAccounts?.selectedAccount;
const accounts = state.metamask?.internalAccounts?.accounts;
if (!selectedAccount || !accounts) {
return null;
}
const account = accounts[selectedAccount];
return account?.metadata?.keyring ?? null;
}

/**

Check failure on line 37 in shared/lib/selectors/metamask-keyring.ts

View workflow job for this annotation

GitHub Actions / Test lint

Missing JSDoc @param "state" declaration
* Returns metamask preferences. Used by smart-transactions to avoid ui import.
*/
export function getPreferences(state: InternalAccountsState): Record<string, unknown> {

Check failure on line 40 in shared/lib/selectors/metamask-keyring.ts

View workflow job for this annotation

GitHub Actions / Test lint

Replace `state:·InternalAccountsState` with `⏎··state:·InternalAccountsState,⏎`
return state.metamask?.preferences ?? {};
}

/**

Check failure on line 44 in shared/lib/selectors/metamask-keyring.ts

View workflow job for this annotation

GitHub Actions / Test lint

Missing JSDoc @param "state" declaration
* Returns true if the current account supports smart transactions (not a snap account).
* Used by smart-transactions to avoid ui import.
*/
export function accountSupportsSmartTx(state: InternalAccountsState): boolean {
const keyring = getCurrentKeyringFromState(state);
return keyring?.type !== KeyringTypes.snap;
}
24 changes: 24 additions & 0 deletions shared/lib/selectors/networks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,30 @@ export const getNetworkConfigurationsByChainId = (
state: NetworkConfigurationsByChainIdState,
) => state.metamask.networkConfigurationsByChainId;

/**
* Parameterized selector: returns network configuration for a given chain ID.
*/
export const selectNetworkConfigurationByChainId = createSelector(
getNetworkConfigurationsByChainId,
(_state: NetworkConfigurationsByChainIdState, chainId: string) => chainId,
(networkConfigurationsByChainId, chainId) =>
networkConfigurationsByChainId?.[chainId],
);

/**
* Parameterized selector: returns the default RPC endpoint for a given chain ID.
*/
export const selectDefaultRpcEndpointByChainId = createSelector(
selectNetworkConfigurationByChainId,
(networkConfiguration) => {
if (!networkConfiguration) {
return undefined;
}
const { defaultRpcEndpointIndex, rpcEndpoints } = networkConfiguration;
return rpcEndpoints[defaultRpcEndpointIndex];
},
);

export const selectDefaultNetworkClientIdsByChainId = createSelector(
getNetworkConfigurationsByChainId,
(networkConfigurationsByChainId) => {
Expand Down
9 changes: 5 additions & 4 deletions shared/lib/selectors/smart-transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,21 @@
getAllowedSmartTransactionsChainIds,
SKIP_STX_RPC_URL_CHECK_CHAIN_IDS,
} from '../../constants/smartTransactions';
import {

Check failure on line 12 in shared/lib/selectors/smart-transactions.ts

View workflow job for this annotation

GitHub Actions / Test lint

Replace `⏎··accountSupportsSmartTx,⏎··getPreferences,⏎` with `·accountSupportsSmartTx,·getPreferences·`
accountSupportsSmartTx,
getPreferences,
} from './metamask-keyring';
import {
getCurrentChainId,
selectDefaultRpcEndpointByChainId,
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
} from '../../../ui/selectors/selectors'; // TODO: Migrate shared selectors to this file.
type NetworkState,
} from './networks';
import {

Check failure on line 21 in shared/lib/selectors/smart-transactions.ts

View workflow job for this annotation

GitHub Actions / Test lint

`../../../ui/selectors/remote-feature-flags` import should occur before import of `./metamask-keyring`
getRemoteFeatureFlags,
type RemoteFeatureFlagsState,
// eslint-disable-next-line import/no-restricted-paths
} from '../../../ui/selectors/remote-feature-flags';
import { isProduction } from '../environment';

Check failure on line 26 in shared/lib/selectors/smart-transactions.ts

View workflow job for this annotation

GitHub Actions / Test lint

`../environment` import should occur before import of `./metamask-keyring`
import { getCurrentChainId, type NetworkState } from './networks';
import { createDeepEqualSelector } from './util';

export type SmartTransactionsMetaMaskState = {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { memo } from 'react';
import { isEqual } from 'lodash';
import { safeComponentList } from './safe-component-list';
import { TemplateRendererContext } from './template-renderer-context';
import { ValidChildren } from './section-shape';

function getElement(section) {
Expand Down Expand Up @@ -42,25 +43,16 @@
}

const MetaMaskTemplateRenderer = ({ sections }) => {
if (!sections) {
// If sections is null eject early by returning null
return null;
} else if (typeof sections === 'string') {
// React can render strings directly, so return the string
return sections;
} else if (
sections &&
typeof sections === 'object' &&
!Array.isArray(sections)
) {
// If dealing with a single entry, then render a single object without key
return renderElement(sections);
}

// The last case is dealing with an array of objects
return (
<>
{sections.reduce((allChildren, child) => {
const content =

Check failure on line 46 in ui/components/app/metamask-template-renderer/metamask-template-renderer.js

View workflow job for this annotation

GitHub Actions / Test lint

Delete `⏎···`
!sections ? null : typeof sections === 'string' ? (

Check failure on line 47 in ui/components/app/metamask-template-renderer/metamask-template-renderer.js

View workflow job for this annotation

GitHub Actions / Test lint

Do not nest ternary expressions

Check failure on line 47 in ui/components/app/metamask-template-renderer/metamask-template-renderer.js

View workflow job for this annotation

GitHub Actions / Test lint

Do not nest ternary expressions

Check failure on line 47 in ui/components/app/metamask-template-renderer/metamask-template-renderer.js

View workflow job for this annotation

GitHub Actions / Test lint

Unexpected negated condition
sections
) : sections &&
typeof sections === 'object' &&
!Array.isArray(sections) ? (
renderElement(sections)
) : (
<>
{sections.reduce((allChildren, child) => {
if (child === undefined || child?.hide === true) {
return allChildren;
}
Expand Down Expand Up @@ -97,7 +89,13 @@
}
return allChildren;
}, [])}
</>
</>
);

return (
<TemplateRendererContext.Provider value={MetaMaskTemplateRenderer}>
{content}
</TemplateRendererContext.Provider>
);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

/**
* Context to inject the template renderer component into MetaMaskTranslation.
* Breaks the circular dependency: MetaMaskTranslation no longer imports
* MetaMaskTemplateRenderer directly; it gets the renderer from this context.
*/
export type TemplateRendererComponent = React.ComponentType<{
sections: unknown;
}>;

export const TemplateRendererContext =
React.createContext<TemplateRendererComponent | null>(null);
12 changes: 9 additions & 3 deletions ui/components/app/metamask-translation/metamask-translation.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useI18nContext } from '../../../hooks/useI18nContext';
import MetaMaskTemplateRenderer from '../metamask-template-renderer';
import { TemplateRendererContext } from '../metamask-template-renderer/template-renderer-context';
import { SectionShape } from '../metamask-template-renderer/section-shape';

/**
Expand All @@ -26,6 +26,7 @@ import { SectionShape } from '../metamask-template-renderer/section-shape';
*/
export default function MetaMaskTranslation({ translationKey, variables }) {
const t = useI18nContext();
const TemplateRenderer = useContext(TemplateRendererContext);

return t(
translationKey,
Expand Down Expand Up @@ -59,8 +60,13 @@ export default function MetaMaskTranslation({ translationKey, variables }) {
'MetaMaskTranslation does not allow for component trees of non trivial depth',
);
}
if (!TemplateRenderer) {
throw new Error(
'MetaMaskTranslation must be used inside MetaMaskTemplateRenderer (TemplateRendererContext.Provider)',
);
}
return (
<MetaMaskTemplateRenderer
<TemplateRenderer
key={`${translationKey}-${variable.key}`}
sections={variable}
/>
Expand Down
Loading
Loading