fix: Normalize remote feature flag value wrappers#8908
Conversation
| }; | ||
| } | ||
|
|
||
| function normalizeFeatureFlagValue(featureFlagValue: Json): Json { |
There was a problem hiding this comment.
For clarity, should we call this normalizeThresholdValue?
|
|
||
| processedFlags[remoteFeatureFlagName] = processedValue; | ||
| processedFlags[remoteFeatureFlagName] = | ||
| normalizeFeatureFlagValue(processedValue); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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:
-
If a threshold hasn't been defined, then just add the whole flag
-
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 ourconfirmations_paywith thresholds use case) -
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?
There was a problem hiding this comment.
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?
37347e8 to
8576910
Compare
66f42ad to
0f896ee
Compare
a525b59 to
761c717
Compare
| return null; | ||
| } | ||
|
|
||
| return isFeatureFlagValueWrapper(versionData) |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 : {}), |
There was a problem hiding this comment.
If this is the fallback for the existing support, can we just return the same?
{
name: featureFlag.name,
value: featureFlag.value
}
There was a problem hiding this comment.
good point, I forgot to revert this part
| ); | ||
| } | ||
|
|
||
| function spreadFeatureFlagValueWrapper( |
| return typeof value === 'object' && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| function isFeatureFlagValueWrapper( |
| }; | ||
| } | ||
|
|
||
| function isJsonObject(value: Json): value is JsonObject { |
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 9dc313d. Configure here.
|
LGTM ! |
NicolasMassart
left a comment
There was a problem hiding this comment.
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.
NicolasMassart
left a comment
There was a problem hiding this comment.
Looks good to me.
## 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 -->

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.
getIsNotificationEnabledByDefaultFeatureFlaginsideui/selectors/metamask-notifications/metamask-notifications.tsexpects a.valueproperty for example. Butconfirmations_payfeature flag, we don't expect such .value property to house the configuration JSON, and instead it's available directly at the root level.Summary
valuefield.valuecontents onto the root flag object while preserving the original.valuefield.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
.valueaccess. Existing consumers that still read.valueremain 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
thresholdVersionvalues), which still emit the{ name, value }wrapper. Types add optionalthresholdName/thresholdVersionon threshold scope entries, andThresholdVersionis 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.