Skip to content

fix: Normalize remote feature flag value wrappers#8908

Merged
pedronfigueiredo merged 6 commits into
mainfrom
pnf/remote-feature-flag-renormalization
Jun 3, 2026
Merged

fix: Normalize remote feature flag value wrappers#8908
pedronfigueiredo merged 6 commits into
mainfrom
pnf/remote-feature-flag-renormalization

Conversation

@pedronfigueiredo
Copy link
Copy Markdown
Contributor

@pedronfigueiredo pedronfigueiredo commented May 27, 2026

There is an issue with the current remote feature flag implementation.

As you can see when the feature flags are configured for threshold based config, we define an array of objects that contains name, scope, and value. The problem with this is that there's lots of feature flags that are not defined in this format, and the selectors are unfortunately coupled to this format.

getIsNotificationEnabledByDefaultFeatureFlag inside ui/selectors/metamask-notifications/metamask-notifications.ts expects a .value property for example. But confirmations_pay feature flag, we don't expect such .value property to house the configuration JSON, and instead it's available directly at the root level.

Summary

  • Normalize processed remote feature flag wrapper objects that include a top-level value field.
  • Copy object-valued value contents onto the root flag object while preserving the original .value field.
  • Preserve direct feature flag config objects that do not use wrapper metadata.

Root Cause

Some consumers read feature flag config directly from the flag object, while threshold or version wrapper shapes expose config under .value. This made selectors depend on the rollout format instead of the feature flag config itself.

Impact

Feature flags can move between direct, threshold, and versioned configurations without requiring selectors to switch between direct config access and .value access. Existing consumers that still read .value remain compatible.


Note

Medium Risk
Changes processed flag shapes for v2 threshold configs, which can affect UI selectors that expect .value; legacy and unversioned thresholds stay wrapped for compatibility.

Overview
Remote feature flag threshold processing now supports a v2 direct-value shape via ThresholdVersion.DirectValue (thresholdVersion: 2): after threshold selection, the controller exposes the chosen config as the flag value itself instead of { name, value }.

Legacy behavior is unchanged for entries without that version (and for unknown thresholdVersion values), which still emit the { name, value } wrapper. Types add optional thresholdName / thresholdVersion on threshold scope entries, and ThresholdVersion is exported from the package. Tests cover direct configs, legacy wrappers, v2 output, and fallback for unrecognized versions.

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

@pedronfigueiredo pedronfigueiredo changed the title [codex] Normalize remote feature flag value wrappers fix: Normalize remote feature flag value wrappers May 27, 2026
@pedronfigueiredo pedronfigueiredo marked this pull request as ready for review May 27, 2026 16:37
@pedronfigueiredo pedronfigueiredo requested review from a team as code owners May 27, 2026 16:37
};
}

function normalizeFeatureFlagValue(featureFlagValue: Json): Json {
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.

For clarity, should we call this normalizeThresholdValue?


processedFlags[remoteFeatureFlagName] = processedValue;
processedFlags[remoteFeatureFlagName] =
normalizeFeatureFlagValue(processedValue);
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.

As we also normalize for versioning, would it be simpler to normalize on line 412 above, so a function specific for getting threshold data?


function normalizeFeatureFlagValue(featureFlagValue: Json): Json {
return isFeatureFlagValueWrapper(featureFlagValue)
? spreadFeatureFlagValueWrapper(featureFlagValue)
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.

Am I misreading or is this saying if the flag is an object with a value property, then return the value property directly also?

But won't that mean we duplicate the data in every threshold flag?

Could we check (just for the threshold flow) if a thresholdVersion property exists and if set to 2, then we just return value directly? And maybe with a thresholdName property instead of name for clarity?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes we do duplicate because although we know in this function what is the shape of the flag defined on launch darkly, we have no way of knowing at this point what is the shape of the data that the selector needs or expects. In other words, we need this change to be backwards compatible.

We have to support three cases:

  1. If a threshold hasn't been defined, then just add the whole flag

  2. If a threshold is defined, and the selectors are currently defined without .value, we want to spread the .value at the root level so no selector change is required (to support our confirmations_pay with thresholds use case)

  3. If a threshold is defined, and the selectors already have .value, we also need to keep the .value prop that the selector expects.

To support 2 and 3 simultaneously we need to duplicate the data.

I am not sure what you mean by the thresholdVersion prop with value 2, I can't see it in the client config api response. Can you clarify?

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 was suggesting we make the distinction explicit with a new property such as thresholdVersion: 2 that we put in one of the array entries, so the code knows to only spread the value, rather than including it as it does currently.

Both should solve the problem, but didn't know if we wanted to future-proof and make the RemoteFeatureFlagController state more readable?

@pedronfigueiredo pedronfigueiredo force-pushed the pnf/remote-feature-flag-renormalization branch from 37347e8 to 8576910 Compare May 28, 2026 09:59
@pedronfigueiredo pedronfigueiredo force-pushed the pnf/remote-feature-flag-renormalization branch from 66f42ad to 0f896ee Compare May 28, 2026 11:31
Comment thread packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Outdated
@pedronfigueiredo pedronfigueiredo force-pushed the pnf/remote-feature-flag-renormalization branch from a525b59 to 761c717 Compare May 28, 2026 13:03
return null;
}

return isFeatureFlagValueWrapper(versionData)
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.

Doesn't the chosen version data get assigned to processedValue and then hit the standard threshold flow so we don't need to touch this?

}

