Skip to content

feat(remote-feature-flag-controller): return threshold values directly and track threshold groups#9289

Merged
asalsys merged 14 commits into
mainfrom
feat/remote-feature-flag-threshold-groups
Jul 2, 2026
Merged

feat(remote-feature-flag-controller): return threshold values directly and track threshold groups#9289
asalsys merged 14 commits into
mainfrom
feat/remote-feature-flag-threshold-groups

Conversation

@asalsys

@asalsys asalsys commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

  • BREAKING: Threshold feature flags now return the selected value directly from remoteFeatureFlags instead of a { name, value } wrapper object.
  • Add optional featureFlagThresholdGroups state field (Record<string, string>) that maps feature flag names to their selected threshold group name when the selected threshold entry includes name.
  • Remove normalizeThresholdValue and 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 featureFlagThresholdGroups field provides explicit access to A/B group assignment when the selected threshold entry includes name.

Entries that only define thresholdName (for example in thresholdVersion: 2 configs) still resolve to the selected value, but do not populate featureFlagThresholdGroups because that field is driven by name.

Migration

Consumers that previously read threshold group names from remoteFeatureFlags[flagName].name should instead:

  1. Read the flag value directly from remoteFeatureFlags[flagName].
  2. Read the threshold group name from featureFlagThresholdGroups[flagName] when available (populated when the selected threshold entry includes name).

Before:

const flag = remoteFeatureFlags.myThresholdFlag;
// { name: 'groupB', value: { enabled: true } }
const groupName = flag.name;
const value = flag.value;

After:

const value = remoteFeatureFlags.myThresholdFlag;
// { enabled: true }

const groupName = featureFlagThresholdGroups.myThresholdFlag;
// 'groupB'

Related

Test plan

  • yarn workspace @metamask/remote-feature-flag-controller run test — all tests pass with 100% coverage
  • Threshold flags return the selected value directly from remoteFeatureFlags
  • Threshold flags populate featureFlagThresholdGroups when the selected entry includes name
  • Threshold flags with only thresholdName return the value directly and leave featureFlagThresholdGroups empty
  • Stale featureFlagThresholdGroups entries are cleaned up when flags are removed from the server response

Note

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 remoteFeatureFlags now expose the selected entry’s value only (string, boolean, object, etc.) instead of a { name, value } wrapper. Group assignment moves to a new optional persisted state field featureFlagThresholdGroups (flagName → group name) when the winning threshold item has a name.

Processing drops normalizeThresholdValue and ThresholdVersion branching—legacy wrappers and “direct value vs wrapper” version handling are gone; threshold arrays are resolved the same way regardless of thresholdVersion. On each fetch, featureFlagThresholdGroups is 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 from featureFlagThresholdGroups[flag] when present.

Reviewed by Cursor Bugbot for commit 98b74a2. Bugbot is set up for automated code reviews on this repo. Configure here.

…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>
@asalsys asalsys requested review from a team as code owners June 26, 2026 14:03
@asalsys asalsys temporarily deployed to default-branch June 26, 2026 14:03 — with GitHub Actions Inactive
Comment thread packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Outdated
Comment thread packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Outdated
Comment thread packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Outdated
if (selectedGroup) {
processedValue = normalizeThresholdValue(selectedGroup);
processedValue = selectedGroup.value;
if (selectedGroup.thresholdName) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is thresholdName coming from? Currently our flags are using name, not thresholdName

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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 asalsys requested a review from Gudahtt June 30, 2026 15:59
@asalsys asalsys enabled auto-merge June 30, 2026 17:40
@DDDDDanica

Copy link
Copy Markdown
Contributor

@asalsys Thanks for the change, could you also update the doc to reflect new API?
https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md?plain=1#L97

…e field

featureFlagThresholdGroups is populated from the selected entry's name,
not thresholdName.

Co-authored-by: Cursor <cursoragent@cursor.com>
@asalsys

asalsys commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

@DDDDDanica I updated the docs in this pr: MetaMask/contributor-docs#177


// Single state update with all changes batched together
this.#processedRemoteFeatureFlags = processedFlags;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Outdated
});

it('returns selected threshold version 2 values without wrapper metadata', async () => {
it('does not map threshold group when only thresholdName is provided', async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This test case seems redundant now that we've removed the "threshold v2" feature

@Gudahtt Gudahtt Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
it('returns selected threshold values for unrecognized threshold versions', async () => {
it('ignores threshold version field, processing threshold values normally', async () => {

flagB: 'groupB',
});

jest.useRealTimers();

@Gudahtt Gudahtt Jul 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Gudahtt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generally looks good! At least one change needed for the test suite, but the rest of my comments are nits

@asalsys asalsys requested a review from Gudahtt July 2, 2026 17:51

@Gudahtt Gudahtt left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@asalsys asalsys added this pull request to the merge queue Jul 2, 2026
Merged via the queue into main with commit 135e415 Jul 2, 2026
413 checks passed
@asalsys asalsys deleted the feat/remote-feature-flag-threshold-groups branch July 2, 2026 22:55
pedronfigueiredo added a commit that referenced this pull request Jul 3, 2026
…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.
pedronfigueiredo added a commit that referenced this pull request Jul 3, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants