From 16c3e628283afafeda37ac8ed5516b6e1d25b63f Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 6 Mar 2026 10:30:28 +0000 Subject: [PATCH 1/4] test(remote-feature-flag-controller): document threshold ordering behavior Co-authored-by: Prithpal Sooriya --- .../CHANGELOG.md | 4 ++ .../remote-feature-flag-controller/README.md | 41 +++++++++++++++++++ .../remote-feature-flag-controller.test.ts | 35 ++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index b6984cfce3e..8e1685ee90f 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Clarify that threshold-scoped feature flag variations are order-dependent and must be sorted in ascending order by `scope.value`; add unit-test coverage for unsorted variation behavior ([#8120](https://github.com/MetaMask/core/pull/8120)) + ## [4.1.0] ### Added diff --git a/packages/remote-feature-flag-controller/README.md b/packages/remote-feature-flag-controller/README.md index 6b094230622..6f2155057a3 100644 --- a/packages/remote-feature-flag-controller/README.md +++ b/packages/remote-feature-flag-controller/README.md @@ -2,6 +2,47 @@ 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 and manage these flags. The controller ensures that feature flags are refreshed based on a specified interval and handles cases where the controller is disabled or the network is unavailable. +## Threshold-scoped variation ordering + +Threshold-scoped variations are evaluated in array order, and the first variation with +`threshold <= scope.value` is selected. + +Because selection is order-dependent, threshold variations **must be sorted in ascending order** +by `scope.value` to get expected bucket behavior. + +```jsonc +// Correct: ascending order (0.1, then 1.0) +[ + { + "name": "Control is OFF", + "scope": { "type": "threshold", "value": 0.1 }, + "value": { "minimumVersion": "7.67.0", "variant": "treatment" } + }, + { + "name": "Control is ON", + "scope": { "type": "threshold", "value": 1.0 }, + "value": { "minimumVersion": "7.67.0", "variant": "control" } + } +] +``` + +```jsonc +// Incorrect: descending order (1.0, then 0.1) +// The first entry will always match first. +[ + { + "name": "Control is ON", + "scope": { "type": "threshold", "value": 1.0 }, + "value": { "minimumVersion": "7.67.0", "variant": "control" } + }, + { + "name": "Control is OFF", + "scope": { "type": "threshold", "value": 0.1 }, + "value": { "minimumVersion": "7.67.0", "variant": "treatment" } + } +] +``` + ## Installation `yarn add @metamask/remote-feature-flag-controller` 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 2ea779a4ae9..9810fbb4611 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 @@ -422,6 +422,41 @@ describe('RemoteFeatureFlagController', () => { }); }); + it('selects the first matching threshold variation when variations are unsorted', async () => { + const clientConfigApiService = buildClientConfigApiService({ + remoteFeatureFlags: { + ...MOCK_FLAGS, + tokenDetailsV2AbTest: [ + { + name: 'Control is ON', + scope: { type: 'threshold', value: 1 }, + value: { minimumVersion: '7.67.0', variant: 'control' }, + }, + { + name: 'Control is OFF', + scope: { type: 'threshold', value: 0.1 }, + value: { minimumVersion: '7.67.0', variant: 'treatment' }, + }, + ], + }, + }); + const controller = createController({ + clientConfigApiService, + getMetaMetricsId: () => MOCK_METRICS_ID, + }); + + await controller.updateRemoteFeatureFlags(); + + // Current behavior is order-dependent: first matching threshold wins. + // Since threshold 1 is first and every generated threshold is <= 1, this variation is always selected. + expect(controller.state.remoteFeatureFlags.tokenDetailsV2AbTest).toStrictEqual( + { + name: 'Control is ON', + value: { minimumVersion: '7.67.0', variant: 'control' }, + }, + ); + }); + it('preserves non-threshold feature flags unchanged', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD, From 7c67866eec4ac03a6290e9e920a362b5b5307029 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 6 Mar 2026 10:35:29 +0000 Subject: [PATCH 2/4] chore(remote-feature-flag-controller): fix changelog PR link Co-authored-by: Prithpal Sooriya --- packages/remote-feature-flag-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 8e1685ee90f..9f4f9f471e6 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Clarify that threshold-scoped feature flag variations are order-dependent and must be sorted in ascending order by `scope.value`; add unit-test coverage for unsorted variation behavior ([#8120](https://github.com/MetaMask/core/pull/8120)) +- Clarify that threshold-scoped feature flag variations are order-dependent and must be sorted in ascending order by `scope.value`; add unit-test coverage for unsorted variation behavior ([#8129](https://github.com/MetaMask/core/pull/8129)) ## [4.1.0] From bde4e99dac11d0671f40a95ef8a8a53a33f8ffcf Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 6 Mar 2026 10:42:07 +0000 Subject: [PATCH 3/4] style(remote-feature-flag-controller): fix test lint formatting Co-authored-by: Prithpal Sooriya --- .../src/remote-feature-flag-controller.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 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 9810fbb4611..81ea98a4247 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 @@ -449,12 +449,12 @@ describe('RemoteFeatureFlagController', () => { // Current behavior is order-dependent: first matching threshold wins. // Since threshold 1 is first and every generated threshold is <= 1, this variation is always selected. - expect(controller.state.remoteFeatureFlags.tokenDetailsV2AbTest).toStrictEqual( - { - name: 'Control is ON', - value: { minimumVersion: '7.67.0', variant: 'control' }, - }, - ); + expect( + controller.state.remoteFeatureFlags.tokenDetailsV2AbTest, + ).toStrictEqual({ + name: 'Control is ON', + value: { minimumVersion: '7.67.0', variant: 'control' }, + }); }); it('preserves non-threshold feature flags unchanged', async () => { From dd3695cf04a2faf7bac85c40309b432e9f83db93 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Fri, 6 Mar 2026 10:55:18 +0000 Subject: [PATCH 4/4] docs(remote-feature-flag-controller): format README examples Co-authored-by: Prithpal Sooriya --- packages/remote-feature-flag-controller/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/remote-feature-flag-controller/README.md b/packages/remote-feature-flag-controller/README.md index 6f2155057a3..17f6489e9bd 100644 --- a/packages/remote-feature-flag-controller/README.md +++ b/packages/remote-feature-flag-controller/README.md @@ -16,13 +16,13 @@ by `scope.value` to get expected bucket behavior. { "name": "Control is OFF", "scope": { "type": "threshold", "value": 0.1 }, - "value": { "minimumVersion": "7.67.0", "variant": "treatment" } + "value": { "minimumVersion": "7.67.0", "variant": "treatment" }, }, { "name": "Control is ON", "scope": { "type": "threshold", "value": 1.0 }, - "value": { "minimumVersion": "7.67.0", "variant": "control" } - } + "value": { "minimumVersion": "7.67.0", "variant": "control" }, + }, ] ``` @@ -33,13 +33,13 @@ by `scope.value` to get expected bucket behavior. { "name": "Control is ON", "scope": { "type": "threshold", "value": 1.0 }, - "value": { "minimumVersion": "7.67.0", "variant": "control" } + "value": { "minimumVersion": "7.67.0", "variant": "control" }, }, { "name": "Control is OFF", "scope": { "type": "threshold", "value": 0.1 }, - "value": { "minimumVersion": "7.67.0", "variant": "treatment" } - } + "value": { "minimumVersion": "7.67.0", "variant": "treatment" }, + }, ] ```