MetaMask / MetaMask/metamask-mobile
[Bug]: Version-style remote flags resolve to undefined in validatedVersionGatedFeatureFlag unless value repeats minimumVersion (VersionGatedFeatureFlag shape)
- Dominant language
- TypeScript
- Stars
- 3k
- Forks
- 1.7k
- Avg merge
- 1d 14h
- Merged PRs (30d)
- 669
Description
## TL;DR
**Any** remote flag whose **resolved** value comes from the **version-based** (and version + threshold) Client Config / LD shapes documented in [Remote Feature Flags](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md) will often look like `{ name, value: { enabled: true } }` or `{ enabled: true }` **without** `minimumVersion`. `validatedVersionGatedFeatureFlag` only accepts a [`VersionGatedFeatureFlag`](https://github.com/MetaMask/metamask-mobile/blob/main/app/util/remoteFeatureFlag/index.ts) (`enabled` **and** `minimumVersion`). Anything else fails `unwrapVersionGatedFeatureFlag` → **`undefined`** → callers using `?? false` treat the feature as off. Version targeting is **already** applied by the `versions` map; requiring `minimumVersion` inside `value` duplicates that contract and breaks valid resolved payloads.
## Describe the bug
**Version-style** feature flags (see [“Object flag with version-based scope”](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md) and [“Composing version-based scope with threshold scope”](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md) in the contributor docs) resolve per app version (and optionally threshold) to a **winning variant**. The docs show inner `value` objects like `{ "enabled": true }` **without** a `minimumVersion` field—versioning is expressed by the **`versions`** structure, not by a field on `value`.
In the mobile app, `validatedVersionGatedFeatureFlag` assumes every acceptable payload unwraps to a **`VersionGatedFeatureFlag`**: `enabled` plus **`minimumVersion`** as a string. The type guard `isVersionGatedFeatureFlag` rejects `{ enabled: true }` if `minimumVersion` is missing, so `unwrapVersionGatedFeatureFlag` returns **`undefined`**, and **`validatedVersionGatedFeatureFlag` returns `undefined`**.
So for the **documented** version/threshold style, the resolved controller state is valid per product/docs, but **invalid** per this helper unless teams **repeat** a client-side minimum version inside `value` (redundant with version-based resolution).
This is **not specific to `homepageSectionsV1`**; that flag is one example where a selector uses `validatedVersionGatedFeatureFlag` and can incorrectly fall back to disabled.
## Expected behavior
If the controller stores a resolved variant that matches the documented version/threshold shapes (e.g. `value: { enabled: true }`), feature-flag consumers should be able to treat **`enabled`** as authoritative for “on/off” after resolution, **or** the client should document and implement a single normalization path so version-style flags are not rejected unless malformed.
## Actual behavior
`validatedVersionGatedFeatureFlag` returns **`undefined`** for many valid resolved values from version-style flags, so selectors that do `validatedVersionGatedFeatureFlag(remoteFlag) ?? false` behave as **always off** unless `value` also includes `minimumVersion` (i.e. effectively requires a **VersionGatedFeatureFlag** shape on top of version-based LD structure).
## Steps to reproduce (example)
1. Configure a flag using **version-based** (and optionally **threshold**) scope as in the [contributor docs](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md), with `value: { "enabled": true }` and **no** `minimumVersion` in `value`.
2. Run an app version that resolves to the enabled variant; confirm controller state shows the wrapped shape with `value: { enabled: true }`.
3. Pass that state through `validatedVersionGatedFeatureFlag` and observe **`undefined`**.
## Reference: documented version + threshold example (from contributor docs)
The docs explicitly show composition of **versions** + **threshold** with `value: { "enabled": true }` only—no `minimumVersion` on `value`:
See: [Remote Feature Flags — “Composing version-based scope with threshold scope”](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md).
### Example resolved controller state (not homepage-specific)
```json
{
"name": "Sections Enabled",
"value": {
"enabled": true
}
}
```
### Example LaunchDarkly / version map (illustrative)
```json
{
"versions": {
"0.0.0": [
{
"name": "Sections Disabled",
"scope": { "type": "threshold", "value": 1 },
"value": { "enabled": false }
}
],
"7.70.0": [
{
"name": "Sections Enabled",
"scope": { "type": "threshold", "value": 0.99 },
"value": { "enabled": true }
},
{
"name": "Sections Disabled",
"scope": { "type": "threshold", "value": 1 },
"value": { "enabled": false }
}
]
}
}
```
## Additional context
- **Docs:** [MetaMask contributor-docs — Remote Feature Flags](https://github.com/MetaMask/contributor-docs/blob/main/docs/remote-feature-flags.md)
- **Mobile helper:** `app/util/remoteFeatureFlag/index.ts` (`isVersionGatedFeatureFlag`, `unwrapVersionGatedFeatureFlag`, `validatedVersionGatedFeatureFlag`)
- **Example selector:** `app/selectors/featureFlagController/homepage/index.ts` (`selectHomepageSectionsV1Enabled`) — illustrative only; other selectors using the same helper may share the issue.
Relevant code (click to expand)
**Type guard** (requires `minimumVersion` on the unwrapped object): `app/util/remoteFeatureFlag/index.ts` — `isVersionGatedFeatureFlag` (lines 17–28)
**Unwrap** (fails when inner `value` is only `{ enabled: true }`): `app/util/remoteFeatureFlag/index.ts` — `unwrapVersionGatedFeatureFlag` (lines 46–66)
**Validation**:
`app/util/remoteFeatureFlag/index.ts` — `validatedVersionGatedFeatureFlag` (lines 68–90)
**Example selector** (illustrative): `app/selectors/featureFlagController/homepage/index.ts` — `selectHomepageSectionsV1Enabled` (lines 21–33)
Contributor guide
Research direction
Start in app/util/remoteFeatureFlag/index.ts, reading isVersionGatedFeatureFlag, unwrapVersionGatedFeatureFlag, and validatedVersionGatedFeatureFlag, then inspect app/selectors/featureFlagController/homepage/index.ts and its selector usage. Reproduce the documented resolved value without minimumVersion and add coverage for the chosen normalization behavior; done means valid version-style flags are no longer returned as undefined while malformed values remain rejected.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- mobile-dev
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100