16 KiB
Executable File
source, confidence, namespace, last_synced, alwaysApply
| source | confidence | namespace | last_synced | alwaysApply |
|---|---|---|---|---|
| ~/DuckDuckGo/apple-browsers.git/main/.cursor/rules/macos-singletons-removal.mdc | 0.9 | work | 2026-04-28 | false |
macOS Singleton Removal Rules
Purpose
Define a concrete pattern for removing .shared singletons in the macOS app by replacing them with app-owned instances and dependency injection. The AIChatPreferences and AboutPreferences refactors in AppDelegate are canonical examples.
Rules
-
Do not introduce new singletons
- Never add new
static let sharedor similar global singletons. - New dependencies must be passed in via initializers or factory methods, not fetched from global state.
- Never add new
-
Move ownership to the composition root (AppDelegate)
- Add a stored property on the macOS composition root (currently
AppDelegate) for the dependency, for example:let aiChatPreferences: AIChatPreferences
- Construct the instance during app setup using real dependencies:
- Inject storage (e.g.
DefaultAIChatPreferencesStorage) - Inject configuration objects (e.g.
AIChatMenuVisibilityConfigurable) - Inject window managers via protocols (e.g.
WindowControllersManagerProtocol) - Inject feature flaggers and other services as needed
- Inject storage (e.g.
- Prefer protocol-typed properties in
AppDelegatewhen the dependency has a clear protocol (to keep testing and substitution easy).
- Add a stored property on the macOS composition root (currently
-
Thread the dependency through initializers
- For view controllers and models that need the former singleton, add initializer parameters and store them as non-optional properties. Example:
init(..., aiChatPreferences: AIChatPreferences = NSApp.delegateTyped.aiChatPreferences, ...)
- Avoid using
NSApp.delegateTypedorApplication.appDelegateand prefer to pass the dependency down from a parent object.- If a parent object doesn't contain the dependency, it should be updated to have it passed down from its own parent object, observing the exceptions mentioned below.
- It is explicitly allowed to use
NSApp.delegateTyped:- In the
Tabinitializer (following the existing pattern for other dependencies) - In default parameter values for the
MainViewControllerinitializer (this is the entry point for the dependency chain) - In default parameter values for the
TabCollectionViewModelinitializer (following the existing pattern for other dependencies) - In default parameter values for the
TabViewModelinitializer (temporary exception, will be refactored later) - Exception: For
@MainActorinitializers, use optional parameters withnildefaults and assign fromNSApp.delegateTypedin the initializer body.
- In the
- Important: Main actor isolation: If an initializer is marked
@MainActorand you need to default a parameter fromNSApp.delegateTyped, use an optional parameter withnildefault instead of accessingNSApp.delegateTypedin the default value. Then assign the value inside the initializer body:- ❌
init(savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating = NSApp.delegateTyped.accessibilityPreferences)(causes main actor isolation warning) - ✅
init(savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating? = nil)withself.savedZoomLevelsCoordinating = savedZoomLevelsCoordinating ?? NSApp.delegateTyped.accessibilityPreferencesin the body
- ❌
- When creating child objects from a parent that already has the dependency, pass the property down rather than re-reading from
NSApp.delegateTyped. - For preferences models that need to reach SwiftUI views, thread through the entire chain:
MainViewController(with default parameter) →BrowserTabViewController→PreferencesViewController→PreferencesSidebarModel→PreferencesRootView- Follow the existing pattern used by other preferences (e.g.,
searchPreferences,tabsPreferences,aiChatPreferences) - When adding to
PreferencesSidebarModel, add the property alongside existing preferences and update both the maininitand convenienceinit
- For dependencies that need to reach UserScripts initialization (e.g.,
DuckPlayerPreferences), thread through the content blocking infrastructure:AppDelegate→AppContentBlocking→UserContentUpdating→ScriptSourceProvider(viaScriptSourceProvidingprotocol) →UserScripts- Add the dependency to
ScriptSourceProvidingprotocol as a property - Add it to
ScriptSourceProviderstruct (property and initializer parameter) - Add it to
UserContentUpdatinginitializer and pass toScriptSourceProviderinmakeValueclosure - Add it to
AppContentBlockinginitializers (both convenience and main) and pass toUserContentUpdating - Pass it from
AppDelegatetoAppContentBlockinginitialization - In
UserScripts, access viasourceProvider.duckPlayerPreferencesinstead of using a default parameter - This follows the same pattern as
WebTrackingProtectionPreferencesandCookiePopupProtectionPreferences
- For view controllers and models that need the former singleton, add initializer parameters and store them as non-optional properties. Example:
-
Update utility code and extensions carefully
- For helpers like
URLextensions where dependency injection is impractical, read the instance from the composition root instead of a singleton:NSApp.delegateTyped.aiChatPreferencesinstead ofAIChatPreferences.shared.
- Keep these usages minimal; prefer passing dependencies into call sites where it's feasible.
- In AppDelegate extensions: When the code is in an extension of
AppDelegate(e.g.,extension AppDelegate), access properties directly viaself.propertyNamerather thanNSApp.delegateTyped.propertyName:- ✅
duckPlayerPreferences.reset()(inextension AppDelegate) - ❌
NSApp.delegateTyped.duckPlayerPreferences.reset()(unnecessary indirection)
- ✅
- For protocol-typed dependencies: If a class conforms to a protocol (e.g.,
AccessibilityPreferencesconforms toSavedZoomLevelsCoordinating), you can use the protocol type in initializers. The dependency can be passed as the protocol type while still being owned as the concrete type inAppDelegate. - In SwiftUI views, once a dependency is available on a model (e.g.,
PreferencesSidebarModel), use the model's property rather than accessing viaNSApp.delegateTyped:- ✅
AboutView(model: model.aboutPreferences) - ❌
AboutView(model: NSApp.delegateTyped.aboutPreferences)
- ✅
- This ensures the view uses the injected instance and maintains proper dependency flow.
- For helpers like
-
Simplify protocol wrappers that only exist for the singleton
- If a protocol exists solely to hide a singleton (e.g. a minimal
AIFeaturesStatusProvidingthat just wrapsAIChatPreferences.shared), prefer depending directly on the concrete type once it is injectible. - Update initializers and stored properties to use the concrete type (
AIChatPreferences) when it already exposes the required API and publishers.
- If a protocol exists solely to hide a singleton (e.g. a minimal
-
Update tests to construct their own instances
- In tests, build the dependency explicitly instead of using global state. For example:
AIChatPreferences(storage: MockAIChatPreferencesStorage(), aiChatMenuConfiguration: MockAIChatConfig(), windowControllersManager: WindowControllersManagerMock(), featureFlagger: MockFeatureFlagger())
- Pass these instances into the subject under test via its initializer (e.g.
PreferencesSidebarModel,BrowserTabViewController,PreferencesViewController). - Remove ad-hoc singleton-like test doubles (e.g.
MockAIChatPreferences.shared) once real instances are injected. - Update all test helper methods: When a test file has helper methods that create instances (e.g.,
PreferencesSidebarModelfactory methods), update all of them to include the new dependency parameter. - Reuse existing mocks: When initializing the dependency in tests, reuse existing mock objects from the test setup:
- Use
mockFeatureFlagger.internalUserDeciderifMockFeatureFlaggeris already available - Use existing
windowControllersManagerinstances (e.g.,WindowControllersManagerMock()) - Example:
AboutPreferences(internalUserDecider: mockFeatureFlagger.internalUserDecider, featureFlagger: mockFeatureFlagger, windowControllersManager: windowControllersManager)
- Use
- Create shared test instances: For test classes that have many tests using the dependency, create a shared instance as a property:
let accessibilityPreferences = AccessibilityPreferences()at the class level- Reuse this instance across multiple test methods to avoid creating duplicate instances
- Update helper methods in test extensions: Don't forget to update helper methods in extensions (e.g.,
TabViewModel.aTabViewModelstatic property) that create instances - Search comprehensively: Use
grepto find all test files that instantiate classes requiring the dependency, including:- Direct instantiations in test methods
- Helper/factory methods that create instances
- Integration tests that create full object graphs
- Static helper properties/methods in test extensions
- In tests, build the dependency explicitly instead of using global state. For example:
-
Remove the singleton API last
- After all production code and tests use the injected instance or the app-owned property, delete
static let sharedand any remaining references to it. - Ensure you do not leave a mixed state where some call sites use the injected instance and others still use
.sharedfor the same type within the modified area. - Change
private inittoinitto make the initializer publicly accessible once the singleton is removed.
- After all production code and tests use the injected instance or the app-owned property, delete
Example: AboutPreferences Refactoring
The AboutPreferences.shared singleton removal demonstrates the complete pattern:
-
AppDelegate: Added
let aboutPreferences: AboutPreferencesand initialized it with dependencies (internalUserDecider,featureFlagger,windowControllersManager) -
MainViewController: Added
aboutPreferences: AboutPreferences = NSApp.delegateTyped.aboutPreferencesparameter (with default) and passed it toBrowserTabViewController -
BrowserTabViewController: Added
aboutPreferencesproperty and parameter, stored it, and passed it toPreferencesViewController -
PreferencesViewController: Added
aboutPreferencesparameter and passed it toPreferencesSidebarModel -
PreferencesSidebarModel: Added
let aboutPreferences: AboutPreferencesproperty and updated both initializers to accept and store it -
PreferencesRootView: Updated to use
model.aboutPreferencesinstead ofNSApp.delegateTyped.aboutPreferences -
Test files: Updated all test files that create instances in the dependency chain:
PreferencesSidebarModelTests.swift: Updated 3 helper methods to includeaboutPreferencesparameterBrowserTabViewControllerOnboardingTests.swift: AddedaboutPreferencestoBrowserTabViewControllerinitializationRootViewV2Tests.swift: AddedaboutPreferencestoPreferencesSidebarModelinitialization- All tests reuse existing mocks:
AboutPreferences(internalUserDecider: mockFeatureFlagger.internalUserDecider, featureFlagger: mockFeatureFlagger, windowControllersManager: windowControllersManager)
-
AboutPreferences: Removed
static let sharedand changedprivate inittoinit
This pattern ensures the dependency flows through the entire chain while maintaining testability and avoiding global state access in views.
Example: AccessibilityPreferences Refactoring
The AccessibilityPreferences.shared singleton removal demonstrates additional patterns:
-
AppDelegate: Added
let accessibilityPreferences: AccessibilityPreferencesand initialized it with default dependencies -
Dependency chain: Threaded through
MainViewController→BrowserTabViewController→PreferencesViewController→PreferencesSidebarModel→PreferencesRootView -
TabViewModel: Updated existing
accessibilityPreferencesparameter default from.sharedtoNSApp.delegateTyped.accessibilityPreferences -
Fire initializer (Main actor isolation): Used optional parameter pattern to avoid main actor isolation warning:
@MainActor init(savedZoomLevelsCoordinating: SavedZoomLevelsCoordinating? = nil, ...) { self.savedZoomLevelsCoordinating = savedZoomLevelsCoordinating ?? NSApp.delegateTyped.accessibilityPreferences } -
Protocol conformance:
AccessibilityPreferencesconforms toSavedZoomLevelsCoordinating, allowing it to be passed as a protocol type where needed -
Test patterns: Created shared
accessibilityPreferencesinstance in test classes:final class TabViewModelTests: XCTestCase { let accessibilityPreferences = AccessibilityPreferences() // ... tests reuse this instance } -
Test helper methods: Updated all helper methods including static properties in extensions (e.g.,
TabViewModel.aTabViewModel)
Example: DuckPlayerPreferences Refactoring
The DuckPlayerPreferences.shared singleton removal demonstrates the pattern for dependencies that need to reach UserScripts initialization:
-
AppDelegate: Added
let duckPlayerPreferences: DuckPlayerPreferencesand initialized it with dependencies (privacyConfigurationManager,internalUserDecider) -
Dependency chain for UserScripts: Threaded through:
AppDelegate→AppContentBlocking→UserContentUpdating→ScriptSourceProvider(viaScriptSourceProvidingprotocol) →UserScripts- This follows the same pattern as
WebTrackingProtectionPreferencesandCookiePopupProtectionPreferences
-
ScriptSourceProviding protocol: Added
var duckPlayerPreferences: DuckPlayerPreferences { get }property -
ScriptSourceProvider: Added
duckPlayerPreferencesproperty and parameter to initializer -
UserContentUpdating: Added
duckPlayerPreferencesparameter and passed it toScriptSourceProviderin themakeValueclosure -
AppContentBlocking: Added
duckPlayerPreferencesto both convenience and main initializers, passed it toUserContentUpdating -
AppDelegate: Passed
duckPlayerPreferencestoAppContentBlockinginitialization (both DEBUG and release paths) -
UserScripts: Removed default parameter
duckPlayerPreferences: DuckPlayerPreferences = NSApp.delegateTyped.duckPlayerPreferencesand accessed it viasourceProvider.duckPlayerPreferencesinstead -
Preferences view chain: Also threaded through
MainViewController→BrowserTabViewController→PreferencesViewController→PreferencesSidebarModel→PreferencesRootViewfor SwiftUI views -
MainMenuActions: Updated to use
duckPlayerPreferencesdirectly (since it's inextension AppDelegate)
This pattern ensures dependencies that need to reach UserScripts initialization are properly injected through the content blocking infrastructure, avoiding default parameters that access NSApp.delegateTyped during initialization.
Test Updates Checklist
When removing a singleton, ensure all tests are updated:
-
Find all test files that instantiate classes in the dependency chain:
grep -r "ClassName(" macOS/UnitTests macOS/IntegrationTests -
Update helper methods: If test files have helper/factory methods that create instances, update all of them:
- Look for
private funcmethods that return the type - Look for
create*ormake*helper methods - Example:
PreferencesSidebarModelTests.swifthad 3 helper methods that all neededaboutPreferences
- Look for
-
Reuse existing mocks: When creating the dependency instance in tests:
- Check what mocks are already available in
setUp()or test properties - Use
mockFeatureFlagger.internalUserDeciderif available - Reuse
WindowControllersManagerMock()instances already created - Avoid creating duplicate mock instances
- Check what mocks are already available in
-
Verify compilation: After updates, ensure:
- No linting errors
- All test files compile successfully
- Run tests to verify they pass
Enforcement
- Never approve PRs that add new
.shared-style singletons in macOS code. - When reviewing singleton removals, require:
- A clearly owned instance on the composition root (
AppDelegate). - Dependencies threaded via initializers with sensible defaults from
NSApp.delegateTyped. - Tests constructing their own instances without relying on global state.
- No remaining usages of the removed
TypeName.sharedin the modified scope.
- A clearly owned instance on the composition root (