function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json {
if (featureFlag.thresholdVersion === THRESHOLD_VALUE_VERSION) {
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.

Minor, if we add future versions, this constant might get confusing. Maybe an enum?


function normalizeThresholdValue(featureFlag: FeatureFlagScopeValue): Json {
if (featureFlag.thresholdVersion === THRESHOLD_VALUE_VERSION) {
return featureFlag.value;
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.

Minor, could still be useful to also include thresholdName property only if an object, for debug in the client?

Not sure how this will impact the new AB testing hooks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good points let's add that if and when it becomes necessary then

const name = featureFlag.thresholdName ?? featureFlag.name;

return {
...(isJsonObject(featureFlag.value) ? featureFlag.value : {}),
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.

If this is the fallback for the existing support, can we just return the same?

{
    name: featureFlag.name,
    value: featureFlag.value
}

Copy link
Copy Markdown
Contributor Author

@pedronfigueiredo pedronfigueiredo May 28, 2026

Choose a reason for hiding this comment

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

good point, I forgot to revert this part

);
}

function spreadFeatureFlagValueWrapper(
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.

Not needed?

return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function isFeatureFlagValueWrapper(
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.

Not needed?

};
}

function isJsonObject(value: Json): value is JsonObject {
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.

Not needed?

Copy link
Copy Markdown

@cursor cursor Bot left a comment

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 9dc313d. Configure here.

Comment thread packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts Outdated
@DDDDDanica
Copy link
Copy Markdown
Contributor

LGTM !

Copy link
Copy Markdown
Contributor

@NicolasMassart NicolasMassart left a comment

Choose a reason for hiding this comment

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

Nice work overall — the normalizeThresholdValue approach is clean and the legacy wrapper fallback is well-preserved. One MEDIUM item to address before merge (type safety), two low-priority notes.

Copy link
Copy Markdown
Contributor

@NicolasMassart NicolasMassart left a comment

Choose a reason for hiding this comment

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

Looks good to me.

@pedronfigueiredo pedronfigueiredo added this pull request to the merge queue Jun 3, 2026
Merged via the queue into main with commit 5ac3102 Jun 3, 2026
370 checks passed
@pedronfigueiredo pedronfigueiredo deleted the pnf/remote-feature-flag-renormalization branch June 3, 2026 09:17
pedronfigueiredo added a commit to MetaMask/contributor-docs that referenced this pull request Jun 5, 2026
## Description

Updates the remote feature flag documentation to explain direct-value
threshold entries using `thresholdVersion: 2`.

## Changes

- Documents the legacy threshold output shape as `{ name, value }`.
- Adds a direct-value threshold example for selectors that read
root-level object fields.
- Clarifies that non-threshold object flags and version-based object
flags do not need `thresholdVersion`.
- Updates the composed version + threshold example to place
`thresholdVersion: 2` inside each threshold entry.

Related to this core PR MetaMask/core#8908


<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Low Risk**
> Documentation-only changes with no runtime, auth, or data-handling
impact.
> 
> **Overview**
> Updates **`docs/remote-feature-flags.md`** to document **direct-value
threshold entries** (`thresholdVersion: 2`) and how they differ from the
legacy threshold output.
> 
> The doc now states that entries **without** `thresholdVersion` resolve
to the legacy **`{ name, value }`** wrapper, and that selectors should
use **`.value`** in that case. A new section explains
**`thresholdVersion: 2`**, which returns the selected entry’s **`value`
at the root** (like a plain object flag), and recommends
**`thresholdName`** instead of **`name`** for entry labels so config
stays clear without colliding with fields inside the value object.
> 
> It also notes that **version-only object flags** do not use
`thresholdVersion`, and refreshes the **version + threshold** composed
example to use **`thresholdName`** / **`thresholdVersion: 2`** on each
threshold entry, with a note that omitting v2 keeps the legacy wrapper
shape.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
6131eb6. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
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.

4 participants