Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions packages/remote-feature-flag-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Bump `@metamask/controller-utils` from `^11.17.0` to `^11.18.0` ([#7583](https://github.com/MetaMask/core/pull/7583))

### Fixed

- Add optional `prevClientVersion` constructor argument to invalidate cached flags when the client version changes ([#7827](https://github.com/MetaMask/core/pull/7827))

## [4.0.0]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ function createController(
disabled: boolean;
getMetaMetricsId: () => string;
clientVersion: string;
prevClientVersion: string;
}> = {},
): RemoteFeatureFlagController {
return new RemoteFeatureFlagController({
Expand All @@ -77,6 +78,7 @@ function createController(
options.getMetaMetricsId ??
((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID),
clientVersion: options.clientVersion ?? MOCK_BASE_VERSION,
prevClientVersion: options.prevClientVersion,
});
}

Expand Down Expand Up @@ -186,6 +188,94 @@ describe('RemoteFeatureFlagController', () => {
expect(controller.state.remoteFeatureFlags).toStrictEqual(MOCK_FLAGS);
});

it('resets cache and fetch when clientVersion changes', async () => {
const versionedFlags = {
exploreFeature: {
versions: {
'7.62.0': { enabled: false },
'7.64.0': { enabled: true },
},
},
};
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: versionedFlags,
});

/**
* Test Util - Arrange, Act, Assert for client version change
*
* @param opts - The options for the arrangeActAssertClientVersionChange test
* @param opts.clientVersion - The client version to use
* @param opts.prevClientVersion - The previous client version to use
* @param opts.controllerState - The controller state to use
* @param opts.expectedFeatureFlagState - The expected feature flag state
* @returns The controller state after the arrangeActAssertClientVersionChange test
*/
const arrangeActAssertClientVersionChange = async (opts: {
clientVersion: string;
prevClientVersion?: string;
controllerState?: RemoteFeatureFlagControllerState;
expectedFeatureFlagState: boolean;
}): Promise<RemoteFeatureFlagControllerState> => {
const {
clientVersion,
prevClientVersion,
controllerState,
expectedFeatureFlagState,
} = opts;

// Arrange - setup controller
jest.clearAllMocks();
const controller = createController({
clientConfigApiService,
clientVersion,
prevClientVersion,
state: controllerState,
});

// Assert - controller cache is set to 0 (either by initial state or reset by client version change)
expect(controller.state.cacheTimestamp).toBe(0);

// Act / Assert - We should make a network request and update the cache (cache is reset to 0)
await controller.updateRemoteFeatureFlags();
expect(
clientConfigApiService.fetchRemoteFeatureFlags,
).toHaveBeenCalledTimes(1);

// Act / Assert - subsequent fetches should not call network again (cache is populated)
await controller.updateRemoteFeatureFlags();
expect(
clientConfigApiService.fetchRemoteFeatureFlags,
).toHaveBeenCalledTimes(1);

// Assert - flag state is as expected
expect(
controller.state.remoteFeatureFlags.exploreFeature,
).toStrictEqual({
enabled: expectedFeatureFlagState,
});

// Assert - cache timestamp has been updated
expect(controller.state.cacheTimestamp).toBeGreaterThan(0);

return controller.state;
};

// Test: New controller initialized (no previous state)
const controllerState = await arrangeActAssertClientVersionChange({
clientVersion: '7.62.0',
expectedFeatureFlagState: false,
});

// Test: Updated controller with a previous client version
await arrangeActAssertClientVersionChange({
clientVersion: '7.64.0',
prevClientVersion: '7.62.0',
controllerState,
expectedFeatureFlagState: true,
});
});

it('makes a network request to fetch when cache is expired, and then updates the cache', async () => {
const clientConfigApiService = buildClientConfigApiService({
cacheTimestamp: Date.now() - 10000,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export class RemoteFeatureFlagController extends BaseController<
* @param options.disabled - Determines if the controller should be disabled initially. Defaults to false.
* @param options.getMetaMetricsId - Returns metaMetricsId.
* @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string.
* @param options.prevClientVersion - The previous client version for feature flag cache invalidation.
*/
constructor({
messenger,
Expand All @@ -177,6 +178,7 @@ export class RemoteFeatureFlagController extends BaseController<
disabled = false,
getMetaMetricsId,
clientVersion,
prevClientVersion,
Copy link
Contributor Author

@Prithpal-Sooriya Prithpal-Sooriya Feb 4, 2026

Choose a reason for hiding this comment

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

Now stateless. We can pass in a constructor parameter for the previous version.

Example on clients:

// the persisted current app ver is the new prevClientVersion we will pass into the `RemoteFeatureFlagController`.
const prevClientVersion =
    persistedState.AppMetadataController?.currentAppVersion;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll spin up preview builds on mobile and extension to validate.

}: {
messenger: RemoteFeatureFlagControllerMessenger;
state?: Partial<RemoteFeatureFlagControllerState>;
Expand All @@ -185,20 +187,32 @@ export class RemoteFeatureFlagController extends BaseController<
fetchInterval?: number;
disabled?: boolean;
clientVersion: string;
prevClientVersion?: string;
}) {
if (!isValidSemVerVersion(clientVersion)) {
throw new Error(
`Invalid clientVersion: "${clientVersion}". Must be a valid 3-part SemVer version string`,
);
}

const initialState: RemoteFeatureFlagControllerState = {
...getDefaultRemoteFeatureFlagControllerState(),
...state,
};

const hasClientVersionChanged =
isValidSemVerVersion(prevClientVersion) &&
prevClientVersion !== clientVersion;

super({
name: controllerName,
metadata: remoteFeatureFlagControllerMetadata,
messenger,
state: {
...getDefaultRemoteFeatureFlagControllerState(),
...state,
...initialState,
cacheTimestamp: hasClientVersionChanged
? 0
: initialState.cacheTimestamp,
},
});

Expand Down
Loading