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')} - - - )} -