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 @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `defaultFeatureFlags` constructor option to `RemoteFeatureFlagController` for client-side defaults as the lowest-precedence layer under processed remote flags and local overrides ([#9747](https://github.com/MetaMask/core/pull/9747))

## [5.0.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ const MOCK_BASE_VERSION = '13.10.0';
* @param options.getMetaMetricsId - Returns metaMetricsId
* @param options.clientVersion - The client version string
* @param options.prevClientVersion - The previous client version string
* @param options.defaultFeatureFlags - Client-side default feature flags
* @returns The controller and the root messenger
*/
function createController(
Expand All @@ -69,6 +70,7 @@ function createController(
getMetaMetricsId: () => string;
clientVersion: string;
prevClientVersion: string;
defaultFeatureFlags: FeatureFlags;
}> = {},
): { controller: RemoteFeatureFlagController; messenger: RootMessenger } {
const { rootMessenger, controllerMessenger } = buildMessenger();
Expand All @@ -83,6 +85,7 @@ function createController(
((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID),
clientVersion: options.clientVersion ?? MOCK_BASE_VERSION,
prevClientVersion: options.prevClientVersion,
defaultFeatureFlags: options.defaultFeatureFlags,
});
return { controller, messenger: rootMessenger };
}
Expand Down Expand Up @@ -1661,6 +1664,172 @@ describe('RemoteFeatureFlagController', () => {
});
});

describe('defaultFeatureFlags', () => {
it('initializes with defaults when no remote or persisted flags exist', () => {
const { controller } = createController({
defaultFeatureFlags: {
defaultFlag: 'defaultValue',
anotherDefault: false,
},
});

expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultFlag: 'defaultValue',
anotherDefault: false,
});
});

it('applies precedence of override over remote over default', () => {
const { controller } = createController({
state: {
remoteFeatureFlags: {
sharedFlag: 'remoteValue',
remoteOnly: true,
},
rawRemoteFeatureFlags: {
sharedFlag: 'remoteValue',
remoteOnly: true,
},
localOverrides: {
sharedFlag: 'overrideValue',
},
},
defaultFeatureFlags: {
sharedFlag: 'defaultValue',
defaultOnly: 'fromDefaults',
},
});

expect(controller.state.remoteFeatureFlags).toStrictEqual({
sharedFlag: 'overrideValue',
remoteOnly: true,
defaultOnly: 'fromDefaults',
});
});

it('keeps defaults for flags absent from a remote fetch', async () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: { remoteFlag: 'fromServer' },
});
const { controller, messenger } = createController({
clientConfigApiService,
defaultFeatureFlags: {
defaultOnly: 'fromDefaults',
remoteFlag: 'defaultRemote',
},
});

await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
);

expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultOnly: 'fromDefaults',
remoteFlag: 'fromServer',
});
});

it('restores default when removing an override with no remote value', () => {
const { controller, messenger } = createController({
defaultFeatureFlags: {
defaultFlag: 'defaultValue',
},
});

messenger.call(
'RemoteFeatureFlagController:setFlagOverride',
'defaultFlag',
'overrideValue',
);
messenger.call(
'RemoteFeatureFlagController:removeFlagOverride',
'defaultFlag',
);

expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultFlag: 'defaultValue',
});
expect(controller.state.localOverrides).toStrictEqual({});
});

it('strips default-only keys from the processed layer on init', () => {
const { controller, messenger } = createController({
state: {
// Stale baked-in default from a previous session
remoteFeatureFlags: {
defaultOnly: 'staleDefault',
remoteFlag: 'remoteValue',
},
rawRemoteFeatureFlags: {
remoteFlag: 'remoteValue',
},
},
defaultFeatureFlags: {
defaultOnly: 'currentDefault',
},
});

expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultOnly: 'currentDefault',
remoteFlag: 'remoteValue',
});

messenger.call('RemoteFeatureFlagController:clearAllFlagOverrides');

expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultOnly: 'currentDefault',
remoteFlag: 'remoteValue',
});
});

