Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bec04d7
feat: add override functionality to remote feature flags
asalsys Nov 30, 2025
e78270a
fix lint
asalsys Dec 1, 2025
fe7db8b
fix lint
asalsys Dec 2, 2025
4413b2d
fix lint
asalsys Dec 2, 2025
e07e67c
fix lint
asalsys Dec 2, 2025
ec17188
edit changelog
asalsys Dec 2, 2025
fbdbd14
update pr info on changelog
asalsys Dec 2, 2025
c9cd5aa
fix test
asalsys Dec 2, 2025
4d02e47
fix transaction-controller test
asalsys Dec 2, 2025
e3a4f8f
fix prettier
asalsys Dec 2, 2025
35b8470
fix transaction pay test
asalsys Dec 2, 2025
4201e40
remove get actions
asalsys Dec 4, 2025
7ae47a3
update changelog and fix lint
asalsys Dec 4, 2025
71e4b7a
fix lint
asalsys Dec 8, 2025
ca479c8
fix tests
asalsys Dec 9, 2025
0063bce
update tests
asalsys Dec 9, 2025
9bbf745
update objects in tests
asalsys Dec 9, 2025
414f0f0
Merge branch 'main' into feat/override-functionality-to-remote-featur…
asalsys Dec 9, 2025
f17c947
fix tests
asalsys Dec 9, 2025
4d349ed
update to use the processVersionBasedFlag
asalsys Dec 10, 2025
6a6b98f
spread state
asalsys Dec 11, 2025
7e3500d
fix lint
asalsys Dec 11, 2025
434c892
Merge branch 'main' into feat/override-functionality-to-remote-featur…
asalsys Dec 11, 2025
6c75509
use rawRemoteFeatureFlags instead of processing each flag
asalsys Dec 11, 2025
e562809
fix update Cache
asalsys Dec 11, 2025
a09ceaa
fix lint
asalsys Dec 11, 2025
ffd58e9
update tests
asalsys Dec 11, 2025
c1af969
fix lint
asalsys Dec 11, 2025
9d99264
Update packages/remote-feature-flag-controller/src/remote-feature-fla…
asalsys Dec 12, 2025
1f49998
Update packages/remote-feature-flag-controller/CHANGELOG.md
asalsys Dec 12, 2025
e8f2ff1
Update packages/remote-feature-flag-controller/src/remote-feature-fla…
asalsys Dec 12, 2025
83017de
Update packages/remote-feature-flag-controller/src/remote-feature-fla…
asalsys Dec 12, 2025
6447545
Update packages/remote-feature-flag-controller/src/remote-feature-fla…
asalsys Dec 12, 2025
28a8548
Update packages/remote-feature-flag-controller/src/remote-feature-fla…
asalsys Dec 12, 2025
c3d09e5
Update packages/remote-feature-flag-controller/CHANGELOG.md
asalsys Dec 12, 2025
10c168d
add changes
asalsys Dec 12, 2025
d1357b7
address comments and fix changes
asalsys Dec 12, 2025
fb818a8
fix lint
asalsys Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add override functionality to remote feature flags ([#7271](https://github.com/MetaMask/core/pull/7271))
- `setFlagOverride(flagName, value)` - Set a local override for a specific feature flag
- `removeFlagOverride(flagName)` - Clear the local override for a specific feature flag
- `clearAllFlagOverrides()` - Clear all local feature flag overrides
- Add new controller state properties ([#7271](https://github.com/MetaMask/core/pull/7271))
- `localOverrides` - Local overrides for feature flags that take precedence over remote flags
- `rawRemoteFeatureFlags` - Raw flag value for all feature flags
- Export additional controller action types ([#7271](https://github.com/MetaMask/core/pull/7271))
- `RemoteFeatureFlagControllerSetFlagOverrideAction`
- `RemoteFeatureFlagControllerremoveFlagOverrideAction`
- `RemoteFeatureFlagControllerclearAllFlagOverridesAction`

### Changed

- Controller state now includes metadata for new properties with appropriate persistence and logging settings ([#7271](https://github.com/MetaMask/core/pull/7271))
Copy link
Contributor

Choose a reason for hiding this comment

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

Isn't this covered by "Add new controller state properties"? Perhaps we don't need it.

Suggested change
- Controller state now includes metadata for new properties with appropriate persistence and logging settings ([#7271](https://github.com/MetaMask/core/pull/7271))


## [3.0.0]

### Added
Expand Down
3 changes: 3 additions & 0 deletions packages/remote-feature-flag-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ export type {
RemoteFeatureFlagControllerActions,
RemoteFeatureFlagControllerGetStateAction,
RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction,
RemoteFeatureFlagControllerSetFlagOverrideAction,
RemoteFeatureFlagControllerRemoveFlagOverrideAction,
RemoteFeatureFlagControllerClearAllFlagOverridesAction,
RemoteFeatureFlagControllerEvents,
RemoteFeatureFlagControllerStateChangeEvent,
} from './remote-feature-flag-controller';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,25 @@ export type ServiceResponse = {
remoteFeatureFlags: FeatureFlags;
cacheTimestamp: number | null;
};

/**
* Describes the shape of the state object for the {@link RemoteFeatureFlagController}.
*/
export type RemoteFeatureFlagControllerState = {
/**
* The collection of feature flags and their respective values, which can be objects.
*/
remoteFeatureFlags: FeatureFlags;
/**
* Local overrides for feature flags that take precedence over remote flags.
*/
localOverrides: FeatureFlags;
/**
* Raw A/B test flag arrays for flags that were processed from arrays to single values.
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this always true? Will feature flags always contain A/B test arrays from now on? There don't seem to be any changes to the FeatureFlags array, so it seems like the format continues to be quite flexible and accommodate other use cases. (I see that Cursor also commented on this.)

*/
rawRemoteFeatureFlags: FeatureFlags;
/**
* The timestamp of the last successful feature flag cache.
*/
cacheTimestamp: number;
};
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service';
import {
RemoteFeatureFlagController,
controllerName,
DEFAULT_CACHE_DURATION,
getDefaultRemoteFeatureFlagControllerState,
} from './remote-feature-flag-controller';
Expand All @@ -18,8 +19,6 @@ import type {
} from './remote-feature-flag-controller';
import type { FeatureFlags } from './remote-feature-flag-controller-types';

const controllerName = 'RemoteFeatureFlagController';

const MOCK_FLAGS: FeatureFlags = {
feature1: true,
feature2: { chrome: '<109' },
Expand Down Expand Up @@ -88,6 +87,8 @@ describe('RemoteFeatureFlagController', () => {

expect(controller.state).toStrictEqual({
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
});
});
Expand All @@ -97,6 +98,8 @@ describe('RemoteFeatureFlagController', () => {

expect(controller.state).toStrictEqual({
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
});
});
Expand All @@ -105,6 +108,8 @@ describe('RemoteFeatureFlagController', () => {
const customState = {
remoteFeatureFlags: MOCK_FLAGS_TWO,
cacheTimestamp: 123456789,
rawRemoteFeatureFlags: {},
localOverrides: {},
};

const controller = createController({ state: customState });
Expand Down Expand Up @@ -640,11 +645,116 @@ describe('RemoteFeatureFlagController', () => {
it('should return default state', () => {
expect(getDefaultRemoteFeatureFlagControllerState()).toStrictEqual({
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
});
});
});

describe('override functionality', () => {
describe('setFlagOverride', () => {
it('sets a local override for a feature flag', () => {
const controller = createController();

controller.setFlagOverride('testFlag', true);

expect(controller.state.localOverrides).toStrictEqual({
testFlag: true,
});
});

it('overwrites existing override for the same flag', () => {
const controller = createController();

controller.setFlagOverride('testFlag', true);
Comment on lines +668 to +670
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of calling the method to prepare the state, and then calling it again to test the intended behavior, what are your thoughts on passing initial state to createControler?

Suggested change
const controller = createController();
controller.setFlagOverride('testFlag', true);
const controller = createController({
options: {
state: {
localOverrides: {
testFlag: true,
},
},
},
});

controller.setFlagOverride('testFlag', false);

expect(controller.state.localOverrides).toStrictEqual({
testFlag: false,
});
});

it('preserves other overrides when setting a new one', () => {
const controller = createController();

controller.setFlagOverride('flag1', 'value1');
Comment on lines +679 to +681
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar suggestion as above:

Suggested change
const controller = createController();
controller.setFlagOverride('flag1', 'value1');
const controller = createController({
options: {
state: {
localOverrides: {
flag1: 'value1',
},
},
},
});

controller.setFlagOverride('flag2', 'value2');

expect(controller.state.localOverrides).toStrictEqual({
flag1: 'value1',
flag2: 'value2',
});
});
});

describe('removeFlagOverride', () => {
it('removes a specific override', () => {
const controller = createController();

controller.setFlagOverride('flag1', 'value1');
controller.setFlagOverride('flag2', 'value2');
Comment on lines +693 to +696
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar suggestion as above. We are testing clearFlagOverride by calling other methods, but we have the power to set the initial state :)

Suggested change
const controller = createController();
controller.setFlagOverride('flag1', 'value1');
controller.setFlagOverride('flag2', 'value2');
const controller = createController({
options: {
state: {
flag1: 'value1',
flag2: 'value2',
},
},
});

controller.removeFlagOverride('flag1');

expect(controller.state.localOverrides).toStrictEqual({
flag2: 'value2',
});
});

it('does not affect state when clearing non-existent override', () => {
const controller = createController();

controller.setFlagOverride('flag1', 'value1');
Comment on lines +705 to +707
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar:

Suggested change
const controller = createController();
controller.setFlagOverride('flag1', 'value1');
const controller = createController({
options: {
state: {
flag1: 'value1',
},
},
});

controller.removeFlagOverride('nonExistentFlag');

expect(controller.state.localOverrides).toStrictEqual({
flag1: 'value1',
});
});
});

describe('clearAllFlagOverrides', () => {
it('removes all overrides', () => {
const controller = createController();

controller.setFlagOverride('flag1', 'value1');
controller.setFlagOverride('flag2', 'value2');
Comment on lines +718 to +721
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar:

Suggested change
const controller = createController();
controller.setFlagOverride('flag1', 'value1');
controller.setFlagOverride('flag2', 'value2');
const controller = createController({
options: {
state: {
flag1: 'value1',
flag2: 'value2',
},
},
});

controller.clearAllFlagOverrides();

expect(controller.state.localOverrides).toStrictEqual({});
});

it('does not affect state when no overrides exist', () => {
const controller = createController();

controller.clearAllFlagOverrides();

expect(controller.state.localOverrides).toStrictEqual({});
});
});

describe('integration with remote flags', () => {
Copy link
Contributor

@mcmire mcmire Dec 11, 2025

Choose a reason for hiding this comment

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

It seems that with these changes we are splitting up the tests for updateRemoteFeatureFlags across two areas: the describe block for updateRemoteFeatureFlags and this describe block. When maintaining these tests and trying to understand the intended behavior of updateRemoteFeatureFlags, I feel like this could get very confusing. What are your thoughts on moving the tests inside "integration with remote flags" so they are alongside the other updateRemoteFeatureFlags tests?

it('preserves overrides when remote flags are updated', async () => {
Copy link
Contributor

@mcmire mcmire Dec 11, 2025

Choose a reason for hiding this comment

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

There are a large number of steps represented here for what I think ought to be a simple test (given my reading of the logic). Similar to other suggestions I've made, is it enough to set up the initial state instead of having to call a few methods?

Copy link
Author

Choose a reason for hiding this comment

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

updated

const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: { remoteFlag: 'initialRemoteValue' },
});
const controller = createController({ clientConfigApiService });

// Set overrides before fetching remote flags
controller.setFlagOverride('overrideFlag', 'overrideValue');
controller.setFlagOverride('remoteFlag', 'updatedRemoteValue');

await controller.updateRemoteFeatureFlags();

// Overrides should be preserved when remote flags are updated.
expect(controller.state.localOverrides).toStrictEqual({
overrideFlag: 'overrideValue',
remoteFlag: 'updatedRemoteValue',
});
});
});
});

describe('metadata', () => {
it('includes expected state in debug snapshots', () => {
const controller = createController();
Expand All @@ -658,6 +768,8 @@ describe('RemoteFeatureFlagController', () => {
).toMatchInlineSnapshot(`
Object {
"cacheTimestamp": 0,
"localOverrides": Object {},
"rawRemoteFeatureFlags": Object {},
"remoteFeatureFlags": Object {},
}
`);
Expand All @@ -675,6 +787,8 @@ describe('RemoteFeatureFlagController', () => {
).toMatchInlineSnapshot(`
Object {
"cacheTimestamp": 0,
"localOverrides": Object {},
"rawRemoteFeatureFlags": Object {},
"remoteFeatureFlags": Object {},
}
`);
Expand All @@ -692,6 +806,8 @@ describe('RemoteFeatureFlagController', () => {
).toMatchInlineSnapshot(`
Object {
"cacheTimestamp": 0,
"localOverrides": Object {},
"rawRemoteFeatureFlags": Object {},
"remoteFeatureFlags": Object {},
}
`);
Expand All @@ -708,6 +824,7 @@ describe('RemoteFeatureFlagController', () => {
),
).toMatchInlineSnapshot(`
Object {
"localOverrides": Object {},
"remoteFeatureFlags": Object {},
}
`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ import { isVersionFeatureFlag, getVersionData } from './utils/version';

// === GENERAL ===

const controllerName = 'RemoteFeatureFlagController';
export const controllerName = 'RemoteFeatureFlagController';
export const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day

// === STATE ===

export type RemoteFeatureFlagControllerState = {
remoteFeatureFlags: FeatureFlags;
localOverrides: FeatureFlags;
rawRemoteFeatureFlags: FeatureFlags;
cacheTimestamp: number;
};

Expand All @@ -38,6 +40,18 @@ const remoteFeatureFlagControllerMetadata = {
includeInDebugSnapshot: true,
usedInUi: true,
},
localOverrides: {
includeInStateLogs: true,
persist: true,
includeInDebugSnapshot: true,
usedInUi: true,
},
rawRemoteFeatureFlags: {
includeInStateLogs: true,
persist: true,
includeInDebugSnapshot: true,
usedInUi: false,
},
cacheTimestamp: {
includeInStateLogs: true,
persist: true,
Expand All @@ -62,9 +76,27 @@ export type RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction = {
handler: RemoteFeatureFlagController['updateRemoteFeatureFlags'];
};

export type RemoteFeatureFlagControllerSetFlagOverrideAction = {
type: `${typeof controllerName}:setFlagOverride`;
handler: RemoteFeatureFlagController['setFlagOverride'];
};

export type RemoteFeatureFlagControllerRemoveFlagOverrideAction = {
type: `${typeof controllerName}:removeFlagOverride`;
handler: RemoteFeatureFlagController['removeFlagOverride'];
};

export type RemoteFeatureFlagControllerClearAllFlagOverridesAction = {
type: `${typeof controllerName}:clearAllFlagOverrides`;
handler: RemoteFeatureFlagController['clearAllFlagOverrides'];
};

export type RemoteFeatureFlagControllerActions =
| RemoteFeatureFlagControllerGetStateAction
| RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction;
| RemoteFeatureFlagControllerUpdateRemoteFeatureFlagsAction
| RemoteFeatureFlagControllerSetFlagOverrideAction
| RemoteFeatureFlagControllerRemoveFlagOverrideAction
| RemoteFeatureFlagControllerClearAllFlagOverridesAction;

export type RemoteFeatureFlagControllerStateChangeEvent =
ControllerStateChangeEvent<
Expand All @@ -89,6 +121,8 @@ export type RemoteFeatureFlagControllerMessenger = Messenger<
export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagControllerState {
return {
remoteFeatureFlags: {},
localOverrides: {},
rawRemoteFeatureFlags: {},
cacheTimestamp: 0,
};
}
Expand Down Expand Up @@ -218,6 +252,8 @@ export class RemoteFeatureFlagController extends BaseController<
this.update(() => {
return {
remoteFeatureFlags: processedRemoteFeatureFlags,
localOverrides: this.state.localOverrides,
rawRemoteFeatureFlags: remoteFeatureFlags,
cacheTimestamp: Date.now(),
};
Comment on lines 252 to 258
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps this is slightly out of scope for this PR, but instead of having to remember to update this list each time a new state property is added, it would be better to only change the state properties we want:

Suggested change
this.update(() => {
return {
remoteFeatureFlags: processedRemoteFeatureFlags,
localOverrides: this.state.localOverrides,
rawRemoteFeatureFlags: remoteFeatureFlags,
cacheTimestamp: Date.now(),
};
this.update((state) => {
state.remoteFeatureFlags = processedRemoteFeatureFlags;
state.rawRemoteFeatureFlags = remoteFeatureFlags;
state.cacheTimestamp = Date.now();
});

Copy link
Contributor

Choose a reason for hiding this comment

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

If this doesn't work, would it at least be better to spread this.state? That way if more state properties get added in the future, they don't get missed here accidentally.

});
Expand Down Expand Up @@ -291,4 +327,50 @@ export class RemoteFeatureFlagController extends BaseController<
disable(): void {
this.#disabled = true;
}

/**
* Sets a local override for a specific feature flag.
*
* @param flagName - The name of the feature flag to override.
* @param value - The override value for the feature flag.
*/
setFlagOverride(flagName: string, value: Json): void {
this.update(() => {
return {
...this.state,
localOverrides: {
...this.state.localOverrides,
[flagName]: value,
},
};
});
}

/**
* Clears the local override for a specific feature flag.
*
* @param flagName - The name of the feature flag to clear.
*/
removeFlagOverride(flagName: string): void {
const newLocalOverrides = { ...this.state.localOverrides };
delete newLocalOverrides[flagName];
this.update(() => {
return {
...this.state,
localOverrides: newLocalOverrides,
};
});
}

/**
* Clears all local feature flag overrides.
*/
clearAllFlagOverrides(): void {
this.update(() => {
return {
...this.state,
localOverrides: {},
};
});
}
}
Loading
Loading