Skip to content

Commit db4311e

Browse files
asalsyscursoragent
andauthored
feat(remote-feature-flag-controller): merge localOverrides into remoteFeatureFlags (#9259)
## Summary - `remoteFeatureFlags` now exposes the **effective** flag values consumers should read: processed remote flags merged with `localOverrides` - Override mutations (`setFlagOverride`, `removeFlagOverride`, `clearAllFlagOverrides`) update `remoteFeatureFlags` directly instead of requiring callers to merge state themselves - Remote flag fetches re-apply active overrides after processing so cached overrides survive refresh ## Behavior Previously, `remoteFeatureFlags` contained only processed remote values and `localOverrides` was a separate map. Consumers that needed the effective value had to merge both manually, e.g. `{ ...remoteFeatureFlags, ...localOverrides }`. With this change, the controller owns that merge: | State field | Purpose | |-------------|---------| | `remoteFeatureFlags` | Effective values (`{ ...processedRemote, ...localOverrides }`) — **read this** | | `localOverrides` | Which flags are locally overridden and their override values | | `rawRemoteFeatureFlags` | Unprocessed API response | ### When merging happens - **`updateRemoteFeatureFlags()`** — after version/threshold processing, overrides are spread onto the processed result - **`setFlagOverride()`** — writes the override value directly into `remoteFeatureFlags` - **`removeFlagOverride()` / `clearAllFlagOverrides()`** — restores processed remote values (tracked in a private in-memory cache refreshed on fetch) ### Consumer impact - **Before:** merge `remoteFeatureFlags` + `localOverrides` at the call site - **After:** read `remoteFeatureFlags` directly for effective values - Call sites that already merge manually should drop the extra merge to avoid double-applying overrides ## Test plan - [x] `yarn workspace @metamask/remote-feature-flag-controller run jest --no-coverage remote-feature-flag-controller.test.ts` - [ ] Verify extension/mobile consumers that read `remoteFeatureFlags` no longer need a manual merge with `localOverrides` Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 750d1b5 commit db4311e

3 files changed

Lines changed: 144 additions & 3 deletions

File tree

packages/remote-feature-flag-controller/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Changed
1111

12+
- Merge `localOverrides` into `remoteFeatureFlags` at the controller level so consumers receive effective flag values directly ([#9259](https://github.com/MetaMask/core/pull/9259))
1213
- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074))
1314
- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218))
1415

packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,18 @@ describe('RemoteFeatureFlagController', () => {
124124
expect(controller.state).toStrictEqual(customState);
125125
});
126126

127+
it('merges undefined localOverrides into remoteFeatureFlags on init', () => {
128+
const { controller } = createController({
129+
state: {
130+
remoteFeatureFlags: { flag: true },
131+
localOverrides: undefined,
132+
},
133+
});
134+
135+
expect(controller.state.remoteFeatureFlags).toStrictEqual({ flag: true });
136+
expect(controller.state.localOverrides).toBeUndefined();
137+
});
138+
127139
it('accepts valid 3-part SemVer clientVersion', () => {
128140
expect(() =>
129141
createController({ clientVersion: MOCK_BASE_VERSION }),
@@ -1093,6 +1105,9 @@ describe('RemoteFeatureFlagController', () => {
10931105
expect(controller.state.localOverrides).toStrictEqual({
10941106
testFlag: true,
10951107
});
1108+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1109+
testFlag: true,
1110+
});
10961111
});
10971112

10981113
it('overwrites existing override for the same flag', () => {
@@ -1113,11 +1128,17 @@ describe('RemoteFeatureFlagController', () => {
11131128
expect(controller.state.localOverrides).toStrictEqual({
11141129
testFlag: false,
11151130
});
1131+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1132+
testFlag: false,
1133+
});
11161134
});
11171135

11181136
it('preserves other overrides when setting a new one', () => {
11191137
const { controller, messenger } = createController({
11201138
state: {
1139+
remoteFeatureFlags: {
1140+
flag1: 'value1',
1141+
},
11211142
localOverrides: {
11221143
flag1: 'value1',
11231144
},
@@ -1134,13 +1155,22 @@ describe('RemoteFeatureFlagController', () => {
11341155
flag1: 'value1',
11351156
flag2: 'value2',
11361157
});
1158+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1159+
flag1: 'value1',
1160+
flag2: 'value2',
1161+
});
11371162
});
11381163
});
11391164