it('treats undefined rawRemoteFeatureFlags as empty when stripping defaults on init', () => {
const { controller } = createController({
state: {
remoteFeatureFlags: {
defaultOnly: 'staleDefault',
remoteFlag: 'remoteValue',
},
rawRemoteFeatureFlags: undefined,
},
defaultFeatureFlags: {
defaultOnly: 'currentDefault',
},
});

// defaultOnly is stripped from processed (not in raw) and replaced by
// the current default; remoteFlag is kept because it is not a default key.
expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultOnly: 'currentDefault',
remoteFlag: 'remoteValue',
});
});

it('treats undefined localOverrides as empty when updating the cache', async () => {
const clientConfigApiService = buildClientConfigApiService({
remoteFeatureFlags: { remoteFlag: 'fromServer' },
});
const { controller, messenger } = createController({
clientConfigApiService,
state: {
localOverrides: undefined,
},
defaultFeatureFlags: {
defaultOnly: 'fromDefaults',
},
});

await messenger.call(
'RemoteFeatureFlagController:updateRemoteFeatureFlags',
);

expect(controller.state.localOverrides).toBeUndefined();
expect(controller.state.remoteFeatureFlags).toStrictEqual({
defaultOnly: 'fromDefaults',
remoteFlag: 'fromServer',
});
});
});

