Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4705f3b
feat(remote-feature-flag-controller): return threshold values directl…
asalsys Jun 30, 2026
c58132f
fix lint
asalsys Jul 1, 2026
c082ce4
fix(remote-feature-flag-controller): align canonical default, lint, a…
asalsys Jul 1, 2026
18f1728
Merge branch 'main' into feat/remote-feature-flag-threshold-groups-clean
asalsys Jul 3, 2026
b933ca5
use metaMetricsFlags to dettermine segmentation id
asalsys Jul 6, 2026
d9d2b80
Merge branch 'main' into feat/remote-feature-flag-threshold-groups-clean
asalsys Jul 6, 2026
0d0cd19
use segmentation id if there is no explicit match for metametrics id
asalsys Jul 6, 2026
cddd960
fix lint
asalsys Jul 6, 2026
89916ea
fix lint
asalsys Jul 6, 2026
80284ca
fix lint
asalsys Jul 6, 2026
9c6a7a1
update changelog
asalsys Jul 7, 2026
73fcb3e
Merge from main
Cal-L Aug 18, 2026
7f114a6
Make canonical ID required
Cal-L Aug 18, 2026
1508059
Remove changelog from wallet
Cal-L Aug 18, 2026
35ffcc1
Update wallet package changelog
Cal-L Aug 18, 2026
1e9b936
Merge from main
Cal-L Aug 18, 2026
8b9dea9
Update packages/remote-feature-flag-controller/CHANGELOG.md
Cal-L Aug 19, 2026
99635b6
Update packages/remote-feature-flag-controller/src/remote-feature-fla…
Cal-L Aug 19, 2026
8a232a4
Update changelogs
Cal-L Aug 19, 2026
52a4292
Use hasProperty
Cal-L Aug 19, 2026
5711e31
Address cursor comment about test
Cal-L Aug 19, 2026
4ecaacc
Update packages/remote-feature-flag-controller/CHANGELOG.md
Cal-L Aug 19, 2026
bc43dff
Update packages/wallet/CHANGELOG.md
Cal-L Aug 19, 2026
1233809
Change metaMetricsFlags to array
Cal-L Aug 19, 2026
bff707e
Merge branch 'feat/remote-feature-flag-threshold-groups-clean' of git…
Cal-L Aug 19, 2026
9190d13
Merge branch 'main' of github.com:MetaMask/core into feat/remote-feat…
Cal-L Aug 19, 2026
09b141f
Merge branch 'main' into feat/remote-feature-flag-threshold-groups-clean
Cal-L Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **BREAKING:** Add required `getCanonicalProfileId` constructor option to `RemoteFeatureFlagController` for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325))
- By default, canonical profile ID is used, but MetaMetrics ID can be used when the flag name is present in `metaMetricsFlags`, typically for scenarios when canonical profile ID is unavailable.
- Add optional `metaMetricsFlags` constructor option to `RemoteFeatureFlagController` to segment flags by MetaMetrics ID ([#9325](https://github.com/MetaMask/core/pull/9325))
- Flags with names present in `metaMetricsFlags` are segmented by MetaMetrics ID; all others segment by canonical profile ID.
- Add optional `defaultFeatureFlags` constructor option to `RemoteFeatureFlagController` for client-side defaults as the lowest-precedence layer under processed remote flags and local overrides ([#9747](https://github.com/MetaMask/core/pull/9747))

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,17 @@ const MOCK_FLAGS_WITH_THRESHOLD = {
scope: { type: 'threshold', value: 0.5 },
value: 'valueB',
},
{ name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' },
{
name: 'groupC',
scope: { type: 'threshold', value: 1 },
value: 'valueC',
},
],
};

const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba';
const MOCK_CANONICAL_ID =
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420';
const MOCK_BASE_VERSION = '13.10.0';

/**
Expand All @@ -57,6 +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.getCanonicalProfileId - Returns canonicalProfileId
* @param options.metaMetricsFlags - Names of feature flags that should use MetaMetrics ID
* @param options.clientVersion - The client version string
* @param options.prevClientVersion - The previous client version string
* @param options.defaultFeatureFlags - Client-side default feature flags
Expand All @@ -68,6 +76,8 @@ function createController(
clientConfigApiService: AbstractClientConfigApiService;
disabled: boolean;
getMetaMetricsId: () => string;
getCanonicalProfileId: () => string;
metaMetricsFlags: readonly string[];
clientVersion: string;
prevClientVersion: string;
defaultFeatureFlags: FeatureFlags;
Expand All @@ -83,6 +93,10 @@ function createController(
getMetaMetricsId:
options.getMetaMetricsId ??
((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID),
getCanonicalProfileId:
options.getCanonicalProfileId ??
((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID),
metaMetricsFlags: options.metaMetricsFlags,
clientVersion: options.clientVersion ?? MOCK_BASE_VERSION,
prevClientVersion: options.prevClientVersion,
defaultFeatureFlags: options.defaultFeatureFlags,
Expand Down Expand Up @@ -474,6 +488,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlagForThreshold'],
});
await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand Down Expand Up @@ -509,6 +524,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
Expand Down Expand Up @@ -575,6 +591,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});
await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand Down Expand Up @@ -619,6 +636,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['featureA', 'featureB'],
});

// Act
Expand Down Expand Up @@ -651,6 +669,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

// Act
Expand Down Expand Up @@ -687,6 +706,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['mixedArray'],
});

// Act
Expand Down Expand Up @@ -728,6 +748,7 @@ describe('RemoteFeatureFlagController', () => {
createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});
await messenger1.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand All @@ -738,6 +759,7 @@ describe('RemoteFeatureFlagController', () => {
createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});
await messenger2.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
Expand All @@ -752,10 +774,74 @@ describe('RemoteFeatureFlagController', () => {
testFlag: 'control',
});
});

it('uses getCanonicalProfileId for threshold flags absent from metaMetricsFlags', async () => {
const mockFlags = {
canonicalThresholdFlag: [
{
name: 'groupA',
scope: { type: 'threshold', value: 0.5 },
value: 'canonicalA',
},
{
name: 'groupB',
scope: { type: 'threshold', value: 1.0 },
value: 'canonicalB',
},
],
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: mockFlags,
});
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => '',
getCanonicalProfileId: () => 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 profile id is empty', async () => {
const mockFlags = {
canonicalThresholdFlag: [
{
name: 'groupA',
scope: { type: 'threshold', value: 1.0 },
value: 'canonicalA',
},
],
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: mockFlags,
});
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
getCanonicalProfileId: () => '',
});

await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
);

expect(
controller.state.remoteFeatureFlags.canonicalThresholdFlag,
).toStrictEqual(mockFlags.canonicalThresholdFlag);
});
});

describe('metaMetricsIds explicit targeting', () => {
const MOCK_FLAGS_WITH_EXPLICIT_IDS = {
const MOCK_FLAGS_WITH_EXPLICIT_IDS: FeatureFlags = {
testFlag: [
{
name: 'qaGroup',
Expand Down Expand Up @@ -783,6 +869,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
Expand All @@ -796,7 +883,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('first entry with a matching metaMetricsId wins when multiple entries match', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'first',
Expand All @@ -818,6 +905,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
Expand All @@ -831,7 +919,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',
Expand All @@ -857,6 +945,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
Expand All @@ -871,7 +960,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',
Expand All @@ -897,21 +986,22 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
);

// Malformed entry ignored; hash-based selects groupA
// Malformed entry ignored; hash of MOCK_METRICS_ID + 'testFlag' selects groupA
expect(controller.state.remoteFeatureFlags.testFlag).toBe('valueA');
expect(controller.state.featureFlagThresholdGroups).toStrictEqual({
testFlag: 'groupA',
});
});

it('ignores non-string items within metaMetricsIds when matching', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
Comment thread
cursor[bot] marked this conversation as resolved.
testFlag: [
{
name: 'badGroup',
Expand Down Expand Up @@ -946,7 +1036,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('normalizes metaMetricsId with trim and toLowerCase before matching', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
name: 'qaGroup',
Expand Down Expand Up @@ -1000,7 +1090,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',
Expand All @@ -1021,6 +1111,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
Expand Down Expand Up @@ -1054,9 +1145,12 @@ describe('RemoteFeatureFlagController', () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS,
});
// This flag segments by MetaMetrics ID, so an unavailable ID leaves the
// threshold array unprocessed.
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => '',
metaMetricsFlags: ['testFlag'],
});

await messenger.call(
Expand Down Expand Up @@ -1091,7 +1185,7 @@ describe('RemoteFeatureFlagController', () => {
});

it('supports ThresholdVersion.DirectValue entries with explicit-ID matching', async () => {
const mockFlags = {
const mockFlags: FeatureFlags = {
testFlag: [
{
thresholdName: 'qaGroup',
Expand Down Expand Up @@ -1424,6 +1518,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'],
});

await messenger.call(
Expand Down Expand Up @@ -2110,6 +2205,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['flagA', 'flagB'],
});

// Act - First update: both flags processed
Expand Down Expand Up @@ -2240,6 +2336,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['persistentFlag'],
});

// Act - Multiple updates with same flag
Expand Down Expand Up @@ -2286,6 +2383,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['testFlag'],
state: {
thresholdCache: {
[`${differentUserId}:oldFlag`]: 0.123, // Different user's cache
Expand Down Expand Up @@ -2322,6 +2420,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['newFlag'],
});

// Act - Process with empty cache
Expand Down Expand Up @@ -2351,6 +2450,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['oldFlag', 'newFlag'],
});

await messenger.call(
Expand Down Expand Up @@ -2413,6 +2513,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => '', // Empty metaMetricsId
metaMetricsFlags: ['thresholdFlag'],
});

// Act
Expand Down Expand Up @@ -2443,6 +2544,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['feature:v2'],
});

// Act
Expand Down Expand Up @@ -2491,6 +2593,7 @@ describe('RemoteFeatureFlagController', () => {
const { controller, messenger } = createController({
clientConfigApiService,
getMetaMetricsId: () => MOCK_METRICS_ID,
metaMetricsFlags: ['flagA', 'flagB'],
});

// Act - First update populates cache
Expand Down
Loading