From 761c717b750b57d61e6c4e2c0c2ec614c1c7b991 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 27 May 2026 14:23:43 +0100 Subject: [PATCH 1/6] fix: Normalize remote feature flag value wrappers --- .../CHANGELOG.md | 4 + .../remote-feature-flag-controller-types.ts | 4 +- .../remote-feature-flag-controller.test.ts | 202 ++++++++++++++++++ .../src/remote-feature-flag-controller.ts | 62 +++++- 4 files changed, 266 insertions(+), 6 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 0b7be74d54..36612eed26 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 + +- Normalize object-valued feature flag wrappers so `value` contents of threshold entries are exposed at the top level while preserving the `value` property when expected by older selectors ([#8908](https://github.com/MetaMask/core/pull/8908)) + ## [4.2.1] ### Changed 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..dd120e1871 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 @@ -40,7 +40,9 @@ export type FeatureFlagScope = { }; export type FeatureFlagScopeValue = { - name: string; + name?: string; + thresholdName?: string; + thresholdVersion?: number; 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..4516629a7d 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 @@ -425,6 +425,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 +473,109 @@ describe('RemoteFeatureFlagController', () => { }); }); + it('spreads selected threshold object values while preserving the value property', 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', + enabled: true, + minimumVersion: '13.10.0', + attemptsMax: 5, + 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: 2, + 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('omits selected threshold name when no name metadata is configured', async () => { + const thresholdFlagValue = { + enabled: true, + }; + const mockFlags = { + thresholdObjectFlag: [ + { + 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({ + enabled: true, + value: thresholdFlagValue, + }); + }); + it('preserves non-threshold feature flags unchanged', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD, @@ -934,10 +1062,84 @@ describe('RemoteFeatureFlagController', () => { // Threshold = 0.094878, which falls in groupA range (t <= 0.3) expect(multiVersionABFlag).toStrictEqual({ name: 'groupA', + feature: 'A', + enabled: true, value: { feature: 'A', enabled: true }, }); expect(regularFlag).toBe(true); }); + + it('spreads selected multi-version object values while preserving the value property', async () => { + const mockApiService = buildClientConfigApiService(); + const versionedFlagValue = { + enabled: true, + slippage: 0.5, + }; + const mockFlags = { + multiVersionWrappedFlag: { + versions: { + '13.1.0': { + value: versionedFlagValue, + }, + }, + }, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const { controller, messenger } = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.1.5', + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.multiVersionWrappedFlag, + ).toStrictEqual({ + enabled: true, + slippage: 0.5, + value: versionedFlagValue, + }); + }); + + it('preserves selected multi-version primitive value wrappers', async () => { + const mockApiService = buildClientConfigApiService(); + const mockFlags = { + multiVersionWrappedFlag: { + versions: { + '13.1.0': { + value: true, + }, + }, + }, + }; + + jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ + remoteFeatureFlags: mockFlags, + cacheTimestamp: Date.now(), + }); + + const { controller, messenger } = createController({ + clientConfigApiService: mockApiService, + clientVersion: '13.1.5', + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.multiVersionWrappedFlag, + ).toStrictEqual({ + value: true, + }); + }); }); describe('getDefaultRemoteFeatureFlagControllerState', () => { 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..1b7a9c160a 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 @@ -24,6 +24,13 @@ import { isVersionFeatureFlag, getVersionData } from './utils/version'; export const controllerName = 'RemoteFeatureFlagController'; export const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day +const THRESHOLD_VALUE_VERSION = 2; + +type JsonObject = Record; + +type FeatureFlagValueWrapper = JsonObject & { + value: Json; +}; // === STATE === @@ -118,6 +125,45 @@ export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagC }; } +function isJsonObject(value: Json): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isFeatureFlagValueWrapper( + value: Json, +): value is FeatureFlagValueWrapper { + return ( + isJsonObject(value) && Object.prototype.hasOwnProperty.call(value, 'value') + ); +} + +function spreadFeatureFlagValueWrapper( + featureFlagValue: FeatureFlagValueWrapper, +): Json { + if (!isJsonObject(featureFlagValue.value)) { + return featureFlagValue; + } + + return { + ...featureFlagValue, + ...featureFlagValue.value, + }; +} + +function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json { + if (featureFlag.thresholdVersion === THRESHOLD_VALUE_VERSION) { + return featureFlag.value; + } + + const name = featureFlag.thresholdName ?? featureFlag.name; + + return { + ...(isJsonObject(featureFlag.value) ? featureFlag.value : {}), + ...(name === undefined ? {} : { 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 @@ -306,7 +352,15 @@ export class RemoteFeatureFlagController extends BaseController< return flagValue; } - return getVersionData(flagValue, this.#clientVersion); + const versionData = getVersionData(flagValue, this.#clientVersion); + + if (versionData === null) { + return null; + } + + return isFeatureFlagValueWrapper(versionData) + ? spreadFeatureFlagValueWrapper(versionData) + : versionData; } async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ @@ -371,11 +425,9 @@ export class RemoteFeatureFlagController extends BaseController< return threshold <= featureFlag.scope.value; }, ); + if (selectedGroup) { - processedValue = { - name: selectedGroup.name, - value: selectedGroup.value, - }; + processedValue = normalizeThresholdValue(selectedGroup); } } From 4b6e1ccbcc3f73522ae9153fe9321d0b03619382 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 28 May 2026 14:28:03 +0100 Subject: [PATCH 2/6] Remove unsupported version flag wrapper normalization --- .../CHANGELOG.md | 2 +- .../remote-feature-flag-controller.test.ts | 72 ------------------- .../src/remote-feature-flag-controller.ts | 29 +------- 3 files changed, 2 insertions(+), 101 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 36612eed26..e16c29af7d 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Normalize object-valued feature flag wrappers so `value` contents of threshold entries are exposed at the top level while preserving the `value` property when expected by older selectors ([#8908](https://github.com/MetaMask/core/pull/8908)) +- Normalize object-valued threshold feature flag entries so `value` contents are exposed at the top level while preserving the `value` property, and support `thresholdVersion: 2` entries that return the selected `value` directly ([#8908](https://github.com/MetaMask/core/pull/8908)) ## [4.2.1] 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 4516629a7d..385fbf6b7b 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 @@ -1068,78 +1068,6 @@ describe('RemoteFeatureFlagController', () => { }); expect(regularFlag).toBe(true); }); - - it('spreads selected multi-version object values while preserving the value property', async () => { - const mockApiService = buildClientConfigApiService(); - const versionedFlagValue = { - enabled: true, - slippage: 0.5, - }; - const mockFlags = { - multiVersionWrappedFlag: { - versions: { - '13.1.0': { - value: versionedFlagValue, - }, - }, - }, - }; - - jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ - remoteFeatureFlags: mockFlags, - cacheTimestamp: Date.now(), - }); - - const { controller, messenger } = createController({ - clientConfigApiService: mockApiService, - clientVersion: '13.1.5', - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - expect( - controller.state.remoteFeatureFlags.multiVersionWrappedFlag, - ).toStrictEqual({ - enabled: true, - slippage: 0.5, - value: versionedFlagValue, - }); - }); - - it('preserves selected multi-version primitive value wrappers', async () => { - const mockApiService = buildClientConfigApiService(); - const mockFlags = { - multiVersionWrappedFlag: { - versions: { - '13.1.0': { - value: true, - }, - }, - }, - }; - - jest.spyOn(mockApiService, 'fetchRemoteFeatureFlags').mockResolvedValue({ - remoteFeatureFlags: mockFlags, - cacheTimestamp: Date.now(), - }); - - const { controller, messenger } = createController({ - clientConfigApiService: mockApiService, - clientVersion: '13.1.5', - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - expect( - controller.state.remoteFeatureFlags.multiVersionWrappedFlag, - ).toStrictEqual({ - value: true, - }); - }); }); describe('getDefaultRemoteFeatureFlagControllerState', () => { 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 1b7a9c160a..c62209e41c 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 @@ -28,10 +28,6 @@ const THRESHOLD_VALUE_VERSION = 2; type JsonObject = Record; -type FeatureFlagValueWrapper = JsonObject & { - value: Json; -}; - // === STATE === export type RemoteFeatureFlagControllerState = { @@ -129,27 +125,6 @@ function isJsonObject(value: Json): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function isFeatureFlagValueWrapper( - value: Json, -): value is FeatureFlagValueWrapper { - return ( - isJsonObject(value) && Object.prototype.hasOwnProperty.call(value, 'value') - ); -} - -function spreadFeatureFlagValueWrapper( - featureFlagValue: FeatureFlagValueWrapper, -): Json { - if (!isJsonObject(featureFlagValue.value)) { - return featureFlagValue; - } - - return { - ...featureFlagValue, - ...featureFlagValue.value, - }; -} - function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json { if (featureFlag.thresholdVersion === THRESHOLD_VALUE_VERSION) { return featureFlag.value; @@ -358,9 +333,7 @@ export class RemoteFeatureFlagController extends BaseController< return null; } - return isFeatureFlagValueWrapper(versionData) - ? spreadFeatureFlagValueWrapper(versionData) - : versionData; + return versionData; } async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ From b0a1f1186c664ff564be6869218ab5e17c99fff8 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 28 May 2026 14:32:06 +0100 Subject: [PATCH 3/6] Use enum for threshold versions --- .../src/remote-feature-flag-controller.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 c62209e41c..18229d872f 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 @@ -24,7 +24,10 @@ import { isVersionFeatureFlag, getVersionData } from './utils/version'; export const controllerName = 'RemoteFeatureFlagController'; export const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day -const THRESHOLD_VALUE_VERSION = 2; + +enum ThresholdVersion { + DirectValue = 2, +} type JsonObject = Record; @@ -126,7 +129,7 @@ function isJsonObject(value: Json): value is JsonObject { } function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json { - if (featureFlag.thresholdVersion === THRESHOLD_VALUE_VERSION) { + if (featureFlag.thresholdVersion === ThresholdVersion.DirectValue) { return featureFlag.value; } From 9dc313d31995de2d3f8eb75b4c1e1c2d2cfa4426 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 28 May 2026 14:37:21 +0100 Subject: [PATCH 4/6] Preserve legacy threshold wrapper fallback --- .../CHANGELOG.md | 2 +- .../remote-feature-flag-controller-types.ts | 2 +- .../remote-feature-flag-controller.test.ts | 39 +------------------ .../src/remote-feature-flag-controller.ts | 11 +----- 4 files changed, 4 insertions(+), 50 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index e16c29af7d..0ef1c8e274 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Normalize object-valued threshold feature flag entries so `value` contents are exposed at the top level while preserving the `value` property, and support `thresholdVersion: 2` entries that return the selected `value` directly ([#8908](https://github.com/MetaMask/core/pull/8908)) +- Support `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] 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 dd120e1871..81b953ba8f 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 @@ -40,7 +40,7 @@ export type FeatureFlagScope = { }; export type FeatureFlagScopeValue = { - name?: string; + name: string; thresholdName?: string; thresholdVersion?: number; scope: FeatureFlagScope; 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 385fbf6b7b..f5f1b3fc23 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 @@ -473,7 +473,7 @@ describe('RemoteFeatureFlagController', () => { }); }); - it('spreads selected threshold object values while preserving the value property', async () => { + it('preserves selected legacy threshold object value wrappers', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -504,9 +504,6 @@ describe('RemoteFeatureFlagController', () => { controller.state.remoteFeatureFlags.thresholdObjectFlag, ).toStrictEqual({ name: 'enabled', - enabled: true, - minimumVersion: '13.10.0', - attemptsMax: 5, value: thresholdFlagValue, }); }); @@ -544,38 +541,6 @@ describe('RemoteFeatureFlagController', () => { ).toStrictEqual(thresholdFlagValue); }); - it('omits selected threshold name when no name metadata is configured', async () => { - const thresholdFlagValue = { - enabled: true, - }; - const mockFlags = { - thresholdObjectFlag: [ - { - 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({ - enabled: true, - value: thresholdFlagValue, - }); - }); - it('preserves non-threshold feature flags unchanged', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD, @@ -1062,8 +1027,6 @@ describe('RemoteFeatureFlagController', () => { // Threshold = 0.094878, which falls in groupA range (t <= 0.3) expect(multiVersionABFlag).toStrictEqual({ name: 'groupA', - feature: 'A', - enabled: true, value: { feature: 'A', enabled: true }, }); expect(regularFlag).toBe(true); 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 18229d872f..20fdc88605 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 @@ -29,8 +29,6 @@ enum ThresholdVersion { DirectValue = 2, } -type JsonObject = Record; - // === STATE === export type RemoteFeatureFlagControllerState = { @@ -124,20 +122,13 @@ export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagC }; } -function isJsonObject(value: Json): value is JsonObject { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json { if (featureFlag.thresholdVersion === ThresholdVersion.DirectValue) { return featureFlag.value; } - const name = featureFlag.thresholdName ?? featureFlag.name; - return { - ...(isJsonObject(featureFlag.value) ? featureFlag.value : {}), - ...(name === undefined ? {} : { name }), + name: featureFlag.name, value: featureFlag.value, }; } From 363c9fd133fcf56929a11abefa648ad23163a2de Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Thu, 28 May 2026 16:17:00 +0100 Subject: [PATCH 5/6] Restore direct version data return --- .../src/remote-feature-flag-controller.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 20fdc88605..94d97c45e2 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 @@ -321,13 +321,7 @@ export class RemoteFeatureFlagController extends BaseController< return flagValue; } - const versionData = getVersionData(flagValue, this.#clientVersion); - - if (versionData === null) { - return null; - } - - return versionData; + return getVersionData(flagValue, this.#clientVersion); } async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ From 1de751720a2189cc8ba99257df08e60546e4da5e Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Wed, 3 Jun 2026 10:00:11 +0100 Subject: [PATCH 6/6] Address threshold version review notes --- .../CHANGELOG.md | 2 +- .../src/index.ts | 1 + .../remote-feature-flag-controller-types.ts | 14 ++++++- .../remote-feature-flag-controller.test.ts | 37 ++++++++++++++++++- .../src/remote-feature-flag-controller.ts | 7 ++-- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 0ef1c8e274..5939453143 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Support `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)) +- 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] 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 81b953ba8f..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,10 +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; - thresholdVersion?: number; + /** + * 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 f5f1b3fc23..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 = { @@ -518,7 +519,7 @@ describe('RemoteFeatureFlagController', () => { thresholdObjectFlag: [ { thresholdName: 'enabled', - thresholdVersion: 2, + thresholdVersion: ThresholdVersion.DirectValue, scope: { type: 'threshold', value: 1.0 }, value: thresholdFlagValue, }, @@ -541,6 +542,40 @@ describe('RemoteFeatureFlagController', () => { ).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 94d97c45e2..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, @@ -25,10 +26,6 @@ import { isVersionFeatureFlag, getVersionData } from './utils/version'; export const controllerName = 'RemoteFeatureFlagController'; export const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day -enum ThresholdVersion { - DirectValue = 2, -} - // === STATE === export type RemoteFeatureFlagControllerState = { @@ -127,6 +124,8 @@ function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json { 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,