11401165
describe('removeFlagOverride', () => {
11411166
it('removes a specific override', () => {
11421167
const { controller, messenger } = createController({
11431168
state: {
1169+
remoteFeatureFlags: {
1170+
remoteFlag: 'remoteValue',
1171+
flag1: 'value1',
1172+
flag2: 'value2',
1173+
},
11441174
localOverrides: {
11451175
flag1: 'value1',
11461176
flag2: 'value2',
@@ -1156,11 +1186,18 @@ describe('RemoteFeatureFlagController', () => {
11561186
expect(controller.state.localOverrides).toStrictEqual({
11571187
flag2: 'value2',
11581188
});
1189+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1190+
remoteFlag: 'remoteValue',
1191+
flag2: 'value2',
1192+
});
11591193
});
11601194

11611195
it('does not affect state when clearing non-existent override', () => {
11621196
const { controller, messenger } = createController({
11631197
state: {
1198+
remoteFeatureFlags: {
1199+
flag1: 'value1',
1200+
},
11641201
localOverrides: {
11651202
flag1: 'value1',
11661203
},
@@ -1175,13 +1212,21 @@ describe('RemoteFeatureFlagController', () => {
11751212
expect(controller.state.localOverrides).toStrictEqual({
11761213
flag1: 'value1',
11771214
});
1215+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1216+
flag1: 'value1',
1217+
});
11781218
});
11791219
});
11801220

11811221
describe('clearAllFlagOverrides', () => {
11821222
it('removes all overrides', () => {
11831223
const { controller, messenger } = createController({
11841224
state: {
1225+
remoteFeatureFlags: {
1226+
remoteFlag: 'remoteValue',
1227+
flag1: 'value1',
1228+
flag2: 'value2',
1229+
},
11851230
localOverrides: {
11861231
flag1: 'value1',
11871232
flag2: 'value2',
@@ -1192,6 +1237,9 @@ describe('RemoteFeatureFlagController', () => {
11921237
messenger.call('RemoteFeatureFlagController:clearAllFlagOverrides');
11931238

11941239
expect(controller.state.localOverrides).toStrictEqual({});
1240+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1241+
remoteFlag: 'remoteValue',
1242+
});
11951243
});
11961244

11971245
it('does not affect state when no overrides exist', () => {
@@ -1233,6 +1281,58 @@ describe('RemoteFeatureFlagController', () => {
12331281
overrideFlag: 'overrideValue',
12341282
remoteFlag: 'updatedRemoteValue',
12351283
});
1284+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1285+
remoteFlag: 'updatedRemoteValue',
1286+
overrideFlag: 'overrideValue',
1287+
});
1288+
});
1289+
1290+
it('uses persisted remoteFeatureFlags with overrides on init', () => {
1291+
const { controller } = createController({
1292+
state: {
1293+
remoteFeatureFlags: {
1294+
remoteFlag: 'remoteValue',
1295+
overrideFlag: 'overrideValue',
1296+
},
1297+
localOverrides: {
1298+
overrideFlag: 'overrideValue',
1299+
},
1300+
},
1301+
});
1302+
1303+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1304+
remoteFlag: 'remoteValue',
1305+
overrideFlag: 'overrideValue',
1306+
});
1307+
});
1308+
1309+
it('merges legacy persisted localOverrides into remoteFeatureFlags on init', () => {
1310+
const { controller, messenger } = createController({
1311+
state: {
1312+
remoteFeatureFlags: {
1313+
remoteFlag: 'remoteValue',
1314+
overrideFlag: 'remoteOnlyValue',
1315+
},
1316+
localOverrides: {
1317+
overrideFlag: 'overrideValue',
1318+
},
1319+
},
1320+
});
1321+
1322+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1323+
remoteFlag: 'remoteValue',
1324+
overrideFlag: 'overrideValue',
1325+
});
1326+
1327+
messenger.call(
1328+
'RemoteFeatureFlagController:removeFlagOverride',
1329+
'overrideFlag',
1330+
);
1331+
1332+
expect(controller.state.remoteFeatureFlags).toStrictEqual({
1333+
remoteFlag: 'remoteValue',
1334+
overrideFlag: 'remoteOnlyValue',
1335+
});
12361336
});
12371337
});
12381338
});

packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ export class RemoteFeatureFlagController extends BaseController<
155155

156156
readonly #clientVersion: SemVerVersion;
157157

158+
#processedRemoteFeatureFlags: FeatureFlags = {};
159+
158160
/**
159161
* Constructs a new RemoteFeatureFlagController instance.
160162
*
@@ -202,18 +204,33 @@ export class RemoteFeatureFlagController extends BaseController<
202204
isValidSemVerVersion(prevClientVersion) &&
203205
prevClientVersion !== clientVersion;
204206

207+
const localOverrides = initialState.localOverrides ?? {};
208+
205209
super({
206210
name: controllerName,
207211
metadata: remoteFeatureFlagControllerMetadata,
208212
messenger,
209213
state: {
210214
...initialState,
215+
remoteFeatureFlags: {
216+
...initialState.remoteFeatureFlags,
217+
...localOverrides,
218+
},
211219
cacheTimestamp: hasClientVersionChanged
212220
? 0
213221
: initialState.cacheTimestamp,
214222
},
215223
});
216224

225+
this.#processedRemoteFeatureFlags = {
226+
...initialState.remoteFeatureFlags,
227+
};
228+
for (const [flagName, overrideValue] of Object.entries(localOverrides)) {
229+
if (this.#processedRemoteFeatureFlags[flagName] === overrideValue) {
230+
delete this.#processedRemoteFeatureFlags[flagName];
231+
}
232+
}
233+
217234
this.#fetchInterval = fetchInterval;
218235
this.#disabled = disabled;
219236
this.#clientConfigApiService = clientConfigApiService;
@@ -298,10 +315,15 @@ export class RemoteFeatureFlagController extends BaseController<
298315
}
299316

300317
// Single state update with all changes batched together
318+
this.#processedRemoteFeatureFlags = processedFlags;
319+
301320
this.update(() => {
302321
return {
303322
...this.state,
304-
remoteFeatureFlags: processedFlags,
323+
remoteFeatureFlags: {
324+
...processedFlags,
325+
...this.state.localOverrides,
326+
},
305327
rawRemoteFeatureFlags: remoteFeatureFlags,
306328
cacheTimestamp: Date.now(),
307329
thresholdCache: updatedThresholdCache,
@@ -419,10 +441,16 @@ export class RemoteFeatureFlagController extends BaseController<
419441
*/
420442
setFlagOverride(flagName: string, value: Json): void {
421443
this.update(() => {
444+
const localOverrides = {
445+
...this.state.localOverrides,
446+
[flagName]: value,
447+
};
448+
422449
return {
423450
...this.state,
424-
localOverrides: {
425-
...this.state.localOverrides,
451+
localOverrides,
452+
remoteFeatureFlags: {
453+
...this.state.remoteFeatureFlags,
426454
[flagName]: value,
427455
},
428456
};
@@ -437,10 +465,21 @@ export class RemoteFeatureFlagController extends BaseController<
437465
removeFlagOverride(flagName: string): void {
438466
const newLocalOverrides = { ...this.state.localOverrides };
439467
delete newLocalOverrides[flagName];
468+
469+
const remoteFeatureFlags = { ...this.state.remoteFeatureFlags };
470+
const processedValue = this.#processedRemoteFeatureFlags[flagName];
471+
472+
if (processedValue === undefined) {
473+
delete remoteFeatureFlags[flagName];
474+
} else {
475+
remoteFeatureFlags[flagName] = processedValue;
476+
}
477+
440478
this.update(() => {
441479
return {
442480
...this.state,
443481
localOverrides: newLocalOverrides,
482+
remoteFeatureFlags,
444483
};
445484
});
446485
}
@@ -453,6 +492,7 @@ export class RemoteFeatureFlagController extends BaseController<
453492
return {
454493
...this.state,
455494
localOverrides: {},
495+
remoteFeatureFlags: { ...this.#processedRemoteFeatureFlags },
456496
};
457497
});
458498
}

0 commit comments

Comments
 (0)