diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift index cc82b65..4b323a8 100644 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ b/Sources/Publishable/PropertyPublisher/Publishable.swift @@ -31,7 +31,7 @@ import Observation ) @attached( extension, - conformances: Publishable + conformances: Publishable, MainActorPublishable ) public macro Publishable() = #externalMacro( module: "PublishableMacros", @@ -57,3 +57,24 @@ public protocol Publishable: AnyObject, Observable { /// var publisher: PropertyPublisher { get } } + +/// A type that can be observed using both the `Observation` and `Combine` frameworks. +/// +/// You don't need to declare conformance to this protocol yourself. +/// It is generated automatically when you apply the ``Publishable()`` macro to your type. +/// +public protocol MainActorPublishable: AnyObject, Observable { + + /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, + /// containing publishers for all mutable instance properties of the type. + /// + associatedtype PropertyPublisher: AnyPropertyPublisher + + /// An instance that exposes `Combine` publishers for all mutable instance properties of the type. + /// + /// - Important: Don't store this instance in an external property. Accessing it after the original object has been deallocated + /// may result in a crash. Always access it directly through the object that exposes it. + /// + @MainActor + var publisher: PropertyPublisher { get } +} diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 245bf25..e40ddf0 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -59,3 +59,78 @@ extension PublishableObservationRegistrar { return result } } + +@_documentation(visibility: private) +public protocol MainActorPublishableObservationRegistrar { + + associatedtype Object: MainActorPublishable, Observable + + var underlying: SwiftObservationRegistrar { get } + + @MainActor + func publish( + _ object: Object, + keyPath: KeyPath + ) +} + +extension MainActorPublishableObservationRegistrar { + + @MainActor + public func willSet( + _ object: Object, + keyPath: KeyPath + ) { + object.publisher.beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + + @MainActor + public func didSet( + _ object: Object, + keyPath: KeyPath + ) { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + } + + public func access( + _ object: Object, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + public func withMutation( + of object: Object, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + try withoutActuallyEscaping(mutation) { mutation in + // withMutation is called from a nonisolated function generated by @Observable, but in + // practice if the object is MainActorPublishable, we can assume that the mutation is + // isolated to the MainActor. + let unchecked = UncheckedSendable(wrappedValue: (self, object, keyPath, mutation)) + return try MainActor.assumeIsolated { + unchecked.wrappedValue.1.publisher.beginModifications() + let result = try unchecked.wrappedValue.0.underlying.withMutation( + of: unchecked.wrappedValue.1, + keyPath: unchecked.wrappedValue.2, + unchecked.wrappedValue.3 + ) + unchecked.wrappedValue.0.publish( + unchecked.wrappedValue.1, + keyPath: unchecked.wrappedValue.2 + ) + unchecked.wrappedValue.1.publisher.endModifications() + return UncheckedSendable(wrappedValue: result) + } + }.wrappedValue + } +} + +private struct UncheckedSendable: @unchecked Sendable { + + var wrappedValue: Value +} diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index 9c5af0c..c586bb3 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift @@ -12,6 +12,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList + let mainActor: Bool var settings: DeclBuilderSettings { .init(accessControlLevel: .init(inheritingDeclaration: .member)) @@ -22,21 +23,40 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { } func build() -> [DeclSyntax] { - [ - """ - private enum Observation { + if mainActor { + [ + """ + private enum Observation { - struct ObservationRegistrar: PublishableObservationRegistrar { + @MainActor + struct ObservationRegistrar: MainActorPublishableObservationRegistrar { - let underlying = SwiftObservationRegistrar() + let underlying = SwiftObservationRegistrar() - \(publishNewValueFunction()) + \(publishNewValueFunction()) - \(subjectFunctions().formatted()) + \(subjectFunctions().formatted()) + } } - } - """ - ] + """ + ] + } else { + [ + """ + private enum Observation { + + struct ObservationRegistrar: PublishableObservationRegistrar { + + let underlying = SwiftObservationRegistrar() + + \(publishNewValueFunction()) + + \(subjectFunctions().formatted()) + } + } + """ + ] + } } private func publishNewValueFunction() -> MemberBlockItemListSyntax { diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index fecdf5c..9b3297e 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -12,24 +12,41 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { let declaration: ClassDeclSyntax let properties: PropertiesList + let mainActor: Bool var settings: DeclBuilderSettings { .init(accessControlLevel: .init(inheritingDeclaration: .member)) } - func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order - [ - """ - \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { + func build() -> [DeclSyntax] { + if mainActor { + [ + """ + @MainActor + \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { - \(deinitializer()) + \(deinitializer()) - \(storedPropertiesPublishers().formatted()) + \(storedPropertiesPublishers().formatted()) - \(computedPropertiesPublishers().formatted()) - } - """ - ] + \(computedPropertiesPublishers().formatted()) + } + """ + ] + } else { + [ + """ + \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { + + \(deinitializer()) + + \(storedPropertiesPublishers().formatted()) + + \(computedPropertiesPublishers().formatted()) + } + """ + ] + } } private func deinitializer() -> MemberBlockItemListSyntax { diff --git a/Sources/PublishableMacros/Main/PublishableMacro.swift b/Sources/PublishableMacros/Main/PublishableMacro.swift index 81fd3a8..a8fc874 100644 --- a/Sources/PublishableMacros/Main/PublishableMacro.swift +++ b/Sources/PublishableMacros/Main/PublishableMacro.swift @@ -45,10 +45,12 @@ extension PublishableMacro: MemberMacro { in: context ) + // Propagate @MainActor isolation if declared on the type + let isMainActor = declaration.attributes.contains(likeOneOf: "@MainActor") let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder(declaration: declaration, properties: properties), - PropertyPublisherDeclBuilder(declaration: declaration, properties: properties), - ObservationRegistrarDeclBuilder(declaration: declaration, properties: properties) + PropertyPublisherDeclBuilder(declaration: declaration, properties: properties, mainActor: isMainActor), + ObservationRegistrarDeclBuilder(declaration: declaration, properties: properties, mainActor: isMainActor) ] return try builderTypes.flatMap { builderType in @@ -70,12 +72,14 @@ extension PublishableMacro: ExtensionMacro { return [] } + let isMainActor = declaration.attributes.contains(likeOneOf: "@MainActor") + return [ .init( extendedType: type, inheritanceClause: .init( inheritedTypes: [ - .init(type: IdentifierTypeSyntax(name: "Publishable")) + .init(type: IdentifierTypeSyntax(name: isMainActor ? "MainActorPublishable" : "Publishable")) ] ), memberBlock: "{}" diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index 9448f09..e82d555 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -157,5 +157,79 @@ macros: macros ) } + + func testMainActorExpansion() { + assertMacroExpansion( + #""" + @MainActor + @Publishable @Observable + public final class Person { + + var name: String + } + """#, + expandedSource: + #""" + @MainActor + @Observable + public final class Person { + + var name: String + + public private(set) lazy var publisher = PropertyPublisher(object: self) + + @MainActor + public final class PropertyPublisher: AnyPropertyPublisher { + + deinit { + _name.send(completion: .finished) + } + + fileprivate let _name = PassthroughSubject() + var name: AnyPublisher { + _storedPropertyPublisher(_name, for: \.name) + } + + + } + + private enum Observation { + + @MainActor + struct ObservationRegistrar: MainActorPublishableObservationRegistrar { + + let underlying = SwiftObservationRegistrar() + + func publish( + _ object: Person, + keyPath: KeyPath + ) { + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + assertionFailure("Unknown keyPath: \(keyPath)") + } + + private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + return nil + } + } + } + } + + extension Person: MainActorPublishable { + } + """#, + macros: macros + ) + } } #endif