Repository navigation
Feature Flags
Feature flags in Firefox iOS control whether a feature is enabled in the application. The system is built around three components, each with a distinct responsibility:
| Component | Protocol | Purpose |
|---|---|---|
FeatureFlagsProvider |
FeatureFlagProviding |
Checks whether a Nimbus feature flag is enabled. Supports debug overrides on beta/developer builds. |
UserFeaturePreferenceManager |
UserFeaturePreferring |
Reads and writes user-facing preferences (e.g. "show Firefox Suggest"). Falls back to Nimbus when no user preference is set. |
CoreBuildFlags |
(static enum, no protocol) | Compile-time flags gated on build channel (#if MOZ_CHANNEL_*). Used for developer-only concerns like mock data or staging endpoints - not for user-facing features. |
All Nimbus-backed components share a common backend: NimbusFeatureFlagLayer, which wraps FxNimbus and translates each FeatureFlagID into a Nimbus config lookup.
Nimbus remote config
│
▼
NimbusFeatureFlagLayer (implements NimbusFeatureFlagLayerProviding)
│
┌─────────────┴─────────────┐
▼ ▼
FeatureFlagsProvider UserFeaturePreferenceManager
│ │
▼ ▼
AppContainer (DI) (consumed via protocol conformance)
-
"Is this feature shipped / enabled by Nimbus?" ->
featureFlagProvider.isEnabled(_:) -
"What did the user choose for this setting?" ->
userPreferences.getPreferenceFor(_:)(bool) or the typed properties (searchBarPosition,startAtHomeSetting) -
"Is this a developer/build-channel concern?" ->
CoreBuildFlags(e.g.CoreBuildFlags.isUsingMockData)
It may be the case that you might need to use both the feature flag provider and a user preference in a check.
Conform to FeatureFlaggable to get access:
class BibimbapViewModel: FeatureFlaggable {
var isNewMenuAvailable: Bool {
featureFlagsProvider.isEnabled(.newBibimbapMenu)
}
}FeatureFlaggable resolves FeatureFlagProviding from AppContainer.shared automatically.
Conform to UserFeaturePreferenceProvider:
class SomeSettingsController: UserFeaturePreferenceProvider {
func updateFirefoxSuggest() {
let isEnabled = userPreferences.getPreferenceFor(.firefoxSuggestFeature)
// ...
}
func saveSearchBarChoice(_ position: SearchBarPosition) {
userPreferences.setSearchBarPosition(position)
}
}In most cases, Bool preferences fall back to the Nimbus default when no user preference has been saved. However, it is possible to hardcode a fallback that doesn't use Nimbus for it's default value, if these need to be different. Typed preferences (search bar position, start-at-home) have their own getters/setters.
These are simple static properties — no DI or protocols needed:
if CoreBuildFlags.isUsingMockData {
// developer-channel-only behavior
}CoreBuildFlags should only be used for developer/infrastructure concerns that are never user-facing.
There may be special cases where accessing something through a protocol is not possibe. Then, it is recommended to access the dependency directly:
let prefs: UserFeaturePreferring = AppContainer.shared.resolve()
prefs.setPreferenceFor(.sentFromFirefox, to: true)The preferred method of adding a feature flag is using the fxios utility. Please see its documentation for how to do so. A simple fxios nimbus --help is a great place to start.
This section will walk you through how to manually add a hypothetical newBibimbapMenu flag.
After having created nimbus file, if needed, then, in the appropriate .fml.yaml file the feature is found in, add a variable indicating the feature status:
enabled:
description: >
Whether or not the feature is enabled.
type: Boolean
default: falseRebuild the application so FxNimbus generates the new feature accessor.
In Client/FeatureFlags/FeatureFlagID.swift, add the new case in alphabetical order:
enum FeatureFlagID: String, CaseIterable {
// ...
case newBibimbapMenu
// ...
}If the feature supports user-togglable preferences, meaning, that that feature can be turned on/off by the user, add it to the userPrefsKey computed property:
var userPrefsKey: String? {
switch self {
case .newBibimbapMenu: return PrefsKeys.FeatureFlags.NewBibimbapMenu
// ...
}
}If the feature should be toggleable in the debug menu (beta/developer builds), add it to the debugKey computed property:
var debugKey: String? {
switch self {
case .newBibimbapMenu:
return rawValue + PrefsKeys.FeatureFlags.DebugSuffixKey
// ...
}
}In Client/Nimbus/NimbusFeatureFlagLayer.swift, add a case to the checkNimbusConfigFor(_:) switch and a corresponding private method:
// In checkNimbusConfigFor(_:)
case .newBibimbapMenu:
return checkBibimbapFeature()
// Private method
private func checkBibimbapFeature() -> Bool {
return nimbus.features.bibimbapFeature.value().enabled
}If you added a debugKey in step 2, add a toggle in Client/Frontend/Settings/Main/Debug/FeatureFlags/FeatureFlagsDebugViewController.swift inside the generateFeatureFlagToggleSettings() method:
FeatureFlagsBoolSetting(
with: .newBibimbapMenu,
titleText: format(string: "Bibimbap Menu"),
statusText: format(string: "Toggle the new bibimbap menu")
) { [weak self] _ in
self?.reloadView()
}FeatureFlagsBoolSetting reads from featureFlagsProvider.isEnabled(_:) and writes via featureFlagsProvider.setDebugOverride(_:to:).
If this feature has a user-facing setting (you added a userPrefsKey), use UserFeaturePreferring to read/write that preference. Add a corresponding PrefsKeys entry if one doesn't exist.
A Feature Flags section is available in the debug menu on beta and developer builds only. It lets you override any flag that has a debugKey defined.
Debug overrides are checked before the Nimbus backend in FeatureFlagsProvider.isEnabled(_:):
func isEnabled(_ flag: FeatureFlagID) -> Bool {
#if MOZ_CHANNEL_beta || MOZ_CHANNEL_developer
if let debugKey = flag.debugKey,
let override = prefs.boolForKey(debugKey) {
return override
}
#endif
return backendLayer.checkNimbusConfigFor(flag)
}This means a debug override will take precedence over whatever Nimbus returns, but only on non-release builds.
- Ensure the
FeatureFlagIDcase has adebugKey(see step 2 of "Adding a Feature Flag Manually" above). - Add a
FeatureFlagsBoolSettingentry inFeatureFlagsDebugViewController.generateFeatureFlagToggleSettings(). - Run the app on a beta/developer build, navigate to secret settings, and verify the toggle appears and works.
The feature flags system is designed around protocol-driven dependency injection. Every component depends on a protocol (NimbusFeatureFlagLayerProviding, FeatureFlagProviding, UserFeaturePreferring), so tests can substitute lightweight mocks without touching Nimbus at all.
The recommended way to test code that depends on feature flags, is to mock the backend layer. Instead of configuring Nimbus, inject a MockNimbusFeatureFlagLayer that implements NimbusFeatureFlagLayerProviding with a simple Set<FeatureFlagID>:
final class MockNimbusFeatureFlagLayer: NimbusFeatureFlagLayerProviding, @unchecked Sendable {
var enabledFlags: Set<FeatureFlagID> = []
func checkNimbusConfigFor(_ featureID: FeatureFlagID) -> Bool {
enabledFlags.contains(featureID)
}
func checkStartAtHomeConfiguration() -> StartAtHome {
return .afterFourHours
}
}Then you have two options, depending on your needs: you can either test using the
DependencyHelperMock, or construct the provider directly in your test:
Many test suites use DependencyHelperMock.bootstrapDependencies(...) to register mocks into AppContainer. This helper accepts injectedFeatureFlagsProvider and injectedUserFeaturePreferences parameters. To construct these, you'll need to inject them with the MockNimbusFeatureFlagLayer:
override func setUp() async throws {
try await super.setUp()
let profile = MockProfile()
let mockNimbusLayer = MockNimbusFeatureFlagLayer()
let mockFeatureFlagProvider = MockFeatureFlagProvider(prefs: profile.prefs, backendLayer: mockNimbusLayer)
let mockUserFeaturePreferences = MockUserFeaturePreferenceManager(prefs: profile.prefs, backendLayer: mockNimbusLayer)
DependencyHelperMock().bootstrapDependencies(
injectedProfile: profile,
injectedFeatureFlagProvider: mockFeatureFlagProvider,
injectedUserFeaturePreferences: mockUserFeaturePreferences
)
}
override func tearDown() async throws {
DependencyHelperMock().reset()
mockUserFeaturePreferences = nil
mockFeatureFlagProvider = nil
mockNimbusLayer = nil
profile = nil
try await super.tearDown()
}
func testSomething() {
// setting up feature flags in the test for specific things to test
mockNimbusLayer.enabledFlags = [.translation]This is useful for integration-style tests where the code under test resolves its dependencies from AppContainer rather than accepting them as constructor arguments.
You can also directly construct the provider in your tests. This is really only preferred if you need direct access to the provider in tests.
final class FeatureFlagsProviderTests: XCTestCase {
private var prefs: MockProfilePrefs!
private var mockLayer: MockNimbusFeatureFlagLayer!
private var subject: FeatureFlagsProvider!
override func setUp() {
super.setUp()
prefs = MockProfilePrefs()
mockLayer = MockNimbusFeatureFlagLayer()
subject = FeatureFlagsProvider(prefs: prefs, backendLayer: mockLayer)
}
func testTranslationEnabled() {
mockLayer.enabledFlags = [.translation]
XCTAssertTrue(subject.isEnabled(.translation))
}
func testTranslationDisabled() {
mockLayer.enabledFlags = []
XCTAssertFalse(subject.isEnabled(.translation))
}
}The same pattern works for UserFeaturePreferenceManager - pass a MockNimbusFeatureFlagLayer and a MockProfilePrefs to control both the Nimbus fallback and the stored user preferences.
In some cases you may need to verify behavior that depends on specific Nimbus feature configurations (not just enabled/disabled), such as experiment treatment arms or multi-field feature configs. In these situations, you can override the Nimbus feature directly using the FxNimbus.shared.features.<feature>.with API:
private func setupNimbusTrendingSearchesTesting(isEnabled: Bool) {
FxNimbus.shared.features.trendingSearchesFeature.with { _, _ in
return TrendingSearchesFeature(
enabled: isEnabled,
maxSuggestions: 5
)
}
}This bypasses remote config and forces the Nimbus feature to return the exact struct you provide. Use this approach when:
- You need to test a specific combination of Nimbus feature fields (not just a bool toggle).
- You are testing the
NimbusFeatureFlagLayeritself or code that reads Nimbus config directly. - You want to verify integration with the real Nimbus plumbing end-to-end.
For most unit tests, prefer injecting a mock backend layer - it is simpler, faster, and does not couple your tests to Nimbus internals or generated config types.