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,116 @@ 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,
},
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('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.
Comment thread
DDDDDanica marked this conversation as resolved.
*/
constructor({
messenger,
Expand All @@ -238,6 +241,7 @@ export class RemoteFeatureFlagController extends BaseController<
getMetaMetricsId,
clientVersion,
prevClientVersion,
defaultFeatureFlags = {},
Comment thread
Cal-L marked this conversation as resolved.
}: {
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 @@ -265,14 +270,26 @@ export class RemoteFeatureFlagController extends BaseController<

const localOverrides = initialState.localOverrides ?? {};

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

super({
name: controllerName,
metadata: remoteFeatureFlagControllerMetadata,
messenger,
state: {
...initialState,
remoteFeatureFlags: {
...initialState.remoteFeatureFlags,
...defaultFeatureFlags,
...processedRemoteFeatureFlags,
...localOverrides,
},
cacheTimestamp: hasClientVersionChanged
Expand All @@ -281,15 +298,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 +312,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 +417,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 +571,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 +588,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 +608,10 @@ export class RemoteFeatureFlagController extends BaseController<
return {
...this.state,
localOverrides: {},
remoteFeatureFlags: { ...this.#processedRemoteFeatureFlags },
remoteFeatureFlags: this.#getEffectiveFeatureFlags(
this.#processedRemoteFeatureFlags,
{},
),
};
});
}
Expand Down
1 change: 1 addition & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add optional `instanceOptions.remoteFeatureFlagController.defaultFeatureFlags` to pass client-side default feature flags through to `RemoteFeatureFlagController` ([#9747](https://github.com/MetaMask/core/pull/9747))
- **BREAKING:** Wire `ShieldController` and `ShieldApiService` into the default wallet initialization ([#9616](https://github.com/MetaMask/core/pull/9616))
- Adds an optional `shieldApiService` slot to `instanceOptions` for optional `env` (defaults to production), polling, and policy configuration. `fetchFunction` defaults to `globalThis.fetch` via `ShieldApiService`.
- `shieldController` instance options are now controller-only (`transactionHistoryLimit`, `coverageHistoryLimit`, `normalizeSignatureRequest`).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,24 @@ describe('remoteFeatureFlagController', () => {
).not.toHaveBeenCalled();
});

it('forwards defaultFeatureFlags to the controller', () => {
const messenger =
remoteFeatureFlagController.getMessenger(getRootMessenger());

const instance = remoteFeatureFlagController.init({
state: undefined,
messenger,
options: {
clientConfigApiService: getClientConfigApiService(),
defaultFeatureFlags: { defaultFlag: true },
},
});

expect(instance.state.remoteFeatureFlags).toStrictEqual({
defaultFlag: true,
});
});

it('exposes its state through the root messenger', () => {
const rootMessenger = getRootMessenger();
const messenger = remoteFeatureFlagController.getMessenger(rootMessenger);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const remoteFeatureFlagController: InitializationConfiguration<
prevClientVersion: options.prevClientVersion,
fetchInterval: options.fetchInterval,
disabled: options.disabled,
defaultFeatureFlags: options.defaultFeatureFlags,
}),
getMessenger: (parent) =>
new Messenger({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,9 @@ export type RemoteFeatureFlagControllerInstanceOptions = {
* `enable`/`disable` actions.
*/
disabled?: RemoteFeatureFlagControllerOptions['disabled'];
/**
* Client-side default feature flags used as the lowest-precedence layer
* under processed remote flags and local overrides. Not persisted.
*/
defaultFeatureFlags?: RemoteFeatureFlagControllerOptions['defaultFeatureFlags'];
};