Skip to content

Feature Flags

roux g. buciu edited this page Apr 30, 2026 · 32 revisions

Feature Flags

Overview

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.

Data Flow

           Nimbus remote config
                     │
                     ▼
          NimbusFeatureFlagLayer  (implements NimbusFeatureFlagLayerProviding)
                     │
       ┌─────────────┴─────────────┐
       ▼                           ▼
FeatureFlagsProvider    UserFeaturePreferenceManager
               │           │
               ▼           ▼
             AppContainer (DI)  (consumed via protocol conformance)

When to Use Which

  • "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.

Using Feature Flags

Nimbus Feature Flags (FeatureFlagProviding)

Conform to FeatureFlaggable to get access:

class BibimbapViewModel: FeatureFlaggable {
    var isNewMenuAvailable: Bool {
        featureFlagsProvider.isEnabled(.newBibimbapMenu)
    }
}

FeatureFlaggable resolves FeatureFlagProviding from AppContainer.shared automatically.

User Preferences (UserFeaturePreferring)

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.

Core Build Flags

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.

Special Cases

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)

Adding a New Feature Flag

Using the fxios Utility

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.

Adding a Feature Flag Manually

This section will walk you through how to manually add a hypothetical newBibimbapMenu flag.

1. Define the Nimbus feature

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: false

Rebuild the application so FxNimbus generates the new feature accessor.

2. Add a case to FeatureFlagID

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
    // ...
    }
}

3. Add the Nimbus check in NimbusFeatureFlagLayer

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
}

4. (Optional) Add a debug menu toggle

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:).

5. (Optional) Wire up a user preference

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.

Feature Flags Debug Menu

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.

Adding a new toggle to the debug menu

  1. Ensure the FeatureFlagID case has a debugKey (see step 2 of "Adding a Feature Flag Manually" above).
  2. Add a FeatureFlagsBoolSetting entry in FeatureFlagsDebugViewController.generateFeatureFlagToggleSettings().
  3. Run the app on a beta/developer build, navigate to secret settings, and verify the toggle appears and works.

Testing & Injection

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:

Injecting via DependencyHelperMock

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.

Constructing Mock Directly in the Test

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.

Testing Nimbus Integration Directly

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 NimbusFeatureFlagLayer itself 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.

Clone this wiki locally