From 4e55605cc95eb3ea0a30399fe218bd3d622285f4 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Fri, 26 Jun 2026 10:02:58 -0400 Subject: [PATCH 01/13] feat(remote-feature-flag-controller): return threshold values directly and track threshold groups Threshold feature flags now expose the selected value directly instead of a {name, value} wrapper. When thresholdName is present on the selected group, store the mapping in featureFlagThresholdGroups on controller state. Co-authored-by: Cursor --- .../CHANGELOG.md | 5 ++ .../remote-feature-flag-controller.test.ts | 48 +++++++++---------- .../src/remote-feature-flag-controller.ts | 47 +++++++++++------- 3 files changed, 60 insertions(+), 40 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 6687d4609e..0fd0eb935a 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `featureFlagThresholdGroups` field to `RemoteFeatureFlagControllerState` to map feature flag names to their selected threshold group names + ### Changed +- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state. - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) 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 049f391b2d..c3c80d8c01 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 @@ -468,13 +468,11 @@ describe('RemoteFeatureFlagController', () => { // Threshold = 0.380673, which falls in groupB range (0.3 < t <= 0.5) expect( controller.state.remoteFeatureFlags.testFlagForThreshold, - ).toStrictEqual({ - name: 'groupB', - value: 'valueB', - }); + ).toBe('valueB'); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); - it('preserves selected legacy threshold object value wrappers', async () => { + it('returns selected threshold values directly without threshold group mapping when thresholdName is absent', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -503,13 +501,11 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, - ).toStrictEqual({ - name: 'enabled', - value: thresholdFlagValue, - }); + ).toStrictEqual(thresholdFlagValue); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); - it('returns selected threshold version 2 values without wrapper metadata', async () => { + it('uses thresholdName when provided for threshold group mapping', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -540,9 +536,12 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, ).toStrictEqual(thresholdFlagValue); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + thresholdObjectFlag: 'enabled', + }); }); - it('falls back to legacy threshold wrappers for unrecognized threshold versions', async () => { + it('returns selected threshold values for unrecognized threshold versions', async () => { const thresholdFlagValue = { enabled: true, }; @@ -570,10 +569,8 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, - ).toStrictEqual({ - name: 'enabled', - value: thresholdFlagValue, - }); + ).toStrictEqual(thresholdFlagValue); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); it('preserves non-threshold feature flags unchanged', async () => { @@ -637,9 +634,10 @@ describe('RemoteFeatureFlagController', () => { // Assert - User gets different groups because each flag uses unique seed const { featureA, featureB } = controller.state.remoteFeatureFlags; // featureA: hash(MOCK_METRICS_ID + 'featureA') → threshold 0.966682 → groupA2 - expect(featureA).toStrictEqual({ name: 'groupA2', value: 'A2' }); + expect(featureA).toBe('A2'); // featureB: hash(MOCK_METRICS_ID + 'featureB') → threshold 0.398654 → groupB1 - expect(featureB).toStrictEqual({ name: 'groupB1', value: 'B1' }); + expect(featureB).toBe('B1'); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); // Different groups proves independence! }); @@ -699,10 +697,10 @@ describe('RemoteFeatureFlagController', () => { ); // Assert - Invalid item skipped, valid item selected - expect(controller.state.remoteFeatureFlags.mixedArray).toStrictEqual({ - name: 'validGroup', - value: 'selectedValue', - }); + expect(controller.state.remoteFeatureFlags.mixedArray).toBe( + 'selectedValue', + ); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); it('assigns users to same group for same feature flag on multiple calls', async () => { @@ -749,7 +747,8 @@ describe('RemoteFeatureFlagController', () => { // Assert - Same user always gets same group (deterministic) // testFlag: hash(MOCK_METRICS_ID + 'testFlag') → threshold 0.496587 → control expect(firstResult).toStrictEqual(secondResult); - expect(firstResult).toStrictEqual({ name: 'control', value: false }); + expect(firstResult).toBe(false); + expect(controller1.state.featureFlagThresholdGroups).toStrictEqual({}); }); }); @@ -1061,9 +1060,10 @@ describe('RemoteFeatureFlagController', () => { // With MOCK_METRICS_ID + 'multiVersionABFlag' hashed: // Threshold = 0.094878, which falls in groupA range (t <= 0.3) expect(multiVersionABFlag).toStrictEqual({ - name: 'groupA', - value: { feature: 'A', enabled: true }, + feature: 'A', + enabled: true, }); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); 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 477e16d7ea..570dd4e985 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,7 +9,6 @@ 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, @@ -34,6 +33,7 @@ export type RemoteFeatureFlagControllerState = { rawRemoteFeatureFlags?: FeatureFlags; cacheTimestamp: number; thresholdCache?: Record; + featureFlagThresholdGroups?: Record; }; const remoteFeatureFlagControllerMetadata = { @@ -67,6 +67,12 @@ const remoteFeatureFlagControllerMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + featureFlagThresholdGroups: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, }; // === MESSENGER === @@ -119,18 +125,6 @@ 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. @@ -271,7 +265,7 @@ export class RemoteFeatureFlagController extends BaseController< * @param remoteFeatureFlags - The new feature flags to cache. */ async #updateCache(remoteFeatureFlags: FeatureFlags): Promise { - const { processedFlags, thresholdCacheUpdates } = + const { processedFlags, thresholdCacheUpdates, featureFlagThresholdGroupUpdates } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); const metaMetricsId = this.#getMetaMetricsId(); @@ -297,6 +291,17 @@ export class RemoteFeatureFlagController extends BaseController< } } + const updatedFeatureFlagThresholdGroups = { + ...(this.state.featureFlagThresholdGroups ?? {}), + ...featureFlagThresholdGroupUpdates, + }; + + for (const flagName of Object.keys(updatedFeatureFlagThresholdGroups)) { + if (!currentFlagNames.includes(flagName)) { + delete updatedFeatureFlagThresholdGroups[flagName]; + } + } + // Single state update with all changes batched together this.update(() => { return { @@ -305,6 +310,7 @@ export class RemoteFeatureFlagController extends BaseController< rawRemoteFeatureFlags: remoteFeatureFlags, cacheTimestamp: Date.now(), thresholdCache: updatedThresholdCache, + featureFlagThresholdGroups: updatedFeatureFlagThresholdGroups, }; }); } @@ -326,10 +332,12 @@ export class RemoteFeatureFlagController extends BaseController< async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ processedFlags: FeatureFlags; thresholdCacheUpdates: Record; + featureFlagThresholdGroupUpdates: Record; }> { const processedFlags: FeatureFlags = {}; const metaMetricsId = this.#getMetaMetricsId(); const thresholdCacheUpdates: Record = {}; + const featureFlagThresholdGroupUpdates: Record = {}; for (const [ remoteFeatureFlagName, @@ -387,14 +395,21 @@ export class RemoteFeatureFlagController extends BaseController< ); if (selectedGroup) { - processedValue = normalizeThresholdValue(selectedGroup); + processedValue = selectedGroup.value; + if (selectedGroup.thresholdName) { + featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = selectedGroup.thresholdName; + } } } processedFlags[remoteFeatureFlagName] = processedValue; } - return { processedFlags, thresholdCacheUpdates }; + return { + processedFlags, + thresholdCacheUpdates, + featureFlagThresholdGroupUpdates, + }; } /** From c9103e506d20cee36180acb1fab6d272022b47d1 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Fri, 26 Jun 2026 10:15:11 -0400 Subject: [PATCH 02/13] chore(remote-feature-flag-controller): fix formatting lint Co-authored-by: Cursor --- .../src/remote-feature-flag-controller.test.ts | 6 +++--- .../src/remote-feature-flag-controller.ts | 11 +++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) 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 8fc4d1b869..91bb0d7be0 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 @@ -478,9 +478,9 @@ describe('RemoteFeatureFlagController', () => { // With MOCK_METRICS_ID + 'testFlagForThreshold' hashed: // Threshold = 0.380673, which falls in groupB range (0.3 < t <= 0.5) - expect( - controller.state.remoteFeatureFlags.testFlagForThreshold, - ).toBe('valueB'); + expect(controller.state.remoteFeatureFlags.testFlagForThreshold).toBe( + 'valueB', + ); expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); 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 1b60569e88..436451dd2b 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 @@ -125,7 +125,6 @@ export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagC }; } - /** * 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 @@ -282,8 +281,11 @@ export class RemoteFeatureFlagController extends BaseController< * @param remoteFeatureFlags - The new feature flags to cache. */ async #updateCache(remoteFeatureFlags: FeatureFlags): Promise { - const { processedFlags, thresholdCacheUpdates, featureFlagThresholdGroupUpdates } = - await this.#processRemoteFeatureFlags(remoteFeatureFlags); + const { + processedFlags, + thresholdCacheUpdates, + featureFlagThresholdGroupUpdates, + } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); const metaMetricsId = this.#getMetaMetricsId(); const currentFlagNames = Object.keys(remoteFeatureFlags); @@ -419,7 +421,8 @@ export class RemoteFeatureFlagController extends BaseController< if (selectedGroup) { processedValue = selectedGroup.value; if (selectedGroup.thresholdName) { - featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = selectedGroup.thresholdName; + featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = + selectedGroup.thresholdName; } } } From 8b796f437a82eb7ade576bd7d5f65b8ab3080b93 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Fri, 26 Jun 2026 10:18:29 -0400 Subject: [PATCH 03/13] chore(remote-feature-flag-controller): link changelog entries to PR #9289 Co-authored-by: Cursor --- packages/remote-feature-flag-controller/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 6396076a4f..9848e47152 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,11 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `featureFlagThresholdGroups` field to `RemoteFeatureFlagControllerState` to map feature flag names to their selected threshold group names +- Add optional `featureFlagThresholdGroups` field to `RemoteFeatureFlagControllerState` to map feature flag names to their selected threshold group names ([#9289](https://github.com/MetaMask/core/pull/9289)) ### Changed -- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state. +- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state when the selected threshold entry includes `thresholdName` ([#9289](https://github.com/MetaMask/core/pull/9289)) - Merge `localOverrides` into `remoteFeatureFlags` at the controller level so consumers receive effective flag values directly ([#9259](https://github.com/MetaMask/core/pull/9259)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) From 6f284026d400d0ff79a89d3a9ab3cd7135ea69de Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 29 Jun 2026 08:56:58 -0400 Subject: [PATCH 04/13] test(remote-feature-flag-controller): cover featureFlagThresholdGroups cleanup Co-authored-by: Cursor --- .../remote-feature-flag-controller.test.ts | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) 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 91bb0d7be0..d6da8bf7f8 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 @@ -1406,6 +1406,72 @@ describe('RemoteFeatureFlagController', () => { jest.useRealTimers(); }); + it('removes stale featureFlagThresholdGroups entries when flags are removed from server', async () => { + jest.useRealTimers(); + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: { + flagA: [ + { + thresholdName: 'groupA', + thresholdVersion: ThresholdVersion.DirectValue, + scope: { type: 'threshold', value: 1.0 }, + value: true, + }, + ], + flagB: [ + { + thresholdName: 'groupB', + thresholdVersion: ThresholdVersion.DirectValue, + scope: { type: 'threshold', value: 1.0 }, + value: false, + }, + ], + }, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + flagA: 'groupA', + flagB: 'groupB', + }); + + jest + .spyOn(clientConfigApiService, 'fetchRemoteFeatureFlags') + .mockResolvedValue({ + remoteFeatureFlags: { + flagB: [ + { + thresholdName: 'groupB', + thresholdVersion: ThresholdVersion.DirectValue, + scope: { type: 'threshold', value: 1.0 }, + value: false, + }, + ], + }, + cacheTimestamp: Date.now(), + }); + + jest.useFakeTimers(); + jest.advanceTimersByTime(2 * DEFAULT_CACHE_DURATION); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + await flushPromises(); + + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + flagB: 'groupB', + }); + + jest.useRealTimers(); + }); + it('preserves threshold cache entries for flags still in server response', async () => { // Arrange const mockFlags = { From a896c109b3944d9386dde79733a77197b4e1c6fb Mon Sep 17 00:00:00 2001 From: asalsys Date: Mon, 29 Jun 2026 16:55:44 -0400 Subject: [PATCH 05/13] Update packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Co-authored-by: Mark Stacey --- .../src/remote-feature-flag-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 436451dd2b..815dc7410e 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 @@ -71,7 +71,7 @@ const remoteFeatureFlagControllerMetadata = { includeInStateLogs: true, persist: true, includeInDebugSnapshot: true, - usedInUi: true, + usedInUi: false, }, }; From 141488bafa1f60eb0ebcf9adb9d6020ddff58110 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 30 Jun 2026 11:28:24 -0400 Subject: [PATCH 06/13] completly overwrite updatedFeatureFlagThresholdGroups --- .../src/remote-feature-flag-controller.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 815dc7410e..22b569bd03 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 @@ -312,9 +312,16 @@ export class RemoteFeatureFlagController extends BaseController< const updatedFeatureFlagThresholdGroups = { ...(this.state.featureFlagThresholdGroups ?? {}), - ...featureFlagThresholdGroupUpdates, }; + for (const [flagName, thresholdGroup] of Object.entries( + featureFlagThresholdGroupUpdates, + )) { + if (currentFlagNames.includes(flagName)) { + updatedFeatureFlagThresholdGroups[flagName] = thresholdGroup; + } + } + for (const flagName of Object.keys(updatedFeatureFlagThresholdGroups)) { if (!currentFlagNames.includes(flagName)) { delete updatedFeatureFlagThresholdGroups[flagName]; From 9d628cefcd5e2978b65f70585bb0d19d2f509f6e Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 30 Jun 2026 11:33:32 -0400 Subject: [PATCH 07/13] update threshold name to name --- .../src/remote-feature-flag-controller.ts | 4 ++-- 1 file changed, 2 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 22b569bd03..a0d702309f 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 @@ -427,9 +427,9 @@ export class RemoteFeatureFlagController extends BaseController< if (selectedGroup) { processedValue = selectedGroup.value; - if (selectedGroup.thresholdName) { + if (selectedGroup.name) { featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = - selectedGroup.thresholdName; + selectedGroup.name; } } } From ecba796359feb3643bd016c1db520b31c856a6dc Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 30 Jun 2026 12:29:23 -0400 Subject: [PATCH 08/13] fix test --- .../remote-feature-flag-controller.test.ts | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) 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 d6da8bf7f8..881236fadf 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 @@ -481,10 +481,12 @@ describe('RemoteFeatureFlagController', () => { expect(controller.state.remoteFeatureFlags.testFlagForThreshold).toBe( 'valueB', ); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + testFlagForThreshold: 'groupB', + }); }); - it('returns selected threshold values directly without threshold group mapping when thresholdName is absent', async () => { + it('stores selected threshold group name from name field when thresholdName is absent', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -514,10 +516,12 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, ).toStrictEqual(thresholdFlagValue); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + thresholdObjectFlag: 'enabled', + }); }); - it('uses thresholdName when provided for threshold group mapping', async () => { + it('does not map threshold group when only thresholdName is provided', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -548,9 +552,7 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, ).toStrictEqual(thresholdFlagValue); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ - thresholdObjectFlag: 'enabled', - }); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); it('returns selected threshold values for unrecognized threshold versions', async () => { @@ -582,7 +584,9 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, ).toStrictEqual(thresholdFlagValue); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + thresholdObjectFlag: 'enabled', + }); }); it('preserves non-threshold feature flags unchanged', async () => { @@ -649,7 +653,10 @@ describe('RemoteFeatureFlagController', () => { expect(featureA).toBe('A2'); // featureB: hash(MOCK_METRICS_ID + 'featureB') → threshold 0.398654 → groupB1 expect(featureB).toBe('B1'); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + featureA: 'groupA2', + featureB: 'groupB1', + }); // Different groups proves independence! }); @@ -712,7 +719,9 @@ describe('RemoteFeatureFlagController', () => { expect(controller.state.remoteFeatureFlags.mixedArray).toBe( 'selectedValue', ); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + mixedArray: 'validGroup', + }); }); it('assigns users to same group for same feature flag on multiple calls', async () => { @@ -760,7 +769,9 @@ describe('RemoteFeatureFlagController', () => { // testFlag: hash(MOCK_METRICS_ID + 'testFlag') → threshold 0.496587 → control expect(firstResult).toStrictEqual(secondResult); expect(firstResult).toBe(false); - expect(controller1.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller1.state.featureFlagThresholdGroups).toStrictEqual({ + testFlag: 'control', + }); }); }); @@ -1075,7 +1086,9 @@ describe('RemoteFeatureFlagController', () => { feature: 'A', enabled: true, }); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + multiVersionABFlag: 'groupA', + }); expect(regularFlag).toBe(true); }); }); @@ -1412,7 +1425,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { - thresholdName: 'groupA', + name: 'groupA', thresholdVersion: ThresholdVersion.DirectValue, scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1420,7 +1433,7 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { - thresholdName: 'groupB', + name: 'groupB', thresholdVersion: ThresholdVersion.DirectValue, scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1447,7 +1460,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagB: [ { - thresholdName: 'groupB', + name: 'groupB', thresholdVersion: ThresholdVersion.DirectValue, scope: { type: 'threshold', value: 1.0 }, value: false, From a201f0a96e4962b52e44577865270a658647cccc Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Thu, 2 Jul 2026 10:10:39 -0400 Subject: [PATCH 09/13] chore(remote-feature-flag-controller): fix changelog to reference name field featureFlagThresholdGroups is populated from the selected entry's name, not thresholdName. Co-authored-by: Cursor --- packages/remote-feature-flag-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 9848e47152..74eacc03c9 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 ### Changed -- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state when the selected threshold entry includes `thresholdName` ([#9289](https://github.com/MetaMask/core/pull/9289)) +- **BREAKING:** Threshold feature flags now return the selected `value` directly instead of a `{ name, value }` wrapper. The selected threshold group name is stored separately in `featureFlagThresholdGroups` on controller state when the selected threshold entry includes `name` ([#9289](https://github.com/MetaMask/core/pull/9289)) - Merge `localOverrides` into `remoteFeatureFlags` at the controller level so consumers receive effective flag values directly ([#9259](https://github.com/MetaMask/core/pull/9259)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) From 0418443af500d03bf7f0097bfa17c6bff0712251 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Thu, 2 Jul 2026 12:22:27 -0400 Subject: [PATCH 10/13] add try catch to secure test flackiness --- .../remote-feature-flag-controller.test.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) 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 881236fadf..76460c1381 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 @@ -1471,18 +1471,21 @@ describe('RemoteFeatureFlagController', () => { }); jest.useFakeTimers(); - jest.advanceTimersByTime(2 * DEFAULT_CACHE_DURATION); - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - await flushPromises(); + try { + jest.advanceTimersByTime(2 * DEFAULT_CACHE_DURATION); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ - flagB: 'groupB', - }); + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + await flushPromises(); - jest.useRealTimers(); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + flagB: 'groupB', + }); + } finally { + jest.useRealTimers(); + } }); it('preserves threshold cache entries for flags still in server response', async () => { From aef4766b73170c02630d45a7e8058e1b056cf0eb Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Thu, 2 Jul 2026 12:25:23 -0400 Subject: [PATCH 11/13] remove redundant tests --- .../remote-feature-flag-controller.test.ts | 71 +------------------ 1 file changed, 1 insertion(+), 70 deletions(-) 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 76460c1381..eeb119b81c 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 @@ -485,76 +485,7 @@ describe('RemoteFeatureFlagController', () => { testFlagForThreshold: 'groupB', }); }); - - it('stores selected threshold group name from name field when thresholdName is absent', 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(thresholdFlagValue); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ - thresholdObjectFlag: 'enabled', - }); - }); - - it('does not map threshold group when only thresholdName is provided', 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); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); - }); - + it('returns selected threshold values for unrecognized threshold versions', async () => { const thresholdFlagValue = { enabled: true, From 0d66d4f465d7dbc5de2dc6c0d3e3a187d29d4f1e Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Thu, 2 Jul 2026 12:29:27 -0400 Subject: [PATCH 12/13] update test --- .../src/remote-feature-flag-controller.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 eeb119b81c..478a0ffa67 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 @@ -485,8 +485,8 @@ describe('RemoteFeatureFlagController', () => { testFlagForThreshold: 'groupB', }); }); - - it('returns selected threshold values for unrecognized threshold versions', async () => { + + it('ignores threshold version field, processing threshold values normally', async () => { const thresholdFlagValue = { enabled: true, }; From 98b74a20f2a3b89034e63d967c05e173ff8bead0 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Thu, 2 Jul 2026 13:50:47 -0400 Subject: [PATCH 13/13] refactor to use featureFlagThresholdGroupUpdates --- .../src/remote-feature-flag-controller.ts | 20 +------------------ 1 file changed, 1 insertion(+), 19 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 a0d702309f..fe6348357f 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 @@ -310,24 +310,6 @@ export class RemoteFeatureFlagController extends BaseController< } } - const updatedFeatureFlagThresholdGroups = { - ...(this.state.featureFlagThresholdGroups ?? {}), - }; - - for (const [flagName, thresholdGroup] of Object.entries( - featureFlagThresholdGroupUpdates, - )) { - if (currentFlagNames.includes(flagName)) { - updatedFeatureFlagThresholdGroups[flagName] = thresholdGroup; - } - } - - for (const flagName of Object.keys(updatedFeatureFlagThresholdGroups)) { - if (!currentFlagNames.includes(flagName)) { - delete updatedFeatureFlagThresholdGroups[flagName]; - } - } - // Single state update with all changes batched together this.#processedRemoteFeatureFlags = processedFlags; @@ -341,7 +323,7 @@ export class RemoteFeatureFlagController extends BaseController< rawRemoteFeatureFlags: remoteFeatureFlags, cacheTimestamp: Date.now(), thresholdCache: updatedThresholdCache, - featureFlagThresholdGroups: updatedFeatureFlagThresholdGroups, + featureFlagThresholdGroups: featureFlagThresholdGroupUpdates, }; }); }