-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: implement remote feature flag controller #12427
base: main
Are you sure you want to change the base?
Conversation
allow remote feature flag controller symlink to get bundled by metro
CLA Signature Action: All authors have signed the CLA. You may need to manually re-run the blocking PR check if it doesn't pass in a few minutes. |
5e9991d
to
6f8d208
Compare
74b9b88
to
9a67929
Compare
const path = require("path"); | ||
|
||
const featureFlagModuleDir = path.resolve(__dirname, "../../core/feature-flags/packages/remote-feature-flag-controller"); | ||
|
||
const extraNodeModules = { | ||
"@metamask/remote-feature-flag-controller": featureFlagModuleDir, | ||
}; | ||
|
||
const watchFolders = [ | ||
featureFlagModuleDir, | ||
]; | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be removed once controller has been published to NPM
Will revert 00a3bd1
watchFolders, | ||
resolver: { | ||
assetExts: assetExts.filter((ext) => ext !== 'svg'), | ||
sourceExts: [...sourceExts, 'svg', 'cjs', 'mjs'], | ||
resolverMainFields: ['sbmodern', 'react-native', 'browser', 'main'], | ||
extraNodeModules: new Proxy (extraNodeModules, { | ||
get: (target, name) => | ||
name in target ? target[name] : path.join(process.cwd(), `node_modules/${name}`), | ||
}), | ||
unstable_enableSymlinks: true, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To be removed once controller has been published to NPM
Will revert 00a3bd1
@@ -175,6 +175,7 @@ | |||
"@metamask/react-native-payments": "^2.0.0", | |||
"@metamask/react-native-search-api": "1.0.1", | |||
"@metamask/react-native-webview": "^14.0.4", | |||
"@metamask/remote-feature-flag-controller": "link:../../core/feature-flags/packages/remote-feature-flag-controller", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
update once controller has been published to NPM
yarn.lock
Outdated
"@metamask/remote-feature-flag-controller@link:../../core/feature-flags/packages/remote-feature-flag-controller": | ||
version "0.0.0" | ||
uid "" | ||
|
||
"@metamask/[email protected]", "@metamask/rpc-errors@^6.0.0", "@metamask/rpc-errors@^6.2.1", "@metamask/rpc-errors@^6.3.1", "@metamask/rpc-errors@^7.0.0", "@metamask/rpc-errors@^7.0.1": |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
update once controller has been published to NPM
Bitrise❌❌❌ Commit hash: ebc940e Note
Tip
|
app/core/Engine/Engine.ts
Outdated
@@ -933,8 +945,7 @@ export class Engine { | |||
encryptor, | |||
getMnemonic: getPrimaryKeyringMnemonic.bind(this), | |||
getFeatureFlags: () => ({ | |||
disableSnaps: | |||
store.getState().settings.basicFunctionalityEnabled === false, | |||
disableSnaps: !isBasicFunctionalityEnabled, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cautious warning.
Before: it would recall getState()
and pick out the basicFunctionalityEnabled
property.
After: Now it is a constant. Meaning that is won't re-fire getState()
to get the latest value.
So hypothetical, if we turned off basic functionality, and this snap getFeatureFlags
is executed, it won't get the up-to-date field from state, but instead the constant when the app was initialised.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could make the constant a function instead so we can evaluate lazily (once called) instead of just once at app init?
- const isBasicFunctionalityEnabled = ...
+ const isBasicFunctionalityEnabled = () => ...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great catch, thank you!
addressed in 8014b45
This reverts commit b4f9cc8.
it('should return feature flag object', () => { | ||
const { | ||
appMinimumBuild, | ||
appleMinimumOS, | ||
androidMinimumAPIVersion, | ||
} = selectMobileMinimumVersions(mockedState as RootState); | ||
expect(appleMinimumOS).toBeDefined(); | ||
expect(appMinimumBuild).toBeDefined(); | ||
expect(androidMinimumAPIVersion).toBeDefined(); | ||
}); | ||
it('should return fallback values', () => { | ||
const { | ||
appMinimumBuild, | ||
appleMinimumOS, | ||
androidMinimumAPIVersion, | ||
} = selectMobileMinimumVersions(mockedEmptyFlagsState as RootState); | ||
expect(appMinimumBuild).toBe(1024); | ||
expect(appleMinimumOS).toBe(1025); | ||
expect(androidMinimumAPIVersion).toBe(1026); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Test feature flag state selectors separate
replace hardcoded numbers with mocked values.
} from './utils'; | ||
|
||
|
||
const init = ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
add remote feature flag controller init
unit tests
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this a TODO?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. Yes unit tests please!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First pass of review
.github/CODEOWNERS
Outdated
app/core/Analytics/ @MetaMask/mobile-platform | ||
app/util/metrics/ @MetaMask/mobile-platform | ||
app/components/hooks/useMetrics/ @MetaMask/mobile-platform | ||
app/selectors/featureFlagController/index.ts @MetaMask/mobile-platform |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
S: maybe you can make the team own the whole app/selectors/featureFlagController directory instead of individual files?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We want app/selectors/featureFlagController/<featureFlagName>
to be owned by other teams
I'll update the CODEOWNERS
syntax to match files like featureFlagController/index.ts
but no further nested files
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
addressed on b784e89
(state: RootState) => state.featureFlags, | ||
); | ||
|
||
const appMinimumBuild = useSelector((state: RootState) => selectAppMinimumBuild(state)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: not sure if this change makes the mock in the test obsolete and how this test doesn't need any changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch, the tests for this hook need to be updated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm surprised the test is passing given the changes in the hook... so maybe rewrite the test in a more robust way. A change like this should make it fail otherwise it probably means it's not testing anything but the mocks...
} from './utils'; | ||
|
||
|
||
const init = ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
is this a TODO?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for this awesome work @joaoloureirop!
Added some guideline suggestions and a few questions on some code parts I'm not sure to fully understand.
@@ -266,6 +268,10 @@ export class Engine { | |||
) { | |||
this.controllerMessenger = new ExtendedControllerMessenger(); | |||
|
|||
// Basic Functionality toggle defaults to true | |||
const getBasicFunctionalityToggleState = () => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
S: should we use a selector here?
initialState, | ||
controllerMessenger: this.controllerMessenger, | ||
fetchFunction, | ||
disabled: getBasicFunctionalityToggleState() === false |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: why testing the exact boolean false equality here?
If the value is true, it's true.
If the value is false, it's true.
If the value eis anything like undefined or null, it will be true because of the getBasicFunctionalityToggleState default.
So if we have a false here, it's necessarily a real false as a value in the state. So the following would be enough:
disabled: getBasicFunctionalityToggleState() === false | |
disabled: !getBasicFunctionalityToggleState() |
and same on line 949
} from './utils'; | ||
|
||
|
||
const init = ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks. Yes unit tests please!
}); | ||
|
||
|
||
it('should return feature flag initial state', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it('should return feature flag initial state', () => { | |
it('returns feature flag initial state', () => { |
jest.clearAllMocks(); | ||
}); | ||
|
||
it('should return feature flag object', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it('should return feature flag object', () => { | |
it('returns feature flag object', () => { |
expect(appMinimumBuild).toBeDefined(); | ||
expect(androidMinimumAPIVersion).toBeDefined(); | ||
}); | ||
it('should return fallback values', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it('should return fallback values', () => { | |
it('returns fallback values', () => { |
appMinimumBuild: 1243, | ||
appleMinimumOS: 6, | ||
androidMinimumAPIVersion: 21, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
S: should we have this in a constants file?
expect(appMinimumBuild).toBeDefined(); | ||
expect(androidMinimumAPIVersion).toBeDefined(); | ||
}); | ||
it('should return fallback values', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Q: we only have this test that tests the fallback values. How do we test the normal values? Could we have a mocked test for this?
]; | ||
|
||
for (const { errorMessage, scenario, state } of invalidStates) { | ||
it(`should capture exception if ${scenario}`, async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it(`should capture exception if ${scenario}`, async () => { | |
it(`captures exception if ${scenario}`, async () => { |
}); | ||
} | ||
|
||
it('remove featureFlags property from redux state', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
it('remove featureFlags property from redux state', async () => { | |
it('removes featureFlags property from redux state', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Left some comments
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
To keep things consistent, let's follow the pattern that resembles that of what is currently in Engine/controllers/accounts
The directory name RemoteFeatureFlagController
is fine. We can keep the index file that you have and use it to re-export utils and anything else in the future.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In this case, it's debatable whether or not we should just put all of the functions in index or utils file. IMO, creating the utils file and re-exporting via index allows us to be more flexible in the future, where index can export other things such as constants, etc.
} from './utils'; | ||
|
||
|
||
const init = ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rename to createRemoteFeatureFlagsController
const init = ({ | |
const createRemoteFeatureFlagsController = ({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move into utils file
allowedEvents: [], | ||
}), | ||
state: { | ||
...initialState.RemoteFeatureFlagController |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No need to spread in an object. Just assign the value. Also pass initialState.RemoteFeatureFlagController
into initialState instead of deconstructing from here
}: RemoteFeatureFlagInitParamTypes) => { | ||
|
||
const remoteFeatureFlagController = new RemoteFeatureFlagController({ | ||
messenger: controllerMessenger.getRestricted({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Move messenger creation back into Engine file since we as a platform team (at least) need to be aware of messenger related changes. You can reference how the accounts controller is set up.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discussed with Mark a few days ago - Here's the PR that updated the accounts controller side - #12416
import { ControllerMessenger, EngineState } from '../../types'; | ||
|
||
export interface RemoteFeatureFlagInitParamTypes { | ||
initialState: Partial<EngineState>; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
initialState: Partial<EngineState>; | |
initialState: RemoteFeatureFlagControllerState; |
|
||
export interface RemoteFeatureFlagInitParamTypes { | ||
initialState: Partial<EngineState>; | ||
controllerMessenger: ControllerMessenger, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
controllerMessenger: ControllerMessenger, | |
controllerMessenger: RemoteFeatureFlagControllerMessenger, |
@@ -87,11 +87,6 @@ const createStoreAndPersistor = async () => { | |||
basicFunctionalityEnabled: | |||
store.getState().settings.basicFunctionalityEnabled, | |||
}); | |||
// Fetch feature flags only if basic functionality is enabled |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's confirm that the code above this is also no longer needed. Rehydration should automatically populate it already
if (hasProperty(state, 'featureFlags')) { | ||
delete state.featureFlags; | ||
} | ||
return state; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this migration to include the default state for RemoteFeatureFlagController
to be safe
Description
Introduction of
@metamask/remote-feature-flag-controller
library.Remote feature flag controller manages data flow, retry policy, and cache expiry.
The controller consumer manages default values, data persistency, and data distribution.
See ADR for a in-depth description
Technical decisions
Controller init on
Engine.ts
with feature flags fetching only on cold app starts.@metamask/remote-feature-flag-controller
is only asked to fetch feature flags after its init inEngine.ts
. Ensures feature flags are only fetched on cold app starts.Fallback values
Default values are used when remote feature flags are undefined.
The fallback mechanism is implemented by each feature flag selector
app/selectors/featureFlagsController/<featureFlagName>
In this PR we include
minimumAppVersion
selector, which manages the LD feature flagmobile-minimum-versions
One selector per each feature flag
LD feature flags can be boolean, number, strings on JSON objects.
We've decided to have each feature flag with its own selector
A feature flag selector contains:
This architecture offers a clear separation between each feature flag and the logic behind it, allowing easier manipulation.
Code owners are assigned to each feature flag.
Related issues
Fixes: https://github.com/MetaMask/mobile-planning/issues/2054
Fixes: https://github.com/MetaMask/mobile-planning/issues/1975
Manual testing steps
Screenshots/Recordings
Before
After
Pre-merge author checklist
Pre-merge reviewer checklist