From 4705f3b497a963eb6a958610376c353ec6f2d849 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 30 Jun 2026 19:04:05 -0400 Subject: [PATCH 1/9] feat(remote-feature-flag-controller): return threshold values directly and support canonical id segmentation Threshold flags now expose selected values directly, track group names in featureFlagThresholdGroups, and can use getCanonicalId when idType is canonical. Co-authored-by: Cursor --- .../CHANGELOG.md | 6 + .../src/index.ts | 1 + .../remote-feature-flag-controller-types.ts | 10 + .../remote-feature-flag-controller.test.ts | 229 ++++++++++++++++-- .../src/remote-feature-flag-controller.ts | 96 ++++++-- .../src/utils/user-segmentation-utils.test.ts | 33 +++ .../src/utils/user-segmentation-utils.ts | 22 +- .../remote-feature-flag-controller.ts | 1 + .../remote-feature-flag-controller/types.ts | 5 + 9 files changed, 353 insertions(+), 50 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 47f685a091..0fc8ccf6a1 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -7,8 +7,14 @@ 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 ([#9289](https://github.com/MetaMask/core/pull/9289)) +- Add optional `getCanonicalId` constructor callback and `FeatureFlagIdType` enum so threshold flags can segment users by canonical ID instead of MetaMetrics ID when configured with `idType: "canonical"` ([#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 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)) diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 00c1c1c2d1..3bafeccab2 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, + FeatureFlagIdType, ThresholdVersion, } 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 0e6ab906ca..7b4f0b5114 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 @@ -43,6 +43,11 @@ export enum ThresholdVersion { DirectValue = 2, } +export enum FeatureFlagIdType { + MetaMetrics = 'metametrics', + Canonical = 'canonical', +} + export type FeatureFlagScopeValue = { name: string; /** @@ -50,6 +55,11 @@ export type FeatureFlagScopeValue = { * v2 configurations and is not emitted in processed controller state. */ thresholdName?: string; + /** + * Selects which client identifier is used for deterministic threshold + * assignment. Defaults to `metametrics` when omitted. + */ + idType?: FeatureFlagIdType; /** * Selects the threshold entry output shape. Unrecognized versions fall back * to the legacy `{ name, value }` wrapper for backwards compatibility. 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 f2009007f9..556f5ebc29 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,7 +18,7 @@ import type { RemoteFeatureFlagControllerMessenger, RemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; -import { ThresholdVersion } from './remote-feature-flag-controller-types'; +import { ThresholdVersion, FeatureFlagIdType } from './remote-feature-flag-controller-types'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; const MOCK_FLAGS: FeatureFlags = { @@ -47,6 +47,8 @@ const MOCK_FLAGS_WITH_THRESHOLD = { }; const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba'; +const MOCK_CANONICAL_ID = + '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420'; const MOCK_BASE_VERSION = '13.10.0'; /** @@ -57,6 +59,7 @@ const MOCK_BASE_VERSION = '13.10.0'; * @param options.clientConfigApiService - The client config API service instance * @param options.disabled - Whether the controller should start disabled * @param options.getMetaMetricsId - Returns metaMetricsId + * @param options.getCanonicalId - Returns canonicalId * @param options.clientVersion - The client version string * @param options.prevClientVersion - The previous client version string * @returns The controller and the root messenger @@ -67,6 +70,7 @@ function createController( clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; getMetaMetricsId: () => string; + getCanonicalId: () => string; clientVersion: string; prevClientVersion: string; }> = {}, @@ -81,6 +85,9 @@ function createController( getMetaMetricsId: options.getMetaMetricsId ?? ((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID), + getCanonicalId: + options.getCanonicalId ?? + ((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID), clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, prevClientVersion: options.prevClientVersion, }); @@ -100,6 +107,36 @@ describe('RemoteFeatureFlagController', () => { }); }); + it('defaults getCanonicalId to an empty string when omitted', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + idType: FeatureFlagIdType.Canonical, + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalA', + }, + ], + }; + const { rootMessenger, controllerMessenger } = buildMessenger(); + const controller = new RemoteFeatureFlagController({ + messenger: controllerMessenger, + clientConfigApiService: buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }), + getMetaMetricsId: () => MOCK_METRICS_ID, + clientVersion: MOCK_BASE_VERSION, + }); + + await rootMessenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toStrictEqual( + mockFlags.canonicalThresholdFlag, + ); + }); + it('initializes with default state if the disabled parameter is provided', () => { const { controller } = createController({ disabled: true }); @@ -478,15 +515,15 @@ 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, - ).toStrictEqual({ - name: 'groupB', - value: 'valueB', + expect(controller.state.remoteFeatureFlags.testFlagForThreshold).toBe( + 'valueB', + ); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + testFlagForThreshold: 'groupB', }); }); - it('preserves selected legacy threshold object value wrappers', async () => { + it('stores selected threshold group name from name field when thresholdName is absent', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -515,13 +552,13 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, - ).toStrictEqual({ - name: 'enabled', - value: thresholdFlagValue, + ).toStrictEqual(thresholdFlagValue); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + thresholdObjectFlag: 'enabled', }); }); - it('returns selected threshold version 2 values without wrapper metadata', async () => { + it('does not map threshold group when only thresholdName is provided', async () => { const thresholdFlagValue = { enabled: true, minimumVersion: '13.10.0', @@ -552,9 +589,10 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, ).toStrictEqual(thresholdFlagValue); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); }); - 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, }; @@ -582,9 +620,9 @@ describe('RemoteFeatureFlagController', () => { expect( controller.state.remoteFeatureFlags.thresholdObjectFlag, - ).toStrictEqual({ - name: 'enabled', - value: thresholdFlagValue, + ).toStrictEqual(thresholdFlagValue); + expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ + thresholdObjectFlag: 'enabled', }); }); @@ -649,9 +687,13 @@ 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({ + featureA: 'groupA2', + featureB: 'groupB1', + }); // Different groups proves independence! }); @@ -711,9 +753,11 @@ 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({ + mixedArray: 'validGroup', }); }); @@ -761,7 +805,77 @@ 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({ + testFlag: 'control', + }); + }); + + it('uses getCanonicalId for threshold flags with canonical idType', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + idType: FeatureFlagIdType.Canonical, + name: 'groupA', + scope: { type: 'threshold', value: 0.5 }, + value: 'canonicalA', + }, + { + idType: FeatureFlagIdType.Canonical, + name: 'groupB', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalB', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => '', + getCanonicalId: () => MOCK_CANONICAL_ID, + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect( + controller.state.remoteFeatureFlags.canonicalThresholdFlag, + ).toBe('canonicalB'); + expect(controller.state.thresholdCache).toStrictEqual({ + [`${MOCK_CANONICAL_ID}:canonicalThresholdFlag`]: expect.any(Number), + }); + }); + + it('preserves threshold arrays when canonical id is empty for canonical idType flags', async () => { + const mockFlags = { + canonicalThresholdFlag: [ + { + idType: FeatureFlagIdType.Canonical, + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: 'canonicalA', + }, + ], + }; + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: mockFlags, + }); + const { controller, messenger } = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + getCanonicalId: () => '', + }); + + await messenger.call( + 'RemoteFeatureFlagController:updateRemoteFeatureFlags', + ); + + expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toStrictEqual( + mockFlags.canonicalThresholdFlag, + ); }); }); @@ -1073,8 +1187,11 @@ 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({ + multiVersionABFlag: 'groupA', }); expect(regularFlag).toBe(true); }); @@ -1406,6 +1523,72 @@ describe('RemoteFeatureFlagController', () => { jest.useRealTimers(); }); + it('removes stale featureFlagThresholdGroups entries when flags are removed from server', async () => { + jest.useRealTimers(); + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: { + flagA: [ + { + name: 'groupA', + thresholdVersion: ThresholdVersion.DirectValue, + scope: { type: 'threshold', value: 1.0 }, + value: true, + }, + ], + flagB: [ + { + name: '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: [ + { + name: '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 = { 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 453b4ed9e2..f5395aaa49 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,14 +9,15 @@ 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, FeatureFlagScopeValue, } from './remote-feature-flag-controller-types'; +import { FeatureFlagIdType } from './remote-feature-flag-controller-types'; import { calculateThresholdForFlag, + getThresholdIdType, isFeatureFlagWithScopeValue, } from './utils/user-segmentation-utils'; import { isVersionFeatureFlag, getVersionData } from './utils/version'; @@ -34,6 +35,7 @@ export type RemoteFeatureFlagControllerState = { rawRemoteFeatureFlags?: FeatureFlags; cacheTimestamp: number; thresholdCache?: Record; + featureFlagThresholdGroups?: Record; }; const remoteFeatureFlagControllerMetadata = { @@ -67,6 +69,12 @@ const remoteFeatureFlagControllerMetadata = { includeInDebugSnapshot: false, usedInUi: false, }, + featureFlagThresholdGroups: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: false, + }, }; // === MESSENGER === @@ -119,19 +127,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. * It fetches feature flags from a remote API, caches them, and provides methods to access @@ -153,6 +148,8 @@ export class RemoteFeatureFlagController extends BaseController< readonly #getMetaMetricsId: () => string; + readonly #getCanonicalId: () => string; + readonly #clientVersion: SemVerVersion; #processedRemoteFeatureFlags: FeatureFlags = {}; @@ -167,6 +164,8 @@ export class RemoteFeatureFlagController extends BaseController< * @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day. * @param options.disabled - Determines if the controller should be disabled initially. Defaults to false. * @param options.getMetaMetricsId - Returns metaMetricsId. + * @param options.getCanonicalId - Returns the canonical client identifier used + * for threshold flags configured with a non-metametrics idType. * @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string. * @param options.prevClientVersion - The previous client version for feature flag cache invalidation. */ @@ -177,6 +176,7 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval = DEFAULT_CACHE_DURATION, disabled = false, getMetaMetricsId, + getCanonicalId = (): string => '', clientVersion, prevClientVersion, }: { @@ -184,6 +184,7 @@ export class RemoteFeatureFlagController extends BaseController< state?: Partial; clientConfigApiService: AbstractClientConfigApiService; getMetaMetricsId: () => string; + getCanonicalId?: () => string; fetchInterval?: number; disabled?: boolean; clientVersion: string; @@ -235,6 +236,7 @@ export class RemoteFeatureFlagController extends BaseController< this.#disabled = disabled; this.#clientConfigApiService = clientConfigApiService; this.#getMetaMetricsId = getMetaMetricsId; + this.#getCanonicalId = getCanonicalId; this.#clientVersion = clientVersion; this.messenger.registerMethodActionHandlers( @@ -288,10 +290,14 @@ export class RemoteFeatureFlagController extends BaseController< * @param remoteFeatureFlags - The new feature flags to cache. */ async #updateCache(remoteFeatureFlags: FeatureFlags): Promise { - const { processedFlags, thresholdCacheUpdates } = - await this.#processRemoteFeatureFlags(remoteFeatureFlags); + const { + processedFlags, + thresholdCacheUpdates, + featureFlagThresholdGroupUpdates, + } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); const metaMetricsId = this.#getMetaMetricsId(); + const canonicalId = this.#getCanonicalId(); const currentFlagNames = Object.keys(remoteFeatureFlags); // Build updated threshold cache @@ -304,16 +310,35 @@ export class RemoteFeatureFlagController extends BaseController< // Clean up stale entries for (const cacheKey of Object.keys(updatedThresholdCache)) { - const [cachedMetaMetricsId, ...cachedFlagNameParts] = cacheKey.split(':'); + const [cachedSegmentationId, ...cachedFlagNameParts] = cacheKey.split(':'); const cachedFlagName = cachedFlagNameParts.join(':'); if ( - cachedMetaMetricsId === metaMetricsId && + (cachedSegmentationId === metaMetricsId || + cachedSegmentationId === canonicalId) && !currentFlagNames.includes(cachedFlagName) ) { delete updatedThresholdCache[cacheKey]; } } + 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; @@ -327,6 +352,7 @@ export class RemoteFeatureFlagController extends BaseController< rawRemoteFeatureFlags: remoteFeatureFlags, cacheTimestamp: Date.now(), thresholdCache: updatedThresholdCache, + featureFlagThresholdGroups: updatedFeatureFlagThresholdGroups, }; }); } @@ -345,13 +371,21 @@ export class RemoteFeatureFlagController extends BaseController< return getVersionData(flagValue, this.#clientVersion); } + #getSegmentationId(idType: FeatureFlagIdType): string { + if (idType === FeatureFlagIdType.MetaMetrics) { + return this.#getMetaMetricsId(); + } + return this.#getCanonicalId(); + } + 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, @@ -376,20 +410,22 @@ export class RemoteFeatureFlagController extends BaseController< continue; } - // Skip threshold processing if metaMetricsId is not available - if (!metaMetricsId) { - // Preserve array as-is when user hasn't opted into MetaMetrics + // Skip threshold processing if the configured identifier is not available + const idType = getThresholdIdType(processedValue); + const segmentationId = this.#getSegmentationId(idType); + + if (!segmentationId) { processedFlags[remoteFeatureFlagName] = processedValue; continue; } // Check cache first, calculate only if needed - const cacheKey = `${metaMetricsId}:${remoteFeatureFlagName}` as const; + const cacheKey = `${segmentationId}:${remoteFeatureFlagName}` as const; let thresholdValue = this.state.thresholdCache?.[cacheKey]; if (thresholdValue === undefined) { thresholdValue = await calculateThresholdForFlag( - metaMetricsId, + segmentationId, remoteFeatureFlagName, ); @@ -409,14 +445,22 @@ export class RemoteFeatureFlagController extends BaseController< ); if (selectedGroup) { - processedValue = normalizeThresholdValue(selectedGroup); + processedValue = selectedGroup.value; + if (selectedGroup.name) { + featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = + selectedGroup.name; + } } } processedFlags[remoteFeatureFlagName] = processedValue; } - return { processedFlags, thresholdCacheUpdates }; + return { + processedFlags, + thresholdCacheUpdates, + featureFlagThresholdGroupUpdates, + }; } /** diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 44e0b10b32..71f5224259 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -1,8 +1,10 @@ import { v4 as uuidV4 } from 'uuid'; +import { FeatureFlagIdType } from '../remote-feature-flag-controller-types'; import { calculateThresholdForFlag, generateDeterministicRandomNumber, + getThresholdIdType, isFeatureFlagWithScopeValue, } from './user-segmentation-utils'; @@ -318,4 +320,35 @@ describe('user-segmentation-utils', () => { ).toBe(false); }); }); + + describe('getThresholdIdType', () => { + it('defaults to metametrics when idType is absent', () => { + expect( + getThresholdIdType([ + { + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: true, + }, + ]), + ).toBe(FeatureFlagIdType.MetaMetrics); + }); + + it('returns canonical when configured on threshold entries', () => { + expect( + getThresholdIdType([ + { + idType: FeatureFlagIdType.Canonical, + name: 'groupA', + scope: { type: 'threshold', value: 1.0 }, + value: true, + }, + ]), + ).toBe(FeatureFlagIdType.Canonical); + }); + + it('defaults to metametrics for non-array flag values', () => { + expect(getThresholdIdType(true)).toBe(FeatureFlagIdType.MetaMetrics); + }); + }); }); diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts index f2f4877310..3607603e96 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts @@ -2,7 +2,10 @@ import type { Json } from '@metamask/utils'; import { sha256, bytesToHex } from '@metamask/utils'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; -import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; +import { + FeatureFlagIdType, + type FeatureFlagScopeValue, +} from '../remote-feature-flag-controller-types'; /** * Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal. @@ -130,3 +133,20 @@ export const isFeatureFlagWithScopeValue = ( 'scope' in featureFlag ); }; + +/** + * Returns the identifier type used for deterministic threshold assignment. + * Defaults to MetaMetrics when no threshold entry specifies an idType. + * + * @param flagValue - The feature flag value to inspect. + * @returns The identifier type for threshold processing. + */ +export function getThresholdIdType(flagValue: Json): FeatureFlagIdType { + if (!Array.isArray(flagValue)) { + return FeatureFlagIdType.MetaMetrics; + } + + const firstThresholdEntry = flagValue.find(isFeatureFlagWithScopeValue); + + return firstThresholdEntry?.idType ?? FeatureFlagIdType.MetaMetrics; +} diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts index 5f155deb02..058e8bdd63 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -17,6 +17,7 @@ export const remoteFeatureFlagController: InitializationConfiguration< messenger, clientConfigApiService: options.clientConfigApiService, getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), + getCanonicalId: options.getCanonicalId ?? ((): string => ''), clientVersion: options.clientVersion ?? '0.0.0', prevClientVersion: options.prevClientVersion, fetchInterval: options.fetchInterval, diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts index 1477c632cf..7711d6be98 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -21,6 +21,11 @@ export type RemoteFeatureFlagControllerInstanceOptions = { * Defaults to `() => ''`. */ getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; + /** + * Returns the canonical client identifier for threshold flags configured + * with a non-metametrics idType. Defaults to `() => ''`. + */ + getCanonicalId?: RemoteFeatureFlagControllerOptions['getCanonicalId']; /** * The current client version for version-based flag filtering. Must be a * valid 3-part SemVer or the controller throws. Defaults to `'0.0.0'`. From c58132fded61afe1fd0086349e255436dfc433f9 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 30 Jun 2026 22:17:47 -0400 Subject: [PATCH 2/9] fix lint --- .../remote-feature-flag-controller.test.ts | 23 +++++++++++-------- .../src/remote-feature-flag-controller.ts | 3 ++- 2 files changed, 15 insertions(+), 11 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 556f5ebc29..73d45369b2 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,7 +18,10 @@ import type { RemoteFeatureFlagControllerMessenger, RemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; -import { ThresholdVersion, FeatureFlagIdType } from './remote-feature-flag-controller-types'; +import { + ThresholdVersion, + FeatureFlagIdType, +} from './remote-feature-flag-controller-types'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; const MOCK_FLAGS: FeatureFlags = { @@ -132,9 +135,9 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toStrictEqual( - mockFlags.canonicalThresholdFlag, - ); + expect( + controller.state.remoteFeatureFlags.canonicalThresholdFlag, + ).toStrictEqual(mockFlags.canonicalThresholdFlag); }); it('initializes with default state if the disabled parameter is provided', () => { @@ -841,9 +844,9 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - expect( - controller.state.remoteFeatureFlags.canonicalThresholdFlag, - ).toBe('canonicalB'); + expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toBe( + 'canonicalB', + ); expect(controller.state.thresholdCache).toStrictEqual({ [`${MOCK_CANONICAL_ID}:canonicalThresholdFlag`]: expect.any(Number), }); @@ -873,9 +876,9 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toStrictEqual( - mockFlags.canonicalThresholdFlag, - ); + expect( + controller.state.remoteFeatureFlags.canonicalThresholdFlag, + ).toStrictEqual(mockFlags.canonicalThresholdFlag); }); }); 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 f5395aaa49..0a57786082 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,7 +310,8 @@ export class RemoteFeatureFlagController extends BaseController< // Clean up stale entries for (const cacheKey of Object.keys(updatedThresholdCache)) { - const [cachedSegmentationId, ...cachedFlagNameParts] = cacheKey.split(':'); + const [cachedSegmentationId, ...cachedFlagNameParts] = + cacheKey.split(':'); const cachedFlagName = cachedFlagNameParts.join(':'); if ( (cachedSegmentationId === metaMetricsId || From c082ce4691a2e55633003ae9c397be5dd3d43c85 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 30 Jun 2026 22:27:14 -0400 Subject: [PATCH 3/9] fix(remote-feature-flag-controller): align canonical default, lint, and changelog links Default threshold segmentation to canonical ID, link changelog entries to PR #9325, add wallet changelog entry, and fix ESLint violations. Co-authored-by: Cursor --- .../CHANGELOG.md | 6 ++-- .../remote-feature-flag-controller-types.ts | 2 +- .../remote-feature-flag-controller.test.ts | 36 +++++++++++++++++-- .../src/utils/user-segmentation-utils.test.ts | 14 ++++---- .../src/utils/user-segmentation-utils.ts | 12 +++---- packages/wallet/CHANGELOG.md | 4 +++ 6 files changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 0fc8ccf6a1..ad872ec267 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,12 +9,12 @@ 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 ([#9289](https://github.com/MetaMask/core/pull/9289)) -- Add optional `getCanonicalId` constructor callback and `FeatureFlagIdType` enum so threshold flags can segment users by canonical ID instead of MetaMetrics ID when configured with `idType: "canonical"` ([#9289](https://github.com/MetaMask/core/pull/9289)) +- Add optional `featureFlagThresholdGroups` field to `RemoteFeatureFlagControllerState` to map feature flag names to their selected threshold group names ([#9325](https://github.com/MetaMask/core/pull/9325)) +- Add optional `getCanonicalId` constructor callback and `FeatureFlagIdType` enum so threshold flags segment users by canonical ID by default, or by MetaMetrics ID when configured with `idType: "metametrics"` ([#9325](https://github.com/MetaMask/core/pull/9325)) ### 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` ([#9325](https://github.com/MetaMask/core/pull/9325)) - 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)) 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 7b4f0b5114..e89295e006 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 @@ -57,7 +57,7 @@ export type FeatureFlagScopeValue = { thresholdName?: string; /** * Selects which client identifier is used for deterministic threshold - * assignment. Defaults to `metametrics` when omitted. + * assignment. Defaults to `canonical` when omitted. */ idType?: FeatureFlagIdType; /** 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 73d45369b2..05fa39dd50 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 @@ -36,16 +36,23 @@ const MOCK_FLAGS_WITH_THRESHOLD = { ...MOCK_FLAGS, testFlagForThreshold: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.3 }, value: 'valueA', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 0.5 }, value: 'valueB', }, - { name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' }, + { + idType: FeatureFlagIdType.MetaMetrics, + name: 'groupC', + scope: { type: 'threshold', value: 1 }, + value: 'valueC', + }, ], }; @@ -127,7 +134,7 @@ describe('RemoteFeatureFlagController', () => { clientConfigApiService: buildClientConfigApiService({ remoteFeatureFlags: mockFlags, }), - getMetaMetricsId: () => MOCK_METRICS_ID, + getMetaMetricsId: (): string => MOCK_METRICS_ID, clientVersion: MOCK_BASE_VERSION, }); @@ -651,11 +658,13 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { featureA: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA1', scope: { type: 'threshold', value: 0.5 }, value: 'A1', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA2', scope: { type: 'threshold', value: 1.0 }, value: 'A2', @@ -663,11 +672,13 @@ describe('RemoteFeatureFlagController', () => { ], featureB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB1', scope: { type: 'threshold', value: 0.5 }, value: 'B1', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB2', scope: { type: 'threshold', value: 1.0 }, value: 'B2', @@ -736,6 +747,7 @@ describe('RemoteFeatureFlagController', () => { mixedArray: [ { name: 'invalid', value: 'no scope' }, // Invalid - missing scope property { + idType: FeatureFlagIdType.MetaMetrics, name: 'validGroup', scope: { type: 'threshold', value: 1.0 }, value: 'selectedValue', @@ -769,11 +781,13 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { testFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'control', scope: { type: 'threshold', value: 0.5 }, value: false, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'treatment', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1137,16 +1151,19 @@ describe('RemoteFeatureFlagController', () => { versions: { '13.1.0': [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.3 }, value: { feature: 'A', enabled: true }, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 0.7 }, value: { feature: 'B', enabled: false }, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupC', scope: { type: 'threshold', value: 1.0 }, value: { feature: 'C', enabled: true }, @@ -1154,11 +1171,13 @@ describe('RemoteFeatureFlagController', () => { ], '13.2.0': [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'newGroupA', scope: { type: 'threshold', value: 0.5 }, value: { feature: 'NewA', enabled: false }, }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'newGroupB', scope: { type: 'threshold', value: 1.0 }, value: { feature: 'NewB', enabled: true }, @@ -1465,6 +1484,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1472,6 +1492,7 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1498,6 +1519,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1597,6 +1619,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { persistentFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1640,6 +1663,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { testFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1679,6 +1703,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { newFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1710,6 +1735,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { oldFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1736,6 +1762,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { newFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1765,11 +1792,13 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { thresholdFlag: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.5 }, value: 'A', }, { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: 'B', @@ -1800,6 +1829,7 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { 'feature:v2': [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1843,6 +1873,7 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1850,6 +1881,7 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { + idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 71f5224259..85412a09ea 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -322,7 +322,7 @@ describe('user-segmentation-utils', () => { }); describe('getThresholdIdType', () => { - it('defaults to metametrics when idType is absent', () => { + it('defaults to canonical when idType is absent', () => { expect( getThresholdIdType([ { @@ -331,24 +331,24 @@ describe('user-segmentation-utils', () => { value: true, }, ]), - ).toBe(FeatureFlagIdType.MetaMetrics); + ).toBe(FeatureFlagIdType.Canonical); }); - it('returns canonical when configured on threshold entries', () => { + it('returns metametrics when configured on threshold entries', () => { expect( getThresholdIdType([ { - idType: FeatureFlagIdType.Canonical, + idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, }, ]), - ).toBe(FeatureFlagIdType.Canonical); + ).toBe(FeatureFlagIdType.MetaMetrics); }); - it('defaults to metametrics for non-array flag values', () => { - expect(getThresholdIdType(true)).toBe(FeatureFlagIdType.MetaMetrics); + it('defaults to canonical for non-array flag values', () => { + expect(getThresholdIdType(true)).toBe(FeatureFlagIdType.Canonical); }); }); }); diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts index 3607603e96..9197b6f3fe 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts @@ -2,10 +2,8 @@ import type { Json } from '@metamask/utils'; import { sha256, bytesToHex } from '@metamask/utils'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; -import { - FeatureFlagIdType, - type FeatureFlagScopeValue, -} from '../remote-feature-flag-controller-types'; +import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; +import { FeatureFlagIdType } from '../remote-feature-flag-controller-types'; /** * Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal. @@ -136,17 +134,17 @@ export const isFeatureFlagWithScopeValue = ( /** * Returns the identifier type used for deterministic threshold assignment. - * Defaults to MetaMetrics when no threshold entry specifies an idType. + * Defaults to Canonical when no threshold entry specifies an idType. * * @param flagValue - The feature flag value to inspect. * @returns The identifier type for threshold processing. */ export function getThresholdIdType(flagValue: Json): FeatureFlagIdType { if (!Array.isArray(flagValue)) { - return FeatureFlagIdType.MetaMetrics; + return FeatureFlagIdType.Canonical; } const firstThresholdEntry = flagValue.find(isFeatureFlagWithScopeValue); - return firstThresholdEntry?.idType ?? FeatureFlagIdType.MetaMetrics; + return firstThresholdEntry?.idType ?? FeatureFlagIdType.Canonical; } diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 7b5d82544d..f5e694d1b8 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Wire `AddressBookController` into the default wallet initialization ([#9291](https://github.com/MetaMask/core/pull/9291)) +### Changed + +- Pass optional `getCanonicalId` through `RemoteFeatureFlagController` wallet initialization for threshold flags that segment by canonical ID ([#9325](https://github.com/MetaMask/core/pull/9325)) + ## [5.0.0] ### Added From b933ca5e8c2bffb0566f1ddaac05433424377d47 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 6 Jul 2026 13:49:38 -0400 Subject: [PATCH 4/9] use metaMetricsFlags to dettermine segmentation id --- .../CHANGELOG.md | 2 +- .../src/index.ts | 1 - .../remote-feature-flag-controller-types.ts | 10 --- .../remote-feature-flag-controller.test.ts | 72 +++++++------------ .../src/remote-feature-flag-controller.ts | 50 +++++-------- .../src/utils/user-segmentation-utils.test.ts | 40 ++--------- .../src/utils/user-segmentation-utils.ts | 32 ++------- packages/wallet/CHANGELOG.md | 2 +- .../remote-feature-flag-controller.ts | 4 +- .../remote-feature-flag-controller/types.ts | 11 ++- 10 files changed, 68 insertions(+), 156 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index ad872ec267..2a1daaaa23 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -10,7 +10,7 @@ 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 ([#9325](https://github.com/MetaMask/core/pull/9325)) -- Add optional `getCanonicalId` constructor callback and `FeatureFlagIdType` enum so threshold flags segment users by canonical ID by default, or by MetaMetrics ID when configured with `idType: "metametrics"` ([#9325](https://github.com/MetaMask/core/pull/9325)) +- Add optional `getCanonicalProfileId` constructor callback and `metaMetricsFlags` map so threshold flags segment users by canonical profile ID by default, or by MetaMetrics ID when the flag name is present in `metaMetricsFlags` ([#9325](https://github.com/MetaMask/core/pull/9325)) ### Changed diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts index 3bafeccab2..00c1c1c2d1 100644 --- a/packages/remote-feature-flag-controller/src/index.ts +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -20,7 +20,6 @@ export { ClientType, DistributionType, EnvironmentType, - FeatureFlagIdType, ThresholdVersion, } 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 e89295e006..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 @@ -43,11 +43,6 @@ export enum ThresholdVersion { DirectValue = 2, } -export enum FeatureFlagIdType { - MetaMetrics = 'metametrics', - Canonical = 'canonical', -} - export type FeatureFlagScopeValue = { name: string; /** @@ -55,11 +50,6 @@ export type FeatureFlagScopeValue = { * v2 configurations and is not emitted in processed controller state. */ thresholdName?: string; - /** - * Selects which client identifier is used for deterministic threshold - * assignment. Defaults to `canonical` when omitted. - */ - idType?: FeatureFlagIdType; /** * Selects the threshold entry output shape. Unrecognized versions fall back * to the legacy `{ name, value }` wrapper for backwards compatibility. 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 454bdf3407..8b873f73d9 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,10 +18,7 @@ import type { RemoteFeatureFlagControllerMessenger, RemoteFeatureFlagControllerState, } from './remote-feature-flag-controller'; -import { - ThresholdVersion, - FeatureFlagIdType, -} from './remote-feature-flag-controller-types'; +import { ThresholdVersion } from './remote-feature-flag-controller-types'; import type { FeatureFlags } from './remote-feature-flag-controller-types'; const MOCK_FLAGS: FeatureFlags = { @@ -36,19 +33,16 @@ const MOCK_FLAGS_WITH_THRESHOLD = { ...MOCK_FLAGS, testFlagForThreshold: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.3 }, value: 'valueA', }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 0.5 }, value: 'valueB', }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC', @@ -69,7 +63,8 @@ const MOCK_BASE_VERSION = '13.10.0'; * @param options.clientConfigApiService - The client config API service instance * @param options.disabled - Whether the controller should start disabled * @param options.getMetaMetricsId - Returns metaMetricsId - * @param options.getCanonicalId - Returns canonicalId + * @param options.getCanonicalProfileId - Returns canonicalProfileId + * @param options.metaMetricsFlags - Feature flags that should use MetaMetrics ID * @param options.clientVersion - The client version string * @param options.prevClientVersion - The previous client version string * @returns The controller and the root messenger @@ -80,7 +75,8 @@ function createController( clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; getMetaMetricsId: () => string; - getCanonicalId: () => string; + getCanonicalProfileId: () => string; + metaMetricsFlags: Record; clientVersion: string; prevClientVersion: string; }> = {}, @@ -95,9 +91,10 @@ function createController( getMetaMetricsId: options.getMetaMetricsId ?? ((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID), - getCanonicalId: - options.getCanonicalId ?? + getCanonicalProfileId: + options.getCanonicalProfileId ?? ((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID), + metaMetricsFlags: options.metaMetricsFlags, clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, prevClientVersion: options.prevClientVersion, }); @@ -117,11 +114,10 @@ describe('RemoteFeatureFlagController', () => { }); }); - it('defaults getCanonicalId to an empty string when omitted', async () => { + it('defaults getCanonicalProfileId to an empty string when omitted', async () => { const mockFlags = { canonicalThresholdFlag: [ { - idType: FeatureFlagIdType.Canonical, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: 'canonicalA', @@ -518,6 +514,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlagForThreshold: true }, }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -589,13 +586,11 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { featureA: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA1', scope: { type: 'threshold', value: 0.5 }, value: 'A1', }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA2', scope: { type: 'threshold', value: 1.0 }, value: 'A2', @@ -603,13 +598,11 @@ describe('RemoteFeatureFlagController', () => { ], featureB: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB1', scope: { type: 'threshold', value: 0.5 }, value: 'B1', }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB2', scope: { type: 'threshold', value: 1.0 }, value: 'B2', @@ -622,6 +615,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { featureA: true, featureB: true }, }); // Act @@ -678,7 +672,6 @@ describe('RemoteFeatureFlagController', () => { mixedArray: [ { name: 'invalid', value: 'no scope' }, // Invalid - missing scope property { - idType: FeatureFlagIdType.MetaMetrics, name: 'validGroup', scope: { type: 'threshold', value: 1.0 }, value: 'selectedValue', @@ -691,6 +684,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { mixedArray: true }, }); // Act @@ -712,13 +706,11 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { testFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'control', scope: { type: 'threshold', value: 0.5 }, value: false, }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'treatment', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -734,6 +726,7 @@ describe('RemoteFeatureFlagController', () => { createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger1.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -744,6 +737,7 @@ describe('RemoteFeatureFlagController', () => { createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger2.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -759,17 +753,15 @@ describe('RemoteFeatureFlagController', () => { }); }); - it('uses getCanonicalId for threshold flags with canonical idType', async () => { + it('uses getCanonicalProfileId for threshold flags absent from metaMetricsFlags', async () => { const mockFlags = { canonicalThresholdFlag: [ { - idType: FeatureFlagIdType.Canonical, name: 'groupA', scope: { type: 'threshold', value: 0.5 }, value: 'canonicalA', }, { - idType: FeatureFlagIdType.Canonical, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: 'canonicalB', @@ -782,7 +774,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', - getCanonicalId: () => MOCK_CANONICAL_ID, + getCanonicalProfileId: () => MOCK_CANONICAL_ID, }); await messenger.call( @@ -797,11 +789,10 @@ describe('RemoteFeatureFlagController', () => { }); }); - it('preserves threshold arrays when canonical id is empty for canonical idType flags', async () => { + it('preserves threshold arrays when canonical profile id is empty', async () => { const mockFlags = { canonicalThresholdFlag: [ { - idType: FeatureFlagIdType.Canonical, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: 'canonicalA', @@ -814,7 +805,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - getCanonicalId: () => '', + getCanonicalProfileId: () => '', }); await messenger.call( @@ -1082,19 +1073,16 @@ describe('RemoteFeatureFlagController', () => { versions: { '13.1.0': [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.3 }, value: { feature: 'A', enabled: true }, }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 0.7 }, value: { feature: 'B', enabled: false }, }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupC', scope: { type: 'threshold', value: 1.0 }, value: { feature: 'C', enabled: true }, @@ -1102,13 +1090,11 @@ describe('RemoteFeatureFlagController', () => { ], '13.2.0': [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'newGroupA', scope: { type: 'threshold', value: 0.5 }, value: { feature: 'NewA', enabled: false }, }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'newGroupB', scope: { type: 'threshold', value: 1.0 }, value: { feature: 'NewB', enabled: true }, @@ -1128,6 +1114,7 @@ describe('RemoteFeatureFlagController', () => { clientConfigApiService: mockApiService, clientVersion: '13.1.5', // Qualifies for 13.1.0 version but not 13.2.0 getMetaMetricsId: () => MOCK_METRICS_ID, // This generates threshold > 0.7 + metaMetricsFlags: { multiVersionABFlag: true }, }); await messenger.call( @@ -1415,7 +1402,6 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1423,7 +1409,6 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1434,6 +1419,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { flagA: true, flagB: true }, }); // Act - First update: both flags processed @@ -1450,7 +1436,6 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagB: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1553,7 +1538,6 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { persistentFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1566,6 +1550,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { persistentFlag: true }, }); // Act - Multiple updates with same flag @@ -1597,7 +1582,6 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { testFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1613,6 +1597,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, state: { thresholdCache: { [`${differentUserId}:oldFlag`]: 0.123, // Different user's cache @@ -1637,7 +1622,6 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { newFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1650,6 +1634,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { newFlag: true }, }); // Act - Process with empty cache @@ -1669,7 +1654,6 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { oldFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1680,6 +1664,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { oldFlag: true, newFlag: true }, }); await messenger.call( @@ -1696,7 +1681,6 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { newFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1726,13 +1710,11 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { thresholdFlag: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 0.5 }, value: 'A', }, { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: 'B', @@ -1745,6 +1727,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', // Empty metaMetricsId + metaMetricsFlags: { thresholdFlag: true }, }); // Act @@ -1763,7 +1746,6 @@ describe('RemoteFeatureFlagController', () => { const mockFlags = { 'feature:v2': [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'group', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1776,6 +1758,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { 'feature:v2': true }, }); // Act @@ -1807,7 +1790,6 @@ describe('RemoteFeatureFlagController', () => { remoteFeatureFlags: { flagA: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupA', scope: { type: 'threshold', value: 1.0 }, value: true, @@ -1815,7 +1797,6 @@ describe('RemoteFeatureFlagController', () => { ], flagB: [ { - idType: FeatureFlagIdType.MetaMetrics, name: 'groupB', scope: { type: 'threshold', value: 1.0 }, value: false, @@ -1826,6 +1807,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { flagA: true, flagB: true }, }); // Act - First update populates cache 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 29fdb9e391..be2f64e78e 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 @@ -14,10 +14,8 @@ import type { ServiceResponse, FeatureFlagScopeValue, } from './remote-feature-flag-controller-types'; -import { FeatureFlagIdType } from './remote-feature-flag-controller-types'; import { calculateThresholdForFlag, - getThresholdIdType, isFeatureFlagWithScopeValue, } from './utils/user-segmentation-utils'; import { isVersionFeatureFlag, getVersionData } from './utils/version'; @@ -148,7 +146,9 @@ export class RemoteFeatureFlagController extends BaseController< readonly #getMetaMetricsId: () => string; - readonly #getCanonicalId: () => string; + readonly #getCanonicalProfileId: () => string; + + readonly #metaMetricsFlags: Record; readonly #clientVersion: SemVerVersion; @@ -164,8 +164,8 @@ export class RemoteFeatureFlagController extends BaseController< * @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day. * @param options.disabled - Determines if the controller should be disabled initially. Defaults to false. * @param options.getMetaMetricsId - Returns metaMetricsId. - * @param options.getCanonicalId - Returns the canonical client identifier used - * for threshold flags configured with a non-metametrics idType. + * @param options.getCanonicalProfileId - Returns the canonical profile identifier used for threshold flags by default. + * @param options.metaMetricsFlags - Map of feature flag names that should use MetaMetrics ID for threshold assignment. * @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string. * @param options.prevClientVersion - The previous client version for feature flag cache invalidation. */ @@ -176,7 +176,8 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval = DEFAULT_CACHE_DURATION, disabled = false, getMetaMetricsId, - getCanonicalId = (): string => '', + getCanonicalProfileId = (): string => '', + metaMetricsFlags = {}, clientVersion, prevClientVersion, }: { @@ -184,7 +185,8 @@ export class RemoteFeatureFlagController extends BaseController< state?: Partial; clientConfigApiService: AbstractClientConfigApiService; getMetaMetricsId: () => string; - getCanonicalId?: () => string; + getCanonicalProfileId?: () => string; + metaMetricsFlags?: Record; fetchInterval?: number; disabled?: boolean; clientVersion: string; @@ -236,7 +238,8 @@ export class RemoteFeatureFlagController extends BaseController< this.#disabled = disabled; this.#clientConfigApiService = clientConfigApiService; this.#getMetaMetricsId = getMetaMetricsId; - this.#getCanonicalId = getCanonicalId; + this.#getCanonicalProfileId = getCanonicalProfileId; + this.#metaMetricsFlags = metaMetricsFlags; this.#clientVersion = clientVersion; this.messenger.registerMethodActionHandlers( @@ -297,7 +300,7 @@ export class RemoteFeatureFlagController extends BaseController< } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); const metaMetricsId = this.#getMetaMetricsId(); - const canonicalId = this.#getCanonicalId(); + const canonicalProfileId = this.#getCanonicalProfileId(); const currentFlagNames = Object.keys(remoteFeatureFlags); // Build updated threshold cache @@ -315,31 +318,13 @@ export class RemoteFeatureFlagController extends BaseController< const cachedFlagName = cachedFlagNameParts.join(':'); if ( (cachedSegmentationId === metaMetricsId || - cachedSegmentationId === canonicalId) && + cachedSegmentationId === canonicalProfileId) && !currentFlagNames.includes(cachedFlagName) ) { delete updatedThresholdCache[cacheKey]; } } - 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; @@ -372,11 +357,11 @@ export class RemoteFeatureFlagController extends BaseController< return getVersionData(flagValue, this.#clientVersion); } - #getSegmentationId(idType: FeatureFlagIdType): string { - if (idType === FeatureFlagIdType.MetaMetrics) { + #getSegmentationId(featureFlagName: string): string { + if (featureFlagName in this.#metaMetricsFlags) { return this.#getMetaMetricsId(); } - return this.#getCanonicalId(); + return this.#getCanonicalProfileId(); } async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ @@ -412,8 +397,7 @@ export class RemoteFeatureFlagController extends BaseController< } // Skip threshold processing if the configured identifier is not available - const idType = getThresholdIdType(processedValue); - const segmentationId = this.#getSegmentationId(idType); + const segmentationId = this.#getSegmentationId(remoteFeatureFlagName); if (!segmentationId) { processedFlags[remoteFeatureFlagName] = processedValue; diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 85412a09ea..6c9665f593 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -1,10 +1,8 @@ import { v4 as uuidV4 } from 'uuid'; -import { FeatureFlagIdType } from '../remote-feature-flag-controller-types'; import { calculateThresholdForFlag, generateDeterministicRandomNumber, - getThresholdIdType, isFeatureFlagWithScopeValue, } from './user-segmentation-utils'; @@ -126,15 +124,15 @@ describe('user-segmentation-utils', () => { expect(threshold).toBeLessThanOrEqual(1); }); - it('throws error when metaMetricsId is empty', async () => { + it('throws error when segmentation ID is empty', async () => { // Arrange - const emptyMetaMetricsId = ''; + const emptySegmentationId = ''; const flagName = 'testFlag'; // Act & Assert await expect( - calculateThresholdForFlag(emptyMetaMetricsId, flagName), - ).rejects.toThrow('MetaMetrics ID cannot be empty'); + calculateThresholdForFlag(emptySegmentationId, flagName), + ).rejects.toThrow('Segmentation ID cannot be empty'); }); it('throws error when featureFlagName is empty', async () => { @@ -321,34 +319,4 @@ describe('user-segmentation-utils', () => { }); }); - describe('getThresholdIdType', () => { - it('defaults to canonical when idType is absent', () => { - expect( - getThresholdIdType([ - { - name: 'groupA', - scope: { type: 'threshold', value: 1.0 }, - value: true, - }, - ]), - ).toBe(FeatureFlagIdType.Canonical); - }); - - it('returns metametrics when configured on threshold entries', () => { - expect( - getThresholdIdType([ - { - idType: FeatureFlagIdType.MetaMetrics, - name: 'groupA', - scope: { type: 'threshold', value: 1.0 }, - value: true, - }, - ]), - ).toBe(FeatureFlagIdType.MetaMetrics); - }); - - it('defaults to canonical for non-array flag values', () => { - expect(getThresholdIdType(true)).toBe(FeatureFlagIdType.Canonical); - }); - }); }); diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts index 9197b6f3fe..0715ea0ecc 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts @@ -3,7 +3,6 @@ import { sha256, bytesToHex } from '@metamask/utils'; import { validate as uuidValidate, version as uuidVersion } from 'uuid'; import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types'; -import { FeatureFlagIdType } from '../remote-feature-flag-controller-types'; /** * Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal. @@ -23,27 +22,27 @@ const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT; /** * Calculates a deterministic threshold value between 0 and 1 for A/B testing. - * This function hashes the user's MetaMetrics ID combined with the feature flag name + * This function hashes the segmentation ID combined with the feature flag name * to ensure consistent group assignment across sessions while varying across different flags. * - * @param metaMetricsId - The user's MetaMetrics ID (must be non-empty) + * @param segmentationId - The identifier used for threshold segmentation (must be non-empty) * @param featureFlagName - The feature flag name to create unique threshold per flag * @returns A promise that resolves to a number between 0 and 1 - * @throws Error if metaMetricsId is empty + * @throws Error if segmentationId is empty */ export async function calculateThresholdForFlag( - metaMetricsId: string, + segmentationId: string, featureFlagName: string, ): Promise { - if (!metaMetricsId) { - throw new Error('MetaMetrics ID cannot be empty'); + if (!segmentationId) { + throw new Error('Segmentation ID cannot be empty'); } if (!featureFlagName) { throw new Error('Feature flag name cannot be empty'); } - const seed = metaMetricsId + featureFlagName; + const seed = segmentationId + featureFlagName; // Hash the combined seed const encoder = new TextEncoder(); @@ -131,20 +130,3 @@ export const isFeatureFlagWithScopeValue = ( 'scope' in featureFlag ); }; - -/** - * Returns the identifier type used for deterministic threshold assignment. - * Defaults to Canonical when no threshold entry specifies an idType. - * - * @param flagValue - The feature flag value to inspect. - * @returns The identifier type for threshold processing. - */ -export function getThresholdIdType(flagValue: Json): FeatureFlagIdType { - if (!Array.isArray(flagValue)) { - return FeatureFlagIdType.Canonical; - } - - const firstThresholdEntry = flagValue.find(isFeatureFlagWithScopeValue); - - return firstThresholdEntry?.idType ?? FeatureFlagIdType.Canonical; -} diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 0138c9872d..8c2b9ac3ab 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Pass optional `getCanonicalId` through `RemoteFeatureFlagController` wallet initialization for threshold flags that segment by canonical ID ([#9325](https://github.com/MetaMask/core/pull/9325)) +- Pass optional `getCanonicalProfileId` and `metaMetricsFlags` through `RemoteFeatureFlagController` wallet initialization for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325)) - Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) - Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts index 058e8bdd63..3ea9fde7ed 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -17,7 +17,9 @@ export const remoteFeatureFlagController: InitializationConfiguration< messenger, clientConfigApiService: options.clientConfigApiService, getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), - getCanonicalId: options.getCanonicalId ?? ((): string => ''), + getCanonicalProfileId: + options.getCanonicalProfileId ?? ((): string => ''), + metaMetricsFlags: options.metaMetricsFlags, clientVersion: options.clientVersion ?? '0.0.0', prevClientVersion: options.prevClientVersion, fetchInterval: options.fetchInterval, diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts index 7711d6be98..4761b9c7bc 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -22,10 +22,15 @@ export type RemoteFeatureFlagControllerInstanceOptions = { */ getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; /** - * Returns the canonical client identifier for threshold flags configured - * with a non-metametrics idType. Defaults to `() => ''`. + * Returns the canonical profile identifier used for threshold flags by + * default. Defaults to `() => ''`. */ - getCanonicalId?: RemoteFeatureFlagControllerOptions['getCanonicalId']; + getCanonicalProfileId?: RemoteFeatureFlagControllerOptions['getCanonicalProfileId']; + /** + * Map of feature flag names that should use MetaMetrics ID for threshold + * assignment. Flags not present in this map use the canonical profile ID. + */ + metaMetricsFlags?: RemoteFeatureFlagControllerOptions['metaMetricsFlags']; /** * The current client version for version-based flag filtering. Must be a * valid 3-part SemVer or the controller throws. Defaults to `'0.0.0'`. From 0d0cd19f4f0c6dccd7e18e4195270973c42182e6 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 6 Jul 2026 16:39:15 -0400 Subject: [PATCH 5/9] use segmentation id if there is no explicit match for metametrics id --- .../CHANGELOG.md | 3 +- .../remote-feature-flag-controller.test.ts | 31 +++++++++------ .../src/remote-feature-flag-controller.ts | 39 ++++++++----------- 3 files changed, 37 insertions(+), 36 deletions(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index fb153dd56e..5813066066 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,7 +9,6 @@ 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 ([#9325](https://github.com/MetaMask/core/pull/9325)) - Add optional `getCanonicalProfileId` constructor callback and `metaMetricsFlags` map so threshold flags segment users by canonical profile ID by default, or by MetaMetrics ID when the flag name is present in `metaMetricsFlags` ([#9325](https://github.com/MetaMask/core/pull/9325)) - 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)) - Add optional `metaMetricsIds` field to threshold feature flag entries for explicit user targeting ([#9340](https://github.com/MetaMask/core/pull/9340)) @@ -22,7 +21,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 `name` ([#9325](https://github.com/MetaMask/core/pull/9325)) +- **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)) 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 6732e3e3bf..591713c450 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 @@ -550,6 +550,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -571,6 +572,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -648,6 +650,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); // Act @@ -819,7 +822,7 @@ describe('RemoteFeatureFlagController', () => { }); describe('metaMetricsIds explicit targeting', () => { - const MOCK_FLAGS_WITH_EXPLICIT_IDS = { + const MOCK_FLAGS_WITH_EXPLICIT_IDS: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -847,6 +850,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -860,7 +864,7 @@ describe('RemoteFeatureFlagController', () => { }); it('first entry with a matching metaMetricsId wins when multiple entries match', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'first', @@ -882,6 +886,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -895,7 +900,7 @@ describe('RemoteFeatureFlagController', () => { }); it('falls back to hash-based threshold when no entry matches the metaMetricsId', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -921,6 +926,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -935,7 +941,7 @@ describe('RemoteFeatureFlagController', () => { }); it('ignores entries with a malformed metaMetricsIds (non-array) and falls back to hash-based threshold', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'badGroup', @@ -975,7 +981,7 @@ describe('RemoteFeatureFlagController', () => { }); it('ignores non-string items within metaMetricsIds when matching', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'badGroup', @@ -1010,7 +1016,7 @@ describe('RemoteFeatureFlagController', () => { }); it('normalizes metaMetricsId with trim and toLowerCase before matching', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -1064,7 +1070,7 @@ describe('RemoteFeatureFlagController', () => { }); it('still populates the threshold cache for hash-based fallback when no explicit ID matches', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { name: 'qaGroup', @@ -1085,6 +1091,7 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -1109,7 +1116,7 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - const rawEntries = controller.state.rawRemoteFeatureFlags + const rawEntries = controller.state.rawRemoteFeatureFlags! .testFlag as Record[]; expect( rawEntries.every((entry) => entry.metaMetricsIds === undefined), @@ -1120,11 +1127,13 @@ describe('RemoteFeatureFlagController', () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS, }); - // No metaMetricsId → threshold arrays are preserved as-is, but - // metaMetricsIds must still be stripped from the processed output. + // This flag is configured to use MetaMetrics ID. When unavailable, + // threshold arrays are preserved as-is, but metaMetricsIds must still be + // stripped from the processed output. const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', + metaMetricsFlags: { testFlag: true }, }); await messenger.call( @@ -1161,7 +1170,7 @@ describe('RemoteFeatureFlagController', () => { }); it('supports ThresholdVersion.DirectValue entries with explicit-ID matching', async () => { - const mockFlags = { + const mockFlags: FeatureFlags = { testFlag: [ { thresholdName: 'qaGroup', 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 58cd659549..4b4582103b 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 @@ -468,29 +468,12 @@ export class RemoteFeatureFlagController extends BaseController< continue; } - // Skip threshold processing if the configured identifier is not available - const segmentationId = this.#getSegmentationId(remoteFeatureFlagName); - - if (!segmentationId) { - processedFlags[remoteFeatureFlagName] = processedValue; - continue; - } - - // Check cache first, calculate only if needed - const cacheKey = `${segmentationId}:${remoteFeatureFlagName}` as const; - let thresholdValue = this.state.thresholdCache?.[cacheKey]; - - if (thresholdValue === undefined) { - thresholdValue = await calculateThresholdForFlag( - segmentationId, - remoteFeatureFlagName, - ); // Explicit-ID matching: check before hash-based threshold, bypasses cache + const metaMetricsId = this.#getMetaMetricsId(); const normalizedMetaMetricsId = metaMetricsId.trim().toLowerCase(); - const explicitMatch = findExplicitIdMatch( - processedValue, - normalizedMetaMetricsId, - ); + const explicitMatch = normalizedMetaMetricsId + ? findExplicitIdMatch(processedValue, normalizedMetaMetricsId) + : undefined; if (explicitMatch) { processedValue = explicitMatch.value; @@ -499,13 +482,23 @@ export class RemoteFeatureFlagController extends BaseController< explicitMatch.name; } } else { + const segmentationId = this.#getSegmentationId( + remoteFeatureFlagName, + ); + + if (!segmentationId) { + processedFlags[remoteFeatureFlagName] = processedValue; + continue; + } + // Fall back to hash-based threshold selection with cache - const cacheKey = `${metaMetricsId}:${remoteFeatureFlagName}` as const; + const cacheKey = + `${segmentationId}:${remoteFeatureFlagName}` as const; let thresholdValue = this.state.thresholdCache?.[cacheKey]; if (thresholdValue === undefined) { thresholdValue = await calculateThresholdForFlag( - metaMetricsId, + segmentationId, remoteFeatureFlagName, ); From cddd9600f20b9387b3dccdf88df9d2ef51dedc36 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 6 Jul 2026 17:09:24 -0400 Subject: [PATCH 6/9] fix lint --- .../src/remote-feature-flag-controller.ts | 4 +--- .../src/utils/user-segmentation-utils.test.ts | 1 - 2 files changed, 1 insertion(+), 4 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 4b4582103b..7acd3c7302 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 @@ -482,9 +482,7 @@ export class RemoteFeatureFlagController extends BaseController< explicitMatch.name; } } else { - const segmentationId = this.#getSegmentationId( - remoteFeatureFlagName, - ); + const segmentationId = this.#getSegmentationId(remoteFeatureFlagName); if (!segmentationId) { processedFlags[remoteFeatureFlagName] = processedValue; diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 6c9665f593..b06e0a6f18 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -318,5 +318,4 @@ describe('user-segmentation-utils', () => { ).toBe(false); }); }); - }); From 89916ead45d22603f446a37edfd8a77e16f75709 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 6 Jul 2026 17:24:39 -0400 Subject: [PATCH 7/9] fix lint --- .../src/remote-feature-flag-controller.test.ts | 9 +++++++-- .../src/remote-feature-flag-controller.ts | 7 ++++++- 2 files changed, 13 insertions(+), 3 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 591713c450..cd0624a2c0 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 @@ -1116,8 +1116,13 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - const rawEntries = controller.state.rawRemoteFeatureFlags! - .testFlag as Record[]; + expect(controller.state.rawRemoteFeatureFlags).toBeDefined(); + const rawRemoteFeatureFlags = + controller.state.rawRemoteFeatureFlags as FeatureFlags; + const rawEntries = rawRemoteFeatureFlags.testFlag as Record< + string, + unknown + >[]; expect( rawEntries.every((entry) => entry.metaMetricsIds === undefined), ).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 7acd3c7302..eabfe2d1a3 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 @@ -430,7 +430,12 @@ export class RemoteFeatureFlagController extends BaseController< } #getSegmentationId(featureFlagName: string): string { - if (featureFlagName in this.#metaMetricsFlags) { + if ( + Object.prototype.hasOwnProperty.call( + this.#metaMetricsFlags, + featureFlagName, + ) + ) { return this.#getMetaMetricsId(); } return this.#getCanonicalProfileId(); From 80284ca615ceffeb263189cef697466b62b119f4 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Mon, 6 Jul 2026 17:24:46 -0400 Subject: [PATCH 8/9] fix lint --- .../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 cd0624a2c0..9f40779d07 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 @@ -1117,8 +1117,8 @@ describe('RemoteFeatureFlagController', () => { ); expect(controller.state.rawRemoteFeatureFlags).toBeDefined(); - const rawRemoteFeatureFlags = - controller.state.rawRemoteFeatureFlags as FeatureFlags; + const rawRemoteFeatureFlags = controller.state + .rawRemoteFeatureFlags as FeatureFlags; const rawEntries = rawRemoteFeatureFlags.testFlag as Record< string, unknown From 9c6a7a104a2761af5e256f23fd40e836a0bccc57 Mon Sep 17 00:00:00 2001 From: sallem-consensys Date: Tue, 7 Jul 2026 08:23:31 -0400 Subject: [PATCH 9/9] update changelog --- packages/wallet/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index 47ee540f0f..211d111ea0 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Pass optional `getCanonicalProfileId` and `metaMetricsFlags` through `RemoteFeatureFlagController` wallet initialization for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325)) + ## [7.0.0] ### Added @@ -25,7 +29,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Pass optional `getCanonicalProfileId` and `metaMetricsFlags` through `RemoteFeatureFlagController` wallet initialization for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325)) - Bump `@metamask/accounts-controller` from `^39.0.3` to `^39.0.4` ([#9349](https://github.com/MetaMask/core/pull/9349)) - Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349))