diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 0b7be74d54..5939453143 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +### Fixed + +- Support `ThresholdVersion.DirectValue` (`thresholdVersion: 2`) threshold feature flag entries that return the selected `value` directly while preserving the existing threshold wrapper shape for unversioned entries ([#8908](https://github.com/MetaMask/core/pull/8908)) + ## [4.2.1] ### Changed diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 666660e482..00c1c1c2d1 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -20,6 +20,7 @@ export { ClientType, DistributionType, EnvironmentType, + ThresholdVersion, } from './remote-feature-flag-controller-types'; export type { FeatureFlags } from './remote-feature-flag-controller-types'; diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts index 6bf8e5ea2f..0e6ab906ca 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts @@ -39,8 +39,22 @@ export type FeatureFlagScope = { value: number; }; +export enum ThresholdVersion { + DirectValue = 2, +} + export type FeatureFlagScopeValue = { name: string; + /** + * Optional label for direct-value threshold entries. This replaces `name` in + * v2 configurations and is not emitted in processed controller state. + */ + thresholdName?: string; + /** + * Selects the threshold entry output shape. Unrecognized versions fall back + * to the legacy `{ name, value }` wrapper for backwards compatibility. + */ + thresholdVersion?: ThresholdVersion; scope: FeatureFlagScope; value: Json; }; diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts index 142995b345..049f391b2d 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -18,6 +18,7 @@ import type { RemoteFeatureFlagControllerMessenger, RemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; +import { ThresholdVersion } from './remote-feature-flag-controller-types'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; const MOCK_FLAGS: FeatureFlags = { @@ -425,6 +426,31 @@ describe('RemoteFeatureFlagController', () => { }); }); + describe('feature flag value normalization', () => { + it('preserves direct feature flag config objects without value metadata', async () => { + const directConfig = { + enabled: true, + minimumVersion: '13.10.0', + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: { + directConfig, + }, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.directConfig).toStrictEqual( + directConfig, + ); + }); + }); + describe('threshold feature flags', () => { it('processes threshold feature flags based on provided metaMetricsId', async () => { const clientConfigApiService = buildClientConfigApiService({ @@ -448,6 +474,108 @@ describe('RemoteFeatureFlagController', () => { }); }); + it('preserves selected legacy threshold object value wrappers', async () => { + const thresholdFlagValue = { + enabled: true, + minimumVersion: '13.10.0', + attemptsMax: 5, + }; + const mockFlags = { + thresholdObjectFlag: [ + { + name: 'enabled', + scope: { type: 'threshold', value: 1.0 }, + value: thresholdFlagValue, + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.thresholdObjectFlag, + ).toStrictEqual({ + name: 'enabled', + value: thresholdFlagValue, + }); + }); + + it('returns selected threshold version 2 values without wrapper metadata', async () => { + const thresholdFlagValue = { + enabled: true, + minimumVersion: '13.10.0', + attemptsMax: 5, + }; + const mockFlags = { + thresholdObjectFlag: [ + { + thresholdName: 'enabled', + thresholdVersion: ThresholdVersion.DirectValue, + scope: { type: 'threshold', value: 1.0 }, + value: thresholdFlagValue, + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.thresholdObjectFlag, + ).toStrictEqual(thresholdFlagValue); + }); + + it('falls back to legacy threshold wrappers for unrecognized threshold versions', async () => { + const thresholdFlagValue = { + enabled: true, + }; + const mockFlags = { + thresholdObjectFlag: [ + { + name: 'enabled', + thresholdVersion: 3, + scope: { type: 'threshold', value: 1.0 }, + value: thresholdFlagValue, + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.thresholdObjectFlag, + ).toStrictEqual({ + name: 'enabled', + value: thresholdFlagValue, + }); + }); + it('preserves non-threshold feature flags unchanged', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD, diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts index 8f8fbbb4db..477e16d7ea 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -9,6 +9,7 @@ import type { Json, SemVerVersion } from '@metamask/utils'; import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; import type { RemoteFeatureFlagControllerMethodActions } from './remote-feature-flag-controller-method-action-types'; +import { ThresholdVersion } from './remote-feature-flag-controller-types'; import type { FeatureFlags, ServiceResponse, @@ -118,6 +119,19 @@ export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagC }; } +function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json { + if (featureFlag.thresholdVersion === ThresholdVersion.DirectValue) { + return featureFlag.value; + } + + // Unknown threshold versions fall back to the legacy wrapper shape for + // backwards compatibility with existing threshold feature flag configs. + return { + name: featureFlag.name, + value: featureFlag.value, + }; +} + /** * The RemoteFeatureFlagController manages the retrieval and caching of remote feature flags. * It fetches feature flags from a remote API, caches them, and provides methods to access @@ -371,11 +385,9 @@ export class RemoteFeatureFlagController extends BaseController< return threshold <= featureFlag.scope.value; }, ); + if (selectedGroup) { - processedValue = { - name: selectedGroup.name, - value: selectedGroup.value, - }; + processedValue = normalizeThresholdValue(selectedGroup); } }