diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index b6984cfce3e..9f4f9f471e6 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 ([#8129](https://github.com/MetaMask/core/pull/8129)) + ## [4.1.0] ### Added diff --git a/packages/remote-feature-flag-controller/README.md b/packages/remote-feature-flag-controller/README.md index 6b094230622..17f6489e9bd 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..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 @@ -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,