From 4e55605cc95eb3ea0a30399fe218bd3d622285f4 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Fri, 26 Jun 2026 10:02:58 -0400 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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 = {