Search code examples
swiftswiftdata

How to create a custom environment value with @Entry that uses a SwiftData's PersistentModel type?


Let's suppose we have this simple SwiftData model.

import Foundation
import SwiftData

@Model
final class Item {
    var timestamp: Date
    
    init(timestamp: Date) {
        self.timestamp = timestamp
    }
}

How to configure an EnvironmentKey with the new @Entry macro that uses the Item type?

The code below triggers an error at build time:

Static property 'defaultValue' is not concurrency-safe because non-'Sendable' type 'EnvironmentValues.__Key_someItem.Value' (aka 'Optional') may have shared mutable state

import SwiftUI
import SwiftData

extension EnvironmentValues {
    @Entry var someItem: Item? = nil
}

Tested with Xcode 16.0 beta (16A5171c) / Swift 6 / iOS 18.


Solution

  • @Entry seems to be designed to generate something like this for the defaultValue:

    static let defaultValue: Item? = nil
    

    This is not safe as per SE-0412.

    If it had generated

    static var defaultValue: Item? { nil }
    

    There would have been no warnings. It's probably not designed to do that because nil here will be evaluated each time defaultValue is accessed. This goes against people's expectations of expressions after a =, which is usually only evaluated once. An extreme example:

    // user expects someSideEffect() to only be called once, but if this 
    // were put into a computed property, it could be run multiple times
    @Entry var someItem: Item? = someSideEffect()
    

    Here is a macro implementation that generates a computed defaultValue instead.

    // implementation:
    enum EntryMacro: AccessorMacro, PeerMacro {
        
        static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
            guard let binding = declaration.as(VariableDeclSyntax.self)?.bindings.first,
                  let type = binding.typeAnnotation?.type,
                  let defaultValueExpr = binding.initializer?.value,
                  let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
                return []
            }
            return [
            """
            private struct \(raw: keyTypeName(forKeyName: name)): Key {
                static var defaultValue: \(type) { \(defaultValueExpr) }
            }
            """
            ]
        }
        
        static func expansion(of node: AttributeSyntax, providingAccessorsOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
            guard let binding = declaration.as(VariableDeclSyntax.self)?.bindings.first,
                  let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
                return []
            }
            return [
            """
            get { self[\(raw: keyTypeName(forKeyName: name)).self] }
            """,
            """
            set { self[\(raw: keyTypeName(forKeyName: name)).self] = newValue }
            """
            ]
        }
        
        static func keyTypeName(forKeyName name: String) -> String {
            "__Key_\(name)"
        }
        
    }
    
    // declaration:
    
    @attached(accessor, names: named(get), named(set))
    @attached(peer, names: prefixed(__Key_))
    public macro ComputedDefaultEntry() = #externalMacro(module: "...", type: "EntryMacro")
    
    // additional things that I will explain below...
    public extension EnvironmentValues {
        typealias Key = EnvironmentKey
    }
    public extension FocusedValues {
        typealias Key = FocusedValueKey
    }
    public extension Transaction {
        typealias Key = TransactionKey
    }
    public extension ContainerValues {
        typealias Key = ContainerValueKey
    }
    

    SwiftUI's @Entry macro works in many types - EnvironmentValues, Transaction, FocusedValues, etc. I've also added the same functionality to this @ComputedDefaultEntry macro. It generates a key type conforming to "Key". I have declared Key as type aliases in each of the applicable types, so that in EnvironmentValues it will resolve to EnvironmentKey, and in FocusedValues it will resolve to FocusValueKey, and so on.