From 6bf158f3c6511cbd8996348757e8a8488e841bca Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 13:27:29 -0700 Subject: [PATCH 1/9] Propagate main actor annotations --- .../ObservationRegistrarDeclBuilder.swift | 40 +++++++--- .../PropertyPublisherDeclBuilder.swift | 35 ++++++--- .../Main/PublishableMacro.swift | 6 +- .../PublishableMacroTests.swift | 78 ++++++++++++++++++- 4 files changed, 136 insertions(+), 23 deletions(-) diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index 9c5af0c..856ecbc 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 { + return [ + """ + private enum Observation { - struct ObservationRegistrar: PublishableObservationRegistrar { + @MainActor + struct ObservationRegistrar: PublishableObservationRegistrar { - let underlying = SwiftObservationRegistrar() + let underlying = SwiftObservationRegistrar() - \(publishNewValueFunction()) + \(publishNewValueFunction()) - \(subjectFunctions().formatted()) + \(subjectFunctions().formatted()) + } } - } - """ - ] + """ + ] + } else { + return [ + """ + 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..0d5fe61 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)> { + if mainActor { + return [ + """ + @MainActor + \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { - \(deinitializer()) + \(deinitializer()) - \(storedPropertiesPublishers().formatted()) + \(storedPropertiesPublishers().formatted()) - \(computedPropertiesPublishers().formatted()) - } - """ - ] + \(computedPropertiesPublishers().formatted()) + } + """ + ] + } else { + return [ + """ + \(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..e7efe66 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 diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index 9448f09..1d72f19 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -155,7 +155,81 @@ } """#, 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: PublishableObservationRegistrar { + + 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: Publishable { + } + """#, + macros: macros + ) + } +} #endif From b88b82b8f5d64ca6a07f88d1f89b417b6d884447 Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 13:51:14 -0700 Subject: [PATCH 2/9] Add MainActor protocol variants --- .../PropertyPublisher/Publishable.swift | 22 +++++++ .../PublishableObservationRegistrar.swift | 57 +++++++++++++++++++ .../ObservationRegistrarDeclBuilder.swift | 2 +- .../PublishableMacroTests.swift | 4 +- 4 files changed, 82 insertions(+), 3 deletions(-) diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift index cc82b65..702c879 100644 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ b/Sources/Publishable/PropertyPublisher/Publishable.swift @@ -57,3 +57,25 @@ 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..05b17e1 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -59,3 +59,60 @@ extension PublishableObservationRegistrar { return result } } + +@_documentation(visibility: private) +public protocol MainActorPublishableObservationRegistrar { + + associatedtype Object: Publishable, 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() + } + + @MainActor + public func access( + _ object: Object, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + @MainActor + public func withMutation( + of object: Object, + keyPath: KeyPath, + _ mutation: () throws -> T + ) rethrows -> T { + object.publisher.beginModifications() + let result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) + publish(object, keyPath: keyPath) + object.publisher.endModifications() + return result + } +} diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index 856ecbc..d859da5 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift @@ -29,7 +29,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { private enum Observation { @MainActor - struct ObservationRegistrar: PublishableObservationRegistrar { + struct ObservationRegistrar: MainActorPublishableObservationRegistrar { let underlying = SwiftObservationRegistrar() diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index 1d72f19..cbf4b38 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -196,7 +196,7 @@ private enum Observation { @MainActor - struct ObservationRegistrar: PublishableObservationRegistrar { + struct ObservationRegistrar: MainActorPublishableObservationRegistrar { let underlying = SwiftObservationRegistrar() @@ -225,7 +225,7 @@ } } - extension Person: Publishable { + extension Person: MainActorPublishable { } """#, macros: macros From c44eea133da8f2ed58f2b17a1396dd82a6e088fa Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 14:03:40 -0700 Subject: [PATCH 3/9] Inherit from correct protocol, remove nonisolated confirmances --- .../Registrars/PublishableObservationRegistrar.swift | 3 --- Sources/PublishableMacros/Main/PublishableMacro.swift | 4 +++- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 05b17e1..95ef887 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -67,7 +67,6 @@ public protocol MainActorPublishableObservationRegistrar { var underlying: SwiftObservationRegistrar { get } - @MainActor func publish( _ object: Object, keyPath: KeyPath @@ -95,7 +94,6 @@ extension MainActorPublishableObservationRegistrar { object.publisher.endModifications() } - @MainActor public func access( _ object: Object, keyPath: KeyPath @@ -103,7 +101,6 @@ extension MainActorPublishableObservationRegistrar { underlying.access(object, keyPath: keyPath) } - @MainActor public func withMutation( of object: Object, keyPath: KeyPath, diff --git a/Sources/PublishableMacros/Main/PublishableMacro.swift b/Sources/PublishableMacros/Main/PublishableMacro.swift index e7efe66..a8fc874 100644 --- a/Sources/PublishableMacros/Main/PublishableMacro.swift +++ b/Sources/PublishableMacros/Main/PublishableMacro.swift @@ -72,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: "{}" From 00abf617eab4085b5f4b096384bfed433b2962f0 Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 14:12:49 -0700 Subject: [PATCH 4/9] Attach extra conformance --- Sources/Publishable/PropertyPublisher/Publishable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift index 702c879..6b5cf48 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", From 37666862400fa8b971c65c11a376529564e19ed0 Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 14:21:39 -0700 Subject: [PATCH 5/9] Fix constraint --- .../Registrars/PublishableObservationRegistrar.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 95ef887..8d9fe8a 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -63,10 +63,11 @@ extension PublishableObservationRegistrar { @_documentation(visibility: private) public protocol MainActorPublishableObservationRegistrar { - associatedtype Object: Publishable, Observable + associatedtype Object: MainActorPublishable, Observable var underlying: SwiftObservationRegistrar { get } + @MainActor func publish( _ object: Object, keyPath: KeyPath @@ -84,7 +85,7 @@ extension MainActorPublishableObservationRegistrar { underlying.willSet(object, keyPath: keyPath) } - @MainActor + @MainActor public func didSet( _ object: Object, keyPath: KeyPath @@ -101,6 +102,7 @@ extension MainActorPublishableObservationRegistrar { underlying.access(object, keyPath: keyPath) } + @MainActor public func withMutation( of object: Object, keyPath: KeyPath, From 7674a1ff4b647ca0f8f852d3f9a97c77ec68c650 Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 14:48:49 -0700 Subject: [PATCH 6/9] Fix withMutation --- .../PublishableObservationRegistrar.swift | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 8d9fe8a..0c540ff 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -102,16 +102,27 @@ extension MainActorPublishableObservationRegistrar { underlying.access(object, keyPath: keyPath) } - @MainActor public func withMutation( of object: Object, keyPath: KeyPath, _ mutation: () throws -> T ) rethrows -> T { - object.publisher.beginModifications() - let result = try underlying.withMutation(of: object, keyPath: keyPath, mutation) - publish(object, keyPath: keyPath) - object.publisher.endModifications() - return result + try withoutActuallyEscaping(mutation) { mutation in + try MainActor.assumeIsolated { [unchecked = UncheckedSendable(wrappedValue: (self, object, keyPath, mutation))] in + 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 + } +} + +struct UncheckedSendable: @unchecked Sendable { + var wrappedValue: Value + + init(wrappedValue: Value) { + self.wrappedValue = wrappedValue } } From 339ca00ffcee277f0c9771ebf3a7e923601d6ceb Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 15:19:01 -0700 Subject: [PATCH 7/9] Add comment about `MainActor.assumeIsolated` --- .../Registrars/PublishableObservationRegistrar.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 0c540ff..f8e4ff7 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -108,6 +108,9 @@ extension MainActorPublishableObservationRegistrar { _ 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. try MainActor.assumeIsolated { [unchecked = UncheckedSendable(wrappedValue: (self, object, keyPath, mutation))] in unchecked.wrappedValue.1.publisher.beginModifications() let result = try unchecked.wrappedValue.0.underlying.withMutation(of: unchecked.wrappedValue.1, keyPath: unchecked.wrappedValue.2, unchecked.wrappedValue.3) From 76bfca7efd4ac5297077bc6cd05ac2e7f14d9297 Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 15:20:09 -0700 Subject: [PATCH 8/9] Run format --- .../PropertyPublisher/Publishable.swift | 1 - .../PublishableObservationRegistrar.swift | 24 ++-- .../ObservationRegistrarDeclBuilder.swift | 4 +- .../PropertyPublisherDeclBuilder.swift | 4 +- .../PublishableMacroTests.swift | 114 +++++++++--------- 5 files changed, 73 insertions(+), 74 deletions(-) diff --git a/Sources/Publishable/PropertyPublisher/Publishable.swift b/Sources/Publishable/PropertyPublisher/Publishable.swift index 6b5cf48..4b323a8 100644 --- a/Sources/Publishable/PropertyPublisher/Publishable.swift +++ b/Sources/Publishable/PropertyPublisher/Publishable.swift @@ -58,7 +58,6 @@ 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. diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index f8e4ff7..305e257 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -107,18 +107,18 @@ extension MainActorPublishableObservationRegistrar { 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. - try MainActor.assumeIsolated { [unchecked = UncheckedSendable(wrappedValue: (self, object, keyPath, mutation))] in - 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 + 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. + try MainActor.assumeIsolated { [unchecked = UncheckedSendable(wrappedValue: (self, object, keyPath, mutation))] in + 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 } } diff --git a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift index d859da5..c586bb3 100644 --- a/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/ObservationRegistrarDeclBuilder.swift @@ -24,7 +24,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { func build() -> [DeclSyntax] { if mainActor { - return [ + [ """ private enum Observation { @@ -41,7 +41,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder { """ ] } else { - return [ + [ """ private enum Observation { diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index 0d5fe61..3511d64 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -20,7 +20,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order if mainActor { - return [ + [ """ @MainActor \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { @@ -34,7 +34,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { """ ] } else { - return [ + [ """ \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedTypeName)> { diff --git a/Tests/PublishableMacrosTests/PublishableMacroTests.swift b/Tests/PublishableMacrosTests/PublishableMacroTests.swift index cbf4b38..e82d555 100644 --- a/Tests/PublishableMacrosTests/PublishableMacroTests.swift +++ b/Tests/PublishableMacrosTests/PublishableMacroTests.swift @@ -155,81 +155,81 @@ } """#, macros: macros - ) - } + ) + } + + func testMainActorExpansion() { + assertMacroExpansion( + #""" + @MainActor + @Publishable @Observable + public final class Person { - func testMainActorExpansion() { - assertMacroExpansion( - #""" - @MainActor - @Publishable @Observable - public final class Person { + var name: String + } + """#, + expandedSource: + #""" + @MainActor + @Observable + public final class Person { - var name: String - } - """#, - expandedSource: - #""" - @MainActor - @Observable - public final class Person { + var name: String - var name: String + public private(set) lazy var publisher = PropertyPublisher(object: self) - public private(set) lazy var publisher = PropertyPublisher(object: self) + @MainActor + public final class PropertyPublisher: AnyPropertyPublisher { - @MainActor - public final class PropertyPublisher: AnyPropertyPublisher { + deinit { + _name.send(completion: .finished) + } + + fileprivate let _name = PassthroughSubject() + var name: AnyPublisher { + _storedPropertyPublisher(_name, for: \.name) + } - deinit { - _name.send(completion: .finished) - } - fileprivate let _name = PassthroughSubject() - var name: AnyPublisher { - _storedPropertyPublisher(_name, for: \.name) } + private enum Observation { - } + @MainActor + struct ObservationRegistrar: MainActorPublishableObservationRegistrar { - private enum Observation { + let underlying = SwiftObservationRegistrar() - @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 + 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)") } - assertionFailure("Unknown keyPath: \(keyPath)") - } - private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { - if keyPath == \.name { - return object.publisher._name + private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + return nil } - return nil } } } - } - extension Person: MainActorPublishable { - } - """#, - macros: macros - ) + extension Person: MainActorPublishable { + } + """#, + macros: macros + ) + } } -} #endif From 77aceb36eb437d1348bc4f466a31947e094c3cf2 Mon Sep 17 00:00:00 2001 From: Eric Horacek Date: Thu, 17 Jul 2025 19:57:31 -0700 Subject: [PATCH 9/9] Address AI feedback --- .../PublishableObservationRegistrar.swift | 21 ++++++++++++------- .../PropertyPublisherDeclBuilder.swift | 2 +- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift index 305e257..e40ddf0 100644 --- a/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift +++ b/Sources/Publishable/Registrars/PublishableObservationRegistrar.swift @@ -111,10 +111,18 @@ extension MainActorPublishableObservationRegistrar { // 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. - try MainActor.assumeIsolated { [unchecked = UncheckedSendable(wrappedValue: (self, object, keyPath, mutation))] in + 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) + 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) } @@ -122,10 +130,7 @@ extension MainActorPublishableObservationRegistrar { } } -struct UncheckedSendable: @unchecked Sendable { - var wrappedValue: Value +private struct UncheckedSendable: @unchecked Sendable { - init(wrappedValue: Value) { - self.wrappedValue = wrappedValue - } + var wrappedValue: Value } diff --git a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift index 3511d64..9b3297e 100644 --- a/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift +++ b/Sources/PublishableMacros/Builders/PropertyPublisherDeclBuilder.swift @@ -18,7 +18,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder { .init(accessControlLevel: .init(inheritingDeclaration: .member)) } - func build() -> [DeclSyntax] { // swiftlint:disable:this type_contents_order + func build() -> [DeclSyntax] { if mainActor { [ """