Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Sources/Publishable/PropertyPublisher/Publishable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import Observation
)
@attached(
extension,
conformances: Publishable
conformances: Publishable, MainActorPublishable
)
public macro Publishable() = #externalMacro(
module: "PublishableMacros",
Expand All @@ -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<Self>

/// 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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Object, some Any>
)
}

extension MainActorPublishableObservationRegistrar {

@MainActor
public func willSet(
_ object: Object,
keyPath: KeyPath<Object, some Any>
) {
object.publisher.beginModifications()
underlying.willSet(object, keyPath: keyPath)
}

@MainActor
public func didSet(
_ object: Object,
keyPath: KeyPath<Object, some Any>
) {
underlying.didSet(object, keyPath: keyPath)
publish(object, keyPath: keyPath)
object.publisher.endModifications()
}

public func access(
_ object: Object,
keyPath: KeyPath<Object, some Any>
) {
underlying.access(object, keyPath: keyPath)
}

public func withMutation<T>(
of object: Object,
keyPath: KeyPath<Object, some Any>,
_ 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<Value>: @unchecked Sendable {

var wrappedValue: Value
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
10 changes: 7 additions & 3 deletions Sources/PublishableMacros/Main/PublishableMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: "{}"
Expand Down
74 changes: 74 additions & 0 deletions Tests/PublishableMacrosTests/PublishableMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Person> {

deinit {
_name.send(completion: .finished)
}

fileprivate let _name = PassthroughSubject<String, Never>()
var name: AnyPublisher<String, Never> {
_storedPropertyPublisher(_name, for: \.name)
}


}

private enum Observation {

@MainActor
struct ObservationRegistrar: MainActorPublishableObservationRegistrar {

let underlying = SwiftObservationRegistrar()

func publish(
_ object: Person,
keyPath: KeyPath<Person, some Any>
) {
if let keyPath = keyPath as? KeyPath<Person, String>,
let subject = subject(for: keyPath, on: object) {
subject.send(object[keyPath: keyPath])
return
}
assertionFailure("Unknown keyPath: \(keyPath)")
}

private func subject(
for keyPath: KeyPath<Person, String>,
on object: Person
) -> PassthroughSubject<String, Never>? {
if keyPath == \.name {
return object.publisher._name
}
return nil
}
}
}
}

extension Person: MainActorPublishable {
}
"""#,
macros: macros
)
}
}
#endif