From f296d6b0896c3d7ea21428fa125f997532992751 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 21 Nov 2025 12:01:34 +0100 Subject: [PATCH 01/45] Removed redundant tests --- .../Memoized/MainActorMemoizedTests.swift | 116 ------------------ .../Memoized/ObservationMemoizedTests.swift | 116 ------------------ .../Memoized/SwiftDataMemoizedTests.swift | 116 ------------------ 3 files changed, 348 deletions(-) diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 0331aef..0325ee3 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -107,58 +107,6 @@ internal enum MainActorMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(!cube.isBaseAreaCached) } - - @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.baseArea - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.y = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(!cube.isBaseAreaCached) - - cube.y = 4.0 - let access3 = cube.baseArea - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -279,70 +227,6 @@ extension MainActorMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(cube.isBaseAreaCached) } - - @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.volume - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 4.0 - let access3 = cube.volume - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index 3e930b7..e5fda23 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -106,58 +106,6 @@ internal enum ObservationMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(!cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.baseArea - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.y = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(!cube.isBaseAreaCached) - - cube.y = 4.0 - let access3 = cube.baseArea - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -277,70 +225,6 @@ extension ObservationMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.volume - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 4.0 - let access3 = cube.volume - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index 53eb56b..8ebd53f 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -107,58 +107,6 @@ internal enum SwiftDataMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(!cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.baseArea - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.y = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(!cube.isBaseAreaCached) - - cube.y = 4.0 - let access3 = cube.baseArea - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -278,70 +226,6 @@ extension SwiftDataMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.volume - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 4.0 - let access3 = cube.volume - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } From 13fbbaabbaae534fadefed98af93c8cc753cedd7 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 21 Nov 2025 12:02:22 +0100 Subject: [PATCH 02/45] Aligned computed property publishers behavior --- .../Publishable/AnyPropertyPublisher.swift | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/Sources/Relay/Publishable/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift index 9cdd966..3a8c5a2 100644 --- a/Sources/Relay/Publishable/AnyPropertyPublisher.swift +++ b/Sources/Relay/Publishable/AnyPropertyPublisher.swift @@ -73,30 +73,40 @@ extension AnyPropertyPublisher { .prepend(object[keyPath: keyPath]) .eraseToAnyPublisher() } +} - public func _storedPropertyPublisher( - _ subject: PassthroughSubject, +extension AnyPropertyPublisher { + + public func _computedPropertyPublisher( for keyPath: KeyPath ) -> AnyPublisher { - subject - .prepend(object[keyPath: keyPath]) - .removeDuplicates() + _didChange + .prepend(object) + .map { $0[keyPath: keyPath] } .eraseToAnyPublisher() } -} -extension AnyPropertyPublisher { + public func _computedPropertyPublisher( + for keyPath: KeyPath + ) -> AnyPublisher { + _didChange + .prepend(object) + .map { $0[keyPath: keyPath] } + .removeDuplicates() + .eraseToAnyPublisher() + } - public func _computedPropertyPublisher( + public func _computedPropertyPublisher( for keyPath: KeyPath ) -> AnyPublisher { _didChange .prepend(object) .map { $0[keyPath: keyPath] } + .removeDuplicates { $0 === $1 } .eraseToAnyPublisher() } - public func _computedPropertyPublisher( + public func _computedPropertyPublisher( for keyPath: KeyPath ) -> AnyPublisher { _didChange From ca967f22a5ba3890f005ba46db9b4e6924ef6545 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 22 Nov 2025 14:21:58 +0100 Subject: [PATCH 03/45] - --- Macros/Dependencies/PrincipleMacros | 2 +- .../RelayMacros/Memoized/MemoizedMacro.swift | 42 +- .../Publishable/PublishableMacro.swift | 28 +- .../Memoized/MainActorMemoizedTests.swift | 2 + .../Memoized/ObservationMemoizedTests.swift | 2 + .../Memoized/PublishableMemoizedTests.swift | 2 + .../Memoized/SwiftDataMemoizedTests.swift | 2 + .../AnyPropertyPublisherTests.swift | 503 ++++++++++++------ 8 files changed, 385 insertions(+), 198 deletions(-) diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index 85aab93..9d7a483 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit 85aab93496550f03b8888a96598fe06ad7685a53 +Subproject commit 9d7a4835024b9e310daf5d1964a5ab22ff59900a diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index c8d66fa..27e07bc 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -21,46 +21,37 @@ public enum MemoizedMacro { _ declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext, with parameters: Parameters - ) -> Input? { + ) throws -> Input? { guard let declaration = declaration.as(FunctionDeclSyntax.self), let trimmedReturnType = trimmedReturnType(of: declaration), declaration.signature.parameterClause.parameters.isEmpty, declaration.signature.effectSpecifiers == nil, declaration.typeScopeSpecifier == nil else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: """ + message: """ Memoized macro can only be applied to non-void, non-async, non-throwing \ methods that don't take any arguments """ ) - return nil } - guard let scope = context.lexicalContext.first?.as(ClassDeclSyntax.self), - scope.attributes.contains(likeOneOf: "@Observable", "@Model") - else { - context.diagnose( + guard context.lexicalContext.first?.is(ClassDeclSyntax.self) == true else { + throw DiagnosticsError( node: declaration, - errorMessage: """ - Memoized macro can only be applied to methods declared in body (not extension) \ - of @Observable or @Model classes + message: """ + Memoized macro can only be applied to methods declared \ + in body (not extension) of Observable classes """ ) - return nil } - let propertyName = validatePropertyName( + let propertyName = try validatePropertyName( for: declaration, - in: context, preferred: parameters.preferredPropertyName ) - guard let propertyName else { - return nil - } - return Input( declaration: declaration, trimmedReturnType: trimmedReturnType, @@ -70,30 +61,27 @@ public enum MemoizedMacro { private static func validatePropertyName( for declaration: FunctionDeclSyntax, - in context: some MacroExpansionContext, preferred: String? - ) -> String? { + ) throws -> String { if let preferred { guard !preferred.isEmpty else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: "Memoized macro requires a non-empty property name" + message: "Memoized macro requires a non-empty property name" ) - return nil } return preferred } let inferred = defaultPropertyName(for: declaration) guard !inferred.isEmpty else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: """ + message: """ Memoized macro requires a method name with at least two words \ or explicit property name """ ) - return nil } return inferred @@ -119,7 +107,7 @@ extension MemoizedMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let parameters = try Parameters(from: node) - let input = validate(declaration, in: context, with: parameters) + let input = try validate(declaration, in: context, with: parameters) guard let input else { return [] diff --git a/Macros/RelayMacros/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Publishable/PublishableMacro.swift index a8368d2..e76d1e4 100644 --- a/Macros/RelayMacros/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Publishable/PublishableMacro.swift @@ -11,18 +11,15 @@ import SwiftSyntaxMacros public enum PublishableMacro { private static func validate( - _ declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext - ) -> ClassDeclSyntax? { + _ declaration: some DeclGroupSyntax + ) throws -> ClassDeclSyntax { guard let declaration = declaration.as(ClassDeclSyntax.self), - declaration.attributes.contains(likeOneOf: "@Observable", "@Model"), declaration.isFinal else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: "Publishable macro can only be applied to final @Observable or @Model classes" + message: "Publishable macro can only be applied to final Observable classes" ) - return nil } return declaration } @@ -36,14 +33,11 @@ extension PublishableMacro: MemberMacro { conformingTo _: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard let declaration = validate(declaration, in: context) else { - return [] - } - + let declaration = try validate(declaration) let parameters = try Parameters(from: node) - let properties = PropertiesParser.parse( - memberBlock: declaration.memberBlock, - in: context + + let properties = try PropertiesParser.parse( + memberBlock: declaration.memberBlock ) let builderTypes: [any ClassDeclBuilder] = [ @@ -78,11 +72,9 @@ extension PublishableMacro: ExtensionMacro { conformingTo _: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard let declaration = validate(declaration, in: context) else { - return [] - } - + let declaration = try validate(declaration) let parameters = try Parameters(from: node) + let globalActorIsolation = GlobalActorIsolation.resolved( for: declaration, preferred: parameters.preferredGlobalActorIsolation diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 0325ee3..574c016 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -240,9 +240,11 @@ extension MainActorMemoizedTests { var y = 1.0 var z = 1.0 + @ObservationIgnored private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @ObservationIgnored private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index e5fda23..c444b16 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -238,9 +238,11 @@ extension ObservationMemoizedTests { var y = 1.0 var z = 1.0 + @ObservationIgnored private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @ObservationIgnored private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 2fa45ac..963487e 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -172,9 +172,11 @@ extension PublishableMemoizedTests { var y = 1.0 var z = 1.0 + @ObservationIgnored private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @ObservationIgnored private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index 8ebd53f..a7128af 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -239,9 +239,11 @@ extension SwiftDataMemoizedTests { var y = 1.0 var z = 1.0 + @Transient private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @Transient private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } diff --git a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift index dafca42..9997bca 100644 --- a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift @@ -9,200 +9,399 @@ import Relay import Testing -internal struct AnyPropertyPublisherTests { +internal enum AnyPropertyPublisherTests { - fileprivate struct NonEquatableStruct {} + struct NonEquatableType { - @Publishable @Observable - fileprivate final class ObjectWithNonEquatableProperties { + fileprivate struct NonEquatableStruct {} - var storedProperty = NonEquatableStruct() - var unrelatedProperty = 0 + fileprivate final class NonEquatableClass {} - var computedProperty: NonEquatableStruct { - storedProperty + @Publishable @Observable + fileprivate final class Object { + + var unrelatedProperty = 0 + + var storedProperty = NonEquatableStruct() + var computedProperty: NonEquatableStruct { + storedProperty + } + + var referenceTypeStoredProperty = NonEquatableClass() + var referenceTypeComputedProperty: NonEquatableClass { + referenceTypeStoredProperty + } } - } - @Test - func nonEquatableStoredProperty() { - var object: ObjectWithNonEquatableProperties? = .init() - var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.storedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.storedProperty - } onChange: { - observationsQueue.append(true) + @Test + func storedProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableStruct]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.storedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.storedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = NonEquatableStruct() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeStoredProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeStoredProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeStoredProperty + } onChange: { + observationsQueue.append(true) + } + } - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = NonEquatableStruct() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == true) - observe() + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } + object?.referenceTypeStoredProperty = NonEquatableClass() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() - @Test - func nonEquatableComputedProperty() { - var object: ObjectWithNonEquatableProperties? = .init() - var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.computedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.computedProperty - } onChange: { - observationsQueue.append(true) + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func computedProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableStruct]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.computedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.computedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = NonEquatableStruct() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeComputedProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeComputedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeComputedProperty + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == nil) + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = NonEquatableStruct() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == true) - observe() + object?.referenceTypeStoredProperty = NonEquatableClass() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } } } extension AnyPropertyPublisherTests { - @Publishable @Observable - fileprivate final class ObjectWithEquatableProperties { + struct EquatableType { + + fileprivate final class EquatableClass: Equatable { + + let value: Int + + init(value: Int) { + self.value = value + } + + static func == (lhs: EquatableClass, rhs: EquatableClass) -> Bool { + lhs.value == rhs.value + } + } + + @Publishable @Observable + fileprivate final class Object { - var storedProperty = 0 - var unrelatedProperty = 0 + var unrelatedProperty = 0 - var computedProperty: Int { - storedProperty + var storedProperty = 0 + var computedProperty: Int { + storedProperty + } + + var referenceTypeStoredProperty = EquatableClass(value: 0) + var referenceTypeComputedProperty: EquatableClass { + referenceTypeStoredProperty + } } - } - @Test - func equatableStoredProperty() { - var object: ObjectWithEquatableProperties? = .init() - var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.storedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.storedProperty - } onChange: { - observationsQueue.append(true) + @Test + func storedProperty() { + var object: Object? = .init() + var publishableQueue = [Int]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.storedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.storedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() == 0) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = 0 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty += 1 + #expect(publishableQueue.popFirst() == 1) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() == 0) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeStoredProperty() { + var object: Object? = .init() + var publishableQueue = [EquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeStoredProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeStoredProperty + } onChange: { + observationsQueue.append(true) + } + } - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + observe() + #expect(publishableQueue.popFirst() == EquatableClass(value: 0)) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = 0 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty += 1 - #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() == true) - observe() + object?.referenceTypeStoredProperty = EquatableClass(value: 0) + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } + object?.referenceTypeStoredProperty = EquatableClass(value: 1) + #expect(publishableQueue.popFirst() == EquatableClass(value: 1)) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } - @Test - func equatableComputedProperty() { - var object: ObjectWithEquatableProperties? = .init() - var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.computedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.computedProperty - } onChange: { - observationsQueue.append(true) + @Test + func computedProperty() { + var object: Object? = .init() + var publishableQueue = [Int]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.computedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.computedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() == 0) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = 0 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty += 1 + #expect(publishableQueue.popFirst() == 1) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() == 0) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeComputedProperty() { + var object: Object? = .init() + var publishableQueue = [EquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeComputedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeComputedProperty + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == EquatableClass(value: 0)) + #expect(observationsQueue.popFirst() == nil) - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = 0 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + object?.referenceTypeStoredProperty = EquatableClass(value: 0) + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty += 1 - #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() == true) - observe() + object?.referenceTypeStoredProperty = EquatableClass(value: 1) + #expect(publishableQueue.popFirst() == EquatableClass(value: 1)) + #expect(observationsQueue.popFirst() == true) + observe() - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } } } From 3aa89fafe4a4132307f2d02d87382263b6073fd4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 22 Nov 2025 14:21:58 +0100 Subject: [PATCH 04/45] [SwiftFormat] Applied formatting --- Macros/RelayMacros/Publishable/PublishableMacro.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Macros/RelayMacros/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Publishable/PublishableMacro.swift index e76d1e4..e4c4102 100644 --- a/Macros/RelayMacros/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Publishable/PublishableMacro.swift @@ -31,7 +31,7 @@ extension PublishableMacro: MemberMacro { of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo _: [TypeSyntax], - in context: some MacroExpansionContext + in _: some MacroExpansionContext ) throws -> [DeclSyntax] { let declaration = try validate(declaration) let parameters = try Parameters(from: node) @@ -70,7 +70,7 @@ extension PublishableMacro: ExtensionMacro { attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, conformingTo _: [TypeSyntax], - in context: some MacroExpansionContext + in _: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { let declaration = try validate(declaration) let parameters = try Parameters(from: node) From 7a241aa75c3f27d16e879a6af2d3d976bf48594c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 22 Nov 2025 14:23:05 +0100 Subject: [PATCH 05/45] - --- Macros/RelayMacros/Memoized/MemoizedMacro.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 27e07bc..0186ab8 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -21,7 +21,7 @@ public enum MemoizedMacro { _ declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext, with parameters: Parameters - ) throws -> Input? { + ) throws -> Input { guard let declaration = declaration.as(FunctionDeclSyntax.self), let trimmedReturnType = trimmedReturnType(of: declaration), declaration.signature.parameterClause.parameters.isEmpty, @@ -109,10 +109,6 @@ extension MemoizedMacro: PeerMacro { let parameters = try Parameters(from: node) let input = try validate(declaration, in: context, with: parameters) - guard let input else { - return [] - } - let builder = MemoizedDeclBuilder( declaration: input.declaration, trimmedReturnType: input.trimmedReturnType, From 346b74a0bc586c202805b430705fa5112642899e Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 22 Nov 2025 14:47:17 +0100 Subject: [PATCH 06/45] - --- .../Common}/PropertyPublisherDeclBuilder.swift | 4 ++++ .../Common}/PublisherDeclBuilder.swift | 11 ++++++++++- .../RelayMacros/Combine/Common/PublisherIgnored.swift | 8 ++++++++ .../Publishable/ObservationRegistrarDeclBuilder.swift | 0 .../{ => Combine}/Publishable/PublishableMacro.swift | 0 Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift | 8 ++++++++ 6 files changed, 30 insertions(+), 1 deletion(-) rename Macros/RelayMacros/{Publishable => Combine/Common}/PropertyPublisherDeclBuilder.swift (97%) rename Macros/RelayMacros/{Publishable => Combine/Common}/PublisherDeclBuilder.swift (74%) create mode 100644 Macros/RelayMacros/Combine/Common/PublisherIgnored.swift rename Macros/RelayMacros/{ => Combine}/Publishable/ObservationRegistrarDeclBuilder.swift (100%) rename Macros/RelayMacros/{ => Combine}/Publishable/PublishableMacro.swift (100%) create mode 100644 Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift diff --git a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift similarity index 97% rename from Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift rename to Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index de0564d..9e202d5 100644 --- a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -14,6 +14,10 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let properties: PropertiesList let preferredGlobalActorIsolation: GlobalActorIsolation? + var maxAllowedAccessControlLevel: AccessControlLevel { + .open + } + func build() -> [DeclSyntax] { [ """ diff --git a/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift similarity index 74% rename from Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift rename to Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift index c5c70c4..8c533ce 100644 --- a/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift @@ -13,8 +13,15 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList + var maxAllowedAccessControlLevel: AccessControlLevel { + .open + } + func build() -> [DeclSyntax] { [ + """ + private lazy var _publisher = PropertyPublisher(object: self) + """, """ /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. @@ -23,7 +30,9 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - \(inheritedAccessControlLevel)private(set) lazy var publisher = PropertyPublisher(object: self) + \(inheritedAccessControlLevel)var publisher: PropertyPublisher { + _publisher + } """ ] } diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift new file mode 100644 index 0000000..b2e42d2 --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift @@ -0,0 +1,8 @@ +// +// PublisherIgnored.swift +// Relay +// +// Created by Kamil Strzelecki on 22/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + diff --git a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift similarity index 100% rename from Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift rename to Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift diff --git a/Macros/RelayMacros/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift similarity index 100% rename from Macros/RelayMacros/Publishable/PublishableMacro.swift rename to Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift new file mode 100644 index 0000000..17d1435 --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -0,0 +1,8 @@ +// +// RelayedMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 22/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + From 79002db8317a0455ff5e9912b6908ab80c9740d8 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 22 Nov 2025 14:47:17 +0100 Subject: [PATCH 07/45] [SwiftFormat] Applied formatting --- Macros/RelayMacros/Combine/Common/PublisherIgnored.swift | 1 - Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift index b2e42d2..eb4e5e9 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift @@ -5,4 +5,3 @@ // Created by Kamil Strzelecki on 22/11/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // - diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift index 17d1435..e7776ea 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -5,4 +5,3 @@ // Created by Kamil Strzelecki on 22/11/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // - From c5b8de2b13f7814412b5125a0afe0b8b65285d21 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 12:36:22 +0100 Subject: [PATCH 08/45] - --- .github/workflows/pull-request.yml | 10 +++++----- .github/workflows/release.yml | 2 +- Macros/Dependencies/PrincipleMacros | 2 +- .../Combine/Common/ObservableMacro.swift | 14 ++++++++++++++ .../Common/ObservationIgnoredMacro.swift | 14 ++++++++++++++ .../Common/PropertyPublisherDeclBuilder.swift | 2 +- .../Combine/Common/PublisherIgnored.swift | 17 ++++++++++++++++- .../Combine/Relayed/RelayedMacro.swift | 7 +++++++ Macros/RelayMacros/Main/RelayPlugin.swift | 1 + Macros/RelayMacros/Memoized/MemoizedMacro.swift | 2 ++ .../Common}/AnyPropertyPublisher.swift | 0 .../Common}/Publishable.swift | 0 .../PublishableObservationRegistrar.swift | 0 .../Publishable/SwiftObservationRegistrar.swift | 0 14 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 Macros/RelayMacros/Combine/Common/ObservableMacro.swift create mode 100644 Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift rename Sources/Relay/{Publishable => Combine/Common}/AnyPropertyPublisher.swift (100%) rename Sources/Relay/{Publishable => Combine/Common}/Publishable.swift (100%) rename Sources/Relay/{ => Combine}/Publishable/PublishableObservationRegistrar.swift (100%) rename Sources/Relay/{ => Combine}/Publishable/SwiftObservationRegistrar.swift (100%) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b6e8af2..ac7ec49 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - XCODE_VERSION: "26.1" + XCODE_VERSION: "26.0" jobs: prepare: @@ -88,16 +88,16 @@ jobs: destination="platform=macOS,variant=Mac Catalyst" ;; ios) - destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.1" + destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.0.1" ;; tvos) - destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.1" + destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.0" ;; watchos) - destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.1" + destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.0" ;; visionos) - destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.1" + destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.0" ;; *) echo "Unknown platform: ${{ matrix.platform }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b076f96..80556d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - '*' env: - XCODE_VERSION: "26.1" + XCODE_VERSION: "26.0" jobs: release: diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index 9d7a483..8cf6797 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit 9d7a4835024b9e310daf5d1964a5ab22ff59900a +Subproject commit 8cf6797ddfdc8cf937cb320639a1b9cd7326d561 diff --git a/Macros/RelayMacros/Combine/Common/ObservableMacro.swift b/Macros/RelayMacros/Combine/Common/ObservableMacro.swift new file mode 100644 index 0000000..427e164 --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/ObservableMacro.swift @@ -0,0 +1,14 @@ +// +// ObservableMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal enum ObservableMacro { + + static let attribute: AttributeSyntax = "@Observation" +} diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift new file mode 100644 index 0000000..4a33e7f --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -0,0 +1,14 @@ +// +// ObservationIgnoredMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 22/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +enum ObservationIgnoredMacro { + + static let attribute: AttributeSyntax = "@ObservationIgnored" +} diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 9e202d5..020bdce 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -85,7 +85,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { for member in declaration.memberBlock.members { if let functionDecl = member.decl.as(FunctionDeclSyntax.self), - let attribute = functionDecl.attributes.first(like: "@Memoized"), + let attribute = functionDecl.attributes.first(like: MemoizedMacro.attribute), let parameters = try? MemoizedMacro.Parameters(from: attribute), let trimmedReturnType = MemoizedMacro.trimmedReturnType(of: functionDecl) { let globalActor = parameters.preferredGlobalActorIsolation ?? inheritedGlobalActorIsolation diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift index eb4e5e9..12b56d2 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift @@ -1,7 +1,22 @@ // -// PublisherIgnored.swift +// PublisherIgnoredMacro.swift // Relay // // Created by Kamil Strzelecki on 22/11/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // + +import SwiftSyntaxMacros + +public enum PublisherIgnoredMacro: AccessorMacro { + + static let attribute: AttributeSyntax = "@PublisherIgnored" + + public static func expansion( + of _: AttributeSyntax, + providingAccessorsOf _: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) -> [AccessorDeclSyntax] { + [] + } +} diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift index e7776ea..f12c297 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -5,3 +5,10 @@ // Created by Kamil Strzelecki on 22/11/2025. // Copyright © 2025 Kamil Strzelecki. All rights reserved. // + +import SwiftSyntaxMacros + +public enum RelayedMacro { + + static let attribute: AttributeSyntax = "@Relayed" +} diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index 9da9a12..bf089f6 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -14,6 +14,7 @@ internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ PublishableMacro.self, + PublisherIgnoredMacro.self, MemoizedMacro.self ] } diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 0186ab8..4208488 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -10,6 +10,8 @@ import SwiftSyntaxMacros public enum MemoizedMacro { + static let attribute: AttributeSyntax = "@Memoized" + private struct Input { let declaration: FunctionDeclSyntax diff --git a/Sources/Relay/Publishable/AnyPropertyPublisher.swift b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift similarity index 100% rename from Sources/Relay/Publishable/AnyPropertyPublisher.swift rename to Sources/Relay/Combine/Common/AnyPropertyPublisher.swift diff --git a/Sources/Relay/Publishable/Publishable.swift b/Sources/Relay/Combine/Common/Publishable.swift similarity index 100% rename from Sources/Relay/Publishable/Publishable.swift rename to Sources/Relay/Combine/Common/Publishable.swift diff --git a/Sources/Relay/Publishable/PublishableObservationRegistrar.swift b/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift similarity index 100% rename from Sources/Relay/Publishable/PublishableObservationRegistrar.swift rename to Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift diff --git a/Sources/Relay/Publishable/SwiftObservationRegistrar.swift b/Sources/Relay/Combine/Publishable/SwiftObservationRegistrar.swift similarity index 100% rename from Sources/Relay/Publishable/SwiftObservationRegistrar.swift rename to Sources/Relay/Combine/Publishable/SwiftObservationRegistrar.swift From d149d39306d0a6b8d8e104d390f23a69e97d115b Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:25:28 +0100 Subject: [PATCH 09/45] - --- .swiftlint.yml | 4 +- Macros/Dependencies/PrincipleMacros | 2 +- .../Common/PropertyPublisherDeclBuilder.swift | 74 +++++++++--- .../Combine/Common/PublisherDeclBuilder.swift | 9 +- .../ObservationRegistrarDeclBuilder.swift | 31 ++++-- .../Combine/Common/AnyPropertyPublisher.swift | 105 ++++++++++-------- 6 files changed, 143 insertions(+), 82 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 5bc3ecb..712e1e7 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -27,7 +27,7 @@ opt_in_rules: - discouraged_assert - discouraged_none_name - discouraged_object_literal - - discouraged_optional_boolean + # discouraged_optional_boolean - discouraged_optional_collection - empty_collection_literal - empty_count @@ -67,7 +67,7 @@ opt_in_rules: - local_doc_comment - lower_acl_than_parent # missing_docs - - modifier_order + # modifier_order - multiline_arguments - multiline_arguments_brackets - multiline_function_chains diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index 8cf6797..906bd5f 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit 8cf6797ddfdc8cf937cb320639a1b9cd7326d561 +Subproject commit 906bd5f9a8f7a229b208520fc40ad13c7822be2a diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 020bdce..6ac3a14 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -12,16 +12,20 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList + let trimmedSuperclassType: TypeSyntax? let preferredGlobalActorIsolation: GlobalActorIsolation? - var maxAllowedAccessControlLevel: AccessControlLevel { - .open - } - func build() -> [DeclSyntax] { [ """ - \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedType)> { + \(inheritedGlobalActorIsolation)\(inheritedAccessControlLevelAllowingOpen)\(inheritedFinalModifier)\ + class PropertyPublisher: \(inheritanceClause()) { + + private final unowned let object: \(trimmedType) + + \(objectWillChangeDidChangePublishers()) + + \(initializer()) \(deinitializer()) @@ -35,9 +39,47 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { ] } + private func inheritanceClause() -> TypeSyntax { + if let trimmedSuperclassType { + "\(trimmedSuperclassType).PropertyPublisher" + } else { + "Relay.AnyPropertyPublisher" + } + } + + private func objectWillChangeDidChangePublishers() -> MemberBlockItemListSyntax { + let notation = CamelCaseNotation(string: trimmedType.description) + let prefix = notation.joined(as: .lowerCamelCase) + + return """ + \(inheritedAccessControlLevel)final var \ + \(raw: prefix)WillChange: some Publisher<\(trimmedType), Never> { + willChange.map { [unowned object] _ in + object + } + } + + \(inheritedAccessControlLevel)final var \ + \(raw: prefix)DidChange: some Publisher<\(trimmedType), Never> { + didChange.map { [unowned object] _ in + object + } + } + """ + } + + private func initializer() -> MemberBlockItemListSyntax { + """ + \(inheritedAccessControlLevel)init(object: \(trimmedType)) { + self.object = object + super.init(object: object) + } + """ + } + private func deinitializer() -> MemberBlockItemListSyntax { """ - deinit { + \(inheritedGlobalActorIsolation)deinit { \(storedPropertiesPublishersFinishCalls().formatted()) } """ @@ -53,14 +95,13 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.stored.mutable.instance.all { - let globalActor = inheritedGlobalActorIsolation let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType """ - fileprivate let _\(name) = PassthroughSubject<\(type), Never>() - \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { - _storedPropertyPublisher(_\(name), for: \\.\(name)) + fileprivate final let _\(name) = PassthroughSubject<\(type), Never>() + \(accessControlLevel)final var \(name): some Publisher<\(type), Never> { + _storedPropertyPublisher(_\(name), for: \\.\(name), object: object) } """ } @@ -69,13 +110,12 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.computed.instance.all { - let globalActor = inheritedGlobalActorIsolation let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType """ - \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(name)) + \(accessControlLevel)final var \(name): some Publisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(name), object: object) } """ } @@ -88,13 +128,13 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let attribute = functionDecl.attributes.first(like: MemoizedMacro.attribute), let parameters = try? MemoizedMacro.Parameters(from: attribute), let trimmedReturnType = MemoizedMacro.trimmedReturnType(of: functionDecl) { - let globalActor = parameters.preferredGlobalActorIsolation ?? inheritedGlobalActorIsolation - let accessControlLevel = parameters.preferredAccessControlLevel + let globalActor = parameters.preferredGlobalActorIsolation + let accessControlLevel = parameters.preferredAccessControlLevel?.inheritedBySibling() let name = parameters.preferredPropertyName ?? MemoizedMacro.defaultPropertyName(for: functionDecl) let type = trimmedReturnType """ - \(globalActor)\(accessControlLevel)var \(raw: name): AnyPublisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(raw: name)) + \(globalActor)\(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(raw: name), object: object) } """ } diff --git a/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift index 8c533ce..f7ee7c1 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift @@ -12,15 +12,12 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax let properties: PropertiesList - - var maxAllowedAccessControlLevel: AccessControlLevel { - .open - } + let trimmedSuperclassType: TypeSyntax? func build() -> [DeclSyntax] { [ """ - private lazy var _publisher = PropertyPublisher(object: self) + private final lazy var _publisher = PropertyPublisher(object: self) """, """ /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable @@ -30,7 +27,7 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - \(inheritedAccessControlLevel)var publisher: PropertyPublisher { + \(inheritedOverrideModifier)\(inheritedAccessControlLevelAllowingOpen)var publisher: PropertyPublisher { _publisher } """ diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index f0551e0..0c69ea4 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -13,17 +13,30 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin let declaration: ClassDeclSyntax let properties: PropertiesList let preferredGlobalActorIsolation: GlobalActorIsolation? + private let withMutationGenericParameter: TokenSyntax - private var registeredProperties: PropertiesList { + private var observableProperties: PropertiesList { properties.stored.mutable.instance } + init( + declaration: ClassDeclSyntax, + properties: PropertiesList, + preferredGlobalActorIsolation: GlobalActorIsolation?, + context: some MacroExpansionContext + ) { + self.declaration = declaration + self.properties = properties + self.preferredGlobalActorIsolation = preferredGlobalActorIsolation + self.withMutationGenericParameter = context.makeUniqueName("T") + } + func build() -> [DeclSyntax] { [ """ private enum Observation { - struct ObservationRegistrar: \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() @@ -55,7 +68,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { - for inferredType in registeredProperties.uniqueInferredTypes { + for inferredType in observableProperties.uniqueInferredTypes { """ if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, let subject = subject(for: keyPath, on: object) { @@ -68,7 +81,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @MemberBlockItemListBuilder private func subjectFunctions() -> MemberBlockItemListSyntax { - for inferredType in registeredProperties.uniqueInferredTypes { + for inferredType in observableProperties.uniqueInferredTypes { """ \(inheritedGlobalActorIsolation)private func subject( for keyPath: KeyPath<\(trimmedType), \(inferredType)>, @@ -82,7 +95,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in registeredProperties.withInferredType(like: inferredType).all { + for property in observableProperties.withInferredType(like: inferredType).all { let name = property.trimmedName """ if keyPath == \\.\(name) { @@ -131,14 +144,14 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin private func observationRegistrarWithMutationFunction() -> MemberBlockItemListSyntax { """ - nonisolated func withMutation( + nonisolated func withMutation<\(withMutationGenericParameter)>( of object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any>, - _ mutation: () throws -> T - ) rethrows -> T { + _ mutation: () throws -> \(withMutationGenericParameter) + ) rethrows -> \(withMutationGenericParameter) { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: T! + nonisolated(unsafe) var result: \(withMutationGenericParameter)! try assumeIsolatedIfNeeded { object.publisher._beginModifications() diff --git a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift index 3a8c5a2..beec736 100644 --- a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift +++ b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift @@ -13,30 +13,27 @@ import Combine /// Subclasses of this class are generated by the ``Publishable()`` macro, and provide publishers /// for all mutable or computed instance properties of the type the macro is applied to. /// -open class AnyPropertyPublisher { +open class AnyPropertyPublisher { - private let _willChange = PassthroughSubject() - private let _didChange = PassthroughSubject() + private final let _willChange = PassthroughSubject() + private final let _didChange = PassthroughSubject() /// Emits the `Object` **before** any of its stored properties are assigned a new value. /// - public var willChange: AnyPublisher { - _willChange.eraseToAnyPublisher() + public final var willChange: some Publisher { + _willChange } /// Emits the `Object` **after** any of its stored properties are assigned a new value. /// - public var didChange: AnyPublisher { - _didChange.eraseToAnyPublisher() + public final var didChange: some Publisher { + _didChange } - private var pendingModifications = 0 - private unowned let object: Object + private final var pendingModifications = 0 @_documentation(visibility: private) - public init(object: Object) { - self.object = object - } + public init(object _: AnyObject) {} deinit { _willChange.send(completion: .finished) @@ -48,16 +45,16 @@ open class AnyPropertyPublisher { extension AnyPropertyPublisher { - public func _beginModifications() { + public final func _beginModifications() { pendingModifications += 1 if pendingModifications == 1 { - _willChange.send(object) + _willChange.send(()) } } - public func _endModifications() { + public final func _endModifications() { if pendingModifications == 1 { - _didChange.send(object) + _didChange.send(()) } pendingModifications -= 1 } @@ -65,55 +62,69 @@ extension AnyPropertyPublisher { extension AnyPropertyPublisher { - public func _storedPropertyPublisher( + public final func _storedPropertyPublisher( _ subject: PassthroughSubject, - for keyPath: KeyPath - ) -> AnyPublisher { - subject - .prepend(object[keyPath: keyPath]) - .eraseToAnyPublisher() + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject { + subject.prepend(object[keyPath: keyPath]) } } extension AnyPropertyPublisher { - public func _computedPropertyPublisher( - for keyPath: KeyPath - ) -> AnyPublisher { + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject { _didChange - .prepend(object) - .map { $0[keyPath: keyPath] } - .eraseToAnyPublisher() + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } } - public func _computedPropertyPublisher( - for keyPath: KeyPath - ) -> AnyPublisher { + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject, T: Equatable { _didChange - .prepend(object) - .map { $0[keyPath: keyPath] } + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } .removeDuplicates() - .eraseToAnyPublisher() } - public func _computedPropertyPublisher( - for keyPath: KeyPath - ) -> AnyPublisher { + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject, T: AnyObject { _didChange - .prepend(object) - .map { $0[keyPath: keyPath] } - .removeDuplicates { $0 === $1 } - .eraseToAnyPublisher() + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } + .removeDuplicates { lhs, rhs in + lhs === rhs + } } - public func _computedPropertyPublisher( - for keyPath: KeyPath - ) -> AnyPublisher { + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject, T: AnyObject & Equatable { _didChange - .prepend(object) - .map { $0[keyPath: keyPath] } + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } .removeDuplicates() - .eraseToAnyPublisher() } } From ec4fbe984863962bf3514e11f2773061a0ea12a4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:25:28 +0100 Subject: [PATCH 10/45] [SwiftFormat] Applied formatting --- .../Combine/Common/PropertyPublisherDeclBuilder.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 6ac3a14..8809cc3 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -20,11 +20,11 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { """ \(inheritedGlobalActorIsolation)\(inheritedAccessControlLevelAllowingOpen)\(inheritedFinalModifier)\ class PropertyPublisher: \(inheritanceClause()) { - + private final unowned let object: \(trimmedType) - + \(objectWillChangeDidChangePublishers()) - + \(initializer()) \(deinitializer()) @@ -58,7 +58,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { object } } - + \(inheritedAccessControlLevel)final var \ \(raw: prefix)DidChange: some Publisher<\(trimmedType), Never> { didChange.map { [unowned object] _ in From 41aab396c9e61c98d38a5efb97edbb2d88aee599 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:27:16 +0100 Subject: [PATCH 11/45] - --- .../Publishable/PublishableMacro.swift | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index e4c4102..182ca45 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -11,14 +11,13 @@ import SwiftSyntaxMacros public enum PublishableMacro { private static func validate( - _ declaration: some DeclGroupSyntax + _ declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext ) throws -> ClassDeclSyntax { - guard let declaration = declaration.as(ClassDeclSyntax.self), - declaration.isFinal - else { + guard let declaration = declaration.as(ClassDeclSyntax.self) else { throw DiagnosticsError( node: declaration, - message: "Publishable macro can only be applied to final Observable classes" + message: "Publishable macro can only be applied to Observable classes" ) } return declaration @@ -31,29 +30,30 @@ extension PublishableMacro: MemberMacro { of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo _: [TypeSyntax], - in _: some MacroExpansionContext + in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - let declaration = try validate(declaration) + let declaration = try validate(declaration, in: context) let parameters = try Parameters(from: node) - - let properties = try PropertiesParser.parse( - memberBlock: declaration.memberBlock - ) + let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) + let inferredSuperclass = try declaration.inferredSuperclass(isExpected: parameters.hasSuperclass) let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, - properties: properties + properties: properties, + trimmedSuperclassType: inferredSuperclass ), PropertyPublisherDeclBuilder( declaration: declaration, properties: properties, + trimmedSuperclassType: inferredSuperclass, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( declaration: declaration, properties: properties, - preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation, + context: context ) ] @@ -69,10 +69,14 @@ extension PublishableMacro: ExtensionMacro { of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, - conformingTo _: [TypeSyntax], - in _: some MacroExpansionContext + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - let declaration = try validate(declaration) + guard !protocols.isEmpty else { + return [] + } + + let declaration = try validate(declaration, in: context) let parameters = try Parameters(from: node) let globalActorIsolation = GlobalActorIsolation.resolved( @@ -103,10 +107,12 @@ extension PublishableMacro { private struct Parameters { + let hasSuperclass: Bool? let preferredGlobalActorIsolation: GlobalActorIsolation? init(from node: AttributeSyntax) throws { let extractor = ParameterExtractor(from: node) + self.hasSuperclass = try extractor.rawBool(withLabel: "hasSuperclass") self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") } } From 131393692c6bef65efb7db5c1722f305cdb419e0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:27:16 +0100 Subject: [PATCH 12/45] [SwiftFormat] Applied formatting --- Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 182ca45..808f70e 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -12,7 +12,7 @@ public enum PublishableMacro { private static func validate( _ declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext + in _: some MacroExpansionContext ) throws -> ClassDeclSyntax { guard let declaration = declaration.as(ClassDeclSyntax.self) else { throw DiagnosticsError( From 0dbc61aee10e4945f2bd328194fe721d8c61adb4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:29:35 +0100 Subject: [PATCH 13/45] - --- .../Combine/Common/PublishableProtocol.swift | 29 +++++++++ .../{Common => Publishable}/Publishable.swift | 65 ++++++++++++------- 2 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 Sources/Relay/Combine/Common/PublishableProtocol.swift rename Sources/Relay/Combine/{Common => Publishable}/Publishable.swift (63%) diff --git a/Sources/Relay/Combine/Common/PublishableProtocol.swift b/Sources/Relay/Combine/Common/PublishableProtocol.swift new file mode 100644 index 0000000..8e6ea6d --- /dev/null +++ b/Sources/Relay/Combine/Common/PublishableProtocol.swift @@ -0,0 +1,29 @@ +// +// PublishableProtocol.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Observation + +/// 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 Publishable: AnyObject, Observable { + + /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, + /// containing publishers for all mutable or computed instance properties of the type. + /// + associatedtype PropertyPublisher: AnyPropertyPublisher + + /// An instance that exposes `Combine` publishers for all mutable or computed 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. + /// + var publisher: PropertyPublisher { get } +} diff --git a/Sources/Relay/Combine/Common/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift similarity index 63% rename from Sources/Relay/Combine/Common/Publishable.swift rename to Sources/Relay/Combine/Publishable/Publishable.swift index 2c081db..ddf5edf 100644 --- a/Sources/Relay/Combine/Common/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -6,8 +6,6 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import Observation - /// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. /// /// - Note: This macro infers the global actor isolation of the type and applies it to the generated declarations. @@ -30,13 +28,16 @@ import Observation names: named(_publisher), named(publisher), named(PropertyPublisher), + named(_PropertyPublisher), named(Observation) ) @attached( extension, conformances: Publishable ) -public macro Publishable() = #externalMacro( +public macro Publishable( + hasSuperclass: Bool = false +) = #externalMacro( module: "RelayMacros", type: "PublishableMacro" ) @@ -64,35 +65,55 @@ public macro Publishable() = #externalMacro( names: named(_publisher), named(publisher), named(PropertyPublisher), + named(_PropertyPublisher), named(Observation) ) @attached( extension, conformances: Publishable ) -public macro Publishable( - isolation: Isolation.Type? +public macro Publishable( + hasSuperclass: Bool = false, + isolation: MainActor.Type? ) = #externalMacro( module: "RelayMacros", type: "PublishableMacro" ) -/// A type that can be observed using both the `Observation` and `Combine` frameworks. +/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. /// -/// You don't need to declare conformance to this protocol yourself. -/// It is generated automatically when you apply the ``Publishable()`` macro to your type. +/// - Parameter isolation: The global actor to which the type is isolated. +/// If set to `nil`, the generated members are `nonisolated`. +/// To infer isolation automatically, use the ``Publishable()`` macro instead. /// -public protocol Publishable: AnyObject, Observable { - - /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, - /// containing publishers for all mutable or computed instance properties of the type. - /// - associatedtype PropertyPublisher: AnyPropertyPublisher - - /// An instance that exposes `Combine` publishers for all mutable or computed 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. - /// - var publisher: PropertyPublisher { get } -} +/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied directly. +/// +/// The `@Publishable` macro adds a new `publisher` property to your type, +/// which exposes `Combine` publishers for all mutable or computed instance properties. +/// +/// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. +/// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. +/// +/// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. +/// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. +/// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. +/// +@attached( + member, + names: named(_publisher), + named(publisher), + named(PropertyPublisher), + named(_PropertyPublisher), + named(Observation) +) +@attached( + extension, + conformances: Publishable +) +public macro Publishable( + hasSuperclass: Bool = false, + isolation: Isolation.Type +) = #externalMacro( + module: "RelayMacros", + type: "PublishableMacro" +) From e88cfb2a38fc448e2a629ffa06d315e9fa1b80c1 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:30:33 +0100 Subject: [PATCH 14/45] - --- Sources/Relay/Combine/Publishable/Publishable.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index ddf5edf..f4b997d 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -28,7 +28,6 @@ names: named(_publisher), named(publisher), named(PropertyPublisher), - named(_PropertyPublisher), named(Observation) ) @attached( @@ -65,7 +64,6 @@ public macro Publishable( names: named(_publisher), named(publisher), named(PropertyPublisher), - named(_PropertyPublisher), named(Observation) ) @attached( @@ -103,7 +101,6 @@ public macro Publishable( names: named(_publisher), named(publisher), named(PropertyPublisher), - named(_PropertyPublisher), named(Observation) ) @attached( From bcfcd716fc7a3f02b64fe1d614e61e8185f47250 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:41:01 +0100 Subject: [PATCH 15/45] - --- .../MainActorPublishableMacroTests.swift | 340 ++++++++++++++++-- .../Publishable/PublishableMacroTests.swift | 321 +++++++++++++++-- .../MainActorPublishableTests.swift | 4 +- .../ObservationPublishableTests.swift | 4 +- .../SwiftDataPublishableTests.swift | 4 +- 5 files changed, 614 insertions(+), 59 deletions(-) diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 9c9537e..05ba4e1 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -8,13 +8,17 @@ #if canImport(RelayMacros) import RelayMacros + import SwiftSyntaxMacroExpansion import SwiftSyntaxMacrosTestSupport import XCTest internal final class MainActorPublishableMacroTests: XCTestCase { - private let macros: [String: any Macro.Type] = [ - "Publishable": PublishableMacro.self + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) ] func testExpansion() { @@ -81,6 +85,8 @@ "\(fullName), \(age)" } + private final lazy var _publisher = PropertyPublisher(object: self) + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -88,44 +94,65 @@ /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - public private(set) lazy var publisher = PropertyPublisher(object: self) - - public final class PropertyPublisher: AnyPropertyPublisher { - - deinit { + public var publisher: PropertyPublisher { + _publisher + } + + @MainActor public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } + + @MainActor deinit { _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) } - fileprivate let _age = PassthroughSubject() - @MainActor var age: AnyPublisher { - _storedPropertyPublisher(_age, for: \.age) + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) } - fileprivate let _name = PassthroughSubject() - @MainActor var name: AnyPublisher { - _storedPropertyPublisher(_name, for: \.name) + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) } - fileprivate let _surname = PassthroughSubject() - @MainActor public var surname: AnyPublisher { - _storedPropertyPublisher(_surname, for: \.surname) + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) } - @MainActor internal var fullName: AnyPublisher { - _computedPropertyPublisher(for: \.fullName) + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) } - @MainActor fileprivate var initials: AnyPublisher { - _computedPropertyPublisher(for: \.initials) + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) } - @MainActor public var label: AnyPublisher { - _computedPropertyPublisher(for: \.label) + public final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() @@ -197,14 +224,14 @@ underlying.access(object, keyPath: keyPath) } - nonisolated func withMutation( + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Person, keyPath: KeyPath, - _ mutation: () throws -> T - ) rethrows -> T { + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: T! + nonisolated(unsafe) var result: __macro_local_1TfMu_! try assumeIsolatedIfNeeded { object.publisher._beginModifications() @@ -247,7 +274,264 @@ extension Person: @MainActor Publishable { } """#, - macros: macros + macroSpecs: macroSpecs + ) + } + + func testExpansionWithExplicitIsolation() { + assertMacroExpansion( + #""" + @CustomActor @Publishable(isolation: MainActor.self) @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + } + """#, + expandedSource: + #""" + @CustomActor @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + public var publisher: PropertyPublisher { + _publisher + } + + @MainActor public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } + + @MainActor deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + } + + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) + } + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + + public final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) + } + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor 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 + } + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + } + + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.age { + return object.publisher._age + } + return nil + } + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + if keyPath == \.surname { + return object.publisher._surname + } + return nil + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + + extension Person: @MainActor Publishable { + } + """#, + macroSpecs: macroSpecs ) } } diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index a225b80..04c88a3 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -8,13 +8,17 @@ #if canImport(RelayMacros) import RelayMacros + import SwiftSyntaxMacroExpansion import SwiftSyntaxMacrosTestSupport import XCTest internal final class PublishableMacroTests: XCTestCase { - private let macros: [String: any Macro.Type] = [ - "Publishable": PublishableMacro.self + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) ] func testExpansion() { @@ -81,6 +85,8 @@ "\(fullName), \(age)" } + private final lazy var _publisher = PropertyPublisher(object: self) + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -88,9 +94,30 @@ /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - public private(set) lazy var publisher = PropertyPublisher(object: self) + public var publisher: PropertyPublisher { + _publisher + } + + public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } - public final class PropertyPublisher: AnyPropertyPublisher { + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } deinit { _age.send(completion: .finished) @@ -98,34 +125,34 @@ _surname.send(completion: .finished) } - fileprivate let _age = PassthroughSubject() - var age: AnyPublisher { - _storedPropertyPublisher(_age, for: \.age) + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) } - fileprivate let _name = PassthroughSubject() - var name: AnyPublisher { - _storedPropertyPublisher(_name, for: \.name) + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) } - fileprivate let _surname = PassthroughSubject() - public var surname: AnyPublisher { - _storedPropertyPublisher(_surname, for: \.surname) + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) } - internal var fullName: AnyPublisher { - _computedPropertyPublisher(for: \.fullName) + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) } - fileprivate var initials: AnyPublisher { - _computedPropertyPublisher(for: \.initials) + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) } - public var label: AnyPublisher { - _computedPropertyPublisher(for: \.label) + public final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - struct ObservationRegistrar: PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() @@ -197,14 +224,14 @@ underlying.access(object, keyPath: keyPath) } - nonisolated func withMutation( + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Person, keyPath: KeyPath, - _ mutation: () throws -> T - ) rethrows -> T { + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: T! + nonisolated(unsafe) var result: __macro_local_1TfMu_! try assumeIsolatedIfNeeded { object.publisher._beginModifications() @@ -234,7 +261,251 @@ extension Person: Publishable { } """#, - macros: macros + macroSpecs: macroSpecs + ) + } + + func testExpansionWithExplicitIsolation() { + assertMacroExpansion( + #""" + @Publishable(isolation: nil) @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + } + """#, + expandedSource: + #""" + @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + public var publisher: PropertyPublisher { + _publisher + } + + nonisolated public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } + + nonisolated deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + } + + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) + } + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + + public final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) + } + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: nonisolated PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + nonisolated 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 + } + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + } + + nonisolated private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.age { + return object.publisher._age + } + return nil + } + nonisolated private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + if keyPath == \.surname { + return object.publisher._surname + } + return nil + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + + extension Person: nonisolated Publishable { + } + """#, + macroSpecs: macroSpecs ) } } diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 8c86215..72f34aa 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -108,7 +108,7 @@ extension MainActorPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.willChange.sink( + let cancellable = person?.publisher.personWillChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -157,7 +157,7 @@ extension MainActorPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.didChange.sink( + let cancellable = person?.publisher.personDidChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index 38f7cc2..83e586d 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -107,7 +107,7 @@ extension ObservationPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.willChange.sink( + let cancellable = person?.publisher.personWillChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -156,7 +156,7 @@ extension ObservationPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.didChange.sink( + let cancellable = person?.publisher.personDidChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) diff --git a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift index 8404c4b..5f1efc7 100644 --- a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift @@ -108,7 +108,7 @@ extension SwiftDataPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.willChange.sink( + let cancellable = person?.publisher.personWillChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -157,7 +157,7 @@ extension SwiftDataPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.didChange.sink( + let cancellable = person?.publisher.personDidChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) From 852dcef726192a51faa568e77178baac559d4bcb Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 18:41:01 +0100 Subject: [PATCH 16/45] [SwiftFormat] Applied formatting --- .../MainActorPublishableMacroTests.swift | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 05ba4e1..0c710e6 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -86,7 +86,7 @@ } private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -97,28 +97,28 @@ public var publisher: PropertyPublisher { _publisher } - + @MainActor public final class PropertyPublisher: Relay.AnyPropertyPublisher { - + private final unowned let object: Person - + public final var personWillChange: some Publisher { willChange.map { [unowned object] _ in object } } - + public final var personDidChange: some Publisher { didChange.map { [unowned object] _ in object } } - + public init(object: Person) { self.object = object super.init(object: object) } - + @MainActor deinit { _age.send(completion: .finished) _name.send(completion: .finished) @@ -283,28 +283,28 @@ #""" @CustomActor @Publishable(isolation: MainActor.self) @Observable public final class Person { - + static var user: Person? - + let id: UUID fileprivate(set) var age: Int var name: String - + public var surname: String { didSet { print(oldValue) } } - + internal var fullName: String { "\(name) \(surname)" } - + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + @Memoized(.public) func makeLabel() -> String { "\(fullName), \(age)" @@ -315,35 +315,35 @@ #""" @CustomActor @Observable public final class Person { - + static var user: Person? - + let id: UUID fileprivate(set) var age: Int var name: String - + public var surname: String { didSet { print(oldValue) } } - + internal var fullName: String { "\(name) \(surname)" } - + private var initials: String { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + @Memoized(.public) func makeLabel() -> String { "\(fullName), \(age)" } - + private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -354,34 +354,34 @@ public var publisher: PropertyPublisher { _publisher } - + @MainActor public final class PropertyPublisher: Relay.AnyPropertyPublisher { - + private final unowned let object: Person - + public final var personWillChange: some Publisher { willChange.map { [unowned object] _ in object } } - + public final var personDidChange: some Publisher { didChange.map { [unowned object] _ in object } } - + public init(object: Person) { self.object = object super.init(object: object) } - + @MainActor deinit { _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) } - + fileprivate final let _age = PassthroughSubject() final var age: some Publisher { _storedPropertyPublisher(_age, for: \.age, object: object) @@ -394,25 +394,25 @@ public final var surname: some Publisher { _storedPropertyPublisher(_surname, for: \.surname, object: object) } - + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } fileprivate final var initials: some Publisher { _computedPropertyPublisher(for: \.initials, object: object) } - + public final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) } } - + private enum Observation { - + nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { - + private let underlying = SwiftObservationRegistrar() - + @MainActor func publish( _ object: Person, keyPath: KeyPath @@ -428,7 +428,7 @@ return } } - + @MainActor private func subject( for keyPath: KeyPath, on object: Person @@ -450,7 +450,7 @@ } return nil } - + nonisolated func willSet( _ object: Person, keyPath: KeyPath @@ -461,7 +461,7 @@ underlying.willSet(object, keyPath: keyPath) } } - + nonisolated func didSet( _ object: Person, keyPath: KeyPath @@ -473,14 +473,14 @@ object.publisher._endModifications() } } - + nonisolated func access( _ object: Person, keyPath: KeyPath ) { underlying.access(object, keyPath: keyPath) } - + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Person, keyPath: KeyPath, @@ -489,7 +489,7 @@ nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath nonisolated(unsafe) var result: __macro_local_1TfMu_! - + try assumeIsolatedIfNeeded { object.publisher._beginModifications() defer { @@ -502,10 +502,10 @@ mutation ) } - + return result } - + private nonisolated func assumeIsolatedIfNeeded( _ operation: @MainActor () throws -> Void, file: StaticString = #fileID, @@ -514,7 +514,7 @@ try withoutActuallyEscaping(operation) { operation in typealias Nonisolated = () throws -> Void let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) - + try MainActor.shared.assumeIsolated( { _ in try rawOperation() @@ -527,7 +527,7 @@ } } } - + extension Person: @MainActor Publishable { } """#, From d188403ffe4d4f467cf24bc30884b24dbc9622ec Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 19:26:01 +0100 Subject: [PATCH 17/45] - --- .../Combine/Common/ObservationIgnoredMacro.swift | 2 +- .../{PublisherIgnored.swift => PublisherIgnoredMacro.swift} | 5 ++++- .../Publishable/ObservationRegistrarDeclBuilder.swift | 3 ++- Macros/RelayMacros/Memoized/MemoizedMacro.swift | 3 +++ Sources/Relay/Combine/Common/AnyPropertyPublisher.swift | 6 +++++- Sources/Relay/Combine/Common/PublishableProtocol.swift | 1 + 6 files changed, 16 insertions(+), 4 deletions(-) rename Macros/RelayMacros/Combine/Common/{PublisherIgnored.swift => PublisherIgnoredMacro.swift} (84%) diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift index 4a33e7f..f030aad 100644 --- a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -8,7 +8,7 @@ import SwiftSyntaxMacros -enum ObservationIgnoredMacro { +internal enum ObservationIgnoredMacro { static let attribute: AttributeSyntax = "@ObservationIgnored" } diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift similarity index 84% rename from Macros/RelayMacros/Combine/Common/PublisherIgnored.swift rename to Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift index 12b56d2..903b9a1 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnored.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift @@ -8,9 +8,12 @@ import SwiftSyntaxMacros -public enum PublisherIgnoredMacro: AccessorMacro { +public enum PublisherIgnoredMacro { static let attribute: AttributeSyntax = "@PublisherIgnored" +} + +extension PublisherIgnoredMacro: AccessorMacro { public static func expansion( of _: AttributeSyntax, diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index 0c69ea4..b47ef91 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -36,7 +36,8 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin """ private enum Observation { - nonisolated struct ObservationRegistrar: \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: \ + \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 4208488..fc0d4a8 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -11,6 +11,9 @@ import SwiftSyntaxMacros public enum MemoizedMacro { static let attribute: AttributeSyntax = "@Memoized" +} + +extension MemoizedMacro { private struct Input { diff --git a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift index beec736..aac3ff8 100644 --- a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift +++ b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift @@ -33,7 +33,9 @@ open class AnyPropertyPublisher { private final var pendingModifications = 0 @_documentation(visibility: private) - public init(object _: AnyObject) {} + public init(object _: AnyObject) { + // Void + } deinit { _willChange.send(completion: .finished) @@ -41,6 +43,7 @@ open class AnyPropertyPublisher { } } +// swiftlint:disable unowned_variable_capture // swiftlint:disable identifier_name extension AnyPropertyPublisher { @@ -129,3 +132,4 @@ extension AnyPropertyPublisher { } // swiftlint:enable identifier_name +// swiftlint:enable unowned_variable_capture diff --git a/Sources/Relay/Combine/Common/PublishableProtocol.swift b/Sources/Relay/Combine/Common/PublishableProtocol.swift index 8e6ea6d..6e02c21 100644 --- a/Sources/Relay/Combine/Common/PublishableProtocol.swift +++ b/Sources/Relay/Combine/Common/PublishableProtocol.swift @@ -1,3 +1,4 @@ +// swiftlint:disable:this file_name // // PublishableProtocol.swift // Relay From e001966b0434462f07453b85920884c54b7bc5f5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 23 Nov 2025 19:26:29 +0100 Subject: [PATCH 18/45] - --- .../GlobalActorPublishableMacroTests.swift | 282 ++++++++++++++++++ .../MainActorPublishableMacroTests.swift | 258 +--------------- .../NonisolatedPublishableMacroTests.swift | 268 +++++++++++++++++ .../Publishable/PublishableMacroTests.swift | 244 --------------- 4 files changed, 551 insertions(+), 501 deletions(-) create mode 100644 Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift new file mode 100644 index 0000000..275142f --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift @@ -0,0 +1,282 @@ +// +// GlobalActorPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + // swiftlint:disable:next type_body_length + internal final class GlobalActorPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @CustomActor @Publishable(isolation: MainActor.self) @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + } + """#, + expandedSource: + #""" + @CustomActor @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + public var publisher: PropertyPublisher { + _publisher + } + + @MainActor public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } + + @MainActor deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + } + + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) + } + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + + public final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) + } + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor 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 + } + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + } + + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.age { + return object.publisher._age + } + return nil + } + @MainActor private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + if keyPath == \.surname { + return object.publisher._surname + } + return nil + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + + extension Person: @MainActor Publishable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift index 0c710e6..28f107f 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift @@ -12,6 +12,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest + // swiftlint:disable:next type_body_length internal final class MainActorPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ @@ -277,262 +278,5 @@ macroSpecs: macroSpecs ) } - - func testExpansionWithExplicitIsolation() { - assertMacroExpansion( - #""" - @CustomActor @Publishable(isolation: MainActor.self) @Observable - public final class Person { - - static var user: Person? - - let id: UUID - fileprivate(set) var age: Int - var name: String - - public var surname: String { - didSet { - print(oldValue) - } - } - - internal var fullName: String { - "\(name) \(surname)" - } - - private var initials: String { - get { "\(name.prefix(1))\(surname.prefix(1))" } - set { _ = newValue } - } - - @Memoized(.public) - func makeLabel() -> String { - "\(fullName), \(age)" - } - } - """#, - expandedSource: - #""" - @CustomActor @Observable - public final class Person { - - static var user: Person? - - let id: UUID - fileprivate(set) var age: Int - var name: String - - public var surname: String { - didSet { - print(oldValue) - } - } - - internal var fullName: String { - "\(name) \(surname)" - } - - private var initials: String { - get { "\(name.prefix(1))\(surname.prefix(1))" } - set { _ = newValue } - } - - @Memoized(.public) - func makeLabel() -> String { - "\(fullName), \(age)" - } - - private final lazy var _publisher = PropertyPublisher(object: self) - - /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable - /// or computed instance properties of this object. - /// - /// - 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. - /// - public var publisher: PropertyPublisher { - _publisher - } - - @MainActor public final class PropertyPublisher: Relay.AnyPropertyPublisher { - - private final unowned let object: Person - - public final var personWillChange: some Publisher { - willChange.map { [unowned object] _ in - object - } - } - - public final var personDidChange: some Publisher { - didChange.map { [unowned object] _ in - object - } - } - - public init(object: Person) { - self.object = object - super.init(object: object) - } - - @MainActor deinit { - _age.send(completion: .finished) - _name.send(completion: .finished) - _surname.send(completion: .finished) - } - - fileprivate final let _age = PassthroughSubject() - final var age: some Publisher { - _storedPropertyPublisher(_age, for: \.age, object: object) - } - fileprivate final let _name = PassthroughSubject() - final var name: some Publisher { - _storedPropertyPublisher(_name, for: \.name, object: object) - } - fileprivate final let _surname = PassthroughSubject() - public final var surname: some Publisher { - _storedPropertyPublisher(_surname, for: \.surname, object: object) - } - - internal final var fullName: some Publisher { - _computedPropertyPublisher(for: \.fullName, object: object) - } - fileprivate final var initials: some Publisher { - _computedPropertyPublisher(for: \.initials, object: object) - } - - public final var label: some Publisher { - _computedPropertyPublisher(for: \.label, object: object) - } - } - - private enum Observation { - - nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { - - private let underlying = SwiftObservationRegistrar() - - @MainActor 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 - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { - if keyPath == \.age { - return object.publisher._age - } - return nil - } - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { - if keyPath == \.name { - return object.publisher._name - } - if keyPath == \.surname { - return object.publisher._surname - } - return nil - } - - nonisolated func willSet( - _ object: Person, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - object.publisher._beginModifications() - underlying.willSet(object, keyPath: keyPath) - } - } - - nonisolated func didSet( - _ object: Person, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - underlying.didSet(object, keyPath: keyPath) - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - } - - nonisolated func access( - _ object: Person, - keyPath: KeyPath - ) { - underlying.access(object, keyPath: keyPath) - } - - nonisolated func withMutation<__macro_local_1TfMu_>( - of object: Person, - keyPath: KeyPath, - _ mutation: () throws -> __macro_local_1TfMu_ - ) rethrows -> __macro_local_1TfMu_ { - nonisolated(unsafe) let mutation = mutation - nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: __macro_local_1TfMu_! - - try assumeIsolatedIfNeeded { - object.publisher._beginModifications() - defer { - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - result = try underlying.withMutation( - of: object, - keyPath: keyPath, - mutation - ) - } - - return result - } - - private nonisolated func assumeIsolatedIfNeeded( - _ operation: @MainActor () throws -> Void, - file: StaticString = #fileID, - line: UInt = #line - ) rethrows { - try withoutActuallyEscaping(operation) { operation in - typealias Nonisolated = () throws -> Void - let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) - - try MainActor.shared.assumeIsolated( - { _ in - try rawOperation() - }, - file: file, - line: line - ) - } - } - } - } - } - - extension Person: @MainActor Publishable { - } - """#, - macroSpecs: macroSpecs - ) - } } #endif diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift new file mode 100644 index 0000000..9765bde --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -0,0 +1,268 @@ +// +// NonisolatedPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class NonisolatedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Publishable(isolation: nil) @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + } + """#, + expandedSource: + #""" + @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + @Memoized(.public) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + public var publisher: PropertyPublisher { + _publisher + } + + nonisolated public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } + + nonisolated deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + } + + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) + } + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + + public final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) + } + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: nonisolated PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + nonisolated 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 + } + if let keyPath = keyPath as? KeyPath, + let subject = subject(for: keyPath, on: object) { + subject.send(object[keyPath: keyPath]) + return + } + } + + nonisolated private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.age { + return object.publisher._age + } + return nil + } + nonisolated private func subject( + for keyPath: KeyPath, + on object: Person + ) -> PassthroughSubject? { + if keyPath == \.name { + return object.publisher._name + } + if keyPath == \.surname { + return object.publisher._surname + } + return nil + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + + extension Person: nonisolated Publishable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 04c88a3..b1e7723 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -264,249 +264,5 @@ macroSpecs: macroSpecs ) } - - func testExpansionWithExplicitIsolation() { - assertMacroExpansion( - #""" - @Publishable(isolation: nil) @Observable - public final class Person { - - static var user: Person? - - let id: UUID - fileprivate(set) var age: Int - var name: String - - public var surname: String { - didSet { - print(oldValue) - } - } - - internal var fullName: String { - "\(name) \(surname)" - } - - private var initials: String { - get { "\(name.prefix(1))\(surname.prefix(1))" } - set { _ = newValue } - } - - @Memoized(.public) - func makeLabel() -> String { - "\(fullName), \(age)" - } - } - """#, - expandedSource: - #""" - @Observable - public final class Person { - - static var user: Person? - - let id: UUID - fileprivate(set) var age: Int - var name: String - - public var surname: String { - didSet { - print(oldValue) - } - } - - internal var fullName: String { - "\(name) \(surname)" - } - - private var initials: String { - get { "\(name.prefix(1))\(surname.prefix(1))" } - set { _ = newValue } - } - - @Memoized(.public) - func makeLabel() -> String { - "\(fullName), \(age)" - } - - private final lazy var _publisher = PropertyPublisher(object: self) - - /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable - /// or computed instance properties of this object. - /// - /// - 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. - /// - public var publisher: PropertyPublisher { - _publisher - } - - nonisolated public final class PropertyPublisher: Relay.AnyPropertyPublisher { - - private final unowned let object: Person - - public final var personWillChange: some Publisher { - willChange.map { [unowned object] _ in - object - } - } - - public final var personDidChange: some Publisher { - didChange.map { [unowned object] _ in - object - } - } - - public init(object: Person) { - self.object = object - super.init(object: object) - } - - nonisolated deinit { - _age.send(completion: .finished) - _name.send(completion: .finished) - _surname.send(completion: .finished) - } - - fileprivate final let _age = PassthroughSubject() - final var age: some Publisher { - _storedPropertyPublisher(_age, for: \.age, object: object) - } - fileprivate final let _name = PassthroughSubject() - final var name: some Publisher { - _storedPropertyPublisher(_name, for: \.name, object: object) - } - fileprivate final let _surname = PassthroughSubject() - public final var surname: some Publisher { - _storedPropertyPublisher(_surname, for: \.surname, object: object) - } - - internal final var fullName: some Publisher { - _computedPropertyPublisher(for: \.fullName, object: object) - } - fileprivate final var initials: some Publisher { - _computedPropertyPublisher(for: \.initials, object: object) - } - - public final var label: some Publisher { - _computedPropertyPublisher(for: \.label, object: object) - } - } - - private enum Observation { - - nonisolated struct ObservationRegistrar: nonisolated PublishableObservationRegistrar { - - private let underlying = SwiftObservationRegistrar() - - nonisolated 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 - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - nonisolated private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { - if keyPath == \.age { - return object.publisher._age - } - return nil - } - nonisolated private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { - if keyPath == \.name { - return object.publisher._name - } - if keyPath == \.surname { - return object.publisher._surname - } - return nil - } - - nonisolated func willSet( - _ object: Person, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - object.publisher._beginModifications() - underlying.willSet(object, keyPath: keyPath) - } - } - - nonisolated func didSet( - _ object: Person, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - underlying.didSet(object, keyPath: keyPath) - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - } - - nonisolated func access( - _ object: Person, - keyPath: KeyPath - ) { - underlying.access(object, keyPath: keyPath) - } - - nonisolated func withMutation<__macro_local_1TfMu_>( - of object: Person, - keyPath: KeyPath, - _ mutation: () throws -> __macro_local_1TfMu_ - ) rethrows -> __macro_local_1TfMu_ { - nonisolated(unsafe) let mutation = mutation - nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: __macro_local_1TfMu_! - - try assumeIsolatedIfNeeded { - object.publisher._beginModifications() - defer { - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - result = try underlying.withMutation( - of: object, - keyPath: keyPath, - mutation - ) - } - - return result - } - - private nonisolated func assumeIsolatedIfNeeded( - _ operation: () throws -> Void - ) rethrows { - try operation() - } - } - } - } - - extension Person: nonisolated Publishable { - } - """#, - macroSpecs: macroSpecs - ) - } } #endif From 2427908ac971fa7822b4ca7171cef07a0b1052b1 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 24 Nov 2025 17:31:30 +0100 Subject: [PATCH 19/45] - --- .../Common/PropertyPublisherDeclBuilder.swift | 18 +++--- .../Combine/Common/PublisherDeclBuilder.swift | 1 - .../Common/PublisherIgnoredMacro.swift | 6 +- .../ObservationRegistrarDeclBuilder.swift | 20 +++---- .../Publishable/PublishableMacro.swift | 10 ++-- .../RelayMacros/Memoized/MemoizedMacro.swift | 56 ++++++++++--------- .../Combine/Common/PublisherIgnored.swift | 13 +++++ .../PublishableObservationRegistrar.swift | 2 +- 8 files changed, 70 insertions(+), 56 deletions(-) create mode 100644 Sources/Relay/Combine/Common/PublisherIgnored.swift diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 8809cc3..3fecf2d 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -11,7 +11,7 @@ import SwiftSyntaxMacros internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax - let properties: PropertiesList + let trackedProperties: PropertiesList let trimmedSuperclassType: TypeSyntax? let preferredGlobalActorIsolation: GlobalActorIsolation? @@ -87,14 +87,14 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @CodeBlockItemListBuilder private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { - for property in properties.stored.mutable.instance.all { + for property in trackedProperties.stored.mutable.instance.all { "_\(property.trimmedName).send(completion: .finished)" } } @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.stored.mutable.instance.all { + for property in trackedProperties.stored.mutable.instance.all { let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType @@ -109,7 +109,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.computed.instance.all { + for property in trackedProperties.computed.instance.all { let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType @@ -126,14 +126,14 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { for member in declaration.memberBlock.members { if let functionDecl = member.decl.as(FunctionDeclSyntax.self), let attribute = functionDecl.attributes.first(like: MemoizedMacro.attribute), + !functionDecl.attributes.contains(like: PublisherIgnoredMacro.attribute), let parameters = try? MemoizedMacro.Parameters(from: attribute), - let trimmedReturnType = MemoizedMacro.trimmedReturnType(of: functionDecl) { - let globalActor = parameters.preferredGlobalActorIsolation + let input = try? MemoizedMacro.validate(functionDecl, with: parameters) { let accessControlLevel = parameters.preferredAccessControlLevel?.inheritedBySibling() - let name = parameters.preferredPropertyName ?? MemoizedMacro.defaultPropertyName(for: functionDecl) - let type = trimmedReturnType + let name = input.propertyName + let type = input.trimmedReturnType """ - \(globalActor)\(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> { + \(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> { _computedPropertyPublisher(for: \\.\(raw: name), object: object) } """ diff --git a/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift index f7ee7c1..d8040a0 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift @@ -11,7 +11,6 @@ import SwiftSyntaxMacros internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax - let properties: PropertiesList let trimmedSuperclassType: TypeSyntax? func build() -> [DeclSyntax] { diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift index 903b9a1..0f7490f 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift @@ -13,13 +13,13 @@ public enum PublisherIgnoredMacro { static let attribute: AttributeSyntax = "@PublisherIgnored" } -extension PublisherIgnoredMacro: AccessorMacro { +extension PublisherIgnoredMacro: PeerMacro { public static func expansion( of _: AttributeSyntax, - providingAccessorsOf _: some DeclSyntaxProtocol, + providingPeersOf _: some DeclSyntaxProtocol, in _: some MacroExpansionContext - ) -> [AccessorDeclSyntax] { + ) -> [DeclSyntax] { [] } } diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index b47ef91..16d8e14 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -11,23 +11,20 @@ import SwiftSyntaxMacros internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax - let properties: PropertiesList let preferredGlobalActorIsolation: GlobalActorIsolation? - private let withMutationGenericParameter: TokenSyntax - private var observableProperties: PropertiesList { - properties.stored.mutable.instance - } + private let trackedStoredMutableInstanceProperties: PropertiesList + private let withMutationGenericParameter: TokenSyntax init( declaration: ClassDeclSyntax, - properties: PropertiesList, + trackedProperties: PropertiesList, preferredGlobalActorIsolation: GlobalActorIsolation?, context: some MacroExpansionContext ) { self.declaration = declaration - self.properties = properties self.preferredGlobalActorIsolation = preferredGlobalActorIsolation + self.trackedStoredMutableInstanceProperties = trackedProperties.stored.mutable.instance self.withMutationGenericParameter = context.makeUniqueName("T") } @@ -36,8 +33,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin """ private enum Observation { - nonisolated struct ObservationRegistrar: \ - \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() @@ -69,7 +65,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { - for inferredType in observableProperties.uniqueInferredTypes { + for inferredType in trackedStoredMutableInstanceProperties.uniqueInferredTypes { """ if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, let subject = subject(for: keyPath, on: object) { @@ -82,7 +78,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @MemberBlockItemListBuilder private func subjectFunctions() -> MemberBlockItemListSyntax { - for inferredType in observableProperties.uniqueInferredTypes { + for inferredType in trackedStoredMutableInstanceProperties.uniqueInferredTypes { """ \(inheritedGlobalActorIsolation)private func subject( for keyPath: KeyPath<\(trimmedType), \(inferredType)>, @@ -96,7 +92,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in observableProperties.withInferredType(like: inferredType).all { + for property in trackedStoredMutableInstanceProperties.withInferredType(like: inferredType).all { let name = property.trimmedName """ if keyPath == \\.\(name) { diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 808f70e..36e7b7a 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -34,24 +34,26 @@ extension PublishableMacro: MemberMacro { ) throws -> [DeclSyntax] { let declaration = try validate(declaration, in: context) let parameters = try Parameters(from: node) - let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) let inferredSuperclass = try declaration.inferredSuperclass(isExpected: parameters.hasSuperclass) + let trackedProperties = try PropertiesParser + .parse(memberBlock: declaration.memberBlock) + .filter { !$0.attributes.contains(like: PublisherIgnoredMacro.attribute) } + let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, - properties: properties, trimmedSuperclassType: inferredSuperclass ), PropertyPublisherDeclBuilder( declaration: declaration, - properties: properties, + trackedProperties: trackedProperties, trimmedSuperclassType: inferredSuperclass, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( declaration: declaration, - properties: properties, + trackedProperties: trackedProperties, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation, context: context ) diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index fc0d4a8..8fccc67 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -15,20 +15,19 @@ public enum MemoizedMacro { extension MemoizedMacro { - private struct Input { + struct Input { let declaration: FunctionDeclSyntax let trimmedReturnType: TypeSyntax let propertyName: String } - private static func validate( + static func validate( _ declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext, with parameters: Parameters ) throws -> Input { guard let declaration = declaration.as(FunctionDeclSyntax.self), - let trimmedReturnType = trimmedReturnType(of: declaration), + let trimmedReturnType = declaration.signature.returnClause?.type.trimmed, declaration.signature.parameterClause.parameters.isEmpty, declaration.signature.effectSpecifiers == nil, declaration.typeScopeSpecifier == nil @@ -42,16 +41,6 @@ extension MemoizedMacro { ) } - guard context.lexicalContext.first?.is(ClassDeclSyntax.self) == true else { - throw DiagnosticsError( - node: declaration, - message: """ - Memoized macro can only be applied to methods declared \ - in body (not extension) of Observable classes - """ - ) - } - let propertyName = try validatePropertyName( for: declaration, preferred: parameters.preferredPropertyName @@ -64,6 +53,27 @@ extension MemoizedMacro { ) } + private static func validate( + _ declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext, + with parameters: Parameters + ) throws -> Input { + guard context.lexicalContext.first?.is(ClassDeclSyntax.self) == true else { + throw DiagnosticsError( + node: declaration, + message: """ + Memoized macro can only be applied to methods declared \ + in body (not extension) of Observable classes + """ + ) + } + + return try validate( + declaration, + with: parameters + ) + } + private static func validatePropertyName( for declaration: FunctionDeclSyntax, preferred: String? @@ -75,10 +85,15 @@ extension MemoizedMacro { message: "Memoized macro requires a non-empty property name" ) } + return preferred } - let inferred = defaultPropertyName(for: declaration) + let functionName = declaration.name.trimmedDescription + var notation = CamelCaseNotation(string: functionName) + notation.removeFirst() + let inferred = notation.joined(as: .lowerCamelCase) + guard !inferred.isEmpty else { throw DiagnosticsError( node: declaration, @@ -91,17 +106,6 @@ extension MemoizedMacro { return inferred } - - static func defaultPropertyName(for declaration: FunctionDeclSyntax) -> String { - let functionName = declaration.name.trimmedDescription - var notation = CamelCaseNotation(string: functionName) - notation.removeFirst() - return notation.joined(as: .lowerCamelCase) - } - - static func trimmedReturnType(of declaration: FunctionDeclSyntax) -> TypeSyntax? { - declaration.signature.returnClause?.type.trimmed - } } extension MemoizedMacro: PeerMacro { diff --git a/Sources/Relay/Combine/Common/PublisherIgnored.swift b/Sources/Relay/Combine/Common/PublisherIgnored.swift new file mode 100644 index 0000000..1c553ef --- /dev/null +++ b/Sources/Relay/Combine/Common/PublisherIgnored.swift @@ -0,0 +1,13 @@ +// +// PublisherIgnored.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@attached(peer) +public macro PublisherIgnored() = #externalMacro( + module: "RelayMacros", + type: "PublisherIgnoredMacro" +) diff --git a/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift b/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift index 10fe6fb..ea059d9 100644 --- a/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift +++ b/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift @@ -11,7 +11,7 @@ import Observation @_documentation(visibility: private) public protocol PublishableObservationRegistrar { - associatedtype Object: Publishable, Observable + associatedtype Object: Observable init() From db148448ecc937ee914a0174afc9704198eb98d4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 24 Nov 2025 17:31:30 +0100 Subject: [PATCH 20/45] [SwiftFormat] Applied formatting --- Macros/RelayMacros/Memoized/MemoizedMacro.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 8fccc67..51fef9a 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -62,9 +62,9 @@ extension MemoizedMacro { throw DiagnosticsError( node: declaration, message: """ - Memoized macro can only be applied to methods declared \ - in body (not extension) of Observable classes - """ + Memoized macro can only be applied to methods declared \ + in body (not extension) of Observable classes + """ ) } From f39be9e227c8230e001738be090aa7b4ad5fba0d Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 24 Nov 2025 17:34:11 +0100 Subject: [PATCH 21/45] - --- Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index e889b5e..cfcce48 100644 --- a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -21,11 +21,11 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { func build() -> [DeclSyntax] { [ """ - \(inheritedGlobalActorIsolation)private \ + \(inheritedGlobalActorIsolation)private final \ var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil """, """ - \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)\ + \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)final \ var \(raw: propertyName): \(trimmedReturnType) { if let cached = _\(raw: propertyName) { access(keyPath: \\._\(raw: propertyName)) From 7beacb065a334d00badae49a2db3aba886b99cf2 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 28 Nov 2025 12:52:26 +0100 Subject: [PATCH 22/45] - --- .../Common/ObservationIgnoredMacro.swift | 11 +++ .../Common/PropertyPublisherDeclBuilder.swift | 15 ++-- .../Common/PublisherIgnoredMacro.swift | 18 +++++ .../ObservationRegistrarDeclBuilder.swift | 27 ++++--- .../Publishable/PublishableMacro.swift | 38 +++++++--- .../RelayMacros/Memoized/MemoizedMacro.swift | 74 ++++++++++--------- 6 files changed, 115 insertions(+), 68 deletions(-) diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift index f030aad..5e0c7c0 100644 --- a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -12,3 +12,14 @@ internal enum ObservationIgnoredMacro { static let attribute: AttributeSyntax = "@ObservationIgnored" } + +extension Property { + + var isStoredObservable: Bool { + kind == .stored + && mutability == .mutable + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute) + } +} diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 3fecf2d..941bcaf 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -11,7 +11,7 @@ import SwiftSyntaxMacros internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax - let trackedProperties: PropertiesList + let properties: PropertiesList let trimmedSuperclassType: TypeSyntax? let preferredGlobalActorIsolation: GlobalActorIsolation? @@ -87,14 +87,14 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @CodeBlockItemListBuilder private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { - for property in trackedProperties.stored.mutable.instance.all { + for property in properties.all where property.isStoredPublishable { "_\(property.trimmedName).send(completion: .finished)" } } @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in trackedProperties.stored.mutable.instance.all { + for property in properties.all where property.isStoredPublishable { let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType @@ -109,7 +109,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in trackedProperties.computed.instance.all { + for property in properties.all where property.isComputedPublishable { let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) let name = property.trimmedName let type = property.inferredType @@ -124,11 +124,8 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { for member in declaration.memberBlock.members { - if let functionDecl = member.decl.as(FunctionDeclSyntax.self), - let attribute = functionDecl.attributes.first(like: MemoizedMacro.attribute), - !functionDecl.attributes.contains(like: PublisherIgnoredMacro.attribute), - let parameters = try? MemoizedMacro.Parameters(from: attribute), - let input = try? MemoizedMacro.validate(functionDecl, with: parameters) { + if let (input, parameters) = MemoizedMacro.extract(from: member.decl), + !input.declaration.attributes.contains(like: PublisherIgnoredMacro.attribute) { let accessControlLevel = parameters.preferredAccessControlLevel?.inheritedBySibling() let name = input.propertyName let type = input.trimmedReturnType diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift index 0f7490f..1a11e65 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift @@ -23,3 +23,21 @@ extension PublisherIgnoredMacro: PeerMacro { [] } } + +extension Property { + + var isStoredPublishable: Bool { + kind == .stored + && mutability == .mutable + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + } + + var isComputedPublishable: Bool { + kind == .computed + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + } +} diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index 16d8e14..b988263 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -12,20 +12,19 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin let declaration: ClassDeclSyntax let preferredGlobalActorIsolation: GlobalActorIsolation? - - private let trackedStoredMutableInstanceProperties: PropertiesList - private let withMutationGenericParameter: TokenSyntax + private let trackedProperties: PropertiesList + private let genericParameter: TokenSyntax init( declaration: ClassDeclSyntax, - trackedProperties: PropertiesList, + properties: PropertiesList, preferredGlobalActorIsolation: GlobalActorIsolation?, context: some MacroExpansionContext ) { self.declaration = declaration self.preferredGlobalActorIsolation = preferredGlobalActorIsolation - self.trackedStoredMutableInstanceProperties = trackedProperties.stored.mutable.instance - self.withMutationGenericParameter = context.makeUniqueName("T") + self.trackedProperties = properties.filter(\.isStoredPublishable) + self.genericParameter = context.makeUniqueName("T") } func build() -> [DeclSyntax] { @@ -54,7 +53,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin private func publishNewValueFunction() -> MemberBlockItemListSyntax { """ - \(inheritedGlobalActorIsolation)func publish( + \(inheritedGlobalActorIsolation)private func publish( _ object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any> ) { @@ -65,7 +64,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { - for inferredType in trackedStoredMutableInstanceProperties.uniqueInferredTypes { + for inferredType in trackedProperties.uniqueInferredTypes { """ if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, let subject = subject(for: keyPath, on: object) { @@ -78,7 +77,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @MemberBlockItemListBuilder private func subjectFunctions() -> MemberBlockItemListSyntax { - for inferredType in trackedStoredMutableInstanceProperties.uniqueInferredTypes { + for inferredType in trackedProperties.uniqueInferredTypes { """ \(inheritedGlobalActorIsolation)private func subject( for keyPath: KeyPath<\(trimmedType), \(inferredType)>, @@ -92,7 +91,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin @CodeBlockItemListBuilder private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in trackedStoredMutableInstanceProperties.withInferredType(like: inferredType).all { + for property in trackedProperties.withInferredType(like: inferredType).all { let name = property.trimmedName """ if keyPath == \\.\(name) { @@ -141,14 +140,14 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin private func observationRegistrarWithMutationFunction() -> MemberBlockItemListSyntax { """ - nonisolated func withMutation<\(withMutationGenericParameter)>( + nonisolated func withMutation<\(genericParameter)>( of object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any>, - _ mutation: () throws -> \(withMutationGenericParameter) - ) rethrows -> \(withMutationGenericParameter) { + _ mutation: () throws -> \(genericParameter) + ) rethrows -> \(genericParameter) { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: \(withMutationGenericParameter)! + nonisolated(unsafe) var result: \(genericParameter)! try assumeIsolatedIfNeeded { object.publisher._beginModifications() diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 36e7b7a..8cd4169 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -11,15 +11,34 @@ import SwiftSyntaxMacros public enum PublishableMacro { private static func validate( - _ declaration: some DeclGroupSyntax, - in _: some MacroExpansionContext + _ node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext ) throws -> ClassDeclSyntax { guard let declaration = declaration.as(ClassDeclSyntax.self) else { throw DiagnosticsError( node: declaration, - message: "Publishable macro can only be applied to Observable classes" + message: "@Publishable macro can only be applied to Observable classes" + ) + } + + if declaration.attributes.contains(like: "@Model") { + context.diagnose( + node: declaration, + warningMessage: """ + @Publishable macro compiles when applied to PersistentModel classes, \ + but internals of SwiftData are incompatible with custom ObservationRegistrar + """, + fixIts: [ + .replace( + message: MacroExpansionFixItMessage("Remove @Publishable macro"), + oldNode: node, + newNode: "\(node.leadingTrivia)" as TokenSyntax + ) + ] ) } + return declaration } } @@ -32,13 +51,10 @@ extension PublishableMacro: MemberMacro { conformingTo _: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - let declaration = try validate(declaration, in: context) + let declaration = try validate(node, attachedTo: declaration, in: context) let parameters = try Parameters(from: node) let inferredSuperclass = try declaration.inferredSuperclass(isExpected: parameters.hasSuperclass) - - let trackedProperties = try PropertiesParser - .parse(memberBlock: declaration.memberBlock) - .filter { !$0.attributes.contains(like: PublisherIgnoredMacro.attribute) } + let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder( @@ -47,13 +63,13 @@ extension PublishableMacro: MemberMacro { ), PropertyPublisherDeclBuilder( declaration: declaration, - trackedProperties: trackedProperties, + properties: properties, trimmedSuperclassType: inferredSuperclass, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( declaration: declaration, - trackedProperties: trackedProperties, + properties: properties, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation, context: context ) @@ -78,7 +94,7 @@ extension PublishableMacro: ExtensionMacro { return [] } - let declaration = try validate(declaration, in: context) + let declaration = try validate(node, attachedTo: declaration, in: context) let parameters = try Parameters(from: node) let globalActorIsolation = GlobalActorIsolation.resolved( diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 51fef9a..1909a61 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -11,19 +11,27 @@ import SwiftSyntaxMacros public enum MemoizedMacro { static let attribute: AttributeSyntax = "@Memoized" -} - -extension MemoizedMacro { - struct Input { + static func extract( + from declaration: DeclSyntax + ) -> (input: Input, parameters: Parameters)? { + guard let declaration = declaration.as(FunctionDeclSyntax.self), + let node = declaration.attributes.first(like: attribute), + let parameters = try? Parameters(from: node), + let input = try? validateNode(attachedTo: declaration, in: nil, with: parameters) + else { + return nil + } - let declaration: FunctionDeclSyntax - let trimmedReturnType: TypeSyntax - let propertyName: String + return (input, parameters) } +} + +extension MemoizedMacro { - static func validate( - _ declaration: some DeclSyntaxProtocol, + private static func validateNode( + attachedTo declaration: some DeclSyntaxProtocol, + in context: (any MacroExpansionContext)?, with parameters: Parameters ) throws -> Input { guard let declaration = declaration.as(FunctionDeclSyntax.self), @@ -35,12 +43,24 @@ extension MemoizedMacro { throw DiagnosticsError( node: declaration, message: """ - Memoized macro can only be applied to non-void, non-async, non-throwing \ + @Memoized macro can only be applied to non-void, non-async, non-throwing \ methods that don't take any arguments """ ) } + if let context { + guard context.lexicalContext.first?.is(ClassDeclSyntax.self) == true else { + throw DiagnosticsError( + node: declaration, + message: """ + @Memoized macro can only be applied to methods declared \ + in body (not extensions) of Observable classes + """ + ) + } + } + let propertyName = try validatePropertyName( for: declaration, preferred: parameters.preferredPropertyName @@ -53,27 +73,6 @@ extension MemoizedMacro { ) } - private static func validate( - _ declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext, - with parameters: Parameters - ) throws -> Input { - guard context.lexicalContext.first?.is(ClassDeclSyntax.self) == true else { - throw DiagnosticsError( - node: declaration, - message: """ - Memoized macro can only be applied to methods declared \ - in body (not extension) of Observable classes - """ - ) - } - - return try validate( - declaration, - with: parameters - ) - } - private static func validatePropertyName( for declaration: FunctionDeclSyntax, preferred: String? @@ -82,7 +81,7 @@ extension MemoizedMacro { guard !preferred.isEmpty else { throw DiagnosticsError( node: declaration, - message: "Memoized macro requires a non-empty property name" + message: "@Memoized macro requires a non-empty property name" ) } @@ -98,7 +97,7 @@ extension MemoizedMacro { throw DiagnosticsError( node: declaration, message: """ - Memoized macro requires a method name with at least two words \ + @Memoized macro requires a method name consisting of at least two words \ or explicit property name """ ) @@ -116,7 +115,7 @@ extension MemoizedMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let parameters = try Parameters(from: node) - let input = try validate(declaration, in: context, with: parameters) + let input = try validateNode(attachedTo: declaration, in: context, with: parameters) let builder = MemoizedDeclBuilder( declaration: input.declaration, @@ -133,6 +132,13 @@ extension MemoizedMacro: PeerMacro { extension MemoizedMacro { + struct Input { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + let propertyName: String + } + struct Parameters { let preferredAccessControlLevel: AccessControlLevel? From 8feca3b95a0bec8dcfedf7810ac30bcda7216382 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Fri, 28 Nov 2025 12:52:26 +0100 Subject: [PATCH 23/45] [SwiftFormat] Applied formatting --- .../Combine/Common/ObservationIgnoredMacro.swift | 8 ++++---- .../Combine/Common/PublisherIgnoredMacro.swift | 14 +++++++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift index 5e0c7c0..4088fcc 100644 --- a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -17,9 +17,9 @@ extension Property { var isStoredObservable: Bool { kind == .stored - && mutability == .mutable - && underlying.typeScopeSpecifier == nil - && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute) + && mutability == .mutable + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute) } } diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift index 1a11e65..3e38843 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift @@ -28,16 +28,16 @@ extension Property { var isStoredPublishable: Bool { kind == .stored - && mutability == .mutable - && underlying.typeScopeSpecifier == nil - && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + && mutability == .mutable + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) } var isComputedPublishable: Bool { kind == .computed - && underlying.typeScopeSpecifier == nil - && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) } } From 65baa0008d14aee98b07f524ed031e4ce6ad55d0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 13:25:32 +0100 Subject: [PATCH 24/45] - --- .../Common/PropertyPublisherDeclBuilder.swift | 101 +++- .../Combine/Common/SwiftDataModelMacro.swift | 14 + .../ObservationRegistrarDeclBuilder.swift | 54 +- .../Publishable/PublishableMacro.swift | 13 +- .../Memoized/MemoizedDeclBuilder.swift | 4 +- .../RelayMacros/Memoized/MemoizedMacro.swift | 38 +- .../Combine/Publishable/Publishable.swift | 39 +- .../ExplicitIsolationMemoizedMacroTests.swift | 165 ++++++ ...InferredIsolationMemoizedMacroTests.swift} | 17 +- .../Memoized/MemoizedMacroTests.swift | 13 +- .../NonisolatedMemoizedMacroTests.swift | 153 ++++++ ...licitIsolationPublishableMacroTests.swift} | 120 +++-- ...erredIsolationPublishableMacroTests.swift} | 124 +++-- .../NonisolatedPublishableMacroTests.swift | 120 +++-- .../Publishable/PublishableMacroTests.swift | 110 ++-- .../SubclassedPublishableMacroTests.swift | 492 ++++++++++++++++++ 16 files changed, 1307 insertions(+), 270 deletions(-) create mode 100644 Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift create mode 100644 Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift rename Tests/RelayMacrosTests/Memoized/{MainActorMemoizedMacroTests.swift => InferredIsolationMemoizedMacroTests.swift} (89%) create mode 100644 Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift rename Tests/RelayMacrosTests/Publishable/{GlobalActorPublishableMacroTests.swift => ExplicitIsolationPublishableMacroTests.swift} (72%) rename Tests/RelayMacrosTests/Publishable/{MainActorPublishableMacroTests.swift => InferredIsolationPublishableMacroTests.swift} (72%) create mode 100644 Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 941bcaf..5172c7f 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -80,61 +80,108 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { private func deinitializer() -> MemberBlockItemListSyntax { """ \(inheritedGlobalActorIsolation)deinit { - \(storedPropertiesPublishersFinishCalls().formatted()) + \(storedPropertiesSubjectsFinishCalls().formatted()) } """ } @CodeBlockItemListBuilder - private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { + private func storedPropertiesSubjectsFinishCalls() -> CodeBlockItemListSyntax { for property in properties.all where property.isStoredPublishable { - "_\(property.trimmedName).send(completion: .finished)" + let call = storedPropertySubjectFinishCall(for: property) + if let ifConfigCall = property.underlying.applyingEnclosingIfConfig(to: call) { + ifConfigCall + } else { + call + } } } + private func storedPropertySubjectFinishCall(for property: Property) -> CodeBlockItemListSyntax { + "_\(property.trimmedName).send(completion: .finished)" + } + @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.all where property.isStoredPublishable { - let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) - let name = property.trimmedName - let type = property.inferredType - """ - fileprivate final let _\(name) = PassthroughSubject<\(type), Never>() - \(accessControlLevel)final var \(name): some Publisher<\(type), Never> { - _storedPropertyPublisher(_\(name), for: \\.\(name), object: object) + let publisher = storedPropertyPublisher(for: property) + if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { + ifConfigPublisher + } else { + publisher } - """ } } + private func storedPropertyPublisher(for property: Property) -> MemberBlockItemListSyntax { + // Stored properties cannot be made potentially unavailable + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) + let name = property.trimmedName + let type = property.inferredType + + return """ + fileprivate final let _\(name) = PassthroughSubject<\(type), Never>() + \(accessControlLevel)final var \(name): some Publisher<\(type), Never> { + _storedPropertyPublisher(_\(name), for: \\.\(name), object: object) + } + """ + } + @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.all where property.isComputedPublishable { - let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) - let name = property.trimmedName - let type = property.inferredType - """ - \(accessControlLevel)final var \(name): some Publisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(name), object: object) + let publisher = computedPropertyPublisher(for: property) + if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { + ifConfigPublisher + } else { + publisher } - """ } } + private func computedPropertyPublisher(for property: Property) -> MemberBlockItemListSyntax { + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) + let availability = property.availability?.trimmed.withTrailingNewline + let name = property.trimmedName + let type = property.inferredType + + return """ + \(availability)\(accessControlLevel)final var \(name): some Publisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(name), object: object) + } + """ + } + @MemberBlockItemListBuilder private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { for member in declaration.memberBlock.members { - if let (input, parameters) = MemoizedMacro.extract(from: member.decl), - !input.declaration.attributes.contains(like: PublisherIgnoredMacro.attribute) { - let accessControlLevel = parameters.preferredAccessControlLevel?.inheritedBySibling() - let name = input.propertyName - let type = input.trimmedReturnType - """ - \(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(raw: name), object: object) + if let extractionResult = MemoizedMacro.extract(from: member.decl) { + let declaration = extractionResult.declaration + + if !declaration.attributes.contains(like: PublisherIgnoredMacro.attribute) { + let publisher = memoizedPropertyPublisher(for: extractionResult) + if let ifConfigPublisher = declaration.applyingEnclosingIfConfig(to: publisher) { + ifConfigPublisher + } else { + publisher + } } - """ } } } + + private func memoizedPropertyPublisher( + for extractionResult: MemoizedMacro.ExtractionResult + ) -> MemberBlockItemListSyntax { + let accessControlLevel = extractionResult.preferredAccessControlLevel?.inheritedBySibling() + let availability = extractionResult.declaration.availability?.trimmed.withTrailingNewline + let name = extractionResult.propertyName + let type = extractionResult.trimmedReturnType + + return """ + \(availability)\(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(raw: name), object: object) + } + """ + } } diff --git a/Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift b/Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift new file mode 100644 index 0000000..05c4f1e --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift @@ -0,0 +1,14 @@ +// +// SwiftDataModelMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 29/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal enum SwiftDataModelMacro { + + static let attribute: AttributeSyntax = "@Model" +} diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index b988263..9af51e5 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -36,9 +36,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin private let underlying = SwiftObservationRegistrar() - \(publishNewValueFunction()) - - \(subjectFunctions().formatted()) + \(publishFunction()) \(observationRegistrarWillSetDidSetAccessFunctions()) @@ -51,57 +49,39 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin ] } - private func publishNewValueFunction() -> MemberBlockItemListSyntax { + private func publishFunction() -> MemberBlockItemListSyntax { """ \(inheritedGlobalActorIsolation)private func publish( _ object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any> ) { - \(publishNewValueKeyPathCasting().formatted()) + \(publishKeyPathLookups().formatted()) } """ } @CodeBlockItemListBuilder - private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { - for inferredType in trackedProperties.uniqueInferredTypes { - """ - if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return + private func publishKeyPathLookups() -> CodeBlockItemListSyntax { + for property in trackedProperties { + let lookup = publishKeyPathLookup(for: property) + if let ifConfigLookup = property.underlying.applyingEnclosingIfConfig(to: lookup) { + ifConfigLookup + } else { + lookup } - """ } } - @MemberBlockItemListBuilder - private func subjectFunctions() -> MemberBlockItemListSyntax { - for inferredType in trackedProperties.uniqueInferredTypes { - """ - \(inheritedGlobalActorIsolation)private func subject( - for keyPath: KeyPath<\(trimmedType), \(inferredType)>, - on object: \(trimmedType) - ) -> PassthroughSubject<\(inferredType), Never>? { - \(subjectKeyPathCasting(for: inferredType).formatted()) - } - """ - } - } + private func publishKeyPathLookup(for property: Property) -> CodeBlockItemListSyntax { + // Stored properties cannot be made potentially unavailable + let name = property.trimmedName - @CodeBlockItemListBuilder - private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in trackedProperties.withInferredType(like: inferredType).all { - let name = property.trimmedName - """ - if keyPath == \\.\(name) { - return object.publisher._\(name) - } - """ + return """ + if keyPath == \\.\(name) { + object.publisher._\(name).send(object[keyPath: \\.\(name)]) + return } """ - return nil - """ } private func observationRegistrarWillSetDidSetAccessFunctions() -> MemberBlockItemListSyntax { diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 8cd4169..5d97fbc 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -10,6 +10,8 @@ import SwiftSyntaxMacros public enum PublishableMacro { + static let attribute: AttributeSyntax = "@Publishable" + private static func validate( _ node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, @@ -22,11 +24,11 @@ public enum PublishableMacro { ) } - if declaration.attributes.contains(like: "@Model") { + if declaration.attributes.contains(like: SwiftDataModelMacro.attribute) { context.diagnose( node: declaration, warningMessage: """ - @Publishable macro compiles when applied to PersistentModel classes, \ + @Publishable macro compiles when applied to @Model classes, \ but internals of SwiftData are incompatible with custom ObservationRegistrar """, fixIts: [ @@ -53,18 +55,18 @@ extension PublishableMacro: MemberMacro { ) throws -> [DeclSyntax] { let declaration = try validate(node, attachedTo: declaration, in: context) let parameters = try Parameters(from: node) - let inferredSuperclass = try declaration.inferredSuperclass(isExpected: parameters.hasSuperclass) + let inferredSuperclassType = try declaration.inferredSuperclassType(isExpected: parameters.hasSuperclass) let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, - trimmedSuperclassType: inferredSuperclass + trimmedSuperclassType: inferredSuperclassType ), PropertyPublisherDeclBuilder( declaration: declaration, properties: properties, - trimmedSuperclassType: inferredSuperclass, + trimmedSuperclassType: inferredSuperclassType, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( @@ -104,6 +106,7 @@ extension PublishableMacro: ExtensionMacro { return [ .init( + attributes: declaration.availability ?? [], extendedType: type, inheritanceClause: .init( inheritedTypes: [ diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index cfcce48..be4dc9d 100644 --- a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -21,11 +21,11 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { func build() -> [DeclSyntax] { [ """ - \(inheritedGlobalActorIsolation)private final \ + \(inheritedAvailability)\(inheritedGlobalActorIsolation)private final \ var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil """, """ - \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)final \ + \(inheritedAvailability)\(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)final \ var \(raw: propertyName): \(trimmedReturnType) { if let cached = _\(raw: propertyName) { access(keyPath: \\._\(raw: propertyName)) diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 1909a61..05c3d4f 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -14,16 +14,19 @@ public enum MemoizedMacro { static func extract( from declaration: DeclSyntax - ) -> (input: Input, parameters: Parameters)? { + ) -> ExtractionResult? { guard let declaration = declaration.as(FunctionDeclSyntax.self), let node = declaration.attributes.first(like: attribute), let parameters = try? Parameters(from: node), - let input = try? validateNode(attachedTo: declaration, in: nil, with: parameters) + let validationResult = try? validateNode(attachedTo: declaration, in: nil, with: parameters) else { return nil } - return (input, parameters) + return ExtractionResult( + validationResult: validationResult, + parameters: parameters + ) } } @@ -33,7 +36,7 @@ extension MemoizedMacro { attachedTo declaration: some DeclSyntaxProtocol, in context: (any MacroExpansionContext)?, with parameters: Parameters - ) throws -> Input { + ) throws -> ValidationResult { guard let declaration = declaration.as(FunctionDeclSyntax.self), let trimmedReturnType = declaration.signature.returnClause?.type.trimmed, declaration.signature.parameterClause.parameters.isEmpty, @@ -66,7 +69,7 @@ extension MemoizedMacro { preferred: parameters.preferredPropertyName ) - return Input( + return ValidationResult( declaration: declaration, trimmedReturnType: trimmedReturnType, propertyName: propertyName @@ -115,12 +118,12 @@ extension MemoizedMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let parameters = try Parameters(from: node) - let input = try validateNode(attachedTo: declaration, in: context, with: parameters) + let validationResult = try validateNode(attachedTo: declaration, in: context, with: parameters) let builder = MemoizedDeclBuilder( - declaration: input.declaration, - trimmedReturnType: input.trimmedReturnType, - propertyName: input.propertyName, + declaration: validationResult.declaration, + trimmedReturnType: validationResult.trimmedReturnType, + propertyName: validationResult.propertyName, lexicalContext: context.lexicalContext, preferredAccessControlLevel: parameters.preferredAccessControlLevel, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation @@ -132,7 +135,22 @@ extension MemoizedMacro: PeerMacro { extension MemoizedMacro { - struct Input { + @dynamicMemberLookup + struct ExtractionResult { + + let validationResult: ValidationResult + let parameters: Parameters + + subscript(dynamicMember keyPath: KeyPath) -> T { + validationResult[keyPath: keyPath] + } + + subscript(dynamicMember keyPath: KeyPath) -> T { + parameters[keyPath: keyPath] + } + } + + struct ValidationResult { let declaration: FunctionDeclSyntax let trimmedReturnType: TypeSyntax diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index f4b997d..a955097 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -72,44 +72,7 @@ public macro Publishable( ) public macro Publishable( hasSuperclass: Bool = false, - isolation: MainActor.Type? -) = #externalMacro( - module: "RelayMacros", - type: "PublishableMacro" -) - -/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. -/// -/// - Parameter isolation: The global actor to which the type is isolated. -/// If set to `nil`, the generated members are `nonisolated`. -/// To infer isolation automatically, use the ``Publishable()`` macro instead. -/// -/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied directly. -/// -/// The `@Publishable` macro adds a new `publisher` property to your type, -/// which exposes `Combine` publishers for all mutable or computed instance properties. -/// -/// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. -/// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. -/// -/// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. -/// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. -/// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. -/// -@attached( - member, - names: named(_publisher), - named(publisher), - named(PropertyPublisher), - named(Observation) -) -@attached( - extension, - conformances: Publishable -) -public macro Publishable( - hasSuperclass: Bool = false, - isolation: Isolation.Type + isolation: (any GlobalActor.Type)? ) = #externalMacro( module: "RelayMacros", type: "PublishableMacro" diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift new file mode 100644 index 0000000..1fe8a78 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift @@ -0,0 +1,165 @@ +// +// ExplicitIsolationMemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class ExplicitIsolationMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + // swiftlint:disable global_actor_attribute_order + + func testExpansion() { + assertMacroExpansion( + #""" + @CustomActor @Observable + public class Square { + + var side = 12.3 + + @Memoized(isolation: MainActor.self) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @CustomActor @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + @MainActor private final var _area: Optional = nil + + @MainActor final var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + MainActor.shared.assumeIsolated { _ in + rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @CustomActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName", isolation: MainActor.self) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @CustomActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + private func calculateArea() -> Double { + side * side + } + + @available(macOS 26, *) + @MainActor private final var _customName: Optional = nil + + @available(macOS 26, *) + @MainActor public final var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + MainActor.shared.assumeIsolated { _ in + rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + // swiftlint:enable global_actor_attribute_order + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift similarity index 89% rename from Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift index d1597c7..b5cfc4f 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// MainActorMemoizedMacroTests.swift +// InferredIsolationMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,7 +11,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class MainActorMemoizedMacroTests: XCTestCase { + internal final class InferredIsolationMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self @@ -43,9 +43,9 @@ side * side } - @MainActor private var _area: Optional = nil + @MainActor private final var _area: Optional = nil - @MainActor var area: Double { + @MainActor final var area: Double { if let cached = _area { access(keyPath: \._area) return cached @@ -95,6 +95,7 @@ var side = 12.3 + @available(macOS 26, *) @Memoized(.public, "customName") private func calculateArea() -> Double { side * side @@ -107,13 +108,17 @@ public final class Square { var side = 12.3 + + @available(macOS 26, *) private func calculateArea() -> Double { side * side } - @MainActor private var _customName: Optional = nil + @available(macOS 26, *) + @MainActor private final var _customName: Optional = nil - @MainActor public var customName: Double { + @available(macOS 26, *) + @MainActor public final var customName: Double { if let cached = _customName { access(keyPath: \._customName) return cached diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index afd9c82..67a205f 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -41,9 +41,9 @@ side * side } - private var _area: Optional = nil + private final var _area: Optional = nil - var area: Double { + final var area: Double { if let cached = _area { access(keyPath: \._area) return cached @@ -87,6 +87,7 @@ var side = 12.3 + @available(macOS 26, *) @Memoized(.public, "customName") private func calculateArea() -> Double { side * side @@ -99,13 +100,17 @@ public final class Square { var side = 12.3 + + @available(macOS 26, *) private func calculateArea() -> Double { side * side } - private var _customName: Optional = nil + @available(macOS 26, *) + private final var _customName: Optional = nil - public var customName: Double { + @available(macOS 26, *) + public final var customName: Double { if let cached = _customName { access(keyPath: \._customName) return cached diff --git a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift new file mode 100644 index 0000000..d77bc7e --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift @@ -0,0 +1,153 @@ +// +// NonisolatedMemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class NonisolatedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + // swiftlint:disable global_actor_attribute_order + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + + @Memoized(isolation: nil) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + nonisolated private final var _area: Optional = nil + + nonisolated final var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName", isolation: nil) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + private func calculateArea() -> Double { + side * side + } + + @available(macOS 26, *) + nonisolated private final var _customName: Optional = nil + + @available(macOS 26, *) + nonisolated public final var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + // swiftlint:enable global_actor_attribute_order + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift similarity index 72% rename from Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift index 275142f..0e9948c 100644 --- a/Tests/RelayMacrosTests/Publishable/GlobalActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift @@ -1,5 +1,5 @@ // -// GlobalActorPublishableMacroTests.swift +// ExplicitIsolationPublishableMacroTests.swift // Relay // // Created by Kamil Strzelecki on 23/11/2025. @@ -13,7 +13,7 @@ import XCTest // swiftlint:disable:next type_body_length - internal final class GlobalActorPublishableMacroTests: XCTestCase { + internal final class ExplicitIsolationPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ "Publishable": MacroSpec( @@ -25,6 +25,7 @@ func testExpansion() { assertMacroExpansion( #""" + @available(iOS 26, macOS 26, *) @CustomActor @Publishable(isolation: MainActor.self) @Observable public final class Person { @@ -48,15 +49,38 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - - @Memoized(.public) + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } """#, expandedSource: #""" + @available(iOS 26, macOS 26, *) @CustomActor @Observable public final class Person { @@ -81,10 +105,32 @@ set { _ = newValue } } - @Memoized(.public) + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } private final lazy var _publisher = PropertyPublisher(object: self) @@ -124,6 +170,9 @@ _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) + #if os(macOS) + _conditionalStoredProperty.send(completion: .finished) + #endif } fileprivate final let _age = PassthroughSubject() @@ -138,6 +187,12 @@ public final var surname: some Publisher { _storedPropertyPublisher(_surname, for: \.surname, object: object) } + #if os(macOS) + fileprivate final let _conditionalStoredProperty = PassthroughSubject() + final var conditionalStoredProperty: some Publisher { + _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + } + #endif internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) @@ -145,54 +200,47 @@ fileprivate final var initials: some Publisher { _computedPropertyPublisher(for: \.initials, object: object) } - - public final var label: some Publisher { + #if os(macOS) + @available(macOS 26, *) + final var conditionalComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + } + #endif + + @available(iOS 26, *) + fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() - @MainActor func publish( + @MainActor private 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 - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.age { - return object.publisher._age + object.publisher._age.send(object[keyPath: \.age]) + return } - return nil - } - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.name { - return object.publisher._name + object.publisher._name.send(object[keyPath: \.name]) + return } if keyPath == \.surname { - return object.publisher._surname + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.conditionalStoredProperty { + object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + return } - return nil + #endif } nonisolated func willSet( @@ -272,7 +320,7 @@ } } - extension Person: @MainActor Publishable { + @available(iOS 26, macOS 26, *) extension Person: @MainActor Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift similarity index 72% rename from Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift index 28f107f..87d0ab4 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift @@ -1,5 +1,5 @@ // -// MainActorPublishableMacroTests.swift +// InferredIsolationPublishableMacroTests.swift // Relay // // Created by Kamil Strzelecki on 24/08/2025. @@ -13,7 +13,7 @@ import XCTest // swiftlint:disable:next type_body_length - internal final class MainActorPublishableMacroTests: XCTestCase { + internal final class InferredIsolationPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ "Publishable": MacroSpec( @@ -25,6 +25,7 @@ func testExpansion() { assertMacroExpansion( #""" + @available(iOS 26, macOS 26, *) @MainActor @Publishable @Observable public final class Person { @@ -48,15 +49,38 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - - @Memoized(.public) + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } """#, expandedSource: #""" + @available(iOS 26, macOS 26, *) @MainActor @Observable public final class Person { @@ -80,11 +104,33 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - - @Memoized(.public) + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } private final lazy var _publisher = PropertyPublisher(object: self) @@ -124,6 +170,9 @@ _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) + #if os(macOS) + _conditionalStoredProperty.send(completion: .finished) + #endif } fileprivate final let _age = PassthroughSubject() @@ -138,61 +187,60 @@ public final var surname: some Publisher { _storedPropertyPublisher(_surname, for: \.surname, object: object) } - + #if os(macOS) + fileprivate final let _conditionalStoredProperty = PassthroughSubject() + final var conditionalStoredProperty: some Publisher { + _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + } + #endif + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } fileprivate final var initials: some Publisher { _computedPropertyPublisher(for: \.initials, object: object) } - - public final var label: some Publisher { + #if os(macOS) + @available(macOS 26, *) + final var conditionalComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + } + #endif + + @available(iOS 26, *) + fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - nonisolated struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() - @MainActor func publish( + @MainActor private 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 - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.age { - return object.publisher._age + object.publisher._age.send(object[keyPath: \.age]) + return } - return nil - } - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.name { - return object.publisher._name + object.publisher._name.send(object[keyPath: \.name]) + return } if keyPath == \.surname { - return object.publisher._surname + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.conditionalStoredProperty { + object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + return } - return nil + #endif } nonisolated func willSet( @@ -272,7 +320,7 @@ } } - extension Person: @MainActor Publishable { + @available(iOS 26, macOS 26, *) extension Person: @MainActor Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index 9765bde..ee65558 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -24,6 +24,7 @@ func testExpansion() { assertMacroExpansion( #""" + @available(iOS 26, macOS 26, *) @Publishable(isolation: nil) @Observable public final class Person { @@ -47,15 +48,38 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - - @Memoized(.public) + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } """#, expandedSource: #""" + @available(iOS 26, macOS 26, *) @Observable public final class Person { @@ -79,11 +103,33 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - - @Memoized(.public) + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } private final lazy var _publisher = PropertyPublisher(object: self) @@ -123,6 +169,9 @@ _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) + #if os(macOS) + _conditionalStoredProperty.send(completion: .finished) + #endif } fileprivate final let _age = PassthroughSubject() @@ -137,61 +186,60 @@ public final var surname: some Publisher { _storedPropertyPublisher(_surname, for: \.surname, object: object) } - + #if os(macOS) + fileprivate final let _conditionalStoredProperty = PassthroughSubject() + final var conditionalStoredProperty: some Publisher { + _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + } + #endif + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } fileprivate final var initials: some Publisher { _computedPropertyPublisher(for: \.initials, object: object) } - - public final var label: some Publisher { + #if os(macOS) + @available(macOS 26, *) + final var conditionalComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + } + #endif + + @available(iOS 26, *) + fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - nonisolated struct ObservationRegistrar: nonisolated PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() - nonisolated func publish( + nonisolated private 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 - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - nonisolated private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.age { - return object.publisher._age + object.publisher._age.send(object[keyPath: \.age]) + return } - return nil - } - nonisolated private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.name { - return object.publisher._name + object.publisher._name.send(object[keyPath: \.name]) + return } if keyPath == \.surname { - return object.publisher._surname + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.conditionalStoredProperty { + object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + return } - return nil + #endif } nonisolated func willSet( @@ -258,7 +306,7 @@ } } - extension Person: nonisolated Publishable { + @available(iOS 26, macOS 26, *) extension Person: nonisolated Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index b1e7723..a366486 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -24,6 +24,7 @@ func testExpansion() { assertMacroExpansion( #""" + @available(iOS 26, macOS 26, *) @Publishable @Observable public final class Person { @@ -47,15 +48,38 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } - @Memoized(.public) + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } """#, expandedSource: #""" + @available(iOS 26, macOS 26, *) @Observable public final class Person { @@ -79,11 +103,33 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } - @Memoized(.public) + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } private final lazy var _publisher = PropertyPublisher(object: self) @@ -123,6 +169,9 @@ _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) + #if os(macOS) + _conditionalStoredProperty.send(completion: .finished) + #endif } fileprivate final let _age = PassthroughSubject() @@ -137,6 +186,12 @@ public final var surname: some Publisher { _storedPropertyPublisher(_surname, for: \.surname, object: object) } + #if os(macOS) + fileprivate final let _conditionalStoredProperty = PassthroughSubject() + final var conditionalStoredProperty: some Publisher { + _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + } + #endif internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) @@ -144,8 +199,15 @@ fileprivate final var initials: some Publisher { _computedPropertyPublisher(for: \.initials, object: object) } + #if os(macOS) + @available(macOS 26, *) + final var conditionalComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + } + #endif - public final var label: some Publisher { + @available(iOS 26, *) + fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) } } @@ -156,42 +218,28 @@ private let underlying = SwiftObservationRegistrar() - func publish( + private 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 - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.age { - return object.publisher._age + object.publisher._age.send(object[keyPath: \.age]) + return } - return nil - } - private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.name { - return object.publisher._name + object.publisher._name.send(object[keyPath: \.name]) + return } if keyPath == \.surname { - return object.publisher._surname + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.conditionalStoredProperty { + object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + return } - return nil + #endif } nonisolated func willSet( @@ -258,7 +306,7 @@ } } - extension Person: Publishable { + @available(iOS 26, macOS 26, *) extension Person: Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift new file mode 100644 index 0000000..e75a920 --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift @@ -0,0 +1,492 @@ +// +// InferredSuperclassPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class SubclassedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: [] + ) + ] + + func testExpansionWithInferredSuperclassType() { + assertMacroExpansion( + #""" + @Publishable @Observable + class Dog: Animal { + + var name: String + + override var age: Int { + willSet { + print(newValue) + } + } + } + """#, + expandedSource: + #""" + @Observable + class Dog: Animal { + + var name: String + + override var age: Int { + willSet { + print(newValue) + } + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + override var publisher: PropertyPublisher { + _publisher + } + + class PropertyPublisher: Animal.PropertyPublisher { + + private final unowned let object: Dog + + final var dogWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + final var dogDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + init(object: Dog) { + self.object = object + super.init(object: object) + } + + deinit { + _name.send(completion: .finished) + } + + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + + + + + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + private func publish( + _ object: Dog, + keyPath: KeyPath + ) { + if keyPath == \.name { + object.publisher._name.send(object[keyPath: \.name]) + return + } + } + + nonisolated func willSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Dog, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Dog, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + """#, + macroSpecs: macroSpecs + ) + } + + func testExpansionWithExplicitSuperclassType() { + assertMacroExpansion( + #""" + @Publishable(hasSuperclass: true) @Observable + class Dog: Animal { + + var name: String + } + """#, + expandedSource: + #""" + @Observable + class Dog: Animal { + + var name: String + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + override var publisher: PropertyPublisher { + _publisher + } + + class PropertyPublisher: Animal.PropertyPublisher { + + private final unowned let object: Dog + + final var dogWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + final var dogDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + init(object: Dog) { + self.object = object + super.init(object: object) + } + + deinit { + _name.send(completion: .finished) + } + + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + + + + + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + private func publish( + _ object: Dog, + keyPath: KeyPath + ) { + if keyPath == \.name { + object.publisher._name.send(object[keyPath: \.name]) + return + } + } + + nonisolated func willSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Dog, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Dog, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + """#, + macroSpecs: macroSpecs + ) + } + + func testExpansionWithMaskedSuperclassType() { + assertMacroExpansion( + #""" + @Publishable(hasSuperclass: false) @Observable + class Dog: Animal { + + var name: String + + override var age: Int { + willSet { + print(newValue) + } + } + } + """#, + expandedSource: + #""" + @Observable + class Dog: Animal { + + var name: String + + override var age: Int { + willSet { + print(newValue) + } + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + var publisher: PropertyPublisher { + _publisher + } + + class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Dog + + final var dogWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + final var dogDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + init(object: Dog) { + self.object = object + super.init(object: object) + } + + deinit { + _name.send(completion: .finished) + } + + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) + } + + + + + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + private func publish( + _ object: Dog, + keyPath: KeyPath + ) { + if keyPath == \.name { + object.publisher._name.send(object[keyPath: \.name]) + return + } + } + + nonisolated func willSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Dog, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Dog, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif From 8ec35284b80374b3ff0aade856d4107630ef7579 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 13:25:32 +0100 Subject: [PATCH 25/45] [SwiftFormat] Applied formatting --- .../ExplicitIsolationMemoizedMacroTests.swift | 2 +- .../InferredIsolationMemoizedMacroTests.swift | 2 +- .../Memoized/MemoizedMacroTests.swift | 2 +- .../NonisolatedMemoizedMacroTests.swift | 2 +- ...plicitIsolationPublishableMacroTests.swift | 16 +-- ...ferredIsolationPublishableMacroTests.swift | 20 +-- .../NonisolatedPublishableMacroTests.swift | 20 +-- .../Publishable/PublishableMacroTests.swift | 12 +- .../SubclassedPublishableMacroTests.swift | 118 +++++++++--------- 9 files changed, 97 insertions(+), 97 deletions(-) diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift index 1fe8a78..75e94d6 100644 --- a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift @@ -108,7 +108,7 @@ public final class Square { var side = 12.3 - + @available(macOS 26, *) private func calculateArea() -> Double { side * side diff --git a/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift index b5cfc4f..6122c60 100644 --- a/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift @@ -108,7 +108,7 @@ public final class Square { var side = 12.3 - + @available(macOS 26, *) private func calculateArea() -> Double { side * side diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index 67a205f..44a161c 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -100,7 +100,7 @@ public final class Square { var side = 12.3 - + @available(macOS 26, *) private func calculateArea() -> Double { side * side diff --git a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift index d77bc7e..8bcf1f4 100644 --- a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift @@ -102,7 +102,7 @@ public final class Square { var side = 12.3 - + @available(macOS 26, *) private func calculateArea() -> Double { side * side diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift index 0e9948c..f179e53 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift @@ -49,7 +49,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -58,7 +58,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -66,12 +66,12 @@ var ignoredComputedProperty: Int { ignoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -113,7 +113,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -121,12 +121,12 @@ var ignoredComputedProperty: Int { ignoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -206,7 +206,7 @@ _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) } #endif - + @available(iOS 26, *) fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) diff --git a/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift index 87d0ab4..a080c1f 100644 --- a/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift @@ -49,7 +49,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -58,7 +58,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -66,12 +66,12 @@ var ignoredComputedProperty: Int { ignoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -104,7 +104,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -113,7 +113,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -121,12 +121,12 @@ var ignoredComputedProperty: Int { ignoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -193,7 +193,7 @@ _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) } #endif - + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } @@ -206,7 +206,7 @@ _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) } #endif - + @available(iOS 26, *) fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index ee65558..06ec47f 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -48,7 +48,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -57,7 +57,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -65,12 +65,12 @@ var ignoredComputedProperty: Int { ignoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -103,7 +103,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -112,7 +112,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -120,12 +120,12 @@ var ignoredComputedProperty: Int { ignoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -192,7 +192,7 @@ _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) } #endif - + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } @@ -205,7 +205,7 @@ _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) } #endif - + @available(iOS 26, *) fileprivate final var label: some Publisher { _computedPropertyPublisher(for: \.label, object: object) diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index a366486..b91f182 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -48,7 +48,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -57,7 +57,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -70,7 +70,7 @@ func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty @@ -103,7 +103,7 @@ get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } - + #if os(macOS) var conditionalStoredProperty = 123 @@ -112,7 +112,7 @@ conditionalStoredProperty } #endif - + @PublisherIgnored var ignoredStoredProperty = 123 @@ -125,7 +125,7 @@ func makeLabel() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherIgnored func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift index e75a920..adddbd9 100644 --- a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift @@ -26,7 +26,7 @@ #""" @Publishable @Observable class Dog: Animal { - + var name: String override var age: Int { @@ -91,10 +91,10 @@ final var name: some Publisher { _storedPropertyPublisher(_name, for: \.name, object: object) } - - - - + + + + } private enum Observation { @@ -186,7 +186,7 @@ #""" @Publishable(hasSuperclass: true) @Observable class Dog: Animal { - + var name: String } """#, @@ -194,11 +194,11 @@ #""" @Observable class Dog: Animal { - + var name: String - + private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -209,48 +209,48 @@ override var publisher: PropertyPublisher { _publisher } - + class PropertyPublisher: Animal.PropertyPublisher { - + private final unowned let object: Dog - + final var dogWillChange: some Publisher { willChange.map { [unowned object] _ in object } } - + final var dogDidChange: some Publisher { didChange.map { [unowned object] _ in object } } - + init(object: Dog) { self.object = object super.init(object: object) } - + deinit { _name.send(completion: .finished) } - + fileprivate final let _name = PassthroughSubject() final var name: some Publisher { _storedPropertyPublisher(_name, for: \.name, object: object) } - - - - + + + + } - + private enum Observation { - + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { - + private let underlying = SwiftObservationRegistrar() - + private func publish( _ object: Dog, keyPath: KeyPath @@ -260,7 +260,7 @@ return } } - + nonisolated func willSet( _ object: Dog, keyPath: KeyPath @@ -271,7 +271,7 @@ underlying.willSet(object, keyPath: keyPath) } } - + nonisolated func didSet( _ object: Dog, keyPath: KeyPath @@ -283,14 +283,14 @@ object.publisher._endModifications() } } - + nonisolated func access( _ object: Dog, keyPath: KeyPath ) { underlying.access(object, keyPath: keyPath) } - + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Dog, keyPath: KeyPath, @@ -299,7 +299,7 @@ nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath nonisolated(unsafe) var result: __macro_local_1TfMu_! - + try assumeIsolatedIfNeeded { object.publisher._beginModifications() defer { @@ -312,10 +312,10 @@ mutation ) } - + return result } - + private nonisolated func assumeIsolatedIfNeeded( _ operation: () throws -> Void ) rethrows { @@ -334,9 +334,9 @@ #""" @Publishable(hasSuperclass: false) @Observable class Dog: Animal { - + var name: String - + override var age: Int { willSet { print(newValue) @@ -348,17 +348,17 @@ #""" @Observable class Dog: Animal { - + var name: String - + override var age: Int { willSet { print(newValue) } } - + private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -369,48 +369,48 @@ var publisher: PropertyPublisher { _publisher } - + class PropertyPublisher: Relay.AnyPropertyPublisher { - + private final unowned let object: Dog - + final var dogWillChange: some Publisher { willChange.map { [unowned object] _ in object } } - + final var dogDidChange: some Publisher { didChange.map { [unowned object] _ in object } } - + init(object: Dog) { self.object = object super.init(object: object) } - + deinit { _name.send(completion: .finished) } - + fileprivate final let _name = PassthroughSubject() final var name: some Publisher { _storedPropertyPublisher(_name, for: \.name, object: object) } - - - - + + + + } - + private enum Observation { - + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { - + private let underlying = SwiftObservationRegistrar() - + private func publish( _ object: Dog, keyPath: KeyPath @@ -420,7 +420,7 @@ return } } - + nonisolated func willSet( _ object: Dog, keyPath: KeyPath @@ -431,7 +431,7 @@ underlying.willSet(object, keyPath: keyPath) } } - + nonisolated func didSet( _ object: Dog, keyPath: KeyPath @@ -443,14 +443,14 @@ object.publisher._endModifications() } } - + nonisolated func access( _ object: Dog, keyPath: KeyPath ) { underlying.access(object, keyPath: keyPath) } - + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Dog, keyPath: KeyPath, @@ -459,7 +459,7 @@ nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath nonisolated(unsafe) var result: __macro_local_1TfMu_! - + try assumeIsolatedIfNeeded { object.publisher._beginModifications() defer { @@ -472,10 +472,10 @@ mutation ) } - + return result } - + private nonisolated func assumeIsolatedIfNeeded( _ operation: () throws -> Void ) rethrows { From ef264b1b80cde67ed72db0ce6b0d75137e9981a7 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 13:26:17 +0100 Subject: [PATCH 26/45] - --- .../MainActorPublishableTests.swift | 17 ++ .../ObservationPublishableTests.swift | 17 ++ .../SwiftDataPublishableTests.swift | 231 ------------------ 3 files changed, 34 insertions(+), 231 deletions(-) delete mode 100644 Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 72f34aa..995a4d6 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -218,5 +218,22 @@ extension MainActorPublishableTests { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index 83e586d..e7afb8d 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -217,5 +217,22 @@ extension ObservationPublishableTests { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + #if os(macOS) + var conditionalStoredProperty = 123 + + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift deleted file mode 100644 index 5f1efc7..0000000 --- a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift +++ /dev/null @@ -1,231 +0,0 @@ -// -// SwiftDataPublishableTests.swift -// Relay -// -// Created by Kamil Strzelecki on 15/05/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import Foundation -import Relay -import SwiftData -import Testing - -internal struct SwiftDataPublishableTests { - - @Test - func storedProperty() { - var person: Person? = .init() - var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.name.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.name - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == "John") - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } - - @Test - func computedProperty() { - var person: Person? = .init() - var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.fullName.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.fullName - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == "John Doe") - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() == true) - observe() - - person?.age += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } -} - -extension SwiftDataPublishableTests { - - @Test - func willChange() { - var person: Person? = .init() - var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.personWillChange.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.age - _ = person?.name - _ = person?.surname - _ = person?.fullName - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person?.age += 1 - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } - - @Test - func didChange() { - var person: Person? = .init() - var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.personDidChange.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.age - _ = person?.name - _ = person?.surname - _ = person?.fullName - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person?.age += 1 - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } -} - -extension SwiftDataPublishableTests { - - @Publishable @Model - final class Person { - - var age: Int - fileprivate(set) var name: String - var surname: String - - internal var fullName: String { - "\(name) \(surname)" - } - - package var initials: String { - get { "\(name.prefix(1))\(surname.prefix(1))" } - set { _ = newValue } - } - - init( - age: Int = 25, - name: String = "John", - surname: String = "Doe" - ) { - self.age = age - self.name = name - self.surname = surname - } - } -} From 15607aba01a9a120ed9b06c41eb96f5dc578b01f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 13:26:17 +0100 Subject: [PATCH 27/45] [SwiftFormat] Applied formatting --- .../Publishable/MainActorPublishableTests.swift | 10 +++++----- .../Publishable/ObservationPublishableTests.swift | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 995a4d6..e2da8ad 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -220,12 +220,12 @@ extension MainActorPublishableTests { } #if os(macOS) - var conditionalStoredProperty = 123 + var conditionalStoredProperty = 123 - @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty - } + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } #endif @PublisherIgnored diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index e7afb8d..08976ff 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -219,12 +219,12 @@ extension ObservationPublishableTests { } #if os(macOS) - var conditionalStoredProperty = 123 + var conditionalStoredProperty = 123 - @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty - } + @available(macOS 26, *) + var conditionalComputedProperty: Int { + conditionalStoredProperty + } #endif @PublisherIgnored From be9b35971fe65b689f753dd074b24299589cd61b Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 13:48:22 +0100 Subject: [PATCH 28/45] - --- Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift | 10 +++++++++- .../Memoized/ExplicitIsolationMemoizedMacroTests.swift | 2 +- .../Memoized/InferredIsolationMemoizedMacroTests.swift | 2 +- .../RelayMacrosTests/Memoized/MemoizedMacroTests.swift | 2 +- .../Memoized/NonisolatedMemoizedMacroTests.swift | 2 +- Tests/RelayTests/Memoized/MainActorMemoizedTests.swift | 9 ++++++++- .../RelayTests/Memoized/ObservationMemoizedTests.swift | 9 ++++++++- .../RelayTests/Memoized/PublishableMemoizedTests.swift | 9 ++++++++- Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift | 9 ++++++++- 9 files changed, 45 insertions(+), 9 deletions(-) diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index be4dc9d..a5776a0 100644 --- a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -21,7 +21,7 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { func build() -> [DeclSyntax] { [ """ - \(inheritedAvailability)\(inheritedGlobalActorIsolation)private final \ + \(raw: storedPropertyAvailabilityComment())\(inheritedGlobalActorIsolation)private final \ var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil """, """ @@ -44,6 +44,14 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { ] } + private func storedPropertyAvailabilityComment() -> String { + if inheritedAvailability != nil { + "// Stored properties cannot be made potentially unavailable\n" + } else { + "" + } + } + private func observationTrackingBlock() -> CodeBlockItemSyntax { """ return withObservationTracking { diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift index 75e94d6..8e9eea0 100644 --- a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift @@ -114,7 +114,7 @@ side * side } - @available(macOS 26, *) + // Stored properties cannot be made potentially unavailable @MainActor private final var _customName: Optional = nil @available(macOS 26, *) diff --git a/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift index 6122c60..f8bea07 100644 --- a/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift @@ -114,7 +114,7 @@ side * side } - @available(macOS 26, *) + // Stored properties cannot be made potentially unavailable @MainActor private final var _customName: Optional = nil @available(macOS 26, *) diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index 44a161c..fdaee85 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -106,7 +106,7 @@ side * side } - @available(macOS 26, *) + // Stored properties cannot be made potentially unavailable private final var _customName: Optional = nil @available(macOS 26, *) diff --git a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift index 8bcf1f4..9b82d0c 100644 --- a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift @@ -108,7 +108,7 @@ side * side } - @available(macOS 26, *) + // Stored properties cannot be made potentially unavailable nonisolated private final var _customName: Optional = nil @available(macOS 26, *) diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 574c016..439cd53 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -254,10 +254,17 @@ extension MainActorMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) @Memoized + func calculateConditionalValue() -> Double { + volume + } + #endif } } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index c444b16..eb3c1bc 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -252,10 +252,17 @@ extension ObservationMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) @Memoized + func calculateConditionalValue() -> Double { + volume + } + #endif } } diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 963487e..061cc0a 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -186,10 +186,17 @@ extension PublishableMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) @Memoized + func calculateConditionalValue() -> Double { + volume + } + #endif } } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index a7128af..c062ace 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -257,10 +257,17 @@ extension SwiftDataMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) @Memoized + func calculateConditionalValue() -> Double { + volume + } + #endif } } From b30f562bf74ba02eef87b2b1f01388fd00f6be2b Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 14:03:46 +0100 Subject: [PATCH 29/45] - --- ...plicitIsolationPublishableMacroTests.swift | 28 +++++++++---------- ...ferredIsolationPublishableMacroTests.swift | 28 +++++++++---------- .../NonisolatedPublishableMacroTests.swift | 28 +++++++++---------- .../Publishable/PublishableMacroTests.swift | 28 +++++++++---------- .../Memoized/MainActorMemoizedTests.swift | 2 +- .../Memoized/ObservationMemoizedTests.swift | 2 +- .../Memoized/PublishableMemoizedTests.swift | 7 ++++- .../Memoized/SwiftDataMemoizedTests.swift | 2 +- .../MainActorPublishableTests.swift | 6 ++-- .../ObservationPublishableTests.swift | 6 ++-- 10 files changed, 71 insertions(+), 66 deletions(-) diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift index f179e53..8fd110d 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift @@ -51,11 +51,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -106,11 +106,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -171,7 +171,7 @@ _name.send(completion: .finished) _surname.send(completion: .finished) #if os(macOS) - _conditionalStoredProperty.send(completion: .finished) + _platformStoredProperty.send(completion: .finished) #endif } @@ -188,9 +188,9 @@ _storedPropertyPublisher(_surname, for: \.surname, object: object) } #if os(macOS) - fileprivate final let _conditionalStoredProperty = PassthroughSubject() - final var conditionalStoredProperty: some Publisher { - _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) } #endif @@ -202,8 +202,8 @@ } #if os(macOS) @available(macOS 26, *) - final var conditionalComputedProperty: some Publisher { - _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif @@ -236,8 +236,8 @@ return } #if os(macOS) - if keyPath == \.conditionalStoredProperty { - object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) return } #endif diff --git a/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift index a080c1f..ddcb539 100644 --- a/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift @@ -51,11 +51,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -106,11 +106,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -171,7 +171,7 @@ _name.send(completion: .finished) _surname.send(completion: .finished) #if os(macOS) - _conditionalStoredProperty.send(completion: .finished) + _platformStoredProperty.send(completion: .finished) #endif } @@ -188,9 +188,9 @@ _storedPropertyPublisher(_surname, for: \.surname, object: object) } #if os(macOS) - fileprivate final let _conditionalStoredProperty = PassthroughSubject() - final var conditionalStoredProperty: some Publisher { - _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) } #endif @@ -202,8 +202,8 @@ } #if os(macOS) @available(macOS 26, *) - final var conditionalComputedProperty: some Publisher { - _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif @@ -236,8 +236,8 @@ return } #if os(macOS) - if keyPath == \.conditionalStoredProperty { - object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) return } #endif diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index 06ec47f..45b3258 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -50,11 +50,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -105,11 +105,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -170,7 +170,7 @@ _name.send(completion: .finished) _surname.send(completion: .finished) #if os(macOS) - _conditionalStoredProperty.send(completion: .finished) + _platformStoredProperty.send(completion: .finished) #endif } @@ -187,9 +187,9 @@ _storedPropertyPublisher(_surname, for: \.surname, object: object) } #if os(macOS) - fileprivate final let _conditionalStoredProperty = PassthroughSubject() - final var conditionalStoredProperty: some Publisher { - _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) } #endif @@ -201,8 +201,8 @@ } #if os(macOS) @available(macOS 26, *) - final var conditionalComputedProperty: some Publisher { - _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif @@ -235,8 +235,8 @@ return } #if os(macOS) - if keyPath == \.conditionalStoredProperty { - object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) return } #endif diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index b91f182..5c20bbb 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -50,11 +50,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -105,11 +105,11 @@ } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif @@ -170,7 +170,7 @@ _name.send(completion: .finished) _surname.send(completion: .finished) #if os(macOS) - _conditionalStoredProperty.send(completion: .finished) + _platformStoredProperty.send(completion: .finished) #endif } @@ -187,9 +187,9 @@ _storedPropertyPublisher(_surname, for: \.surname, object: object) } #if os(macOS) - fileprivate final let _conditionalStoredProperty = PassthroughSubject() - final var conditionalStoredProperty: some Publisher { - _storedPropertyPublisher(_conditionalStoredProperty, for: \.conditionalStoredProperty, object: object) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) } #endif @@ -201,8 +201,8 @@ } #if os(macOS) @available(macOS 26, *) - final var conditionalComputedProperty: some Publisher { - _computedPropertyPublisher(for: \.conditionalComputedProperty, object: object) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif @@ -235,8 +235,8 @@ return } #if os(macOS) - if keyPath == \.conditionalStoredProperty { - object.publisher._conditionalStoredProperty.send(object[keyPath: \.conditionalStoredProperty]) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) return } #endif diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 439cd53..a64e90d 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -262,7 +262,7 @@ extension MainActorMemoizedTests { #if os(macOS) @available(macOS 26, *) @Memoized - func calculateConditionalValue() -> Double { + func calculatePlatformValue() -> Double { volume } #endif diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index eb3c1bc..86a2f25 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -260,7 +260,7 @@ extension ObservationMemoizedTests { #if os(macOS) @available(macOS 26, *) @Memoized - func calculateConditionalValue() -> Double { + func calculatePlatformValue() -> Double { volume } #endif diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 061cc0a..594c981 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -192,9 +192,14 @@ extension PublishableMemoizedTests { return baseArea * z } + @Memoized @PublisherIgnored + func calculateIgnoredValue() -> Double { + volume + } + #if os(macOS) @available(macOS 26, *) @Memoized - func calculateConditionalValue() -> Double { + func calculatePlatformValue() -> Double { volume } #endif diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index c062ace..cc5dd79 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -265,7 +265,7 @@ extension SwiftDataMemoizedTests { #if os(macOS) @available(macOS 26, *) @Memoized - func calculateConditionalValue() -> Double { + func calculatePlatformValue() -> Double { volume } #endif diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index e2da8ad..6217b38 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -220,11 +220,11 @@ extension MainActorPublishableTests { } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index 08976ff..00d1699 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -219,11 +219,11 @@ extension ObservationPublishableTests { } #if os(macOS) - var conditionalStoredProperty = 123 + var platformStoredProperty = 123 @available(macOS 26, *) - var conditionalComputedProperty: Int { - conditionalStoredProperty + var platformComputedProperty: Int { + platformStoredProperty } #endif From c4bddd09d44659ce93c468f6ba925a587da78ce4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 20:47:26 +0100 Subject: [PATCH 30/45] - --- .../Publishable/PublishableMacro.swift | 14 +- .../RelayMacros/Memoized/MemoizedMacro.swift | 2 +- .../Combine/Publishable/Publishable.swift | 7 +- ...xplicitlyIsolatedMemoizedMacroTests.swift} | 4 +- ...mplicitlyIsolatedMemoizedMacroTests.swift} | 4 +- ...icitlyIsolatedPublishableMacroTests.swift} | 4 +- ...icitlyIsolatedPublishableMacroTests.swift} | 4 +- ...licitlyIsolatedPublishableMacroTests.swift | 217 +++++++++++ .../SubclassedPublishableMacroTests.swift | 360 ++---------------- .../SubclassedMainActorPublishableTests.swift | 358 +++++++++++++++++ .../SubclassedPublishableTests.swift | 356 +++++++++++++++++ 11 files changed, 986 insertions(+), 344 deletions(-) rename Tests/RelayMacrosTests/Memoized/{ExplicitIsolationMemoizedMacroTests.swift => ExplicitlyIsolatedMemoizedMacroTests.swift} (97%) rename Tests/RelayMacrosTests/Memoized/{InferredIsolationMemoizedMacroTests.swift => ImplicitlyIsolatedMemoizedMacroTests.swift} (97%) rename Tests/RelayMacrosTests/Publishable/{ExplicitIsolationPublishableMacroTests.swift => ExplicitlyIsolatedPublishableMacroTests.swift} (99%) rename Tests/RelayMacrosTests/Publishable/{InferredIsolationPublishableMacroTests.swift => ImplicitlyIsolatedPublishableMacroTests.swift} (99%) create mode 100644 Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift create mode 100644 Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift create mode 100644 Tests/RelayTests/Publishable/SubclassedPublishableTests.swift diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 5d97fbc..90f5554 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -50,23 +50,25 @@ extension PublishableMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, - conformingTo _: [TypeSyntax], + conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let declaration = try validate(node, attachedTo: declaration, in: context) - let parameters = try Parameters(from: node) - let inferredSuperclassType = try declaration.inferredSuperclassType(isExpected: parameters.hasSuperclass) let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) + let parameters = try Parameters(from: node) + + let hasPublishableSuperclass = protocols.isEmpty + let trimmedSuperclassType = hasPublishableSuperclass ? declaration.possibleSuperclassType : nil let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, - trimmedSuperclassType: inferredSuperclassType + trimmedSuperclassType: trimmedSuperclassType ), PropertyPublisherDeclBuilder( declaration: declaration, properties: properties, - trimmedSuperclassType: inferredSuperclassType, + trimmedSuperclassType: trimmedSuperclassType, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( @@ -128,12 +130,10 @@ extension PublishableMacro { private struct Parameters { - let hasSuperclass: Bool? let preferredGlobalActorIsolation: GlobalActorIsolation? init(from node: AttributeSyntax) throws { let extractor = ParameterExtractor(from: node) - self.hasSuperclass = try extractor.rawBool(withLabel: "hasSuperclass") self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") } } diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 05c3d4f..82e6668 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -58,7 +58,7 @@ extension MemoizedMacro { node: declaration, message: """ @Memoized macro can only be applied to methods declared \ - in body (not extensions) of Observable classes + in primary definition (not extensions) of Observable classes """ ) } diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index a955097..de3a4c5 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -25,6 +25,7 @@ /// @attached( member, + conformances: Publishable, names: named(_publisher), named(publisher), named(PropertyPublisher), @@ -34,9 +35,7 @@ extension, conformances: Publishable ) -public macro Publishable( - hasSuperclass: Bool = false -) = #externalMacro( +public macro Publishable() = #externalMacro( module: "RelayMacros", type: "PublishableMacro" ) @@ -61,6 +60,7 @@ public macro Publishable( /// @attached( member, + conformances: Publishable, names: named(_publisher), named(publisher), named(PropertyPublisher), @@ -71,7 +71,6 @@ public macro Publishable( conformances: Publishable ) public macro Publishable( - hasSuperclass: Bool = false, isolation: (any GlobalActor.Type)? ) = #externalMacro( module: "RelayMacros", diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift similarity index 97% rename from Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift index 8e9eea0..915ff6e 100644 --- a/Tests/RelayMacrosTests/Memoized/ExplicitIsolationMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// ExplicitIsolationMemoizedMacroTests.swift +// ExplicitlyIsolatedMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,7 +11,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class ExplicitIsolationMemoizedMacroTests: XCTestCase { + internal final class ExplicitlyIsolatedMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self diff --git a/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift similarity index 97% rename from Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift index f8bea07..7a07447 100644 --- a/Tests/RelayMacrosTests/Memoized/InferredIsolationMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// InferredIsolationMemoizedMacroTests.swift +// ImplicitlyIsolatedMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,7 +11,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class InferredIsolationMemoizedMacroTests: XCTestCase { + internal final class ImplicitlyIsolatedMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift similarity index 99% rename from Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index 8fd110d..e969a5e 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitIsolationPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -1,5 +1,5 @@ // -// ExplicitIsolationPublishableMacroTests.swift +// ExplicitlyIsolatedPublishableMacroTests.swift // Relay // // Created by Kamil Strzelecki on 23/11/2025. @@ -13,7 +13,7 @@ import XCTest // swiftlint:disable:next type_body_length - internal final class ExplicitIsolationPublishableMacroTests: XCTestCase { + internal final class ExplicitlyIsolatedPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ "Publishable": MacroSpec( diff --git a/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift similarity index 99% rename from Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index ddcb539..f76900e 100644 --- a/Tests/RelayMacrosTests/Publishable/InferredIsolationPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -1,5 +1,5 @@ // -// InferredIsolationPublishableMacroTests.swift +// ImplicitlyIsolatedPublishableMacroTests.swift // Relay // // Created by Kamil Strzelecki on 24/08/2025. @@ -13,7 +13,7 @@ import XCTest // swiftlint:disable:next type_body_length - internal final class InferredIsolationPublishableMacroTests: XCTestCase { + internal final class ImplicitlyIsolatedPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ "Publishable": MacroSpec( diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift new file mode 100644 index 0000000..2029149 --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift @@ -0,0 +1,217 @@ +// +// SubclassedImplicitlyIsolatedPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class SubclassedImplicitlyIsolatedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: [] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Publishable @Observable + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - 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. + /// + override var publisher: PropertyPublisher { + _publisher + } + + @MainActor class PropertyPublisher: Animal.PropertyPublisher { + + private final unowned let object: Dog + + final var dogWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + final var dogDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + init(object: Dog) { + self.object = object + super.init(object: object) + } + + @MainActor deinit { + _breed.send(completion: .finished) + } + + fileprivate final let _breed = PassthroughSubject, Never>() + final var breed: some Publisher, Never> { + _storedPropertyPublisher(_breed, for: \.breed, object: object) + } + + final var isBulldog: some Publisher { + _computedPropertyPublisher(for: \.isBulldog, object: object) + } + + + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor private func publish( + _ object: Dog, + keyPath: KeyPath + ) { + if keyPath == \.breed { + object.publisher._breed.send(object[keyPath: \.breed]) + return + } + } + + nonisolated func willSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Dog, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Dog, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift index adddbd9..8126b59 100644 --- a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift @@ -1,5 +1,5 @@ // -// InferredSuperclassPublishableMacroTests.swift +// SubclassedPublishableMacroTests.swift // Relay // // Created by Kamil Strzelecki on 23/11/2025. @@ -21,340 +21,50 @@ ) ] - func testExpansionWithInferredSuperclassType() { + func testExpansion() { assertMacroExpansion( #""" @Publishable @Observable class Dog: Animal { - var name: String - - override var age: Int { - willSet { - print(newValue) - } + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" } - } - """#, - expandedSource: - #""" - @Observable - class Dog: Animal { - - var name: String - + + @ObservationIgnored override var age: Int { - willSet { - print(newValue) + didSet { + _ = oldValue } } - - private final lazy var _publisher = PropertyPublisher(object: self) - - /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable - /// or computed instance properties of this object. - /// - /// - 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. - /// - override var publisher: PropertyPublisher { - _publisher - } - - class PropertyPublisher: Animal.PropertyPublisher { - - private final unowned let object: Dog - - final var dogWillChange: some Publisher { - willChange.map { [unowned object] _ in - object - } - } - - final var dogDidChange: some Publisher { - didChange.map { [unowned object] _ in - object - } - } - - init(object: Dog) { - self.object = object - super.init(object: object) - } - - deinit { - _name.send(completion: .finished) - } - - fileprivate final let _name = PassthroughSubject() - final var name: some Publisher { - _storedPropertyPublisher(_name, for: \.name, object: object) - } - - - - - } - - private enum Observation { - - nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { - - private let underlying = SwiftObservationRegistrar() - - private func publish( - _ object: Dog, - keyPath: KeyPath - ) { - if keyPath == \.name { - object.publisher._name.send(object[keyPath: \.name]) - return - } - } - - nonisolated func willSet( - _ object: Dog, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - object.publisher._beginModifications() - underlying.willSet(object, keyPath: keyPath) - } - } - - nonisolated func didSet( - _ object: Dog, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - underlying.didSet(object, keyPath: keyPath) - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - } - - nonisolated func access( - _ object: Dog, - keyPath: KeyPath - ) { - underlying.access(object, keyPath: keyPath) - } - - nonisolated func withMutation<__macro_local_1TfMu_>( - of object: Dog, - keyPath: KeyPath, - _ mutation: () throws -> __macro_local_1TfMu_ - ) rethrows -> __macro_local_1TfMu_ { - nonisolated(unsafe) let mutation = mutation - nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: __macro_local_1TfMu_! - - try assumeIsolatedIfNeeded { - object.publisher._beginModifications() - defer { - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - result = try underlying.withMutation( - of: object, - keyPath: keyPath, - mutation - ) - } - - return result - } - - private nonisolated func assumeIsolatedIfNeeded( - _ operation: () throws -> Void - ) rethrows { - try operation() - } - } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" } } """#, - macroSpecs: macroSpecs - ) - } - - func testExpansionWithExplicitSuperclassType() { - assertMacroExpansion( - #""" - @Publishable(hasSuperclass: true) @Observable - class Dog: Animal { - - var name: String - } - """#, expandedSource: #""" @Observable class Dog: Animal { - - var name: String - - private final lazy var _publisher = PropertyPublisher(object: self) - - /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable - /// or computed instance properties of this object. - /// - /// - 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. - /// - override var publisher: PropertyPublisher { - _publisher - } - - class PropertyPublisher: Animal.PropertyPublisher { - - private final unowned let object: Dog - - final var dogWillChange: some Publisher { - willChange.map { [unowned object] _ in - object - } - } - - final var dogDidChange: some Publisher { - didChange.map { [unowned object] _ in - object - } - } - - init(object: Dog) { - self.object = object - super.init(object: object) - } - - deinit { - _name.send(completion: .finished) - } - - fileprivate final let _name = PassthroughSubject() - final var name: some Publisher { - _storedPropertyPublisher(_name, for: \.name, object: object) - } - - - - - } - - private enum Observation { - - nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { - - private let underlying = SwiftObservationRegistrar() - - private func publish( - _ object: Dog, - keyPath: KeyPath - ) { - if keyPath == \.name { - object.publisher._name.send(object[keyPath: \.name]) - return - } - } - - nonisolated func willSet( - _ object: Dog, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - object.publisher._beginModifications() - underlying.willSet(object, keyPath: keyPath) - } - } - - nonisolated func didSet( - _ object: Dog, - keyPath: KeyPath - ) { - nonisolated(unsafe) let keyPath = keyPath - assumeIsolatedIfNeeded { - underlying.didSet(object, keyPath: keyPath) - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - } - - nonisolated func access( - _ object: Dog, - keyPath: KeyPath - ) { - underlying.access(object, keyPath: keyPath) - } - - nonisolated func withMutation<__macro_local_1TfMu_>( - of object: Dog, - keyPath: KeyPath, - _ mutation: () throws -> __macro_local_1TfMu_ - ) rethrows -> __macro_local_1TfMu_ { - nonisolated(unsafe) let mutation = mutation - nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: __macro_local_1TfMu_! - - try assumeIsolatedIfNeeded { - object.publisher._beginModifications() - defer { - publish(object, keyPath: keyPath) - object.publisher._endModifications() - } - result = try underlying.withMutation( - of: object, - keyPath: keyPath, - mutation - ) - } - - return result - } - - private nonisolated func assumeIsolatedIfNeeded( - _ operation: () throws -> Void - ) rethrows { - try operation() - } - } + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" } - } - """#, - macroSpecs: macroSpecs - ) - } - - func testExpansionWithMaskedSuperclassType() { - assertMacroExpansion( - #""" - @Publishable(hasSuperclass: false) @Observable - class Dog: Animal { - - var name: String - + + @ObservationIgnored override var age: Int { - willSet { - print(newValue) + didSet { + _ = oldValue } } - } - """#, - expandedSource: - #""" - @Observable - class Dog: Animal { - - var name: String - - override var age: Int { - willSet { - print(newValue) - } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" } private final lazy var _publisher = PropertyPublisher(object: self) @@ -366,11 +76,11 @@ /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - var publisher: PropertyPublisher { + override var publisher: PropertyPublisher { _publisher } - class PropertyPublisher: Relay.AnyPropertyPublisher { + class PropertyPublisher: Animal.PropertyPublisher { private final unowned let object: Dog @@ -392,15 +102,17 @@ } deinit { - _name.send(completion: .finished) + _breed.send(completion: .finished) } - fileprivate final let _name = PassthroughSubject() - final var name: some Publisher { - _storedPropertyPublisher(_name, for: \.name, object: object) + fileprivate final let _breed = PassthroughSubject, Never>() + final var breed: some Publisher, Never> { + _storedPropertyPublisher(_breed, for: \.breed, object: object) } - + final var isBulldog: some Publisher { + _computedPropertyPublisher(for: \.isBulldog, object: object) + } } @@ -415,8 +127,8 @@ _ object: Dog, keyPath: KeyPath ) { - if keyPath == \.name { - object.publisher._name.send(object[keyPath: \.name]) + if keyPath == \.breed { + object.publisher._breed.send(object[keyPath: \.breed]) return } } diff --git a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift new file mode 100644 index 0000000..126e882 --- /dev/null +++ b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift @@ -0,0 +1,358 @@ +// +// SubclassedMainActorPublishable.swift +// Relay +// +// Created by Kamil Strzelecki on 29/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +@MainActor +internal struct SubclassedMainActorPublishableTests { + + @Test + func storedProperty() { + var dog: Dog? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.name.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.name + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "Unknown") + #expect(observationsQueue.popFirst() == nil) + + dog?.age = 5 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == "Paco") + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func storedOverridenProperty() { + var dog: Dog? = .init() + var publishableQueue = [Int]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.age.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == 0) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.age = 5 + #expect(publishableQueue.popFirst() == 5) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedMainActorPublishableTests { + + @Test + func computedProperty() { + var dog: Dog? = .init() + var publishableQueue = [Bool]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.isBulldog.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() == true) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = nil + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func computedOverridenProperty() { + var dog: Dog? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.description.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.description + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "-, 0") + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() == "Bulldog, 0") + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.age += 1 + #expect(publishableQueue.popFirst() == "Bulldog, 1") + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedMainActorPublishableTests { + + @Test + func willChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogWillChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func didChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogDidChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedMainActorPublishableTests { + + @MainActor @Publishable @Observable + class PublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @MainActor @Publishable @Observable + final class Dog: PublishableAnimal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} + +extension SubclassedMainActorPublishableTests { + + @MainActor + class NonPublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @MainActor @Publishable @Observable + final class Cat: NonPublishableAnimal { + + var breed: String? + + var isSphynx: Bool { + breed == "Sphynx" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} diff --git a/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift new file mode 100644 index 0000000..4f905fe --- /dev/null +++ b/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift @@ -0,0 +1,356 @@ +// +// SubclassedPublishableTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +internal struct SubclassedPublishableTests { + + @Test + func storedProperty() { + var dog: Dog? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.name.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.name + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "Unknown") + #expect(observationsQueue.popFirst() == nil) + + dog?.age = 5 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == "Paco") + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func storedOverridenProperty() { + var dog: Dog? = .init() + var publishableQueue = [Int]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.age.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == 0) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.age = 5 + #expect(publishableQueue.popFirst() == 5) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedPublishableTests { + + @Test + func computedProperty() { + var dog: Dog? = .init() + var publishableQueue = [Bool]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.isBulldog.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() == true) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = nil + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func computedOverridenProperty() { + var dog: Dog? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.description.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.description + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "-, 0") + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() == "Bulldog, 0") + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.age += 1 + #expect(publishableQueue.popFirst() == "Bulldog, 1") + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedPublishableTests { + + @Test + func willChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogWillChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func didChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogDidChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedPublishableTests { + + @Publishable @Observable + class PublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @Publishable @Observable + final class Dog: PublishableAnimal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} + +extension SubclassedPublishableTests { + + class NonPublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @Publishable @Observable + final class Cat: NonPublishableAnimal { + + var breed: String? + + var isSphynx: Bool { + breed == "Sphynx" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} From 13b2a3f4eaa08741269cc533ec3f02cd027685bf Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 20:47:26 +0100 Subject: [PATCH 31/45] [SwiftFormat] Applied formatting --- ...ImplicitlyIsolatedPublishableMacroTests.swift | 16 ++++++++-------- .../SubclassedPublishableMacroTests.swift | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift index 2029149..296217b 100644 --- a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift @@ -28,18 +28,18 @@ class Dog: Animal { var breed: String? - + var isBulldog: Bool { breed == "Bulldog" } - + @ObservationIgnored override var age: Int { didSet { _ = oldValue } } - + override var description: String { "\(breed ?? "Unknown"), \(age)" } @@ -49,20 +49,20 @@ #""" @MainActor @Observable class Dog: Animal { - + var breed: String? - + var isBulldog: Bool { breed == "Bulldog" } - + @ObservationIgnored override var age: Int { didSet { _ = oldValue } } - + override var description: String { "\(breed ?? "Unknown"), \(age)" } @@ -196,7 +196,7 @@ try withoutActuallyEscaping(operation) { operation in typealias Nonisolated = () throws -> Void let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) - + try MainActor.shared.assumeIsolated( { _ in try rawOperation() diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift index 8126b59..abfa906 100644 --- a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift @@ -28,18 +28,18 @@ class Dog: Animal { var breed: String? - + var isBulldog: Bool { breed == "Bulldog" } - + @ObservationIgnored override var age: Int { didSet { _ = oldValue } } - + override var description: String { "\(breed ?? "Unknown"), \(age)" } @@ -49,20 +49,20 @@ #""" @Observable class Dog: Animal { - + var breed: String? - + var isBulldog: Bool { breed == "Bulldog" } - + @ObservationIgnored override var age: Int { didSet { _ = oldValue } } - + override var description: String { "\(breed ?? "Unknown"), \(age)" } From b05b862071bcc2acc9dcc9d8663b72e6d9fc7ef1 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sat, 29 Nov 2025 21:15:52 +0100 Subject: [PATCH 32/45] - --- Macros/Dependencies/PrincipleMacros | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index 906bd5f..cc9d2e8 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit 906bd5f9a8f7a229b208520fc40ad13c7822be2a +Subproject commit cc9d2e815f835417413e42be4043b7cc113515ac From 95f73692d733d8cf528002761509fea72e2a5ed4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 10:05:20 +0100 Subject: [PATCH 33/45] - --- .swiftlint.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 712e1e7..d6774bd 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,7 +8,7 @@ opt_in_rules: - anonymous_argument_in_multiline_closure - array_init - async_without_await - # attributes + # attributes (swiftformat) # balanced_xctest_lifecycle # closure_body_length - closure_end_indentation @@ -55,9 +55,9 @@ opt_in_rules: - ibinspectable_in_extension - identical_operands - implicit_return - # implicitly_unwrapped_optional + - implicitly_unwrapped_optional # incompatible_concurrency_annotation - # indentation_width + # indentation_width (swiftformat) - joined_default_parameter - last_where - legacy_multiple @@ -67,7 +67,7 @@ opt_in_rules: - local_doc_comment - lower_acl_than_parent # missing_docs - # modifier_order + # modifier_order (swiftformat) - multiline_arguments - multiline_arguments_brackets - multiline_function_chains @@ -144,7 +144,7 @@ opt_in_rules: # vertical_whitespace_opening_braces - weak_delegate - xct_specific_matcher - # yoda_condition + # yoda_condition (swiftformat) analyzer_rules: - capture_variable From 57b437b9e0cb01fb4c6523bf38e989a7b2bd39d7 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 10:17:18 +0100 Subject: [PATCH 34/45] - --- .../Combine/Common/ObservationIgnoredMacro.swift | 2 +- .../Combine/Common/PropertyPublisherDeclBuilder.swift | 6 +++--- .../RelayMacros/Combine/Common/PublisherIgnoredMacro.swift | 4 ++-- .../Publishable/ObservationRegistrarDeclBuilder.swift | 2 +- .../Memoized/NonisolatedMemoizedMacroTests.swift | 4 ---- .../Publishable/SubclassedMainActorPublishableTests.swift | 2 +- 6 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift index 4088fcc..b1ac4f8 100644 --- a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -15,7 +15,7 @@ internal enum ObservationIgnoredMacro { extension Property { - var isStoredObservable: Bool { + var isStoredObservationTracked: Bool { kind == .stored && mutability == .mutable && underlying.typeScopeSpecifier == nil diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index 5172c7f..c278392 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -87,7 +87,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @CodeBlockItemListBuilder private func storedPropertiesSubjectsFinishCalls() -> CodeBlockItemListSyntax { - for property in properties.all where property.isStoredPublishable { + for property in properties.all where property.isStoredPublisherTracked { let call = storedPropertySubjectFinishCall(for: property) if let ifConfigCall = property.underlying.applyingEnclosingIfConfig(to: call) { ifConfigCall @@ -103,7 +103,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.all where property.isStoredPublishable { + for property in properties.all where property.isStoredPublisherTracked { let publisher = storedPropertyPublisher(for: property) if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { ifConfigPublisher @@ -129,7 +129,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.all where property.isComputedPublishable { + for property in properties.all where property.isComputedPublisherTracked { let publisher = computedPropertyPublisher(for: property) if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { ifConfigPublisher diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift index 3e38843..b8d8d1a 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift @@ -26,7 +26,7 @@ extension PublisherIgnoredMacro: PeerMacro { extension Property { - var isStoredPublishable: Bool { + var isStoredPublisherTracked: Bool { kind == .stored && mutability == .mutable && underlying.typeScopeSpecifier == nil @@ -34,7 +34,7 @@ extension Property { && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) } - var isComputedPublishable: Bool { + var isComputedPublisherTracked: Bool { kind == .computed && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index 9af51e5..0ff2d97 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -23,7 +23,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin ) { self.declaration = declaration self.preferredGlobalActorIsolation = preferredGlobalActorIsolation - self.trackedProperties = properties.filter(\.isStoredPublishable) + self.trackedProperties = properties.filter(\.isStoredPublisherTracked) self.genericParameter = context.makeUniqueName("T") } diff --git a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift index 9b82d0c..f49cd85 100644 --- a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift @@ -17,8 +17,6 @@ "Memoized": MemoizedMacro.self ] - // swiftlint:disable global_actor_attribute_order - func testExpansion() { assertMacroExpansion( #""" @@ -147,7 +145,5 @@ macros: macros ) } - - // swiftlint:enable global_actor_attribute_order } #endif diff --git a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift index 126e882..4e64be1 100644 --- a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift @@ -1,5 +1,5 @@ // -// SubclassedMainActorPublishable.swift +// SubclassedMainActorPublishableTests.swift // Relay // // Created by Kamil Strzelecki on 29/11/2025. From 8c98a2571e3b2faed9beb99bdfe7508865d2b4e7 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 11:15:12 +0100 Subject: [PATCH 35/45] - --- .swiftlint.tests.yml | 1 + .swiftlint.yml | 5 +---- .../Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift | 4 ---- .../Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift | 4 ---- .../ExplicitlyIsolatedPublishableMacroTests.swift | 6 ++++-- .../ImplicitlyIsolatedPublishableMacroTests.swift | 6 ++++-- .../Publishable/NonisolatedPublishableMacroTests.swift | 6 ++++-- .../Publishable/PublishableMacroTests.swift | 6 ++++-- Tests/RelayTests/Memoized/MainActorMemoizedTests.swift | 3 ++- Tests/RelayTests/Memoized/ObservationMemoizedTests.swift | 3 ++- Tests/RelayTests/Memoized/PublishableMemoizedTests.swift | 3 ++- Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift | 3 ++- 12 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.swiftlint.tests.yml b/.swiftlint.tests.yml index ee516cf..504622d 100644 --- a/.swiftlint.tests.yml +++ b/.swiftlint.tests.yml @@ -1,3 +1,4 @@ disabled_rules: - function_body_length + - type_body_length - no_magic_numbers \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index d6774bd..53560a9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -173,6 +173,7 @@ identifier_name: excluded: [id, ui, x, y, z, dx, dy, dz] line_length: + ignores_multiline_strings: true ignores_comments: true nesting: @@ -189,10 +190,6 @@ type_contents_order: order: [[case], [type_alias, associated_type], [subtype], [type_property], [instance_property], [ib_inspectable], [ib_outlet], [initializer], [deinitializer], [type_method], [view_life_cycle_method], [ib_action, ib_segue_action], [other_method], [subscript]] custom_rules: - global_actor_attribute_order: - name: "Global actor attribute order" - message: "Global actor should be the first attribute." - regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor\\s)" sendable_attribute_order: name: "Sendable attribute order" message: "Sendable should be the first attribute." diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift index 915ff6e..3f88ed2 100644 --- a/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift @@ -17,8 +17,6 @@ "Memoized": MemoizedMacro.self ] - // swiftlint:disable global_actor_attribute_order - func testExpansion() { assertMacroExpansion( #""" @@ -159,7 +157,5 @@ macros: macros ) } - - // swiftlint:enable global_actor_attribute_order } #endif diff --git a/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift index 7a07447..bbe887b 100644 --- a/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift @@ -17,8 +17,6 @@ "Memoized": MemoizedMacro.self ] - // swiftlint:disable global_actor_attribute_order - func testExpansion() { assertMacroExpansion( #""" @@ -159,7 +157,5 @@ macros: macros ) } - - // swiftlint:enable global_actor_attribute_order } #endif diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index e969a5e..2c17cce 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -67,7 +67,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } @@ -122,7 +123,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } diff --git a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index f76900e..f6dbf75 100644 --- a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -67,7 +67,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } @@ -122,7 +123,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index 45b3258..b1569a5 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -66,7 +66,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } @@ -121,7 +122,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 5c20bbb..059536c 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -66,7 +66,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } @@ -121,7 +122,8 @@ ignoredStoredProperty } - @available(iOS 26, *) @Memoized(.private) + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index a64e90d..50336d8 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -261,7 +261,8 @@ extension MainActorMemoizedTests { } #if os(macOS) - @available(macOS 26, *) @Memoized + @available(macOS 26, *) + @Memoized func calculatePlatformValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index 86a2f25..b691469 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -259,7 +259,8 @@ extension ObservationMemoizedTests { } #if os(macOS) - @available(macOS 26, *) @Memoized + @available(macOS 26, *) + @Memoized func calculatePlatformValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 594c981..934060e 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -198,7 +198,8 @@ extension PublishableMemoizedTests { } #if os(macOS) - @available(macOS 26, *) @Memoized + @available(macOS 26, *) + @Memoized func calculatePlatformValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index cc5dd79..e2b8399 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -264,7 +264,8 @@ extension SwiftDataMemoizedTests { } #if os(macOS) - @available(macOS 26, *) @Memoized + @available(macOS 26, *) + @Memoized func calculatePlatformValue() -> Double { volume } From a34a39279bd6e78b5c0d1e5c80a2d3ea1a180110 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 11:30:56 +0100 Subject: [PATCH 36/45] - --- Sources/Relay/Documentation.docc/Changelog.md | 18 ++++++++++++++++++ Sources/Relay/Documentation.docc/Relay.md | 4 ++++ 2 files changed, 22 insertions(+) create mode 100644 Sources/Relay/Documentation.docc/Changelog.md diff --git a/Sources/Relay/Documentation.docc/Changelog.md b/Sources/Relay/Documentation.docc/Changelog.md new file mode 100644 index 0000000..5b93ff9 --- /dev/null +++ b/Sources/Relay/Documentation.docc/Changelog.md @@ -0,0 +1,18 @@ +# Changelog + +Summary of breaking changes between major releases. + +## Version 3.0 + +- ``AnyPropertyPublisher`` is no longer generic in order to allow subclassing of ``Publishable-protocol`` types. +In consequence, its ``AnyPropertyPublisher/willChange`` and ``AnyPropertyPublisher/didChange`` publishers changed their output type +from the specialized `Object` type to `Void`. Generated ``AnyPropertyPublisher`` subclasses still expose specialized publishers +with class names as their prefix, so an example class named `Person` will expose `personWillChange` and `personDidChange` publishers. +- Calling `@Model` macro compatible with `@Publishable` was unfortunately premature. SwiftData uses reflection to find property named +`_$observationRegistrar` and asserts if it cannot cast it to the default `ObservationRegistrar` type. Although its possible to circumvent +this assertion, for example by making `Publishable` types conform to `CustomReflectable`, internals of the framework won't be able to send values +through the generated publishers. Thus, `@Publishable` macro now emits a warning when applied to `@Model` classes. + +## Version 2.0 + +- `swift-tools-version` changed from 6.1 to 6.2. diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md index 57d66ce..6143a61 100644 --- a/Sources/Relay/Documentation.docc/Relay.md +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -6,3 +6,7 @@ Essential tools that extend the capabilities of `Observation`. - - + +## Changelog + +- From def73b1c0491511d39a24d77acddce28b13a8324 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 12:44:49 +0100 Subject: [PATCH 37/45] Added docs --- README.md | 8 ++++---- .../Combine/Common/AnyPropertyPublisher.swift | 10 ++++++++-- .../Combine/Common/PublisherIgnored.swift | 8 ++++++++ .../Combine/Publishable/Publishable.swift | 10 ++++++---- Sources/Relay/Documentation.docc/Changelog.md | 20 +++++++++++-------- .../Documentation.docc/HowPublishableWorks.md | 16 +++++++++++++++ .../Documentation.docc/PublishableMacros.md | 10 ++++++---- 7 files changed, 60 insertions(+), 22 deletions(-) create mode 100644 Sources/Relay/Documentation.docc/HowPublishableWorks.md diff --git a/README.md b/README.md index ded2b40..a8a53d6 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ as it publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. -This is where `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you -take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates -with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: +This is where the `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features while processing changes synchronously when needed. It integrates with the `@Observable` +macro and is designed to be compatible with other macros built on top of `Observation`: ```swift import Relay @@ -76,7 +76,7 @@ By leveraging these facts, the `@Publishable` macro can overload the default `Ob While I acknowledge that this usage might not have been intended by the authors, I would refrain from calling it a hack. It relies solely on well-understood behaviors of Swift and its public APIs. -This approach has been carefully tested and verified to work with both `@Observable` and `@Model` macros. +This approach has been carefully tested and verified to work with the `@Observable` macro. diff --git a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift index aac3ff8..882d3a7 100644 --- a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift +++ b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift @@ -18,13 +18,19 @@ open class AnyPropertyPublisher { private final let _willChange = PassthroughSubject() private final let _didChange = PassthroughSubject() - /// Emits the `Object` **before** any of its stored properties are assigned a new value. + /// Emits **before** any of the ``Publishable`` object's stored properties are assigned a new value. + /// + /// Generated subclasses also expose specialized publisher that emits the ``Publishable`` object itself, + /// named using the class name as a prefix. For example, a class named `Person` will provide a `personWillChange` publisher. /// public final var willChange: some Publisher { _willChange } - /// Emits the `Object` **after** any of its stored properties are assigned a new value. + /// Emits **after** any of the ``Publishable`` object's stored properties are assigned a new value. + /// + /// Generated subclasses also expose specialized publisher that emits the ``Publishable`` object itself, + /// named using the class name as a prefix. For example, a class named `Person` will provide a `personDidChange` publisher. /// public final var didChange: some Publisher { _didChange diff --git a/Sources/Relay/Combine/Common/PublisherIgnored.swift b/Sources/Relay/Combine/Common/PublisherIgnored.swift index 1c553ef..02a3101 100644 --- a/Sources/Relay/Combine/Common/PublisherIgnored.swift +++ b/Sources/Relay/Combine/Common/PublisherIgnored.swift @@ -6,6 +6,14 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // +/// Disables tracking of a property through the generated ``Publishable/publisher``. +/// +/// By default, an object can track any mutable or computed instance property of ``Publishable`` type that is accessible to the given object. +/// To prevent tracking of an accessible property through `Combine`, attach this macro to the property or ``Memoized(_:_:)`` method. +/// +/// The `@PublisherIgnored` macro is independent of the `@ObservationIgnored` macro. +/// If you want to prevent tracking through `Observation` as well, apply both macros. +/// @attached(peer) public macro PublisherIgnored() = #externalMacro( module: "RelayMacros", diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index de3a4c5..30e912b 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -6,12 +6,13 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` conformance to `Observable` types. /// /// - Note: This macro infers the global actor isolation of the type and applies it to the generated declarations. /// If this causes compilation errors, use ``Publishable(isolation:)`` instead. /// -/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied. +/// - Note: This macro works with `Observable` classes, but it does not generate `Observable` conformance by itself. +/// To make the two compatible, apply another macro - such as `@Observable` - to the type alongside `@Publishable`. /// /// The `@Publishable` macro adds a new `publisher` property to your type, /// which exposes `Combine` publishers for all mutable or computed instance properties. @@ -40,13 +41,14 @@ public macro Publishable() = #externalMacro( type: "PublishableMacro" ) -/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` conformance to `Observable` types. /// /// - Parameter isolation: The global actor to which the type is isolated. /// If set to `nil`, the generated members are `nonisolated`. /// To infer isolation automatically, use the ``Publishable()`` macro instead. /// -/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied directly. +/// - Note: This macro works with `Observable` classes, but it does not generate `Observable` conformance by itself. +/// To make the two compatible, apply another macro - such as `@Observable` - to the type alongside `@Publishable`. /// /// The `@Publishable` macro adds a new `publisher` property to your type, /// which exposes `Combine` publishers for all mutable or computed instance properties. diff --git a/Sources/Relay/Documentation.docc/Changelog.md b/Sources/Relay/Documentation.docc/Changelog.md index 5b93ff9..b57d055 100644 --- a/Sources/Relay/Documentation.docc/Changelog.md +++ b/Sources/Relay/Documentation.docc/Changelog.md @@ -4,14 +4,18 @@ Summary of breaking changes between major releases. ## Version 3.0 -- ``AnyPropertyPublisher`` is no longer generic in order to allow subclassing of ``Publishable-protocol`` types. -In consequence, its ``AnyPropertyPublisher/willChange`` and ``AnyPropertyPublisher/didChange`` publishers changed their output type -from the specialized `Object` type to `Void`. Generated ``AnyPropertyPublisher`` subclasses still expose specialized publishers -with class names as their prefix, so an example class named `Person` will expose `personWillChange` and `personDidChange` publishers. -- Calling `@Model` macro compatible with `@Publishable` was unfortunately premature. SwiftData uses reflection to find property named -`_$observationRegistrar` and asserts if it cannot cast it to the default `ObservationRegistrar` type. Although its possible to circumvent -this assertion, for example by making `Publishable` types conform to `CustomReflectable`, internals of the framework won't be able to send values -through the generated publishers. Thus, `@Publishable` macro now emits a warning when applied to `@Model` classes. +- All generated publishers now return an opaque `some Publisher` type instread of an erased `AnyPublisher`. + +- ``AnyPropertyPublisher`` is no longer generic, allowing subclassing of ``Publishable`` types. +As a consequence, its ``AnyPropertyPublisher/willChange`` and ``AnyPropertyPublisher/didChange`` publishers now output `Void` +instead of the specialized `Object` type. Generated subclasses still expose specialized publishers using the class name as a prefix. +For example, a class named `Person` will provide `personWillChange` and `personDidChange` publishers. + +- Calling the `@Model` macro compatible with ``Publishable()`` turned out to be premature. `SwiftData` uses reflection +to find property named `_$observationRegistrar` and asserts if it cannot cast it to the default `ObservationRegistrar` type. +Although it's technically possible to bypass this assertion (for example, by making ``Publishable`` types conform to `CustomReflectable`), +the framework internals would still fail to send values through the generated publishers. Therefore, the ``Publishable()`` macro +now emits a warning when applied to `@Model` classes. ## Version 2.0 diff --git a/Sources/Relay/Documentation.docc/HowPublishableWorks.md b/Sources/Relay/Documentation.docc/HowPublishableWorks.md new file mode 100644 index 0000000..3304b3a --- /dev/null +++ b/Sources/Relay/Documentation.docc/HowPublishableWorks.md @@ -0,0 +1,16 @@ +# How Publishable Works? + +Learn how the ``Publishable()`` macro works under the hood. + +The ``Publishable()`` macro relies on two key properties of Swift Macros and `Observation` module: +- Macro expansions are compiled in the context of the module where they’re used. This allows references in the macro to be overloaded by locally available symbols. +- Swift exposes `ObservationRegistrar` as a documented, public API, making it possible to use it safely and directly. + +By leveraging these facts, the ``Publishable()`` macro can overload the default `ObservationRegistrar` with a custom one that: +- Forwards changes to Swift’s native `ObservationRegistrar` +- Simultaneously emits values through generated `Combine` publishers + +While I acknowledge that this usage might not have been intended by the authors, I would refrain from calling it a hack. +It relies solely on well-understood behaviors of Swift and its public APIs. + +This approach has been carefully tested and verified to work with the `@Observable` macro. diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md index b50492f..017c048 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -10,9 +10,9 @@ as it publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. -This is where ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you -take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates -with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: +This is where the ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features while processing changes synchronously when needed. It integrates with the `@Observable` +macro and is designed to be compatible with other macros built on top of `Observation`: ```swift import Relay @@ -55,8 +55,10 @@ person.surname = "Strzelecki" - ``Publishable()`` - ``Publishable(isolation:)`` -- ``Publishable-protocol`` +- ``PublisherIgnored()`` +- ### Observing Changes with Combine +- ``Publishable-protocol`` - ``AnyPropertyPublisher`` From b19908c08c199710c3a6eff531d87aba2d1a0a89 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 12:50:51 +0100 Subject: [PATCH 38/45] - --- .swiftlint.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index 53560a9..d7e50da 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -201,4 +201,4 @@ custom_rules: empty_line_after_type_declaration: name: "Empty line after type declaration" message: "Type declaration should start with an empty line." - regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\{]*? \\{(?!\\s*\\}) *\\n? *\\S" + regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\n\\{]*? \\{(?!\\s*\\}) *\\n? *\\S" From 178c3af88175205379d29d583194761d64c9c3b4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 12:52:51 +0100 Subject: [PATCH 39/45] - --- .../Publishable/ExplicitlyIsolatedPublishableMacroTests.swift | 2 +- .../Publishable/ImplicitlyIsolatedPublishableMacroTests.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index 2c17cce..4be04aa 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -123,7 +123,7 @@ ignoredStoredProperty } - @available(iOS 26, *) + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" diff --git a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index f6dbf75..2ef4147 100644 --- a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -67,7 +67,7 @@ ignoredStoredProperty } - @available(iOS 26, *) + @available(iOS 26, *) @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" From 06a87f3965ed45114d1ee55bd92a169f4c0e95f3 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 13:02:40 +0100 Subject: [PATCH 40/45] - --- Sources/Relay/Combine/Publishable/Publishable.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index 30e912b..659acd0 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -20,6 +20,9 @@ /// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. /// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. /// +/// Classes to which the `@Publishable` macro has been attached can be subclassed. To generate publishers for any properties added in a subclass, +/// the macro must be applied again to the subclass definition. Subclasses should either be isolated to the same global actor as their superclass or remain nonisolated. +/// /// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. /// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. /// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. @@ -55,6 +58,9 @@ public macro Publishable() = #externalMacro( /// /// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. /// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. +/// +/// Classes to which the `@Publishable` macro has been attached can be subclassed. To generate publishers for any properties added in a subclass, +/// the macro must be applied again to the subclass definition. Subclasses should either be isolated to the same global actor as their superclass or remain nonisolated. /// /// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. /// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. From 0d5c05200838585dbe3088fc35ecc855c1b3b2fd Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 13:02:40 +0100 Subject: [PATCH 41/45] [SwiftFormat] Applied formatting --- Sources/Relay/Combine/Publishable/Publishable.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index 659acd0..d57cc07 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -58,7 +58,7 @@ public macro Publishable() = #externalMacro( /// /// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. /// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. -/// +/// /// Classes to which the `@Publishable` macro has been attached can be subclassed. To generate publishers for any properties added in a subclass, /// the macro must be applied again to the subclass definition. Subclasses should either be isolated to the same global actor as their superclass or remain nonisolated. /// From 8af638f2f97e12459f3d9f6d7da4fb041968f092 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 13:07:44 +0100 Subject: [PATCH 42/45] - --- Macros/RelayMacros/Combine/Common/ObservableMacro.swift | 2 +- Sources/Relay/Documentation.docc/Changelog.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Macros/RelayMacros/Combine/Common/ObservableMacro.swift b/Macros/RelayMacros/Combine/Common/ObservableMacro.swift index 427e164..c36150f 100644 --- a/Macros/RelayMacros/Combine/Common/ObservableMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservableMacro.swift @@ -10,5 +10,5 @@ import SwiftSyntaxMacros internal enum ObservableMacro { - static let attribute: AttributeSyntax = "@Observation" + static let attribute: AttributeSyntax = "@Observable" } diff --git a/Sources/Relay/Documentation.docc/Changelog.md b/Sources/Relay/Documentation.docc/Changelog.md index b57d055..988f41e 100644 --- a/Sources/Relay/Documentation.docc/Changelog.md +++ b/Sources/Relay/Documentation.docc/Changelog.md @@ -4,7 +4,7 @@ Summary of breaking changes between major releases. ## Version 3.0 -- All generated publishers now return an opaque `some Publisher` type instread of an erased `AnyPublisher`. +- All generated publishers now return an opaque `some Publisher` type instead of an erased `AnyPublisher`. - ``AnyPropertyPublisher`` is no longer generic, allowing subclassing of ``Publishable`` types. As a consequence, its ``AnyPropertyPublisher/willChange`` and ``AnyPropertyPublisher/didChange`` publishers now output `Void` From 3f29a5a554ab3bfd22d194693c01cb08bbde0a86 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 13:35:46 +0100 Subject: [PATCH 43/45] - --- .../RelayMacros/Combine/Relayed/RelayedMacro.swift | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift deleted file mode 100644 index f12c297..0000000 --- a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// RelayedMacro.swift -// Relay -// -// Created by Kamil Strzelecki on 22/11/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import SwiftSyntaxMacros - -public enum RelayedMacro { - - static let attribute: AttributeSyntax = "@Relayed" -} From 5b0e2ea5c1ca706961addd6ac214c29442868ac9 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 13:43:40 +0100 Subject: [PATCH 44/45] - --- Sources/Relay/Documentation.docc/Changelog.md | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Relay/Documentation.docc/Changelog.md b/Sources/Relay/Documentation.docc/Changelog.md index 988f41e..938a6e7 100644 --- a/Sources/Relay/Documentation.docc/Changelog.md +++ b/Sources/Relay/Documentation.docc/Changelog.md @@ -19,4 +19,5 @@ now emits a warning when applied to `@Model` classes. ## Version 2.0 +- Renamed library from `Publishable` to `Relay`. - `swift-tools-version` changed from 6.1 to 6.2. From 4e65ed4ecc020ff8ac0d5677b60f0ff3202355d5 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 18:10:10 +0100 Subject: [PATCH 45/45] - --- .../Publishable/SubclassedMainActorPublishableTests.swift | 4 ++-- Tests/RelayTests/Publishable/SubclassedPublishableTests.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift index 4e64be1..5ee1535 100644 --- a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift @@ -53,7 +53,7 @@ internal struct SubclassedMainActorPublishableTests { } @Test - func storedOverridenProperty() { + func overridenStoredProperty() { var dog: Dog? = .init() var publishableQueue = [Int]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -141,7 +141,7 @@ extension SubclassedMainActorPublishableTests { } @Test - func computedOverridenProperty() { + func overridenComputedProperty() { var dog: Dog? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]() diff --git a/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift index 4f905fe..49fdeb0 100644 --- a/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift +++ b/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift @@ -52,7 +52,7 @@ internal struct SubclassedPublishableTests { } @Test - func storedOverridenProperty() { + func overridenStoredProperty() { var dog: Dog? = .init() var publishableQueue = [Int]() nonisolated(unsafe) var observationsQueue = [Bool]() @@ -140,7 +140,7 @@ extension SubclassedPublishableTests { } @Test - func computedOverridenProperty() { + func overridenComputedProperty() { var dog: Dog? = .init() var publishableQueue = [String]() nonisolated(unsafe) var observationsQueue = [Bool]()