feat(remote-feature-flag-controller): return threshold values directly and track threshold groups#9289
Conversation
…y and track threshold groups
Threshold feature flags now expose the selected value directly instead of a
{name, value} wrapper. When thresholdName is present on the selected group,
store the mapping in featureFlagThresholdGroups on controller state.
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
…9289 Co-authored-by: Cursor <cursoragent@cursor.com>
…s cleanup Co-authored-by: Cursor <cursoragent@cursor.com>
| if (selectedGroup) { | ||
| processedValue = normalizeThresholdValue(selectedGroup); | ||
| processedValue = selectedGroup.value; | ||
| if (selectedGroup.thresholdName) { |
There was a problem hiding this comment.
Where is thresholdName coming from? Currently our flags are using name, not thresholdName
…g-controller.ts Co-authored-by: Mark Stacey <markjstacey@gmail.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 9d628ce. Configure here.
|
@asalsys Thanks for the change, could you also update the doc to reflect new API? |
…e field featureFlagThresholdGroups is populated from the selected entry's name, not thresholdName. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@DDDDDanica I updated the docs in this pr: MetaMask/contributor-docs#177 |
|
|
||
| // Single state update with all changes batched together | ||
| this.#processedRemoteFeatureFlags = processedFlags; | ||
|
|
There was a problem hiding this comment.
I noticed that the way we're updating state here is suboptimal. We're replacing the entire state each time, rather than updating just the properties that have changed.
But since it's a pre-existing problem, better not to solve it in this PR. Maybe we can address it in a later PR.
| }); | ||
|
|
||
| it('returns selected threshold version 2 values without wrapper metadata', async () => { | ||
| it('does not map threshold group when only thresholdName is provided', async () => { |
There was a problem hiding this comment.
Nit: This test case seems redundant now that we've removed the "threshold v2" feature
| }); | ||
|
|
||
| it('falls back to legacy threshold wrappers for unrecognized threshold versions', async () => { | ||
| it('returns selected threshold values for unrecognized threshold versions', async () => { |
There was a problem hiding this comment.
Nit: This test case seems redundant now that we've removed the "threshold v2" feature
There was a problem hiding this comment.
Actually nevermind, maybe we keep this one so that we ensure the thresholdVersion flag is ignored and does not interfere with feature flag processing? We'd want to re-word the decsription though.
Really all thresholdVersion values are "unrecognized" now. So we could say, for example:
| it('returns selected threshold values for unrecognized threshold versions', async () => { | |
| it('ignores threshold version field, processing threshold values normally', async () => { |
| flagB: 'groupB', | ||
| }); | ||
|
|
||
| jest.useRealTimers(); |
There was a problem hiding this comment.
It would be safer to put this in an afterEach hook or in a try.. finally block, so that it still runs even if there is a crash.
if this test crashes after file timers are enabled but before restoring real timers, the fake timers could remain in-place for later tests as well, causing confusing cascading test failures.
Gudahtt
left a comment
There was a problem hiding this comment.
Generally looks good! At least one change needed for the test suite, but the rest of my comments are nits
…old flags Rebase onto main's breaking change (#9289) that returns the selected threshold value directly instead of wrapping it in { name, value }, storing the selected group name in featureFlagThresholdGroups instead.
…old flags Rebase onto main's breaking change (#9289) that returns the selected threshold value directly instead of wrapping it in { name, value }, storing the selected group name in featureFlagThresholdGroups instead.

Summary
valuedirectly fromremoteFeatureFlagsinstead of a{ name, value }wrapper object.featureFlagThresholdGroupsstate field (Record<string, string>) that maps feature flag names to their selected threshold group name when the selected threshold entry includesname.normalizeThresholdValueand the legacy{ name, value }wrapper fallback.References
Motivation
Separating the processed flag value from threshold group metadata makes it easier for consumers to use threshold flags as plain values (booleans, objects, etc.) without unwrapping. The new
featureFlagThresholdGroupsfield provides explicit access to A/B group assignment when the selected threshold entry includesname.Entries that only define
thresholdName(for example inthresholdVersion: 2configs) still resolve to the selectedvalue, but do not populatefeatureFlagThresholdGroupsbecause that field is driven byname.Migration
Consumers that previously read threshold group names from
remoteFeatureFlags[flagName].nameshould instead:remoteFeatureFlags[flagName].featureFlagThresholdGroups[flagName]when available (populated when the selected threshold entry includesname).Before:
After:
Related
Test plan
yarn workspace @metamask/remote-feature-flag-controller run test— all tests pass with 100% coveragevaluedirectly fromremoteFeatureFlagsfeatureFlagThresholdGroupswhen the selected entry includesnamethresholdNamereturn the value directly and leavefeatureFlagThresholdGroupsemptyfeatureFlagThresholdGroupsentries are cleaned up when flags are removed from the server responseNote
Medium Risk
Breaking change to the public shape of threshold flag values affects all consumers of
remoteFeatureFlags; persisted state gains a new field but assignment logic is otherwise unchanged.Overview
BREAKING: Threshold/A/B flags in
remoteFeatureFlagsnow expose the selected entry’svalueonly (string, boolean, object, etc.) instead of a{ name, value }wrapper. Group assignment moves to a new optional persisted state fieldfeatureFlagThresholdGroups(flagName → group name) when the winning threshold item has aname.Processing drops
normalizeThresholdValueandThresholdVersionbranching—legacy wrappers and “direct value vs wrapper” version handling are gone; threshold arrays are resolved the same way regardless ofthresholdVersion. On each fetch,featureFlagThresholdGroupsis rebuilt from the current server payload so entries for removed flags are cleared (covered by tests).Migration: read values from
remoteFeatureFlags[flag]; read group names fromfeatureFlagThresholdGroups[flag]when present.Reviewed by Cursor Bugbot for commit 98b74a2. Bugbot is set up for automated code reviews on this repo. Configure here.