swiftuiswiftdata

@Entry Keyword in SwiftUI and Actor-Isolated Initializer


I am trying to create a Data Access Layer in SwiftUI. I have the following implementation. But for @Entry line it keeps saying "Call to main actor-isolated initializer 'init(container:)' in a synchronous nonisolated context". What can I do to resolve this issue so I can access dataAccess in my view.

import Foundation
import SwiftData

@MainActor
protocol DataAccess {
    func getBudgets() throws -> [BudgetPlain]
    func addBudget(name: String, limit: Double)
}


@Entry var dataAccess: DataAccess =  BudgetSwiftDataAccess()


@MainActor
class BudgetSwiftDataAccess: DataAccess {
    
    var container: ModelContainer
    var context: ModelContext
    
    init(container: ModelContainer = try! ModelContainer(for: Budget.self, configurations: ModelConfiguration(isStoredInMemoryOnly: false))) {
        self.container = container
        self.context = container.mainContext
    }
    
    func getBudgets() throws -> [BudgetPlain] {
        let budgets = try context.fetch(FetchDescriptor<Budget>())
        return budgets.map(BudgetPlain.init)
    }
    
    func addBudget(name: String, limit: Double) {
        let budget = Budget(name: name, limit: limit)
        context.insert(budget)
    }
    
}

UPDATE:

Still having the same issues with actor-isolated.

extension EnvironmentValues {
    @Entry var dataAccess: DataAccess = BudgetSwiftDataAccess()
}

@MainActor
class BudgetSwiftDataAccess: DataAccess {
    
    var container: ModelContainer
    var context: ModelContext
    
    @MainActor
    init(container: ModelContainer = ModelContainer.default()) {
        self.container = container
        self.context = container.mainContext
    }
    
    func getBudgets() throws -> [BudgetPlain] {
        let budgets = try context.fetch(FetchDescriptor<Budget>())
        return budgets.map(BudgetPlain.init)
    }
    
    func addBudget(name: String, limit: Double) {
        let budget = Budget(name: name, limit: limit)
        context.insert(budget)
    }
    
}

Solution

  • I recall, that there was an issue with the macro @Entry. I fear, for now, you need to not use the @Entry macro for values which are associated to a global actor.

    So, I would recommend to declare the environment value the "old" way:

    @MainActor
    protocol DataAccess {
        func getBudgets() throws -> [BudgetPlain]
        func addBudget(name: String, limit: Double)
    }
    
    
    @MainActor
    class BudgetSwiftDataAccess: DataAccess {
        ...
    }
    

    Then, explicitly declare the defaultValue and the environment value (dataAccess) on the MainActor:

    struct DataAccessKey: EnvironmentKey {
        @MainActor static let defaultValue: any DataAccess = BudgetSwiftDataAccess()
    }
    
    extension EnvironmentValues {
        @MainActor var dataAccess: DataAccessKey {
            get { self[DataAccessKey] }
            set { self[DataAccessKey] = newValue }
        }
    }
    

    ** Note ** You probably want to use an optional for the default value and use nil as the default in your use case!

    Update Swift 6

    As Sweeper pointed out, this code will not compile with Swift 6. The main reason for this is, that the compiler is now very strict, when it comes to initialising global variables - which needs to be explicitly made thread-safe.

    To make the code valid for Swift 6, we can declare the environment value Optional, and additionally we have to declare any concrete environment value conforming to DataAccess also conforming to Sendable:

    @MainActor
    protocol DataAccess: Sendable {
        ...
    }
    
    struct DataAccessKey: EnvironmentKey {
        static let defaultValue: (any DataAccess)? = nil
    }
    
    extension EnvironmentValues {
        var dataAccess: (any DataAccess)? {
            get { self[DataAccessKey] }
            set { self[DataAccessKey] = newValue }
        }
    }
    

    Note, the defaultValue is a "non-isolated requirement" given by the EnvironmentKey protocol, which means, it needs to be valid to initialise the global defaultValue when running on any thread. We cannot instantiate a DataAccess instance here, since it is declared to be isolated on the MainActor. However, it's safe to assign a nil value. Since the default value will be assigned non-isolated, the concrete value crosses isolation boundaries, and thus needs to conform to Sendable (Swift 6).

    The code above also compiles with Swift 5.9.

    For Swift 6 only

    For Swift 6, we can use the macro @Entry, which really provides a nice solution:

    We do not need to declare DataAccess conform to Sendable for this case (it may need to for other reasons, though). And, we can assign a default value, which is not nil:

    @MainActor
    protocol DataAccess {
       ...
    }
    
    @MainActor
    class BudgetSwiftDataAccess: DataAccess {
        ...
    }
    
    extension EnvironmentValues {
        @Entry var dataAccess: any DataAccess = BudgetSwiftDataAccess()
    }
    

    Note, macro @Entry is available since iOS 15.0, macOS 12, but the above code requires the Swift 6 compiler. It fails with Swift 5.9.

    If you use an optional environment value you can also use macro @Entry with Swift 5.9:

    extension EnvironmentValues {
        @Entry var dataAccess: (any DataAccess)? = nil
    }
    

    IMHO, this is probably the way you should go.

    Many thanks to Sweeper, pointing out that the original answer does not compile with Swift 6.