diff --git a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx index ff31fda0f3ea..8ce533518db4 100644 --- a/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx +++ b/app/components/Views/FeatureFlagOverride/FeatureFlagOverride.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useState, + useRef, +} from 'react'; import { ScrollView, Alert, TextInput, Switch, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; @@ -23,8 +29,12 @@ import { } from '../../../util/feature-flags'; import { useFeatureFlagOverride } from '../../../contexts/FeatureFlagOverrideContext'; import { useFeatureFlagStats } from '../../../hooks/useFeatureFlagStats'; -import { FeatureFlagNames } from '../../hooks/useFeatureFlag'; - +import { + selectrawRemoteFeatureFlags, + selectLocalOverrides, +} from '../../../selectors/featureFlagController'; +import { useSelector } from 'react-redux'; +import SelectOptionSheet from '../../UI/SelectOptionSheet'; interface FeatureFlagRowProps { flag: FeatureFlagInfo; onToggle: (key: string, newValue: unknown) => void; @@ -35,9 +45,24 @@ export interface MinimumVersionFlagValue { minimumVersion: string; } const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { + const rawRemoteFeatureFlags = useSelector(selectrawRemoteFeatureFlags); + const override = useSelector(selectLocalOverrides); const tw = useTailwind(); const theme = useTheme(); const [localValue, setLocalValue] = useState(flag.value); + const prevIsOverriddenRef = useRef(flag.isOverridden); + + useEffect(() => { + const wasOverridden = prevIsOverriddenRef.current; + const isNowOverridden = flag.isOverridden; + + if (wasOverridden && !isNowOverridden) { + // Reset localValue to flag.value when override is cleared + setLocalValue(flag.value); + } + + prevIsOverriddenRef.current = isNowOverridden; + }, [override, flag.value, flag.isOverridden, flag.key]); const minimumVersion = (localValue as MinimumVersionFlagValue) ?.minimumVersion; const isVersionSupported = useMemo( @@ -57,12 +82,7 @@ const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { { const updatedValue = { ...(localValue as MinimumVersionFlagValue), @@ -96,11 +116,6 @@ const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { case 'boolean': return ( { setLocalValue(newValue); @@ -114,6 +129,41 @@ const FeatureFlagRow: React.FC = ({ flag, onToggle }) => { ios_backgroundColor={theme.colors.border.muted} /> ); + case 'abTest': { + interface AbTestType { + name: string; + value: unknown; + } + const abTestOptions: AbTestType[] = Array.isArray( + rawRemoteFeatureFlags[flag.key], + ) + ? (rawRemoteFeatureFlags[flag.key] as AbTestType[]) + : []; + const flagValue = flag.value as AbTestType; + + const handleSelectOption = (name: string) => { + const selectedOption = abTestOptions.find( + (option: { name: string }) => option.name === name, + ); + setLocalValue(selectedOption); + onToggle(flag.key, selectedOption); + }; + + return ( + + ({ + label: option.name, + value: option.name, + }))} + label={flag.key} + defaultValue={flagValue.name} + onValueChange={handleSelectOption} + selectedValue={(localValue as AbTestType).name} + /> + + ); + } case 'string': case 'number': return ( @@ -255,8 +305,14 @@ const FeatureFlagOverride: React.FC = () => { const tw = useTailwind(); const flagStats = useFeatureFlagStats(); - const { setOverride, removeOverride, clearAllOverrides, featureFlagsList } = - useFeatureFlagOverride(); + const { + setOverride, + removeOverride, + clearAllOverrides, + featureFlagsList, + getOverrideCount, + } = useFeatureFlagOverride(); + const overrideCount = getOverrideCount(); const [searchQuery, setSearchQuery] = useState(''); const [typeFilter, setTypeFilter] = useState<'all' | 'boolean'>('all'); @@ -426,7 +482,7 @@ const FeatureFlagOverride: React.FC = () => { size={ButtonSize.Sm} onPress={handleClearAllOverrides} > - Clear All Overrides + {`Clear All Overrides (${overrideCount})`} diff --git a/app/contexts/FeatureFlagOverrideContext.tsx b/app/contexts/FeatureFlagOverrideContext.tsx index a21a014a08f0..18976c0f5e84 100644 --- a/app/contexts/FeatureFlagOverrideContext.tsx +++ b/app/contexts/FeatureFlagOverrideContext.tsx @@ -1,13 +1,17 @@ import React, { createContext, useContext, - useState, useCallback, ReactNode, useMemo, + useEffect, } from 'react'; import { useSelector } from 'react-redux'; -import { selectRemoteFeatureFlags } from '../selectors/featureFlagController'; +import { + selectRemoteFeatureFlags, + selectLocalOverrides, + selectRawFeatureFlags, +} from '../selectors/featureFlagController'; import { FeatureFlagInfo, getFeatureFlagDescription, @@ -20,11 +24,48 @@ import { } from '../component-library/components/Toast'; import { MinimumVersionFlagValue } from '../components/Views/FeatureFlagOverride/FeatureFlagOverride'; import useMetrics from '../components/hooks/useMetrics/useMetrics'; +import Engine from '../core/Engine'; +import type { Json } from '@metamask/utils'; +import type { RemoteFeatureFlagController } from '@metamask/remote-feature-flag-controller'; interface FeatureFlagOverrides { [key: string]: unknown; } +// Extended interface for controller methods not in the base type definition +// These methods exist at runtime in the mobile app's version of RemoteFeatureFlagController +// but are not included in the @metamask/remote-feature-flag-controller type definitions +export interface ExtendedRemoteFeatureFlagController + extends RemoteFeatureFlagController { + setFlagOverride: (key: string, value: Json) => void; + clearFlagOverride: (key: string) => void; + getAllFlags: () => FeatureFlagOverrides; + clearAllOverrides: () => void; +} + +// Helper to safely access the RemoteFeatureFlagController with proper typing +const getRemoteFeatureFlagController = (): + | ExtendedRemoteFeatureFlagController + | undefined => Engine.context?.RemoteFeatureFlagController as + | ExtendedRemoteFeatureFlagController + | undefined; + +// Helper to safely execute controller methods with error handling +const withRemoteFeatureFlagController = ( + fn: (controller: ExtendedRemoteFeatureFlagController) => void, + errorMessage: string, +): void => { + const controller = getRemoteFeatureFlagController(); + if (!controller) { + return; + } + try { + fn(controller); + } catch (error) { + console.error(errorMessage, error); + } +}; + export interface FeatureFlagOverrideContextType { featureFlags: { [key: string]: FeatureFlagInfo }; originalFlags: FeatureFlagOverrides; @@ -35,9 +76,6 @@ export interface FeatureFlagOverrideContextType { removeOverride: (key: string) => void; clearAllOverrides: () => void; hasOverride: (key: string) => boolean; - getOverride: (key: string) => unknown; - getAllOverrides: () => FeatureFlagOverrides; - applyOverrides: (originalFlags: FeatureFlagOverrides) => FeatureFlagOverrides; getOverrideCount: () => number; } @@ -54,34 +92,59 @@ export const FeatureFlagOverrideProvider: React.FC< > = ({ children }) => { const { addTraitsToUser } = useMetrics(); // Get the initial feature flags from Redux - const rawFeatureFlagsSelected = useSelector(selectRemoteFeatureFlags); - const rawFeatureFlags = useMemo( - () => rawFeatureFlagsSelected || {}, - [rawFeatureFlagsSelected], - ); + const featureFlagsWithOverrides = useSelector(selectRemoteFeatureFlags); + const rawFeatureFlags = useSelector(selectRawFeatureFlags); + + // Get overrides from controller state via Redux + const overrides = useSelector(selectLocalOverrides); const toastContext = useContext(ToastContext); const toastRef = toastContext?.toastRef; - // Local state for overrides - const [overrides, setOverrides] = useState({}); + // Subscribe to controller state changes to ensure we stay in sync + useEffect(() => { + const handler = () => { + // State change will trigger Redux update via selector + // No need to do anything here as Redux will handle the update + }; + + try { + Engine.controllerMessenger?.subscribe( + 'RemoteFeatureFlagController:stateChange', + handler, + ); + } catch (error) { + // Engine might not be fully initialized yet, ignore error + console.warn( + 'Failed to subscribe to RemoteFeatureFlagController state changes:', + error, + ); + } + + return () => { + // Note: Messenger subscribe doesn't return unsubscribe, but the subscription + // will be cleaned up when the component unmounts + }; + }, []); const setOverride = useCallback((key: string, value: unknown) => { - setOverrides((prev) => ({ - ...prev, - [key]: value, - })); + withRemoteFeatureFlagController((controller) => { + // Use the controller's setFlagOverride method which properly updates localOverrides in state + controller.setFlagOverride(key, value as Json); + }, 'Failed to set feature flag override:'); }, []); const removeOverride = useCallback((key: string) => { - setOverrides((prev) => { - const newOverrides = { ...prev }; - delete newOverrides[key]; - return newOverrides; - }); + withRemoteFeatureFlagController( + (controller) => controller.clearFlagOverride(key), + 'Failed to remove feature flag override:', + ); }, []); const clearAllOverrides = useCallback(() => { - setOverrides({}); + withRemoteFeatureFlagController( + (controller) => controller.clearAllOverrides(), + 'Failed to clear feature flag overrides:', + ); }, []); const hasOverride = useCallback( @@ -89,35 +152,11 @@ export const FeatureFlagOverrideProvider: React.FC< [overrides], ); - const getOverride = useCallback( - (key: string): unknown => overrides[key], - [overrides], - ); - - const getAllOverrides = useCallback( - (): FeatureFlagOverrides => ({ ...overrides }), - [overrides], - ); - - const applyOverrides = useCallback( - (originalFlags: FeatureFlagOverrides): FeatureFlagOverrides => ({ - ...originalFlags, - ...overrides, - }), - [overrides], - ); - - const featureFlagsWithOverrides = useMemo( - () => applyOverrides(rawFeatureFlags), - [rawFeatureFlags, applyOverrides], - ); - const featureFlags = useMemo(() => { // Get all unique keys from both raw and overridden flags const allKeys = new Set([ ...Object.keys(rawFeatureFlags), ...Object.keys(featureFlagsWithOverrides), - ...Object.keys(getAllOverrides()), ]); const allFlags: { [key: string]: FeatureFlagInfo } = {}; @@ -138,12 +177,7 @@ export const FeatureFlagOverrideProvider: React.FC< allFlags[key] = flagValue; }); return allFlags; - }, [ - rawFeatureFlags, - featureFlagsWithOverrides, - hasOverride, - getAllOverrides, - ]); + }, [rawFeatureFlags, featureFlagsWithOverrides, hasOverride]); const featureFlagsList = useMemo( () => @@ -224,9 +258,6 @@ export const FeatureFlagOverrideProvider: React.FC< removeOverride, clearAllOverrides, hasOverride, - getOverride, - getAllOverrides, - applyOverrides, getOverrideCount, }), [ @@ -239,9 +270,6 @@ export const FeatureFlagOverrideProvider: React.FC< removeOverride, clearAllOverrides, hasOverride, - getOverride, - getAllOverrides, - applyOverrides, getOverrideCount, ], ); diff --git a/app/hooks/useFeatureFlagStats.ts b/app/hooks/useFeatureFlagStats.ts index 7edf9dbbd303..84a549eb4b63 100644 --- a/app/hooks/useFeatureFlagStats.ts +++ b/app/hooks/useFeatureFlagStats.ts @@ -21,7 +21,8 @@ export const useFeatureFlagStats = (): Record => { featureFlagsList.forEach((flag: FeatureFlagInfo) => { if ( flag.type === 'boolean with minimumVersion' || - flag.type === 'boolean nested' + flag.type === 'boolean nested' || + flag.type === 'abTest' ) { stats.boolean++; } else { diff --git a/app/selectors/featureFlagController/index.ts b/app/selectors/featureFlagController/index.ts index 83e1f8ccc8e5..543e9adff853 100644 --- a/app/selectors/featureFlagController/index.ts +++ b/app/selectors/featureFlagController/index.ts @@ -1,17 +1,57 @@ import { createSelector } from 'reselect'; import { isRemoteFeatureFlagOverrideActivated } from '../../core/Engine/controllers/remote-feature-flag-controller'; import { StateWithPartialEngine } from './types'; +import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; +// Extended state type that includes mobile-specific properties +// These properties exist at runtime but are not in the base type definition +interface ExtendedRemoteFeatureFlagControllerState + extends RemoteFeatureFlagControllerState { + localOverrides?: Record; + rawRemoteFeatureFlags?: Record; +} + +// Access the controller state directly export const selectRemoteFeatureFlagControllerState = ( state: StateWithPartialEngine, ) => state.engine.backgroundState.RemoteFeatureFlagController; +export const selectRawFeatureFlags = createSelector( + selectRemoteFeatureFlagControllerState, + (remoteFeatureFlagControllerState: unknown) => + (remoteFeatureFlagControllerState as RemoteFeatureFlagControllerState) + ?.remoteFeatureFlags ?? {}, +); + export const selectRemoteFeatureFlags = createSelector( selectRemoteFeatureFlagControllerState, - (remoteFeatureFlagControllerState) => { + (remoteFeatureFlagControllerState: unknown) => { if (isRemoteFeatureFlagOverrideActivated) { return {}; } - return remoteFeatureFlagControllerState?.remoteFeatureFlags ?? {}; + const state = + remoteFeatureFlagControllerState as ExtendedRemoteFeatureFlagControllerState; + const localOverrides = state?.localOverrides ?? {}; + const remoteFeatureFlags = state?.remoteFeatureFlags ?? {}; + return { + ...remoteFeatureFlags, + ...localOverrides, + }; }, ); + +export const selectLocalOverrides = createSelector( + selectRemoteFeatureFlagControllerState, + (remoteFeatureFlagControllerState: unknown) => + ( + remoteFeatureFlagControllerState as ExtendedRemoteFeatureFlagControllerState + )?.localOverrides ?? {}, +); + +export const selectrawRemoteFeatureFlags = createSelector( + selectRemoteFeatureFlagControllerState, + (remoteFeatureFlagControllerState: unknown) => + ( + remoteFeatureFlagControllerState as ExtendedRemoteFeatureFlagControllerState + )?.rawRemoteFeatureFlags ?? {}, +); diff --git a/app/util/feature-flags/index.ts b/app/util/feature-flags/index.ts index 0d651d108c15..caa7abea90d6 100644 --- a/app/util/feature-flags/index.ts +++ b/app/util/feature-flags/index.ts @@ -12,6 +12,7 @@ export interface FeatureFlagInfo { | 'array' | 'boolean with minimumVersion' | 'boolean nested' + | 'abTest' | 'object'; description: string | undefined; isOverridden: boolean; @@ -39,6 +40,12 @@ export const getFeatureFlagType = (value: unknown): FeatureFlagInfo['type'] => { Object.hasOwnProperty.call(value, 'minimumVersion') ) { return 'boolean with minimumVersion'; + } else if ( + typeof value === 'object' && + Object.hasOwnProperty.call(value, 'name') && + Object.hasOwnProperty.call(value, 'value') + ) { + return 'abTest'; } else if ( typeof value === 'object' && typeof (value as { value: boolean })?.value === 'boolean' diff --git a/package.json b/package.json index 64233d94ef44..15d2b573328c 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,8 @@ "@metamask/key-tree@npm:^10.1.1": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/key-tree@npm:^10.0.2": "patch:@metamask/key-tree@npm%3A10.1.1#~/.yarn/patches/@metamask-key-tree-npm-10.1.1-0bfab435ac.patch", "@metamask/transaction-controller@npm:^62.6.0": "patch:@metamask/transaction-controller@npm%3A62.6.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch", - "@metamask/bridge-controller@npm:^64.0.0": "patch:@metamask/bridge-controller@npm%3A61.0.0#~/.yarn/patches/@metamask-bridge-controller-npm-61.0.0-8c413c463f.patch" + "@metamask/bridge-controller@npm:^64.0.0": "patch:@metamask/bridge-controller@npm%3A61.0.0#~/.yarn/patches/@metamask-bridge-controller-npm-61.0.0-8c413c463f.patch", + "@metamask/remote-feature-flag-controller@npm:^3.0.0": "npm:@metamask-previews/remote-feature-flag-controller@3.0.0-preview-c208c1a" }, "dependencies": { "@config-plugins/detox": "^9.0.0", @@ -266,7 +267,7 @@ "@metamask/react-native-payments": "^2.0.0", "@metamask/react-native-search-api": "1.0.1", "@metamask/react-native-webview": "patch:@metamask/react-native-webview@npm%3A14.5.0#~/.yarn/patches/@metamask-react-native-webview-npm-14.5.0-b34fed6d50.patch", - "@metamask/remote-feature-flag-controller": "^3.0.0", + "@metamask/remote-feature-flag-controller": "3.0.0", "@metamask/rpc-errors": "^7.0.2", "@metamask/sample-controllers": "^3.0.0", "@metamask/scure-bip39": "^2.1.0", diff --git a/yarn.lock b/yarn.lock index 2981f21b3df9..0dc68d101968 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9034,7 +9034,7 @@ __metadata: languageName: node linkType: hard -"@metamask/remote-feature-flag-controller@npm:^3.0.0": +"@metamask/remote-feature-flag-controller@npm:3.0.0": version: 3.0.0 resolution: "@metamask/remote-feature-flag-controller@npm:3.0.0" dependencies: @@ -9047,6 +9047,19 @@ __metadata: languageName: node linkType: hard +"@metamask/remote-feature-flag-controller@npm:@metamask-previews/remote-feature-flag-controller@3.0.0-preview-c208c1a": + version: 3.0.0-preview-c208c1a + resolution: "@metamask-previews/remote-feature-flag-controller@npm:3.0.0-preview-c208c1a" + dependencies: + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/controller-utils": "npm:^11.16.0" + "@metamask/messenger": "npm:^0.3.0" + "@metamask/utils": "npm:^11.8.1" + uuid: "npm:^8.3.2" + checksum: 10/1923b2a08228edac6f5cdd185b9a1baf64130df0d2f29a09646e8c5f81e42c232bfed84d2535c899b8218d058967f8cd7f528f5e9fd7da25b5223a75e722dba8 + languageName: node + linkType: hard + "@metamask/rpc-errors@npm:7.0.2": version: 7.0.2 resolution: "@metamask/rpc-errors@npm:7.0.2" @@ -34294,7 +34307,7 @@ __metadata: "@metamask/react-native-payments": "npm:^2.0.0" "@metamask/react-native-search-api": "npm:1.0.1" "@metamask/react-native-webview": "patch:@metamask/react-native-webview@npm%3A14.5.0#~/.yarn/patches/@metamask-react-native-webview-npm-14.5.0-b34fed6d50.patch" - "@metamask/remote-feature-flag-controller": "npm:^3.0.0" + "@metamask/remote-feature-flag-controller": "npm:3.0.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/sample-controllers": "npm:^3.0.0" "@metamask/scure-bip39": "npm:^2.1.0"