diff --git a/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.test.tsx b/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.test.tsx
index f01e1a99d9bc..11158bbad6e5 100644
--- a/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.test.tsx
+++ b/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import * as Redux from 'react-redux';
import configureMockStore from 'redux-mock-store';
-import { render, fireEvent } from '@testing-library/react';
+import { render, fireEvent, waitFor } from '@testing-library/react';
import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage';
import * as useBackupAndSyncHook from '../../../../hooks/identity/useBackupAndSync/useBackupAndSync';
import { MetamaskIdentityProvider } from '../../../../contexts/identity';
@@ -169,6 +169,53 @@ describe('BackupAndSyncFeaturesToggles', () => {
);
});
+ it('disables main backup and sync when all sub-features are manually turned off', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+ store.metamask.isAccountSyncingEnabled = false; // Already off
+ store.metamask.isContactSyncingEnabled = false; // Already off
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ render(
+
+
+ ,
+ );
+
+ // Wait for the reverse cascade effect to fire
+ await waitFor(() => {
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ });
+ });
+
+ it('does not disable main backup and sync when at least one sub-feature is enabled', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+ store.metamask.isAccountSyncingEnabled = true; // One is ON
+ store.metamask.isContactSyncingEnabled = false; // One is OFF
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ render(
+
+
+ ,
+ );
+
+ // Wait a bit to ensure useEffect doesn't fire
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Should not have disabled main toggle
+ expect(setIsBackupAndSyncFeatureEnabledMock).not.toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ });
+
function arrangeMocks() {
const setIsBackupAndSyncFeatureEnabledMock = jest.fn(() =>
Promise.resolve(),
diff --git a/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.tsx b/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.tsx
index f1054875c8c7..67d98054c2ae 100644
--- a/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.tsx
+++ b/ui/components/app/identity/backup-and-sync-features-toggles/backup-and-sync-features-toggles.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useContext } from 'react';
+import React, { useCallback, useContext, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage';
import { useI18nContext } from '../../../../hooks/useI18nContext';
@@ -149,6 +149,40 @@ export const BackupAndSyncFeaturesToggles = () => {
const isBackupAndSyncUpdateLoading = useSelector(
selectIsBackupAndSyncUpdateLoading,
);
+ const isAccountSyncingEnabled = useSelector(selectIsAccountSyncingEnabled);
+ const isContactSyncingEnabled = useSelector(selectIsContactSyncingEnabled);
+
+ const { setIsBackupAndSyncFeatureEnabled } = useBackupAndSync();
+
+ // Reverse cascading: if all sub-features are manually turned off, turn off main toggle
+ // Guard against race conditions by not running while updates are in progress
+ useEffect(() => {
+ const allSubFeaturesDisabled =
+ !isAccountSyncingEnabled && !isContactSyncingEnabled;
+
+ if (
+ isBackupAndSyncEnabled &&
+ allSubFeaturesDisabled &&
+ !isBackupAndSyncUpdateLoading
+ ) {
+ (async () => {
+ try {
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ } catch (err) {
+ console.error('Failed to disable main backup and sync toggle:', err);
+ }
+ })();
+ }
+ }, [
+ isBackupAndSyncEnabled,
+ isAccountSyncingEnabled,
+ isContactSyncingEnabled,
+ isBackupAndSyncUpdateLoading,
+ setIsBackupAndSyncFeatureEnabled,
+ ]);
return (
{
});
});
- it('enables backup and sync when the toggle is turned on and basic functionality is already on', () => {
+ it('enables backup and sync and all sub-features when the toggle is turned on and basic functionality is already on', async () => {
const store = initialStore();
store.metamask.isBackupAndSyncEnabled = false;
@@ -98,14 +98,29 @@ describe('BackupAndSyncToggle', () => {
,
);
+
fireEvent.click(getByTestId(backupAndSyncToggleTestIds.toggleButton));
+
+ // Wait for the async toggle handler to complete
+ await waitFor(() => {
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ true,
+ );
+ });
+
+ // Should also enable all sub-features for 1-click restore convenience
expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
- BACKUPANDSYNC_FEATURES.main,
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ true,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
true,
);
});
- it('opens the confirm modal when the toggle is turned on and basic functionality is off', () => {
+ it('opens the confirm modal when the toggle is turned on and basic functionality is off', async () => {
const store = initialStore();
store.metamask.isBackupAndSyncEnabled = false;
store.metamask.useExternalServices = false;
@@ -123,6 +138,172 @@ describe('BackupAndSyncToggle', () => {
enableBackupAndSync: expect.any(Function),
}),
);
+
+ // Test that the modal's enableBackupAndSync callback enables all features
+ const modalAction = mockDispatch.mock.calls[0][0];
+ const enableCallback = modalAction.payload.enableBackupAndSync;
+ await enableCallback();
+
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ true,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ true,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ true,
+ );
+ });
+
+ it('disables all backup and sync features when basic functionality is disabled', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+ store.metamask.useExternalServices = false; // Basic functionality disabled
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ render(
+
+
+ ,
+ );
+
+ // Wait for the async useEffect to complete
+ await waitFor(() => {
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ });
+
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ false,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ false,
+ );
+ });
+
+ it('disables all backup and sync features when onboarding basic functionality is disabled', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+ store.metamask.useExternalServices = true; // Production basic functionality enabled
+ store.appState.externalServicesOnboardingToggleState = false; // Onboarding basic functionality disabled
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ render(
+
+
+ ,
+ );
+
+ // Wait for the async useEffect to complete
+ await waitFor(() => {
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ });
+
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ false,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ false,
+ );
+ });
+
+ it('disables all backup and sync features when both basic functionality states are disabled', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+ store.metamask.useExternalServices = false; // Production basic functionality disabled
+ store.appState.externalServicesOnboardingToggleState = false; // Onboarding basic functionality disabled
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ render(
+
+
+ ,
+ );
+
+ // Wait for the async useEffect to complete
+ await waitFor(() => {
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ });
+
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ false,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ false,
+ );
+ });
+
+ it('does not disable backup and sync when both basic functionality states are enabled', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+ store.metamask.useExternalServices = true; // Production basic functionality enabled
+ store.appState.externalServicesOnboardingToggleState = true; // Onboarding basic functionality enabled
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ render(
+
+
+ ,
+ );
+
+ // Wait a bit to ensure useEffect doesn't fire
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Should not have called the disable function at all
+ expect(setIsBackupAndSyncFeatureEnabledMock).not.toHaveBeenCalled();
+ });
+
+ it('disables all sub-features when manually turning off backup and sync', async () => {
+ const store = initialStore();
+ store.metamask.isBackupAndSyncEnabled = true;
+
+ const { setIsBackupAndSyncFeatureEnabledMock } = arrangeMocks();
+
+ const { getByTestId } = render(
+
+
+ ,
+ );
+
+ fireEvent.click(getByTestId(backupAndSyncToggleTestIds.toggleButton));
+
+ // Wait for the async toggle handler to complete
+ await waitFor(() => {
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ });
+
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ false,
+ );
+ expect(setIsBackupAndSyncFeatureEnabledMock).toHaveBeenCalledWith(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ false,
+ );
});
function arrangeMocks() {
diff --git a/ui/components/app/identity/backup-and-sync-toggle/backup-and-sync-toggle.tsx b/ui/components/app/identity/backup-and-sync-toggle/backup-and-sync-toggle.tsx
index 0ff87e9cf429..217c9a6afa41 100644
--- a/ui/components/app/identity/backup-and-sync-toggle/backup-and-sync-toggle.tsx
+++ b/ui/components/app/identity/backup-and-sync-toggle/backup-and-sync-toggle.tsx
@@ -87,13 +87,38 @@ export const BackupAndSyncToggle = () => {
[trackEvent, isBackupAndSyncEnabled, isMetamaskNotificationsEnabled],
);
- // Cascading side effects
+ // Cascading side effects - disable backup & sync when basic functionality is disabled
useEffect(() => {
- if (!isBasicFunctionalityEnabled && isBackupAndSyncEnabled) {
- setIsBackupAndSyncFeatureEnabled(BACKUPANDSYNC_FEATURES.main, false);
+ // Check both basic functionality states: production and onboarding
+ const isBasicFunctionalityDisabled =
+ isBasicFunctionalityEnabled === false ||
+ isOnboardingBasicFunctionalityEnabled === false;
+
+ if (isBasicFunctionalityDisabled && isBackupAndSyncEnabled) {
+ (async () => {
+ try {
+ // Turn off main backup and sync
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.main,
+ false,
+ );
+ // Also turn off all sub-features when basic functionality is disabled
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ false,
+ );
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ false,
+ );
+ } catch (err) {
+ console.error('Failed to disable backup and sync features:', err);
+ }
+ })();
}
}, [
isBasicFunctionalityEnabled,
+ isOnboardingBasicFunctionalityEnabled,
isBackupAndSyncEnabled,
setIsBackupAndSyncFeatureEnabled,
]);
@@ -101,10 +126,20 @@ export const BackupAndSyncToggle = () => {
const handleBackupAndSyncToggleSetValue = async () => {
if (isBackupAndSyncEnabled) {
trackBackupAndSyncToggleEvent(false);
+ // Turn off main backup and sync
await setIsBackupAndSyncFeatureEnabled(
BACKUPANDSYNC_FEATURES.main,
false,
);
+ // Also turn off all sub-features when main toggle is disabled
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ false,
+ );
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ false,
+ );
} else {
trackBackupAndSyncToggleEvent(true);
@@ -116,18 +151,38 @@ export const BackupAndSyncToggle = () => {
showModal({
name: CONFIRM_TURN_ON_BACKUP_AND_SYNC_MODAL_NAME,
enableBackupAndSync: async () => {
+ // Turn on main backup and sync
await setIsBackupAndSyncFeatureEnabled(
BACKUPANDSYNC_FEATURES.main,
true,
);
+ // Also turn on all sub-features for convenient 1-click restore
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ true,
+ );
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ true,
+ );
},
}),
);
} else {
+ // Turn on main backup and sync
await setIsBackupAndSyncFeatureEnabled(
BACKUPANDSYNC_FEATURES.main,
true,
);
+ // Also turn on all sub-features for convenient 1-click restore
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.accountSyncing,
+ true,
+ );
+ await setIsBackupAndSyncFeatureEnabled(
+ BACKUPANDSYNC_FEATURES.contactSyncing,
+ true,
+ );
}
}
};
diff --git a/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js b/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js
index d5209d99b622..36d29664e54d 100644
--- a/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js
+++ b/ui/pages/onboarding-flow/privacy-settings/privacy-settings.js
@@ -1,14 +1,12 @@
-import React, { useContext, useState, useEffect } from 'react';
+import React, { useContext, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom-v5-compat';
import classnames from 'classnames';
import { ButtonVariant } from '@metamask/snaps-sdk';
-import { BACKUPANDSYNC_FEATURES } from '@metamask/profile-sync-controller/user-storage';
import log from 'loglevel';
// TODO: Remove restricted import
// eslint-disable-next-line import/no-restricted-paths
import { addUrlProtocolPrefix } from '../../../../app/scripts/lib/util';
-import { useBackupAndSync } from '../../../hooks/identity/useBackupAndSync';
import {
MetaMetricsEventCategory,
MetaMetricsEventName,
@@ -138,17 +136,6 @@ export default function PrivacySettings() {
const isBackupAndSyncEnabled = useSelector(selectIsBackupAndSyncEnabled);
- const { setIsBackupAndSyncFeatureEnabled, error: backupAndSyncError } =
- useBackupAndSync();
-
- useEffect(() => {
- if (externalServicesOnboardingToggleState) {
- setIsBackupAndSyncFeatureEnabled(BACKUPANDSYNC_FEATURES.main, true);
- } else {
- setIsBackupAndSyncFeatureEnabled(BACKUPANDSYNC_FEATURES.main, false);
- }
- }, [externalServicesOnboardingToggleState, setIsBackupAndSyncFeatureEnabled]);
-
const handleSubmit = () => {
dispatch(setUse4ByteResolution(turnOn4ByteResolution));
dispatch(setUseTokenDetection(turnOnTokenDetection));
@@ -160,11 +147,6 @@ export default function PrivacySettings() {
setUseTransactionSimulations(isTransactionSimulationsEnabled);
setUseExternalNameSources(turnOnExternalNameSources);
- // Backup and sync Setup
- if (!externalServicesOnboardingToggleState) {
- setIsBackupAndSyncFeatureEnabled(BACKUPANDSYNC_FEATURES.main, false);
- }
-
if (ipfsURL && !ipfsError) {
const { host } = new URL(addUrlProtocolPrefix(ipfsURL));
dispatch(setIpfsGateway(host));
@@ -425,18 +407,6 @@ export default function PrivacySettings() {
- {backupAndSyncError && (
-
-
- {t('notificationsSettingsBoxError')}
-
-
- )}
-