describe('threshold cache cleanup', () => {
it('removes stale threshold cache entries when flags are removed from server', async () => {
jest.useRealTimers();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,8 @@ export class RemoteFeatureFlagController extends BaseController<

readonly #clientVersion: SemVerVersion;

readonly #defaultFeatureFlags: FeatureFlags;

#processedRemoteFeatureFlags: FeatureFlags = {};

/**
Expand All @@ -228,6 +230,7 @@ export class RemoteFeatureFlagController extends BaseController<
* @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.
* @param options.defaultFeatureFlags - Client-side default feature flags used as the lowest-precedence layer under processed remote flags and local overrides. Not persisted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question: what is the different between rawRemoteFeatureFlags and defaultFeatureFlags?

*/
constructor({
messenger,
Expand All @@ -238,6 +241,7 @@ export class RemoteFeatureFlagController extends BaseController<
getMetaMetricsId,
clientVersion,
prevClientVersion,
defaultFeatureFlags = {},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ability for platforms to provide feature flag defaults

}: {
messenger: RemoteFeatureFlagControllerMessenger;
state?: Partial<RemoteFeatureFlagControllerState>;
Expand All @@ -247,6 +251,7 @@ export class RemoteFeatureFlagController extends BaseController<
disabled?: boolean;
clientVersion: string;
prevClientVersion?: string;
defaultFeatureFlags?: FeatureFlags;
}) {
if (!isValidSemVerVersion(clientVersion)) {
throw new Error(
Expand All @@ -264,6 +269,23 @@ export class RemoteFeatureFlagController extends BaseController<
prevClientVersion !== clientVersion;

const localOverrides = initialState.localOverrides ?? {};
const rawRemoteFeatureFlags = initialState.rawRemoteFeatureFlags ?? {};

// Rebuild the processed remote layer from last session's effective flags by
// stripping local overrides and default-only keys (absent from raw).
const processedRemoteFeatureFlags = {
...initialState.remoteFeatureFlags,
};
for (const [flagName, overrideValue] of Object.entries(localOverrides)) {
if (processedRemoteFeatureFlags[flagName] === overrideValue) {
delete processedRemoteFeatureFlags[flagName];
}
}
for (const flagName of Object.keys(defaultFeatureFlags)) {
if (rawRemoteFeatureFlags[flagName] === undefined) {
delete processedRemoteFeatureFlags[flagName];
}
}

super({
name: controllerName,
Expand All @@ -272,7 +294,8 @@ export class RemoteFeatureFlagController extends BaseController<
state: {
...initialState,
remoteFeatureFlags: {
...initialState.remoteFeatureFlags,
...defaultFeatureFlags,
...processedRemoteFeatureFlags,
...localOverrides,
},
cacheTimestamp: hasClientVersionChanged
Expand All @@ -281,15 +304,8 @@ export class RemoteFeatureFlagController extends BaseController<
},
});

this.#processedRemoteFeatureFlags = {
...initialState.remoteFeatureFlags,
};
for (const [flagName, overrideValue] of Object.entries(localOverrides)) {
if (this.#processedRemoteFeatureFlags[flagName] === overrideValue) {
delete this.#processedRemoteFeatureFlags[flagName];
}
}

this.#defaultFeatureFlags = defaultFeatureFlags;
this.#processedRemoteFeatureFlags = processedRemoteFeatureFlags;
this.#fetchInterval = fetchInterval;
this.#disabled = disabled;
this.#clientConfigApiService = clientConfigApiService;
Expand All @@ -302,6 +318,25 @@ export class RemoteFeatureFlagController extends BaseController<
);
}

Comment thread
cursor[bot] marked this conversation as resolved.
/**
* Computes effective feature flags with precedence:
* defaults < processed remote < local overrides.
*
* @param processedRemote - The processed remote feature flags.
* @param localOverrides - Local overrides. Defaults to current state overrides.
* @returns The effective feature flags.
*/
#getEffectiveFeatureFlags(
processedRemote: FeatureFlags,
localOverrides: FeatureFlags = this.state.localOverrides ?? {},
): FeatureFlags {
return {
...this.#defaultFeatureFlags,
...processedRemote,
...localOverrides,
};
}

/**
* Checks if the cached feature flags are expired based on the fetch interval.
*
Expand Down Expand Up @@ -388,10 +423,9 @@ export class RemoteFeatureFlagController extends BaseController<
this.update(() => {
return {
...this.state,
remoteFeatureFlags: {
...redactedProcessedFlags,
...this.state.localOverrides,
},
remoteFeatureFlags: this.#getEffectiveFeatureFlags(
redactedProcessedFlags,
),
rawRemoteFeatureFlags: redactMetaMetricsIds(remoteFeatureFlags),
cacheTimestamp: Date.now(),
thresholdCache: updatedThresholdCache,
Expand Down Expand Up @@ -543,10 +577,10 @@ export class RemoteFeatureFlagController extends BaseController<
return {
...this.state,
localOverrides,
remoteFeatureFlags: {
...this.state.remoteFeatureFlags,
[flagName]: value,
},
remoteFeatureFlags: this.#getEffectiveFeatureFlags(
this.#processedRemoteFeatureFlags,
localOverrides,
),
};
});
}
Expand All @@ -560,20 +594,14 @@ export class RemoteFeatureFlagController extends BaseController<
const newLocalOverrides = { ...this.state.localOverrides };
delete newLocalOverrides[flagName];

const remoteFeatureFlags = { ...this.state.remoteFeatureFlags };
const processedValue = this.#processedRemoteFeatureFlags[flagName];

if (processedValue === undefined) {
delete remoteFeatureFlags[flagName];
} else {
remoteFeatureFlags[flagName] = processedValue;
}

this.update(() => {
return {
...this.state,
localOverrides: newLocalOverrides,
remoteFeatureFlags,
remoteFeatureFlags: this.#getEffectiveFeatureFlags(
this.#processedRemoteFeatureFlags,
newLocalOverrides,
),
};
});
}
Expand All @@ -586,7 +614,10 @@ export class RemoteFeatureFlagController extends BaseController<
return {
...this.state,
localOverrides: {},
remoteFeatureFlags: { ...this.#processedRemoteFeatureFlags },
remoteFeatureFlags: this.#getEffectiveFeatureFlags(
this.#processedRemoteFeatureFlags,
{},
),
};
});
}
Expand Down
4 changes: 4 additions & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add optional `instanceOptions.remoteFeatureFlagController.defaultFeatureFlags` to pass client-side default feature flags through to `RemoteFeatureFlagController` ([#9747](https://github.com/MetaMask/core/pull/9747))

### Changed

- Bump `@metamask/transaction-controller` from `^69.4.0` to `^69.5.0` ([#9780](https://github.com/MetaMask/core/pull/9780))
Expand Down
Loading