From b2336ea96ab64581589fca32b95a2393356f8c16 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 22:03:54 +0100 Subject: [PATCH 01/23] - --- .../Common}/AnyPropertyPublisherTests.swift | 0 .../{ => Combine}/Publishable/MainActorPublishableTests.swift | 0 .../{ => Combine}/Publishable/ObservationPublishableTests.swift | 0 .../Publishable/SubclassedMainActorPublishableTests.swift | 0 .../{ => Combine}/Publishable/SubclassedPublishableTests.swift | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename Tests/RelayTests/{Publishable => Combine/Common}/AnyPropertyPublisherTests.swift (100%) rename Tests/RelayTests/{ => Combine}/Publishable/MainActorPublishableTests.swift (100%) rename Tests/RelayTests/{ => Combine}/Publishable/ObservationPublishableTests.swift (100%) rename Tests/RelayTests/{ => Combine}/Publishable/SubclassedMainActorPublishableTests.swift (100%) rename Tests/RelayTests/{ => Combine}/Publishable/SubclassedPublishableTests.swift (100%) diff --git a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Combine/Common/AnyPropertyPublisherTests.swift similarity index 100% rename from Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift rename to Tests/RelayTests/Combine/Common/AnyPropertyPublisherTests.swift diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift similarity index 100% rename from Tests/RelayTests/Publishable/MainActorPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservationPublishableTests.swift similarity index 100% rename from Tests/RelayTests/Publishable/ObservationPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/ObservationPublishableTests.swift diff --git a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift similarity index 100% rename from Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift diff --git a/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift similarity index 100% rename from Tests/RelayTests/Publishable/SubclassedPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift From 45a97d0d0d1d08438fd7bd5a098a406c96e16d8a Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 22:04:32 +0100 Subject: [PATCH 02/23] - --- .../Publishable/PublishableMacro.swift | 19 +++++++++---------- .../RelayMacros/Memoized/MemoizedMacro.swift | 4 ++-- ...licitlyIsolatedPublishableMacroTests.swift | 3 +-- ...licitlyIsolatedPublishableMacroTests.swift | 3 +-- .../NonisolatedPublishableMacroTests.swift | 2 +- .../Publishable/PublishableMacroTests.swift | 2 +- 6 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 90f5554..4ae5aa9 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -12,7 +12,7 @@ public enum PublishableMacro { static let attribute: AttributeSyntax = "@Publishable" - private static func validate( + private static func validateNode( _ node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, in context: some MacroExpansionContext @@ -32,10 +32,9 @@ public enum PublishableMacro { but internals of SwiftData are incompatible with custom ObservationRegistrar """, fixIts: [ - .replace( - message: MacroExpansionFixItMessage("Remove @Publishable macro"), - oldNode: node, - newNode: "\(node.leadingTrivia)" as TokenSyntax + .remove( + message: "Remove @Publishable macro", + oldNode: node ) ] ) @@ -53,7 +52,7 @@ extension PublishableMacro: MemberMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - let declaration = try validate(node, attachedTo: declaration, in: context) + let declaration = try validateNode(node, attachedTo: declaration, in: context) let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) let parameters = try Parameters(from: node) @@ -98,7 +97,7 @@ extension PublishableMacro: ExtensionMacro { return [] } - let declaration = try validate(node, attachedTo: declaration, in: context) + let declaration = try validateNode(node, attachedTo: declaration, in: context) let parameters = try Parameters(from: node) let globalActorIsolation = GlobalActorIsolation.resolved( @@ -107,15 +106,15 @@ extension PublishableMacro: ExtensionMacro { ) return [ - .init( + ExtensionDeclSyntax( attributes: declaration.availability ?? [], extendedType: type, - inheritanceClause: .init( + inheritanceClause: InheritanceClauseSyntax( inheritedTypes: [ InheritedTypeSyntax( type: AttributedTypeSyntax( globalActorIsolation: globalActorIsolation, - baseType: IdentifierTypeSyntax(name: "Publishable") + baseType: "Relay.Publishable" ) ) ] diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 82e6668..4da3f63 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -100,8 +100,8 @@ extension MemoizedMacro { throw DiagnosticsError( node: declaration, message: """ - @Memoized macro requires a method name consisting of at least two words \ - or explicit property name + @Memoized macro requires a method name consisting of \ + at least two words or an explicit property name """ ) } diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index 4be04aa..9da5e21 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -12,7 +12,6 @@ import SwiftSyntaxMacrosTestSupport import XCTest - // swiftlint:disable:next type_body_length internal final class ExplicitlyIsolatedPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ @@ -322,7 +321,7 @@ } } - @available(iOS 26, macOS 26, *) extension Person: @MainActor Publishable { + @available(iOS 26, macOS 26, *) extension Person: @MainActor Relay.Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index 2ef4147..7ae2a0a 100644 --- a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -12,7 +12,6 @@ import SwiftSyntaxMacrosTestSupport import XCTest - // swiftlint:disable:next type_body_length internal final class ImplicitlyIsolatedPublishableMacroTests: XCTestCase { private let macroSpecs: [String: MacroSpec] = [ @@ -322,7 +321,7 @@ } } - @available(iOS 26, macOS 26, *) extension Person: @MainActor Publishable { + @available(iOS 26, macOS 26, *) extension Person: @MainActor Relay.Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index b1569a5..785b1ff 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -308,7 +308,7 @@ } } - @available(iOS 26, macOS 26, *) extension Person: nonisolated Publishable { + @available(iOS 26, macOS 26, *) extension Person: nonisolated Relay.Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 059536c..1b43075 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -308,7 +308,7 @@ } } - @available(iOS 26, macOS 26, *) extension Person: Publishable { + @available(iOS 26, macOS 26, *) extension Person: Relay.Publishable { } """#, macroSpecs: macroSpecs From 1140028a6a80df7495153d493b4d2bfeec7e243f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 22:12:17 +0100 Subject: [PATCH 03/23] Added Relayed macro --- .../Publishable/PublishableMacro.swift | 6 +- .../Relayed/ObservableDeclBuilder.swift | 81 +++++++++ .../Combine/Relayed/RelayedMacro.swift | 162 ++++++++++++++++++ .../RelayedPropertyDeclAccessorBuilder.swift | 146 ++++++++++++++++ .../Relayed/RelayedPropertyMacro.swift | 96 +++++++++++ Macros/RelayMacros/Main/RelayPlugin.swift | 2 + Sources/Relay/Combine/Relayed/Relayed.swift | 100 +++++++++++ .../Combine/Relayed/RelayedProperty.swift | 26 +++ 8 files changed, 616 insertions(+), 3 deletions(-) create mode 100644 Macros/RelayMacros/Combine/Relayed/ObservableDeclBuilder.swift create mode 100644 Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift create mode 100644 Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift create mode 100644 Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift create mode 100644 Sources/Relay/Combine/Relayed/Relayed.swift create mode 100644 Sources/Relay/Combine/Relayed/RelayedProperty.swift diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 4ae5aa9..2ab4be7 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -59,7 +59,7 @@ extension PublishableMacro: MemberMacro { let hasPublishableSuperclass = protocols.isEmpty let trimmedSuperclassType = hasPublishableSuperclass ? declaration.possibleSuperclassType : nil - let builderTypes: [any ClassDeclBuilder] = [ + let builders: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, trimmedSuperclassType: trimmedSuperclassType @@ -78,8 +78,8 @@ extension PublishableMacro: MemberMacro { ) ] - return try builderTypes.flatMap { builderType in - try builderType.build() + return try builders.flatMap{ builder in + try builder.build() } } } diff --git a/Macros/RelayMacros/Combine/Relayed/ObservableDeclBuilder.swift b/Macros/RelayMacros/Combine/Relayed/ObservableDeclBuilder.swift new file mode 100644 index 0000000..7707e05 --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/ObservableDeclBuilder.swift @@ -0,0 +1,81 @@ +// +// ObservableDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 28/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct ObservableDeclBuilder: ClassDeclBuilder, MemberBuilding { + + let declaration: ClassDeclSyntax + private let genericParameter: TokenSyntax + + init( + declaration: ClassDeclSyntax, + context: some MacroExpansionContext + ) { + self.declaration = declaration + self.genericParameter = context.makeUniqueName("T") + } + + func build() -> [DeclSyntax] { + [ + observationRegistrarProperty(), + shouldNotifyObserversFunction(), + shouldNotifyObserversEquatableFunction(), + shouldNotifyObserversAnyObjectFunction(), + shouldNotifyObserversAnyObjectEquatableFunction() + ] + } + + private func observationRegistrarProperty() -> DeclSyntax { + "private let _$observationRegistrar = Observation.ObservationRegistrar()" + } + + private func shouldNotifyObserversFunction() -> DeclSyntax { + """ + private nonisolated func shouldNotifyObservers<\(genericParameter)>( + _ lhs: \(genericParameter), + _ rhs: \(genericParameter) + ) -> Bool { + true + } + """ + } + + private func shouldNotifyObserversEquatableFunction() -> DeclSyntax { + """ + private nonisolated func shouldNotifyObservers<\(genericParameter): Equatable>( + _ lhs: \(genericParameter), + _ rhs: \(genericParameter) + ) -> Bool { + lhs != rhs + } + """ + } + + private func shouldNotifyObserversAnyObjectFunction() -> DeclSyntax { + """ + private nonisolated func shouldNotifyObservers<\(genericParameter): AnyObject>( + _ lhs: \(genericParameter), + _ rhs: \(genericParameter) + ) -> Bool { + lhs !== rhs + } + """ + } + + private func shouldNotifyObserversAnyObjectEquatableFunction() -> DeclSyntax { + """ + private nonisolated func shouldNotifyObservers<\(genericParameter): AnyObject & Equatable>( + _ lhs: \(genericParameter), + _ rhs: \(genericParameter) + ) -> Bool { + lhs != rhs + } + """ + } +} diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift new file mode 100644 index 0000000..42921fa --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -0,0 +1,162 @@ +// +// 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" + + private static func validateNode( + attachedTo declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext + ) throws -> ClassDeclSyntax { + guard let declaration = declaration.as(ClassDeclSyntax.self) else { + throw DiagnosticsError( + node: declaration, + message: "@Relayed macro can only be applied to classes" + ) + } + + if let observableAttribute = declaration.attributes.first(like: ObservableMacro.attribute) { + throw DiagnosticsError( + node: declaration, + message: "@Relayed macro generates its own Observable protocol conformance", + fixIts: [ + .remove( + message: "Remove @Observable macro", + oldNode: observableAttribute + ) + ] + ) + } + + return declaration + } +} + +extension RelayedMacro: MemberMacro { + + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + let declaration = try validateNode(attachedTo: declaration, in: context) + let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) + let parameters = try Parameters(from: node) + + let hasPublishableSuperclass = !protocols.contains { $0.isLike("Publishable") } + let trimmedSuperclassType = hasPublishableSuperclass ? declaration.possibleSuperclassType : nil + + let builders: [any ClassDeclBuilder] = [ + PublisherDeclBuilder( + declaration: declaration, + trimmedSuperclassType: trimmedSuperclassType + ), + PropertyPublisherDeclBuilder( + declaration: declaration, + properties: properties, + trimmedSuperclassType: trimmedSuperclassType, + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + ), + ObservableDeclBuilder( + declaration: declaration, + context: context + ) + ] + + return try builders.flatMap{ builder in + try builder.build() + } + } +} + +extension RelayedMacro: MemberAttributeMacro { + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + if try RelayedPropertyMacro.shouldAttach(to: declaration) { + [RelayedPropertyMacro.attribute] + } else { + [] + } + } +} + +extension RelayedMacro: ExtensionMacro { + + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard !protocols.isEmpty else { + return [] + } + + let declaration = try validateNode(attachedTo: declaration, in: context) + let parameters = try Parameters(from: node) + var inheritedTypes = [AttributedTypeSyntax]() + + if protocols.contains(where: { $0.isLike("Publishable") }) { + let globalActorIsolation = GlobalActorIsolation.resolved( + for: declaration, + preferred: parameters.preferredGlobalActorIsolation + ) + + inheritedTypes.append( + AttributedTypeSyntax( + globalActorIsolation: globalActorIsolation, + baseType: "Relay.Publishable" + ) + ) + } + + if protocols.contains(where: { $0.isLike("Observable") }) { + inheritedTypes.append( + AttributedTypeSyntax( + globalActorIsolation: .nonisolated, + baseType: "Observation.Observable" + ) + + ) + } + + return inheritedTypes.map { inheritedType in + ExtensionDeclSyntax( + attributes: declaration.availability ?? [], + extendedType: type, + inheritanceClause: InheritanceClauseSyntax( + inheritedTypes: [InheritedTypeSyntax(type: inheritedType)] + ), + memberBlock: "{}" + ) + } + } +} + +extension RelayedMacro { + + private struct Parameters { + + let preferredGlobalActorIsolation: GlobalActorIsolation? + + init(from node: AttributeSyntax) throws { + let extractor = ParameterExtractor(from: node) + self.preferredGlobalActorIsolation = try extractor.globalActorIsolation(withLabel: "isolation") + } + } +} diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift new file mode 100644 index 0000000..9dc622f --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift @@ -0,0 +1,146 @@ +// +// RelayedPropertyDeclAccessorBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 30/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct RelayedPropertyDeclAccessorBuilder: PropertyDeclAccessorBuilder { + + let declaration: Property + + func buildAccessors() -> [AccessorDeclSyntax] { + [ + initAccessor(), + getAccessor(), + setAccessor(), + modifyAccessor() + ] + } + + private func initAccessor() -> AccessorDeclSyntax { + """ + @storageRestrictions(initializes: _\(declaration.trimmedName)) + init(initialValue) { + _\(declaration.trimmedName) = initialValue + } + """ + } + + private func getAccessor() -> AccessorDeclSyntax { + if declaration.isStoredObservationTracked { + """ + get { + _$observationRegistrar.access(self, keyPath: \\.\(declaration.trimmedName)) + return _\(declaration.trimmedName) + } + """ + } else { + """ + get { + return _\(declaration.trimmedName) + } + """ + } + } + + private func setAccessor() -> AccessorDeclSyntax { + if declaration.isStoredObservationTracked { + if declaration.isStoredPublisherTracked { + """ + set { + guard shouldNotifyObservers(_\(declaration.trimmedName), newValue) else { + _\(declaration.trimmedName) = newValue + return + } + + publisher._beginModifications() + _$observationRegistrar.willSet(self, keyPath: \\.\(declaration.trimmedName)) + _\(declaration.trimmedName) = newValue + _$observationRegistrar.didSet(self, keyPath: \\.\(declaration.trimmedName)) + publisher._\(declaration.trimmedName).send(newValue) + publisher._endModifications() + } + """ + } else { + """ + set { + guard shouldNotifyObservers(_\(declaration.trimmedName), newValue) else { + _\(declaration.trimmedName) = newValue + return + } + + _$observationRegistrar.willSet(self, keyPath: \\.\(declaration.trimmedName)) + _\(declaration.trimmedName) = newValue + _$observationRegistrar.didSet(self, keyPath: \\.\(declaration.trimmedName)) + } + """ + } + } else { + """ + set { + guard shouldNotifyObservers(_\(declaration.trimmedName), newValue) else { + _\(declaration.trimmedName) = newValue + return + } + + publisher._beginModifications() + _\(declaration.trimmedName) = newValue + publisher._\(declaration.trimmedName).send(newValue) + publisher._endModifications() + } + """ + } + } + + private func modifyAccessor() -> AccessorDeclSyntax { + if declaration.isStoredObservationTracked, declaration.isStoredPublisherTracked { + if declaration.isStoredPublisherTracked { + """ + _modify { + publisher._beginModifications() + _$observationRegistrar.access(self, keyPath: \\.\(declaration.trimmedName)) + _$observationRegistrar.willSet(self, keyPath: \\.\(declaration.trimmedName)) + + defer { + _$observationRegistrar.didSet(self, keyPath: \\.\(declaration.trimmedName)) + publisher._\(declaration.trimmedName).send(_\(declaration.trimmedName)) + publisher._endModifications() + } + + yield &_\(declaration.trimmedName) + } + """ + } else { + """ + _modify { + _$observationRegistrar.access(self, keyPath: \\.\(declaration.trimmedName)) + _$observationRegistrar.willSet(self, keyPath: \\.\(declaration.trimmedName)) + + defer { + _$observationRegistrar.didSet(self, keyPath: \\.\(declaration.trimmedName)) + } + + yield &_\(declaration.trimmedName) + } + """ + } + } else { + """ + _modify { + publisher._beginModifications() + + defer { + publisher._\(declaration.trimmedName).send(_\(declaration.trimmedName)) + publisher._endModifications() + } + + yield &_\(declaration.trimmedName) + } + """ + } + } +} diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift new file mode 100644 index 0000000..e0ae13f --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift @@ -0,0 +1,96 @@ +// +// RelayedPropertyMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 24/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +public enum RelayedPropertyMacro { + + static let attribute: AttributeSyntax = "@RelayedProperty" + + static func shouldAttach( + to declaration: some DeclSyntaxProtocol + ) throws -> Bool { + if let property = try validateNode(attachedTo: declaration) { + !property.attributes.contains(like: RelayedPropertyMacro.attribute) + } else { + false + } + } +} + +extension RelayedPropertyMacro { + + private static func validateNode( + attachedTo declaration: some DeclSyntaxProtocol + ) throws -> Property? { + let properties = try PropertiesParser.parse( + declaration: declaration + ) + + guard let property = properties.first, + properties.count == 1, + property.isStoredObservationTracked || property.isStoredPublisherTracked + else { + return nil + } + + return property + } +} + +extension RelayedPropertyMacro: AccessorMacro { + + public static func expansion( + of node: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [AccessorDeclSyntax] { + guard let property = try validateNode(attachedTo: declaration) else { + return [] + } + + let builder = RelayedPropertyDeclAccessorBuilder(declaration: property) + return builder.buildAccessors() + } +} + +extension RelayedPropertyMacro: PeerMacro { + + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let property = try validateNode(attachedTo: declaration) else { + return [] + } + + let attributes = property.attributes.filter { + $0.attribute?.isLike(RelayedPropertyMacro.attribute) != true + } + + let modifiers = property.modifiers.withAccessControlLevel(.private) + let name: TokenSyntax = "_\(property.trimmedName)" + + let pattern = PatternSyntax(IdentifierPatternSyntax(identifier: name)) + let binding = property.binding.with(\.pattern, pattern) + var bindings = property.bindings + + bindings.replaceSubrange( + bindings.startIndex ..< bindings.endIndex, + with: CollectionOfOne(binding) + ) + + let storage = property.underlying + .with(\.attributes, attributes) + .with(\.modifiers, modifiers) + .with(\.bindings, bindings) + + return [DeclSyntax(storage)] + } +} diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index bf089f6..aad22fe 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -14,6 +14,8 @@ internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ PublishableMacro.self, + RelayedMacro.self, + RelayedPropertyMacro.self, PublisherIgnoredMacro.self, MemoizedMacro.self ] diff --git a/Sources/Relay/Combine/Relayed/Relayed.swift b/Sources/Relay/Combine/Relayed/Relayed.swift new file mode 100644 index 0000000..7bba76e --- /dev/null +++ b/Sources/Relay/Combine/Relayed/Relayed.swift @@ -0,0 +1,100 @@ +// +// Relayed.swift +// Relay +// +// Created by Kamil Strzelecki on 24/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Observation + +/// 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 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. +/// +/// 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. +/// +@attached( + member, + conformances: Publishable, + Observable, + names: named(_publisher), + named(publisher), + named(PropertyPublisher), + named(_$observationRegistrar), + named(shouldNotifyObservers) +) +@attached( + extension, + conformances: Publishable, + Observable +) +@attached( + memberAttribute +) +public macro Relayed() = #externalMacro( + module: "RelayMacros", + type: "RelayedMacro" +) + +/// 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 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. +/// +/// 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. +/// +@attached( + member, + conformances: Publishable, + Observable, + names: named(_publisher), + named(publisher), + named(PropertyPublisher), + named(_$observationRegistrar), + named(shouldNotifyObservers) +) +@attached( + extension, + conformances: Publishable, + Observable +) +@attached( + memberAttribute +) +public macro Relayed( + isolation: (any GlobalActor.Type)? +) = #externalMacro( + module: "RelayMacros", + type: "RelayedMacro" +) diff --git a/Sources/Relay/Combine/Relayed/RelayedProperty.swift b/Sources/Relay/Combine/Relayed/RelayedProperty.swift new file mode 100644 index 0000000..156d3e5 --- /dev/null +++ b/Sources/Relay/Combine/Relayed/RelayedProperty.swift @@ -0,0 +1,26 @@ +// +// RelayedProperty.swift +// Relay +// +// Created by Kamil Strzelecki on 30/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@_documentation( + visibility: private +) +@attached( + accessor, + names: named(init), + named(get), + named(set), + named(_modify) +) +@attached( + peer, + names: prefixed(_) +) +public macro RelayedProperty() = #externalMacro( + module: "RelayMacros", + type: "RelayedPropertyMacro" +) From 7cacb51ba0c0e85ef14805de8ac74f0ef2bfe96e Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Sun, 30 Nov 2025 22:12:17 +0100 Subject: [PATCH 04/23] [SwiftFormat] Applied formatting --- .../Combine/Publishable/PublishableMacro.swift | 2 +- Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift | 11 +++++------ .../Combine/Relayed/RelayedPropertyMacro.swift | 8 ++++---- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 2ab4be7..de04991 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -78,7 +78,7 @@ extension PublishableMacro: MemberMacro { ) ] - return try builders.flatMap{ builder in + return try builders.flatMap { builder in try builder.build() } } diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift index 42921fa..db86907 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -14,7 +14,7 @@ public enum RelayedMacro { private static func validateNode( attachedTo declaration: some DeclGroupSyntax, - in context: some MacroExpansionContext + in _: some MacroExpansionContext ) throws -> ClassDeclSyntax { guard let declaration = declaration.as(ClassDeclSyntax.self) else { throw DiagnosticsError( @@ -72,7 +72,7 @@ extension RelayedMacro: MemberMacro { ) ] - return try builders.flatMap{ builder in + return try builders.flatMap { builder in try builder.build() } } @@ -81,10 +81,10 @@ extension RelayedMacro: MemberMacro { extension RelayedMacro: MemberAttributeMacro { public static func expansion( - of node: AttributeSyntax, + of _: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, - providingAttributesFor member: some DeclSyntaxProtocol, - in context: some MacroExpansionContext + providingAttributesFor _: some DeclSyntaxProtocol, + in _: some MacroExpansionContext ) throws -> [AttributeSyntax] { if try RelayedPropertyMacro.shouldAttach(to: declaration) { [RelayedPropertyMacro.attribute] @@ -131,7 +131,6 @@ extension RelayedMacro: ExtensionMacro { globalActorIsolation: .nonisolated, baseType: "Observation.Observable" ) - ) } diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift index e0ae13f..215f28f 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift @@ -46,9 +46,9 @@ extension RelayedPropertyMacro { extension RelayedPropertyMacro: AccessorMacro { public static func expansion( - of node: AttributeSyntax, + of _: AttributeSyntax, providingAccessorsOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext + in _: some MacroExpansionContext ) throws -> [AccessorDeclSyntax] { guard let property = try validateNode(attachedTo: declaration) else { return [] @@ -62,9 +62,9 @@ extension RelayedPropertyMacro: AccessorMacro { extension RelayedPropertyMacro: PeerMacro { public static func expansion( - of node: AttributeSyntax, + of _: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext + in _: some MacroExpansionContext ) throws -> [DeclSyntax] { guard let property = try validateNode(attachedTo: declaration) else { return [] From 45af2c1f0ed4e183fc3ad0ce48b5a28977b09569 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:12:46 +0100 Subject: [PATCH 05/23] - --- .../Common/ObservationIgnoredMacro.swift | 11 ----- .../Common/ObservationSupressedMacro.swift | 45 +++++++++++++++++++ .../Common/PropertyPublisherDeclBuilder.swift | 28 +++--------- ...ro.swift => PublisherSupressedMacro.swift} | 19 +++++--- .../ObservationRegistrarDeclBuilder.swift | 6 +-- .../Publishable/PublishableMacro.swift | 2 +- .../Combine/Relayed/RelayedMacro.swift | 8 ++-- .../RelayedPropertyDeclAccessorBuilder.swift | 2 +- .../Relayed/RelayedPropertyMacro.swift | 20 +++------ Macros/RelayMacros/Main/RelayPlugin.swift | 3 +- .../Combine/Common/PublisherSupressed.swift | 21 +++++++++ .../Relayed/ObservationSupressed.swift | 21 +++++++++ .../Documentation.docc/PublishableMacros.md | 2 +- ...licitlyIsolatedPublishableMacroTests.swift | 20 ++++----- ...licitlyIsolatedPublishableMacroTests.swift | 20 ++++----- .../NonisolatedPublishableMacroTests.swift | 20 ++++----- .../Publishable/PublishableMacroTests.swift | 20 ++++----- .../MainActorPublishableTests.swift | 4 +- ...swift => ObservablePublishableTests.swift} | 12 ++--- .../AnyPropertyPublisherRelayedTests.swift} | 10 ++--- .../Memoized/PublishableMemoizedTests.swift | 2 +- 21 files changed, 177 insertions(+), 119 deletions(-) create mode 100644 Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift rename Macros/RelayMacros/Combine/Common/{PublisherIgnoredMacro.swift => PublisherSupressedMacro.swift} (60%) create mode 100644 Sources/Relay/Combine/Common/PublisherSupressed.swift create mode 100644 Sources/Relay/Combine/Relayed/ObservationSupressed.swift rename Tests/RelayTests/Combine/Publishable/{ObservationPublishableTests.swift => ObservablePublishableTests.swift} (96%) rename Tests/RelayTests/Combine/{Common/AnyPropertyPublisherTests.swift => Relayed/AnyPropertyPublisherRelayedTests.swift} (98%) diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift index b1ac4f8..f030aad 100644 --- a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -12,14 +12,3 @@ internal enum ObservationIgnoredMacro { static let attribute: AttributeSyntax = "@ObservationIgnored" } - -extension Property { - - var isStoredObservationTracked: Bool { - kind == .stored - && mutability == .mutable - && underlying.typeScopeSpecifier == nil - && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute) - } -} diff --git a/Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift new file mode 100644 index 0000000..4f5c9e3 --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift @@ -0,0 +1,45 @@ +// +// ObservationSupressedMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 01/12/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +public enum ObservationSupressedMacro { + + static let attribute: AttributeSyntax = "@ObservationSupressed" +} + +extension ObservationSupressedMacro: PeerMacro { + + public static func expansion( + of _: AttributeSyntax, + providingPeersOf _: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) -> [DeclSyntax] { + [] + } +} + +extension Property { + + var isStoredObservationTracked: Bool { + kind == .stored + && mutability == .mutable + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute) + && !underlying.attributes.contains(like: ObservationSupressedMacro.attribute) + } +} + +extension FunctionDeclSyntax { + + var isObservationTracked: Bool { + !attributes.contains(like: ObservationIgnoredMacro.attribute) + && !attributes.contains(like: ObservationSupressedMacro.attribute) + } +} diff --git a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift index c278392..26da317 100644 --- a/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -89,11 +89,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { private func storedPropertiesSubjectsFinishCalls() -> CodeBlockItemListSyntax { for property in properties.all where property.isStoredPublisherTracked { let call = storedPropertySubjectFinishCall(for: property) - if let ifConfigCall = property.underlying.applyingEnclosingIfConfig(to: call) { - ifConfigCall - } else { - call - } + call.withIfConfigIfPresent(from: property.underlying) } } @@ -105,11 +101,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.all where property.isStoredPublisherTracked { let publisher = storedPropertyPublisher(for: property) - if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { - ifConfigPublisher - } else { - publisher - } + publisher.withIfConfigIfPresent(from: property.underlying) } } @@ -131,11 +123,7 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { for property in properties.all where property.isComputedPublisherTracked { let publisher = computedPropertyPublisher(for: property) - if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { - ifConfigPublisher - } else { - publisher - } + publisher.withIfConfigIfPresent(from: property.underlying) } } @@ -154,17 +142,13 @@ internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { @MemberBlockItemListBuilder private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { - for member in declaration.memberBlock.members { + for member in declaration.memberBlock.members.flattened { if let extractionResult = MemoizedMacro.extract(from: member.decl) { let declaration = extractionResult.declaration - if !declaration.attributes.contains(like: PublisherIgnoredMacro.attribute) { + if declaration.isPublisherTracked { let publisher = memoizedPropertyPublisher(for: extractionResult) - if let ifConfigPublisher = declaration.applyingEnclosingIfConfig(to: publisher) { - ifConfigPublisher - } else { - publisher - } + publisher.withIfConfigIfPresent(from: extractionResult.declaration) } } } diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherSupressedMacro.swift similarity index 60% rename from Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift rename to Macros/RelayMacros/Combine/Common/PublisherSupressedMacro.swift index b8d8d1a..6cde9dc 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherSupressedMacro.swift @@ -1,5 +1,5 @@ // -// PublisherIgnoredMacro.swift +// PublisherSupressedMacro.swift // Relay // // Created by Kamil Strzelecki on 22/11/2025. @@ -8,12 +8,12 @@ import SwiftSyntaxMacros -public enum PublisherIgnoredMacro { +public enum PublisherSupressedMacro { - static let attribute: AttributeSyntax = "@PublisherIgnored" + static let attribute: AttributeSyntax = "@PublisherSupressed" } -extension PublisherIgnoredMacro: PeerMacro { +extension PublisherSupressedMacro: PeerMacro { public static func expansion( of _: AttributeSyntax, @@ -31,13 +31,20 @@ extension Property { && mutability == .mutable && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + && !underlying.attributes.contains(like: PublisherSupressedMacro.attribute) } var isComputedPublisherTracked: Bool { kind == .computed && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + && !underlying.attributes.contains(like: PublisherSupressedMacro.attribute) + } +} + +extension FunctionDeclSyntax { + + var isPublisherTracked: Bool { + !attributes.contains(like: PublisherSupressedMacro.attribute) } } diff --git a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index 0ff2d97..bf3928e 100644 --- a/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -64,11 +64,7 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin private func publishKeyPathLookups() -> CodeBlockItemListSyntax { for property in trackedProperties { let lookup = publishKeyPathLookup(for: property) - if let ifConfigLookup = property.underlying.applyingEnclosingIfConfig(to: lookup) { - ifConfigLookup - } else { - lookup - } + lookup.withIfConfigIfPresent(from: property.underlying) } } diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index de04991..43d5536 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -53,7 +53,7 @@ extension PublishableMacro: MemberMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let declaration = try validateNode(node, attachedTo: declaration, in: context) - let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) + let properties = try PropertiesParser.parse(declarationGroup: declaration) let parameters = try Parameters(from: node) let hasPublishableSuperclass = protocols.isEmpty diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift index db86907..82f414b 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -49,7 +49,7 @@ extension RelayedMacro: MemberMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let declaration = try validateNode(attachedTo: declaration, in: context) - let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) + let properties = try PropertiesParser.parse(declarationGroup: declaration) let parameters = try Parameters(from: node) let hasPublishableSuperclass = !protocols.contains { $0.isLike("Publishable") } @@ -82,11 +82,11 @@ extension RelayedMacro: MemberAttributeMacro { public static func expansion( of _: AttributeSyntax, - attachedTo declaration: some DeclGroupSyntax, - providingAttributesFor _: some DeclSyntaxProtocol, + attachedTo _: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, in _: some MacroExpansionContext ) throws -> [AttributeSyntax] { - if try RelayedPropertyMacro.shouldAttach(to: declaration) { + if try RelayedPropertyMacro.shouldAttach(to: member) { [RelayedPropertyMacro.attribute] } else { [] diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift index 9dc622f..8f5c7dd 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyDeclAccessorBuilder.swift @@ -97,7 +97,7 @@ internal struct RelayedPropertyDeclAccessorBuilder: PropertyDeclAccessorBuilder } private func modifyAccessor() -> AccessorDeclSyntax { - if declaration.isStoredObservationTracked, declaration.isStoredPublisherTracked { + if declaration.isStoredObservationTracked { if declaration.isStoredPublisherTracked { """ _modify { diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift index 215f28f..9657d66 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift @@ -16,7 +16,7 @@ public enum RelayedPropertyMacro { to declaration: some DeclSyntaxProtocol ) throws -> Bool { if let property = try validateNode(attachedTo: declaration) { - !property.attributes.contains(like: RelayedPropertyMacro.attribute) + !property.attributes.contains(like: attribute) } else { false } @@ -28,18 +28,12 @@ extension RelayedPropertyMacro { private static func validateNode( attachedTo declaration: some DeclSyntaxProtocol ) throws -> Property? { - let properties = try PropertiesParser.parse( - declaration: declaration - ) - - guard let property = properties.first, - properties.count == 1, - property.isStoredObservationTracked || property.isStoredPublisherTracked - else { - return nil + if let property = try PropertiesParser.parseStandalone(declaration: declaration), + property.isStoredObservationTracked || property.isStoredPublisherTracked { + property + } else { + nil } - - return property } } @@ -86,7 +80,7 @@ extension RelayedPropertyMacro: PeerMacro { with: CollectionOfOne(binding) ) - let storage = property.underlying + let storage = property.trimmed .with(\.attributes, attributes) .with(\.modifiers, modifiers) .with(\.bindings, bindings) diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index aad22fe..1a04053 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -16,7 +16,8 @@ internal struct RelayPlugin: CompilerPlugin { PublishableMacro.self, RelayedMacro.self, RelayedPropertyMacro.self, - PublisherIgnoredMacro.self, + PublisherSupressedMacro.self, + ObservationSupressedMacro.self, MemoizedMacro.self ] } diff --git a/Sources/Relay/Combine/Common/PublisherSupressed.swift b/Sources/Relay/Combine/Common/PublisherSupressed.swift new file mode 100644 index 0000000..4442068 --- /dev/null +++ b/Sources/Relay/Combine/Common/PublisherSupressed.swift @@ -0,0 +1,21 @@ +// +// PublisherSupressed.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// 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 `@PublisherSupressed` macro is independent of the `@ObservationIgnored` macro. +/// If you want to prevent tracking through `Observation` as well, apply both macros. +/// +@attached(peer) +public macro PublisherSupressed() = #externalMacro( + module: "RelayMacros", + type: "PublisherSupressedMacro" +) diff --git a/Sources/Relay/Combine/Relayed/ObservationSupressed.swift b/Sources/Relay/Combine/Relayed/ObservationSupressed.swift new file mode 100644 index 0000000..4e84a8f --- /dev/null +++ b/Sources/Relay/Combine/Relayed/ObservationSupressed.swift @@ -0,0 +1,21 @@ +// +// PublisherSupressed.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// 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 `@PublisherSupressed` macro is independent of the `@ObservationIgnored` macro. +/// If you want to prevent tracking through `Observation` as well, apply both macros. +/// +@attached(peer) +public macro ObservationSupressed() = #externalMacro( + module: "RelayMacros", + type: "ObservationSupressedMacro" +) diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md index 017c048..a4a4cb0 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -55,7 +55,7 @@ person.surname = "Strzelecki" - ``Publishable()`` - ``Publishable(isolation:)`` -- ``PublisherIgnored()`` +- ``PublisherSupressed()`` - ### Observing Changes with Combine diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index 9da5e21..d889a40 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -58,21 +58,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -114,21 +114,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -209,8 +209,8 @@ #endif @available(iOS 26, *) - fileprivate final var label: some Publisher { - _computedPropertyPublisher(for: \.label, object: object) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } diff --git a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index 7ae2a0a..2e4f863 100644 --- a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -58,21 +58,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -114,21 +114,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -209,8 +209,8 @@ #endif @available(iOS 26, *) - fileprivate final var label: some Publisher { - _computedPropertyPublisher(for: \.label, object: object) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index 785b1ff..2908528 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -58,21 +58,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -114,21 +114,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -209,8 +209,8 @@ #endif @available(iOS 26, *) - fileprivate final var label: some Publisher { - _computedPropertyPublisher(for: \.label, object: object) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index 1b43075..aa980e0 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -58,21 +58,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -114,21 +114,21 @@ } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -209,8 +209,8 @@ #endif @available(iOS 26, *) - fileprivate final var label: some Publisher { - _computedPropertyPublisher(for: \.label, object: object) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } diff --git a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift index 6217b38..ed963c8 100644 --- a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift @@ -228,10 +228,10 @@ extension MainActorPublishableTests { } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } diff --git a/Tests/RelayTests/Combine/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift similarity index 96% rename from Tests/RelayTests/Combine/Publishable/ObservationPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift index 00d1699..d49c23a 100644 --- a/Tests/RelayTests/Combine/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift @@ -1,5 +1,5 @@ // -// ObservationPublishableTests.swift +// ObservablePublishableTests.swift // Relay // // Created by Kamil Strzelecki on 18/01/2025. @@ -10,7 +10,7 @@ import Foundation import Relay import Testing -internal struct ObservationPublishableTests { +internal struct ObservablePublishableTests { @Test func storedProperty() { @@ -98,7 +98,7 @@ internal struct ObservationPublishableTests { } } -extension ObservationPublishableTests { +extension ObservablePublishableTests { @Test func willChange() { @@ -199,7 +199,7 @@ extension ObservationPublishableTests { } } -extension ObservationPublishableTests { +extension ObservablePublishableTests { @Publishable @Observable final class Person { @@ -227,10 +227,10 @@ extension ObservationPublishableTests { } #endif - @PublisherIgnored + @PublisherSupressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSupressed var ignoredComputedProperty: Int { ignoredStoredProperty } diff --git a/Tests/RelayTests/Combine/Common/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Combine/Relayed/AnyPropertyPublisherRelayedTests.swift similarity index 98% rename from Tests/RelayTests/Combine/Common/AnyPropertyPublisherTests.swift rename to Tests/RelayTests/Combine/Relayed/AnyPropertyPublisherRelayedTests.swift index 9997bca..406fb94 100644 --- a/Tests/RelayTests/Combine/Common/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Combine/Relayed/AnyPropertyPublisherRelayedTests.swift @@ -1,5 +1,5 @@ // -// AnyPropertyPublisherTests.swift +// AnyPropertyPublisherRelayedTests.swift // Relay // // Created by Kamil Strzelecki on 15/05/2025. @@ -9,7 +9,7 @@ import Relay import Testing -internal enum AnyPropertyPublisherTests { +internal enum AnyPropertyPublisherRelayedTests { struct NonEquatableType { @@ -17,7 +17,7 @@ internal enum AnyPropertyPublisherTests { fileprivate final class NonEquatableClass {} - @Publishable @Observable + @Relayed fileprivate final class Object { var unrelatedProperty = 0 @@ -195,7 +195,7 @@ internal enum AnyPropertyPublisherTests { } } -extension AnyPropertyPublisherTests { +extension AnyPropertyPublisherRelayedTests { struct EquatableType { @@ -212,7 +212,7 @@ extension AnyPropertyPublisherTests { } } - @Publishable @Observable + @Relayed fileprivate final class Object { var unrelatedProperty = 0 diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 934060e..bc0beb9 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -192,7 +192,7 @@ extension PublishableMemoizedTests { return baseArea * z } - @Memoized @PublisherIgnored + @Memoized @PublisherSupressed func calculateIgnoredValue() -> Double { volume } From 073a9a43a6d814628df8013eba1063935bcc7855 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:13:31 +0100 Subject: [PATCH 06/23] - --- .../Memoized/MemoizedDeclBuilder.swift | 70 +++++++++++++--- .../RelayMacros/Memoized/MemoizedMacro.swift | 80 +++++++++++++------ 2 files changed, 115 insertions(+), 35 deletions(-) diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index a5776a0..f924ffd 100644 --- a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -11,6 +11,7 @@ import SwiftSyntaxMacros internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { let declaration: FunctionDeclSyntax + let enclosingClassDeclaration: ClassDeclSyntax let trimmedReturnType: TypeSyntax let propertyName: String @@ -27,10 +28,7 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { """ \(inheritedAvailability)\(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)final \ var \(raw: propertyName): \(trimmedReturnType) { - if let cached = _\(raw: propertyName) { - access(keyPath: \\._\(raw: propertyName)) - return cached - } + \(returnCachedIfAvailableBlock()) nonisolated(unsafe) weak var instance = self @@ -52,6 +50,23 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { } } + private func returnCachedIfAvailableBlock() -> CodeBlockItemSyntax { + if declaration.isObservationTracked { + """ + if let cached = _\(raw: propertyName) { + _$observationRegistrar.access(self, keyPath: \\.\(raw: propertyName)) + return cached + } + """ + } else { + """ + if let cached = _\(raw: propertyName) { + return cached + } + """ + } + } + private func observationTrackingBlock() -> CodeBlockItemSyntax { """ return withObservationTracking { @@ -65,15 +80,52 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { } private func invalidateCacheFunction() -> CodeBlockItemSyntax { - """ - @Sendable nonisolated func invalidateCache() { - assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \\._\(raw: propertyName)) { + if enclosingClassDeclaration.attributes.contains(like: RelayedMacro.attribute), + declaration.isPublisherTracked { + if declaration.isObservationTracked { + """ + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + guard let instance else { return } + instance.publisher._beginModifications() + instance._$observationRegistrar.willSet(instance, keyPath: \\.\(raw: propertyName)) + instance._\(raw: propertyName) = nil + instance._$observationRegistrar.didSet(instance, keyPath: \\.\(raw: propertyName)) + instance.publisher._endModifications() + } + } + """ + } else { + """ + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.publisher._beginModifications() + instance?._\(raw: propertyName) = nil + instance?.publisher._endModifications() + } + } + """ + } + } else if declaration.isObservationTracked { + """ + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + guard let instance else { return } + instance._$observationRegistrar.willSet(instance, keyPath: \\.\(raw: propertyName)) + instance._\(raw: propertyName) = nil + instance._$observationRegistrar.didSet(instance, keyPath: \\.\(raw: propertyName)) + } + } + """ + } else { + """ + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { instance?._\(raw: propertyName) = nil } } + """ } - """ } private func assumeIsolatedIfNeededFunction() -> CodeBlockItemSyntax { diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index 4da3f63..a63adea 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -18,13 +18,23 @@ public enum MemoizedMacro { guard let declaration = declaration.as(FunctionDeclSyntax.self), let node = declaration.attributes.first(like: attribute), let parameters = try? Parameters(from: node), - let validationResult = try? validateNode(attachedTo: declaration, in: nil, with: parameters) + let declarationValidationResult = try? validateDeclaration(declaration) else { return nil } + let propertyName = try? validatePropertyName( + for: declarationValidationResult.declaration, + preferred: parameters.preferredPropertyName + ) + + guard let propertyName else { + return nil + } + return ExtractionResult( - validationResult: validationResult, + declarationValidationResult: declarationValidationResult, + propertyName: propertyName, parameters: parameters ) } @@ -34,9 +44,36 @@ extension MemoizedMacro { private static func validateNode( attachedTo declaration: some DeclSyntaxProtocol, - in context: (any MacroExpansionContext)?, + in context: some MacroExpansionContext, with parameters: Parameters ) throws -> ValidationResult { + guard let enclosingClassDeclaration = context.lexicalContext.first?.as(ClassDeclSyntax.self) else { + throw DiagnosticsError( + node: declaration, + message: """ + @Memoized macro can only be applied to methods declared \ + in primary definition (not extensions) of Observable classes + """ + ) + } + + let declarationValidationResult = try validateDeclaration(declaration) + let propertyName = try validatePropertyName( + for: declarationValidationResult.declaration, + preferred: parameters.preferredPropertyName + ) + + return ValidationResult( + declaration: declarationValidationResult.declaration, + enclosingClassDeclaration: enclosingClassDeclaration, + trimmedReturnType: declarationValidationResult.trimmedReturnType, + propertyName: propertyName + ) + } + + private static func validateDeclaration( + _ declaration: some DeclSyntaxProtocol + ) throws -> DeclarationValidationResult { guard let declaration = declaration.as(FunctionDeclSyntax.self), let trimmedReturnType = declaration.signature.returnClause?.type.trimmed, declaration.signature.parameterClause.parameters.isEmpty, @@ -52,27 +89,9 @@ extension MemoizedMacro { ) } - 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 primary definition (not extensions) of Observable classes - """ - ) - } - } - - let propertyName = try validatePropertyName( - for: declaration, - preferred: parameters.preferredPropertyName - ) - - return ValidationResult( + return DeclarationValidationResult( declaration: declaration, - trimmedReturnType: trimmedReturnType, - propertyName: propertyName + trimmedReturnType: trimmedReturnType ) } @@ -122,6 +141,7 @@ extension MemoizedMacro: PeerMacro { let builder = MemoizedDeclBuilder( declaration: validationResult.declaration, + enclosingClassDeclaration: validationResult.enclosingClassDeclaration, trimmedReturnType: validationResult.trimmedReturnType, propertyName: validationResult.propertyName, lexicalContext: context.lexicalContext, @@ -138,11 +158,12 @@ extension MemoizedMacro { @dynamicMemberLookup struct ExtractionResult { - let validationResult: ValidationResult + let declarationValidationResult: DeclarationValidationResult + let propertyName: String let parameters: Parameters - subscript(dynamicMember keyPath: KeyPath) -> T { - validationResult[keyPath: keyPath] + subscript(dynamicMember keyPath: KeyPath) -> T { + declarationValidationResult[keyPath: keyPath] } subscript(dynamicMember keyPath: KeyPath) -> T { @@ -150,9 +171,16 @@ extension MemoizedMacro { } } + struct DeclarationValidationResult { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + } + struct ValidationResult { let declaration: FunctionDeclSyntax + let enclosingClassDeclaration: ClassDeclSyntax let trimmedReturnType: TypeSyntax let propertyName: String } From 4c37401fa587f925d285876fbd27176ff4f4a758 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:15:03 +0100 Subject: [PATCH 07/23] - --- ...AnyPropertyPublisherPublishableTests.swift | 407 ++++++++++++++++++ .../SubclassedMainActorPublishableTests.swift | 2 +- .../SubclassedPublishableTests.swift | 2 +- .../Relayed/MainActorRelayedTests.swift | 239 ++++++++++ .../Combine/Relayed/RelayedTests.swift | 238 ++++++++++ .../SubclassedMainActorRelayedTests.swift | 356 +++++++++++++++ .../Relayed/SubclassedRelayedTests.swift | 355 +++++++++++++++ .../Memoized/RelayedMemoizedTests.swift | 208 +++++++++ 8 files changed, 1805 insertions(+), 2 deletions(-) create mode 100644 Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift create mode 100644 Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift create mode 100644 Tests/RelayTests/Combine/Relayed/RelayedTests.swift create mode 100644 Tests/RelayTests/Combine/Relayed/SubclassedMainActorRelayedTests.swift create mode 100644 Tests/RelayTests/Combine/Relayed/SubclassedRelayedTests.swift create mode 100644 Tests/RelayTests/Memoized/RelayedMemoizedTests.swift diff --git a/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift new file mode 100644 index 0000000..335128c --- /dev/null +++ b/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift @@ -0,0 +1,407 @@ +// +// AnyPropertyPublisherPublishableTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/05/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +internal enum AnyPropertyPublisherPublishableTests { + + struct NonEquatableType { + + fileprivate struct NonEquatableStruct {} + + fileprivate final class NonEquatableClass {} + + @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 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() + } + + @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) + } + } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + 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() + } + + @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() + } + + @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?.referenceTypeStoredProperty = NonEquatableClass() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + } +} + +extension AnyPropertyPublisherPublishableTests { + + 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 unrelatedProperty = 0 + + var storedProperty = 0 + var computedProperty: Int { + storedProperty + } + + var referenceTypeStoredProperty = EquatableClass(value: 0) + var referenceTypeComputedProperty: EquatableClass { + referenceTypeStoredProperty + } + } + + @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() + } + + @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) + } + } + + observe() + #expect(publishableQueue.popFirst() == EquatableClass(value: 0)) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.referenceTypeStoredProperty = EquatableClass(value: 0) + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + 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 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() + } + + @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?.referenceTypeStoredProperty = EquatableClass(value: 0) + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + 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() + } + } +} diff --git a/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift index 5ee1535..faeefc0 100644 --- a/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Testing @MainActor diff --git a/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift index 49fdeb0..ca6761d 100644 --- a/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift @@ -6,7 +6,7 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -@testable import Relay +import Relay import Testing internal struct SubclassedPublishableTests { diff --git a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift new file mode 100644 index 0000000..a06c6f7 --- /dev/null +++ b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift @@ -0,0 +1,239 @@ +// +// MainActorRelayedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 18/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Foundation +import Relay +import Testing + +@MainActor +internal struct MainActorRelayedTests { + + @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 MainActorRelayedTests { + + @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 MainActorRelayedTests { + + @MainActor @Relayed + final class Person { + + let id = UUID() + var age = 25 + fileprivate(set) var name = "John" + var surname = "Doe" + + internal var fullName: String { + "\(name) \(surname)" + } + + package var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherSupressed + var ignoredStoredProperty = 123 + + @PublisherSupressed + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + } +} diff --git a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift new file mode 100644 index 0000000..0062209 --- /dev/null +++ b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift @@ -0,0 +1,238 @@ +// +// RelayedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 18/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Foundation +import Relay +import Testing + +internal struct RelayedTests { + + @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 RelayedTests { + + @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 RelayedTests { + + @Relayed + final class Person { + + let id = UUID() + var age = 25 + fileprivate(set) var name = "John" + var surname = "Doe" + + internal var fullName: String { + "\(name) \(surname)" + } + + package var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherSupressed + var ignoredStoredProperty = 123 + + @PublisherSupressed + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + } +} diff --git a/Tests/RelayTests/Combine/Relayed/SubclassedMainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/SubclassedMainActorRelayedTests.swift new file mode 100644 index 0000000..6dc2c5e --- /dev/null +++ b/Tests/RelayTests/Combine/Relayed/SubclassedMainActorRelayedTests.swift @@ -0,0 +1,356 @@ +// +// SubclassedMainActorRelayedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 29/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +@MainActor +internal struct SubclassedMainActorRelayedTests { + + @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 overridenStoredProperty() { + 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 SubclassedMainActorRelayedTests { + + @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 overridenComputedProperty() { + 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 SubclassedMainActorRelayedTests { + + @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 SubclassedMainActorRelayedTests { + + @MainActor @Relayed + class PublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @MainActor @Relayed + final class Dog: PublishableAnimal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} + +extension SubclassedMainActorRelayedTests { + + @MainActor @Observable + class NonPublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @MainActor @Relayed + final class Cat: NonPublishableAnimal { + + var breed: String? + + var isSphynx: Bool { + breed == "Sphynx" + } + + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} diff --git a/Tests/RelayTests/Combine/Relayed/SubclassedRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/SubclassedRelayedTests.swift new file mode 100644 index 0000000..9930a43 --- /dev/null +++ b/Tests/RelayTests/Combine/Relayed/SubclassedRelayedTests.swift @@ -0,0 +1,355 @@ +// +// SubclassedRelayedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +internal struct SubclassedRelayedTests { + + @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 overridenStoredProperty() { + 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 SubclassedRelayedTests { + + @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 overridenComputedProperty() { + 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 SubclassedRelayedTests { + + @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 SubclassedRelayedTests { + + @Relayed + class PublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @Relayed + final class Dog: PublishableAnimal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} + +extension SubclassedRelayedTests { + + @Observable + class NonPublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @Relayed + final class Cat: NonPublishableAnimal { + + var breed: String? + + var isSphynx: Bool { + breed == "Sphynx" + } + + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} diff --git a/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift new file mode 100644 index 0000000..b8f382a --- /dev/null +++ b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift @@ -0,0 +1,208 @@ +// +// RelayedMemoizedTests.swift +// Relay +// +// Created by Kamil Strzelecki on 15/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Relay +import Testing + +internal struct RelayedMemoizedTests { + + @Test + func independent() { + let cube = Cube() + var queue = [Double]() + + let access1 = cube.baseArea + #expect(access1 == 1.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + // access2 + let cancellable = cube.publisher.baseArea.sink { baseArea in + queue.append(baseArea) + } + + #expect(queue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 // access3 + #expect(queue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + let access4 = cube.baseArea + #expect(access4 == 2.0) + #expect(queue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cancellable.cancel() + #expect(queue.isEmpty) + + cube.y = 3.0 + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(!cube.isBaseAreaCached) + } + + @Test + func dependent() { + let cube = Cube() + var volumeQueue = [Double]() + var baseAreaQueue = [Double]() + + let accessBaseArea1 = cube.baseArea + #expect(accessBaseArea1 == 1.0) + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 0) + #expect(!cube.isVolumeCached) + + // accessVolume1, accessBaseArea2, accessBaseArea3 + let volumeCancellable = cube.publisher.volume.sink { volume in + volumeQueue.append(volume) + } + let baseAreaCancellable = cube.publisher.baseArea.sink { baseArea in + baseAreaQueue.append(baseArea) + } + + #expect(baseAreaQueue.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 1.0) + #expect(cube.calculateVolumeCallsCount == 1) + #expect(cube.isVolumeCached) + + cube.x = 2.0 // accessVolume2, accessBaseArea4, accessBaseArea5 + #expect(baseAreaQueue.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 2.0) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + let accessVolume3 = cube.volume + #expect(accessVolume3 == 2.0) + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == nil) + #expect(cube.calculateVolumeCallsCount == 2) + #expect(cube.isVolumeCached) + + cube.z = 3.0 // accessVolume4, accessBaseArea6 + #expect(baseAreaQueue.popFirst() == nil) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + #expect(volumeQueue.popFirst() == 6.0) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(cube.isVolumeCached) + + volumeCancellable.cancel() + #expect(volumeQueue.isEmpty) + + cube.y = 4.0 // accessBaseArea7 + #expect(baseAreaQueue.popFirst() == 8.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + #expect(cube.calculateVolumeCallsCount == 3) + #expect(!cube.isVolumeCached) + + baseAreaCancellable.cancel() + #expect(baseAreaQueue.isEmpty) + + cube.y = 5.0 + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(!cube.isBaseAreaCached) + } + + @Test + func share() { + let cube = Cube() + var queue1 = [Double]() + var queue2 = [Double]() + + // access1, access2 + let cancellable1 = cube.publisher.baseArea.sink { baseArea in + queue1.append(baseArea) + } + let cancellable2 = cube.publisher.baseArea.sink { baseArea in + queue2.append(baseArea) + } + + #expect(queue1.popFirst() == 1.0) + #expect(queue2.popFirst() == 1.0) + #expect(cube.calculateBaseAreaCallsCount == 1) + #expect(cube.isBaseAreaCached) + + cube.x = 2.0 // access3, access4 + #expect(queue1.popFirst() == 2.0) + #expect(queue2.popFirst() == 2.0) + #expect(cube.calculateBaseAreaCallsCount == 2) + #expect(cube.isBaseAreaCached) + + cancellable1.cancel() + #expect(queue1.isEmpty) + + cube.y = 3.0 // access5 + #expect(queue2.popFirst() == 6.0) + #expect(cube.calculateBaseAreaCallsCount == 3) + #expect(cube.isBaseAreaCached) + + cancellable2.cancel() + #expect(queue2.isEmpty) + } + +} + +extension RelayedMemoizedTests { + + @Relayed + final class Cube { + + var offset = 0.0 + var x = 1.0 + var y = 1.0 + var z = 1.0 + + @ObservationSupressed + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } + + @ObservationSupressed + private(set) var calculateVolumeCallsCount = 0 + var isVolumeCached: Bool { _volume != nil } + + @Memoized + func calculateBaseArea() -> Double { + calculateBaseAreaCallsCount += 1 + return x * y + } + + @Memoized(.fileprivate, "volume") + func calculateVolume() -> Double { + calculateVolumeCallsCount += 1 + return baseArea * z + } + + @Memoized @PublisherSupressed + func calculateIgnoredValue() -> Double { + volume + } + + #if os(macOS) + @available(macOS 26, *) + @Memoized + func calculatePlatformValue() -> Double { + volume + } + #endif + } +} From 7d0c658d803557ec185cc3d91627c006c70a24da Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:32:14 +0100 Subject: [PATCH 08/23] - --- .../ExplicitlyIsolatedMemoizedMacroTests.swift | 18 ++++++++++++------ .../ImplicitlyIsolatedMemoizedMacroTests.swift | 18 ++++++++++++------ .../Memoized/MemoizedMacroTests.swift | 18 ++++++++++++------ .../NonisolatedMemoizedMacroTests.swift | 18 ++++++++++++------ ...plicitlyIsolatedPublishableMacroTests.swift | 15 +++++++++++++++ ...plicitlyIsolatedPublishableMacroTests.swift | 15 +++++++++++++++ .../NonisolatedPublishableMacroTests.swift | 15 +++++++++++++++ .../Publishable/PublishableMacroTests.swift | 15 +++++++++++++++ .../MainActorPublishableTests.swift | 16 ++++++++++++++++ .../ObservablePublishableTests.swift | 16 ++++++++++++++++ .../Relayed/MainActorRelayedTests.swift | 16 ++++++++++++++++ .../Combine/Relayed/RelayedTests.swift | 16 ++++++++++++++++ 12 files changed, 172 insertions(+), 24 deletions(-) diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift index 3f88ed2..a7dda98 100644 --- a/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift @@ -45,7 +45,7 @@ @MainActor final var area: Double { if let cached = _area { - access(keyPath: \._area) + _$observationRegistrar.access(self, keyPath: \.area) return cached } @@ -65,9 +65,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._area) { - instance?._area = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.area) + instance._area = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.area) } } @@ -118,7 +121,7 @@ @available(macOS 26, *) @MainActor public final var customName: Double { if let cached = _customName { - access(keyPath: \._customName) + _$observationRegistrar.access(self, keyPath: \.customName) return cached } @@ -138,9 +141,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._customName) { - instance?._customName = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.customName) + instance._customName = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.customName) } } diff --git a/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift index bbe887b..a5af56c 100644 --- a/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift @@ -45,7 +45,7 @@ @MainActor final var area: Double { if let cached = _area { - access(keyPath: \._area) + _$observationRegistrar.access(self, keyPath: \.area) return cached } @@ -65,9 +65,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._area) { - instance?._area = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.area) + instance._area = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.area) } } @@ -118,7 +121,7 @@ @available(macOS 26, *) @MainActor public final var customName: Double { if let cached = _customName { - access(keyPath: \._customName) + _$observationRegistrar.access(self, keyPath: \.customName) return cached } @@ -138,9 +141,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._customName) { - instance?._customName = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.customName) + instance._customName = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.customName) } } diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index fdaee85..336b9f1 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -45,7 +45,7 @@ final var area: Double { if let cached = _area { - access(keyPath: \._area) + _$observationRegistrar.access(self, keyPath: \.area) return cached } @@ -59,9 +59,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._area) { - instance?._area = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.area) + instance._area = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.area) } } @@ -112,7 +115,7 @@ @available(macOS 26, *) public final var customName: Double { if let cached = _customName { - access(keyPath: \._customName) + _$observationRegistrar.access(self, keyPath: \.customName) return cached } @@ -126,9 +129,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._customName) { - instance?._customName = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.customName) + instance._customName = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.customName) } } diff --git a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift index f49cd85..ce78319 100644 --- a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift @@ -45,7 +45,7 @@ nonisolated final var area: Double { if let cached = _area { - access(keyPath: \._area) + _$observationRegistrar.access(self, keyPath: \.area) return cached } @@ -59,9 +59,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._area) { - instance?._area = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.area) + instance._area = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.area) } } @@ -112,7 +115,7 @@ @available(macOS 26, *) nonisolated public final var customName: Double { if let cached = _customName { - access(keyPath: \._customName) + _$observationRegistrar.access(self, keyPath: \.customName) return cached } @@ -126,9 +129,12 @@ @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { - instance?.withMutation(keyPath: \._customName) { - instance?._customName = nil + guard let instance else { + return } + instance._$observationRegistrar.willSet(instance, keyPath: \.customName) + instance._customName = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.customName) } } diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index d889a40..fa3272a 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -56,6 +56,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -112,6 +117,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -208,6 +218,11 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif @available(iOS 26, *) fileprivate final var memoizedProperty: some Publisher { _computedPropertyPublisher(for: \.memoizedProperty, object: object) diff --git a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index 2e4f863..08768e3 100644 --- a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -56,6 +56,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -112,6 +117,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -208,6 +218,11 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif @available(iOS 26, *) fileprivate final var memoizedProperty: some Publisher { _computedPropertyPublisher(for: \.memoizedProperty, object: object) diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift index 2908528..b0037b3 100644 --- a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -56,6 +56,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -112,6 +117,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -208,6 +218,11 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif @available(iOS 26, *) fileprivate final var memoizedProperty: some Publisher { _computedPropertyPublisher(for: \.memoizedProperty, object: object) diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index aa980e0..c84f593 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -56,6 +56,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -112,6 +117,11 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -208,6 +218,11 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif @available(iOS 26, *) fileprivate final var memoizedProperty: some Publisher { _computedPropertyPublisher(for: \.memoizedProperty, object: object) diff --git a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift index ed963c8..d89ac7f 100644 --- a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift @@ -226,6 +226,11 @@ extension MainActorPublishableTests { var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -235,5 +240,16 @@ extension MainActorPublishableTests { var ignoredComputedProperty: Int { ignoredStoredProperty } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift index d49c23a..8c8aedd 100644 --- a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift @@ -225,6 +225,11 @@ extension ObservablePublishableTests { var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -234,5 +239,16 @@ extension ObservablePublishableTests { var ignoredComputedProperty: Int { ignoredStoredProperty } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift index a06c6f7..8826706 100644 --- a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift @@ -226,6 +226,11 @@ extension MainActorRelayedTests { var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -235,5 +240,16 @@ extension MainActorRelayedTests { var ignoredComputedProperty: Int { ignoredStoredProperty } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift index 0062209..0d6e4ad 100644 --- a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift @@ -225,6 +225,11 @@ extension RelayedTests { var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif @PublisherSupressed @@ -234,5 +239,16 @@ extension RelayedTests { var ignoredComputedProperty: Int { ignoredStoredProperty } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } } From db7d00bf4a8b62351cf21a548a2b21d2bf2ba1e1 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:32:14 +0100 Subject: [PATCH 09/23] [SwiftFormat] Applied formatting --- .../RelayMacrosTests/Publishable/PublishableMacroTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index c84f593..db105b9 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -56,7 +56,7 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty @@ -117,7 +117,7 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty From bc76cf2e64498549e3f63795bb202b7239b9c1d4 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:47:25 +0100 Subject: [PATCH 10/23] - --- ...servationSupressedMemoizedMacroTests.swift | 147 ++++++++++++++++ .../Memoized/RelayedMemoizedMacroTests.swift | 159 ++++++++++++++++++ ...servationSupressedMemoizedMacroTests.swift | 151 +++++++++++++++++ ...PublisherSupressedMemoizedMacroTests.swift | 159 ++++++++++++++++++ 4 files changed, 616 insertions(+) create mode 100644 Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift diff --git a/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift new file mode 100644 index 0000000..95e87a1 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift @@ -0,0 +1,147 @@ +// +// ObservationSupressedMemoizedMacroTests.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 ObservationSupressedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Observable + public class Square { + + var side = 12.3 + + @Memoized @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public class Square { + + var side = 12.3 + + @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + + private final var _area: Optional = nil + + final var area: Double { + if let cached = _area { + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?._area = nil + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName") + @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + + // Stored properties cannot be made potentially unavailable + private final var _customName: Optional = nil + + @available(macOS 26, *) + public final var customName: Double { + if let cached = _customName { + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?._customName = nil + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift new file mode 100644 index 0000000..dc36b63 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift @@ -0,0 +1,159 @@ +// +// RelayedMemoizedMacroTests.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 RelayedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Relayed + public class Square { + + var side = 12.3 + + @Memoized + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + private final var _area: Optional = nil + + final var area: Double { + if let cached = _area { + _$observationRegistrar.access(self, keyPath: \.area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + guard let instance else { + return + } + instance.publisher._beginModifications() + instance._$observationRegistrar.willSet(instance, keyPath: \.area) + instance._area = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.area) + instance.publisher._endModifications() + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName") + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + private func calculateArea() -> Double { + side * side + } + + // Stored properties cannot be made potentially unavailable + private final var _customName: Optional = nil + + @available(macOS 26, *) + public final var customName: Double { + if let cached = _customName { + _$observationRegistrar.access(self, keyPath: \.customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + guard let instance else { + return + } + instance.publisher._beginModifications() + instance._$observationRegistrar.willSet(instance, keyPath: \.customName) + instance._customName = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.customName) + instance.publisher._endModifications() + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift new file mode 100644 index 0000000..44c8e86 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift @@ -0,0 +1,151 @@ +// +// RelayedObservationSupressedMemoizedMacroTests.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 RelayedObservationSupressedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Relayed + public class Square { + + var side = 12.3 + + @Memoized @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public class Square { + + var side = 12.3 + + @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + + private final var _area: Optional = nil + + final var area: Double { + if let cached = _area { + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.publisher._beginModifications() + instance?._area = nil + instance?.publisher._endModifications() + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName") + @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @ObservationSupressed + private func calculateArea() -> Double { + side * side + } + + // Stored properties cannot be made potentially unavailable + private final var _customName: Optional = nil + + @available(macOS 26, *) + public final var customName: Double { + if let cached = _customName { + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.publisher._beginModifications() + instance?._customName = nil + instance?.publisher._endModifications() + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift new file mode 100644 index 0000000..b906eb2 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift @@ -0,0 +1,159 @@ +// +// RelayedPublisherSupressedMemoizedMacroTests.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 RelayedPublisherSupressedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Relayed + public class Square { + + var side = 12.3 + + @Memoized @PublisherSupressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public class Square { + + var side = 12.3 + + @PublisherSupressed + private func calculateArea() -> Double { + side * side + } + + private final var _area: Optional = nil + + final var area: Double { + if let cached = _area { + _$observationRegistrar.access(self, keyPath: \.area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + guard let instance else { + return + } + instance._$observationRegistrar.willSet(instance, keyPath: \.area) + instance._area = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.area) + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName") + @PublisherSupressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @PublisherSupressed + private func calculateArea() -> Double { + side * side + } + + // Stored properties cannot be made potentially unavailable + private final var _customName: Optional = nil + + @available(macOS 26, *) + public final var customName: Double { + if let cached = _customName { + _$observationRegistrar.access(self, keyPath: \.customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + guard let instance else { + return + } + instance._$observationRegistrar.willSet(instance, keyPath: \.customName) + instance._customName = nil + instance._$observationRegistrar.didSet(instance, keyPath: \.customName) + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif From 13c2ea695e8a04f501fa4fae35600be77b257a20 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:47:25 +0100 Subject: [PATCH 11/23] [SwiftFormat] Applied formatting --- ...servationSupressedMemoizedMacroTests.swift | 2 +- .../Memoized/RelayedMemoizedMacroTests.swift | 38 +++++++++--------- ...servationSupressedMemoizedMacroTests.swift | 2 +- ...PublisherSupressedMemoizedMacroTests.swift | 40 +++++++++---------- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift index 95e87a1..1a03bb8 100644 --- a/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift @@ -37,7 +37,7 @@ public class Square { var side = 12.3 - + @ObservationSupressed private func calculateArea() -> Double { side * side diff --git a/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift index dc36b63..45f97fc 100644 --- a/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/RelayedMemoizedMacroTests.swift @@ -22,9 +22,9 @@ #""" @Relayed public class Square { - + var side = 12.3 - + @Memoized private func calculateArea() -> Double { side * side @@ -35,28 +35,28 @@ #""" @Relayed public class Square { - + var side = 12.3 private func calculateArea() -> Double { side * side } - + private final var _area: Optional = nil - + final var area: Double { if let cached = _area { _$observationRegistrar.access(self, keyPath: \.area) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: () -> Void ) { operation() } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { guard let instance else { @@ -69,7 +69,7 @@ instance.publisher._endModifications() } } - + return withObservationTracking { let result = calculateArea() _area = result @@ -89,9 +89,9 @@ #""" @Relayed public final class Square { - + var side = 12.3 - + @available(macOS 26, *) @Memoized(.public, "customName") private func calculateArea() -> Double { @@ -103,32 +103,32 @@ #""" @Relayed public final class Square { - + var side = 12.3 - + @available(macOS 26, *) private func calculateArea() -> Double { side * side } - + // Stored properties cannot be made potentially unavailable private final var _customName: Optional = nil - + @available(macOS 26, *) public final var customName: Double { if let cached = _customName { _$observationRegistrar.access(self, keyPath: \.customName) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: () -> Void ) { operation() } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { guard let instance else { @@ -141,7 +141,7 @@ instance.publisher._endModifications() } } - + return withObservationTracking { let result = calculateArea() _customName = result diff --git a/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift index 44c8e86..aa39710 100644 --- a/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift @@ -37,7 +37,7 @@ public class Square { var side = 12.3 - + @ObservationSupressed private func calculateArea() -> Double { side * side diff --git a/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift index b906eb2..7c5b433 100644 --- a/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift @@ -22,9 +22,9 @@ #""" @Relayed public class Square { - + var side = 12.3 - + @Memoized @PublisherSupressed private func calculateArea() -> Double { side * side @@ -35,30 +35,30 @@ #""" @Relayed public class Square { - + var side = 12.3 - + @PublisherSupressed private func calculateArea() -> Double { side * side } - + private final var _area: Optional = nil - + final var area: Double { if let cached = _area { _$observationRegistrar.access(self, keyPath: \.area) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: () -> Void ) { operation() } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { guard let instance else { @@ -69,7 +69,7 @@ instance._$observationRegistrar.didSet(instance, keyPath: \.area) } } - + return withObservationTracking { let result = calculateArea() _area = result @@ -89,9 +89,9 @@ #""" @Relayed public final class Square { - + var side = 12.3 - + @available(macOS 26, *) @Memoized(.public, "customName") @PublisherSupressed @@ -104,33 +104,33 @@ #""" @Relayed public final class Square { - + var side = 12.3 - + @available(macOS 26, *) @PublisherSupressed private func calculateArea() -> Double { side * side } - + // Stored properties cannot be made potentially unavailable private final var _customName: Optional = nil - + @available(macOS 26, *) public final var customName: Double { if let cached = _customName { _$observationRegistrar.access(self, keyPath: \.customName) return cached } - + nonisolated(unsafe) weak var instance = self - + @Sendable nonisolated func assumeIsolatedIfNeeded( _ operation: () -> Void ) { operation() } - + @Sendable nonisolated func invalidateCache() { assumeIsolatedIfNeeded { guard let instance else { @@ -141,7 +141,7 @@ instance._$observationRegistrar.didSet(instance, keyPath: \.customName) } } - + return withObservationTracking { let result = calculateArea() _customName = result From b292e785a6ffa84cd9287a80bcff53f5995fde3f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 19:52:21 +0100 Subject: [PATCH 12/23] - --- Tests/RelayTests/Memoized/MainActorMemoizedTests.swift | 5 +++++ .../RelayTests/Memoized/ObservationMemoizedTests.swift | 5 +++++ .../RelayTests/Memoized/PublishableMemoizedTests.swift | 10 ++++++++++ Tests/RelayTests/Memoized/RelayedMemoizedTests.swift | 10 ++++++++++ Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift | 5 +++++ 5 files changed, 35 insertions(+) diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 50336d8..dcaa2b3 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -260,6 +260,11 @@ extension MainActorMemoizedTests { return baseArea * z } + @Memoized @ObservationSupressed + func calculateIgnoredValue() -> Double { + volume + } + #if os(macOS) @available(macOS 26, *) @Memoized diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index b691469..fc6d1d5 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -258,6 +258,11 @@ extension ObservationMemoizedTests { return baseArea * z } + @Memoized @ObservationSupressed + func calculateIgnoredValue() -> Double { + volume + } + #if os(macOS) @available(macOS 26, *) @Memoized diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index bc0beb9..6b3cb8c 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -192,7 +192,17 @@ extension PublishableMemoizedTests { return baseArea * z } + @Memoized @ObservationSupressed + func calculateObservationIgnoredValue() -> Double { + volume + } + @Memoized @PublisherSupressed + func calculatePublisherIgnoredValue() -> Double { + volume + } + + @Memoized @ObservationSupressed @PublisherSupressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift index b8f382a..5db1c01 100644 --- a/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift @@ -192,7 +192,17 @@ extension RelayedMemoizedTests { return baseArea * z } + @Memoized @ObservationSupressed + func calculateObservationIgnoredValue() -> Double { + volume + } + @Memoized @PublisherSupressed + func calculatePublisherIgnoredValue() -> Double { + volume + } + + @Memoized @ObservationSupressed @PublisherSupressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index e2b8399..a328dbd 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -263,6 +263,11 @@ extension SwiftDataMemoizedTests { return baseArea * z } + @Memoized @ObservationSupressed + func calculateIgnoredValue() -> Double { + volume + } + #if os(macOS) @available(macOS 26, *) @Memoized From 1a332ffb119b68b788b52cf7e2a06760f5639ca2 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 20:48:05 +0100 Subject: [PATCH 13/23] - --- .../ObservationSupressed.swift | 0 .../ExplicitlyIsolatedRelayedMacroTests.swift | 295 ++++++++++++++++++ .../ImplicitlyIsolatedRelayedMacroTests.swift | 295 ++++++++++++++++++ .../NonisolatedRelayedMacroTests.swift | 294 +++++++++++++++++ .../Relayed/RelayedMacroTests.swift | 294 +++++++++++++++++ .../Relayed/RelayedPropertyMacroTests.swift | 212 +++++++++++++ ...dImplicitlyIsolatedRelayedMacroTests.swift | 154 +++++++++ .../Relayed/SubclassedRelayedMacroTests.swift | 153 +++++++++ .../Relayed/MainActorRelayedTests.swift | 12 +- .../Combine/Relayed/RelayedTests.swift | 12 +- 10 files changed, 1715 insertions(+), 6 deletions(-) rename Sources/Relay/Combine/{Relayed => Common}/ObservationSupressed.swift (100%) create mode 100644 Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift create mode 100644 Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift diff --git a/Sources/Relay/Combine/Relayed/ObservationSupressed.swift b/Sources/Relay/Combine/Common/ObservationSupressed.swift similarity index 100% rename from Sources/Relay/Combine/Relayed/ObservationSupressed.swift rename to Sources/Relay/Combine/Common/ObservationSupressed.swift diff --git a/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift new file mode 100644 index 0000000..55de1ee --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift @@ -0,0 +1,295 @@ +// +// ExplicitlyIsolatedRelayedMacroTests.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 ExplicitlyIsolatedRelayedMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Relayed": MacroSpec( + type: RelayedMacro.self, + conformances: ["Publishable", "Observable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @available(iOS 26, macOS 26, *) + @CustomActor @Relayed(isolation: MainActor.self) + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + } + """#, + expandedSource: + #""" + @available(iOS 26, macOS 26, *) + @CustomActor + public final class Person { + + static var user: Person? + + let id: UUID + @RelayedProperty + fileprivate(set) var age: Int + @RelayedProperty + var name: String + @RelayedProperty + + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + + 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) + #if os(macOS) + _platformStoredProperty.send(completion: .finished) + #endif + _observationIgnoredStoredProperty.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) + } + #if os(macOS) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) + } + #endif + fileprivate final let _observationIgnoredStoredProperty = PassthroughSubject() + final var observationIgnoredStoredProperty: some Publisher { + _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + #if os(macOS) + @available(macOS 26, *) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) + } + #endif + + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif + @available(iOS 26, *) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) + } + } + + private let _$observationRegistrar = Observation.ObservationRegistrar() + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + } + + @available(iOS 26, macOS 26, *) extension Person: @MainActor Relay.Publishable { + } + + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift new file mode 100644 index 0000000..8f93b13 --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift @@ -0,0 +1,295 @@ +// +// ImplicitlyIsolatedRelayedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 24/08/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class ImplicitlyIsolatedRelayedMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Relayed": MacroSpec( + type: RelayedMacro.self, + conformances: ["Publishable", "Observable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @available(iOS 26, macOS 26, *) + @MainActor @Relayed + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + } + """#, + expandedSource: + #""" + @available(iOS 26, macOS 26, *) + @MainActor + public final class Person { + + static var user: Person? + + let id: UUID + @RelayedProperty + fileprivate(set) var age: Int + @RelayedProperty + var name: String + @RelayedProperty + + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + + 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) + #if os(macOS) + _platformStoredProperty.send(completion: .finished) + #endif + _observationIgnoredStoredProperty.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) + } + #if os(macOS) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) + } + #endif + fileprivate final let _observationIgnoredStoredProperty = PassthroughSubject() + final var observationIgnoredStoredProperty: some Publisher { + _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + #if os(macOS) + @available(macOS 26, *) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) + } + #endif + + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif + @available(iOS 26, *) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) + } + } + + private let _$observationRegistrar = Observation.ObservationRegistrar() + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + } + + @available(iOS 26, macOS 26, *) extension Person: @MainActor Relay.Publishable { + } + + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift new file mode 100644 index 0000000..de56c9b --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift @@ -0,0 +1,294 @@ +// +// NonisolatedRelayedMacroTests.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 NonisolatedRelayedMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Relayed": MacroSpec( + type: RelayedMacro.self, + conformances: ["Publishable", "Observable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @available(iOS 26, macOS 26, *) + @Relayed(isolation: nil) + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + } + """#, + expandedSource: + #""" + @available(iOS 26, macOS 26, *) + public final class Person { + + static var user: Person? + + let id: UUID + @RelayedProperty + fileprivate(set) var age: Int + @RelayedProperty + var name: String + @RelayedProperty + + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + + 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) + #if os(macOS) + _platformStoredProperty.send(completion: .finished) + #endif + _observationIgnoredStoredProperty.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) + } + #if os(macOS) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) + } + #endif + fileprivate final let _observationIgnoredStoredProperty = PassthroughSubject() + final var observationIgnoredStoredProperty: some Publisher { + _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + #if os(macOS) + @available(macOS 26, *) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) + } + #endif + + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif + @available(iOS 26, *) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) + } + } + + private let _$observationRegistrar = Observation.ObservationRegistrar() + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + } + + @available(iOS 26, macOS 26, *) extension Person: nonisolated Relay.Publishable { + } + + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift new file mode 100644 index 0000000..75d71bc --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift @@ -0,0 +1,294 @@ +// +// RelayedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class RelayedMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Relayed": MacroSpec( + type: RelayedMacro.self, + conformances: ["Publishable", "Observable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @available(iOS 26, macOS 26, *) + @Relayed + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + } + """#, + expandedSource: + #""" + @available(iOS 26, macOS 26, *) + public final class Person { + + static var user: Person? + + let id: UUID + @RelayedProperty + fileprivate(set) var age: Int + @RelayedProperty + var name: String + @RelayedProperty + + 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 } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + + @ObservationSupressed @PublisherSupressed + var ignoredStoredProperty = 123 + + @ObservationSupressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSupressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSupressed + func makeIgnoredMemoizedProperty() -> Int { + publisherIgnoredStoredProperty + } + + 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 + } + + 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) + } + + deinit { + _age.send(completion: .finished) + _name.send(completion: .finished) + _surname.send(completion: .finished) + #if os(macOS) + _platformStoredProperty.send(completion: .finished) + #endif + _observationIgnoredStoredProperty.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) + } + #if os(macOS) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) + } + #endif + fileprivate final let _observationIgnoredStoredProperty = PassthroughSubject() + final var observationIgnoredStoredProperty: some Publisher { + _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) + } + + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) + } + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) + } + #if os(macOS) + @available(macOS 26, *) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) + } + #endif + + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #endif + @available(iOS 26, *) + fileprivate final var memoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.memoizedProperty, object: object) + } + } + + private let _$observationRegistrar = Observation.ObservationRegistrar() + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + } + + @available(iOS 26, macOS 26, *) extension Person: Relay.Publishable { + } + + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift b/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift new file mode 100644 index 0000000..ad9c39e --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift @@ -0,0 +1,212 @@ +// +// RelayedPropertyMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 01/12/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class RelayedPropertyMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "RelayedProperty": RelayedPropertyMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor + @RelayedProperty + public internal(set) final var name = 123 { + didSet { + _ = newValue + } + } + """#, + expandedSource: + #""" + @MainActor + public internal(set) final var name { + didSet { + _ = newValue + } + @storageRestrictions(initializes: _name) + init(initialValue) { + _name = initialValue + } + + get { + _$observationRegistrar.access(self, keyPath: \.name) + return _name + } + + set { + guard shouldNotifyObservers(_name, newValue) else { + _name = newValue + return + } + + publisher._beginModifications() + _$observationRegistrar.willSet(self, keyPath: \.name) + _name = newValue + _$observationRegistrar.didSet(self, keyPath: \.name) + publisher._name.send(newValue) + publisher._endModifications() + } + + _modify { + publisher._beginModifications() + _$observationRegistrar.access(self, keyPath: \.name) + _$observationRegistrar.willSet(self, keyPath: \.name) + + defer { + _$observationRegistrar.didSet(self, keyPath: \.name) + publisher._name.send(_name) + publisher._endModifications() + } + + yield &_name + } + } + + @MainActor private final var _name = 123 { + didSet { + _ = newValue + } + } + """#, + macros: macros + ) + } + + func testPublisherSupressedExpansion() { + assertMacroExpansion( + #""" + @MainActor + @RelayedProperty @PublisherSupressed + public internal(set) final var name = 123 { + didSet { + _ = newValue + } + } + """#, + expandedSource: + #""" + @MainActor + @PublisherSupressed + public internal(set) final var name { + didSet { + _ = newValue + } + @storageRestrictions(initializes: _name) + init(initialValue) { + _name = initialValue + } + + get { + _$observationRegistrar.access(self, keyPath: \.name) + return _name + } + + set { + guard shouldNotifyObservers(_name, newValue) else { + _name = newValue + return + } + + _$observationRegistrar.willSet(self, keyPath: \.name) + _name = newValue + _$observationRegistrar.didSet(self, keyPath: \.name) + } + + _modify { + _$observationRegistrar.access(self, keyPath: \.name) + _$observationRegistrar.willSet(self, keyPath: \.name) + + defer { + _$observationRegistrar.didSet(self, keyPath: \.name) + } + + yield &_name + } + } + + @MainActor @PublisherSupressed private final var _name = 123 { + didSet { + _ = newValue + } + } + """#, + macros: macros + ) + } + + func testObservationSupressedExpansion() { + assertMacroExpansion( + #""" + @MainActor + @RelayedProperty @ObservationSupressed + public internal(set) final var name = 123 { + didSet { + _ = newValue + } + } + """#, + expandedSource: + #""" + @MainActor + @ObservationSupressed + public internal(set) final var name { + didSet { + _ = newValue + } + @storageRestrictions(initializes: _name) + init(initialValue) { + _name = initialValue + } + + get { + return _name + } + + set { + guard shouldNotifyObservers(_name, newValue) else { + _name = newValue + return + } + + publisher._beginModifications() + _name = newValue + publisher._name.send(newValue) + publisher._endModifications() + } + + _modify { + publisher._beginModifications() + + defer { + publisher._name.send(_name) + publisher._endModifications() + } + + yield &_name + } + } + + @MainActor @ObservationSupressed private final var _name = 123 { + didSet { + _ = newValue + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift new file mode 100644 index 0000000..493aaf4 --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift @@ -0,0 +1,154 @@ +// +// SubclassedImplicitlyIsolatedRelayedMacroTests.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 SubclassedImplicitlyIsolatedRelayedMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Relayed": MacroSpec( + type: RelayedMacro.self, + conformances: [] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Relayed + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + } + """#, + expandedSource: + #""" + @MainActor + class Dog: Animal { + @RelayedProperty + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + 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 let _$observationRegistrar = Observation.ObservationRegistrar() + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift new file mode 100644 index 0000000..e408a06 --- /dev/null +++ b/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift @@ -0,0 +1,153 @@ +// +// SubclassedRelayedMacroTests.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 SubclassedRelayedMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Relayed": MacroSpec( + type: RelayedMacro.self, + conformances: [] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Relayed + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + } + """#, + expandedSource: + #""" + class Dog: Animal { + @RelayedProperty + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + 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 + } + + 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 { + _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 let _$observationRegistrar = Observation.ObservationRegistrar() + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + true + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs !== rhs + } + + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( + _ lhs: __macro_local_1TfMu_, + _ rhs: __macro_local_1TfMu_ + ) -> Bool { + lhs != rhs + } + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift index 8826706..3252158 100644 --- a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift @@ -233,12 +233,18 @@ extension MainActorRelayedTests { } #endif - @PublisherSupressed + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 + @ObservationSupressed + var observationIgnoredStoredProperty = 123 + @PublisherSupressed - var ignoredComputedProperty: Int { - ignoredStoredProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty } @available(iOS 26, *) diff --git a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift index 0d6e4ad..93614bb 100644 --- a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift @@ -232,12 +232,18 @@ extension RelayedTests { } #endif - @PublisherSupressed + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 + @ObservationSupressed + var observationIgnoredStoredProperty = 123 + @PublisherSupressed - var ignoredComputedProperty: Int { - ignoredStoredProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSupressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty } @available(iOS 26, *) From cad288931faa12bff153280d8f6ed51da22dd206 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 20:48:05 +0100 Subject: [PATCH 14/23] [SwiftFormat] Applied formatting --- .../ExplicitlyIsolatedRelayedMacroTests.swift | 20 ++--- .../ImplicitlyIsolatedRelayedMacroTests.swift | 84 +++++++++---------- .../NonisolatedRelayedMacroTests.swift | 84 +++++++++---------- .../Relayed/RelayedMacroTests.swift | 84 +++++++++---------- .../Relayed/RelayedPropertyMacroTests.swift | 42 +++++----- ...dImplicitlyIsolatedRelayedMacroTests.swift | 10 +-- .../Relayed/SubclassedRelayedMacroTests.swift | 14 ++-- 7 files changed, 169 insertions(+), 169 deletions(-) diff --git a/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift index 55de1ee..35bf383 100644 --- a/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift @@ -62,10 +62,10 @@ platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed var observationIgnoredStoredProperty = 123 @@ -132,10 +132,10 @@ platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed @RelayedProperty var observationIgnoredStoredProperty = 123 @@ -250,30 +250,30 @@ _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } - + private let _$observationRegistrar = Observation.ObservationRegistrar() - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { true } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs != rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs !== rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ @@ -284,7 +284,7 @@ @available(iOS 26, macOS 26, *) extension Person: @MainActor Relay.Publishable { } - + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { } """#, diff --git a/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift index 8f93b13..7294f37 100644 --- a/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift @@ -27,28 +27,28 @@ @available(iOS 26, macOS 26, *) @MainActor @Relayed 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 } } - + #if os(macOS) var platformStoredProperty = 123 @@ -56,19 +56,19 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed var observationIgnoredStoredProperty = 123 - + @PublisherSupressed var publisherIgnoredStoredProperty = 123 @@ -76,13 +76,13 @@ var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty @@ -94,31 +94,31 @@ @available(iOS 26, macOS 26, *) @MainActor public final class Person { - + static var user: Person? - + let id: UUID @RelayedProperty fileprivate(set) var age: Int @RelayedProperty var name: String @RelayedProperty - + 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 } } - + #if os(macOS) var platformStoredProperty = 123 @@ -126,20 +126,20 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed @RelayedProperty var observationIgnoredStoredProperty = 123 - + @PublisherSupressed @RelayedProperty var publisherIgnoredStoredProperty = 123 @@ -148,20 +148,20 @@ var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } - + private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -172,28 +172,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) @@ -203,7 +203,7 @@ #endif _observationIgnoredStoredProperty.send(completion: .finished) } - + fileprivate final let _age = PassthroughSubject() final var age: some Publisher { _storedPropertyPublisher(_age, for: \.age, object: object) @@ -226,7 +226,7 @@ final var observationIgnoredStoredProperty: some Publisher { _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) } - + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } @@ -239,7 +239,7 @@ _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif - + #if os(macOS) final var platformMemoizedProperty: some Publisher { _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) @@ -250,30 +250,30 @@ _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } - + private let _$observationRegistrar = Observation.ObservationRegistrar() - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { true } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs != rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs !== rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ @@ -281,10 +281,10 @@ lhs != rhs } } - + @available(iOS 26, macOS 26, *) extension Person: @MainActor Relay.Publishable { } - + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { } """#, diff --git a/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift index de56c9b..34d3f9e 100644 --- a/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift @@ -27,28 +27,28 @@ @available(iOS 26, macOS 26, *) @Relayed(isolation: nil) 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 } } - + #if os(macOS) var platformStoredProperty = 123 @@ -56,19 +56,19 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed var observationIgnoredStoredProperty = 123 - + @PublisherSupressed var publisherIgnoredStoredProperty = 123 @@ -76,13 +76,13 @@ var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty @@ -93,31 +93,31 @@ #""" @available(iOS 26, macOS 26, *) public final class Person { - + static var user: Person? - + let id: UUID @RelayedProperty fileprivate(set) var age: Int @RelayedProperty var name: String @RelayedProperty - + 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 } } - + #if os(macOS) var platformStoredProperty = 123 @@ -125,20 +125,20 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed @RelayedProperty var observationIgnoredStoredProperty = 123 - + @PublisherSupressed @RelayedProperty var publisherIgnoredStoredProperty = 123 @@ -147,20 +147,20 @@ var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } - + private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -171,28 +171,28 @@ 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) @@ -202,7 +202,7 @@ #endif _observationIgnoredStoredProperty.send(completion: .finished) } - + fileprivate final let _age = PassthroughSubject() final var age: some Publisher { _storedPropertyPublisher(_age, for: \.age, object: object) @@ -225,7 +225,7 @@ final var observationIgnoredStoredProperty: some Publisher { _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) } - + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } @@ -238,7 +238,7 @@ _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif - + #if os(macOS) final var platformMemoizedProperty: some Publisher { _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) @@ -249,30 +249,30 @@ _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } - + private let _$observationRegistrar = Observation.ObservationRegistrar() - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { true } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs != rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs !== rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ @@ -280,10 +280,10 @@ lhs != rhs } } - + @available(iOS 26, macOS 26, *) extension Person: nonisolated Relay.Publishable { } - + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { } """#, diff --git a/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift index 75d71bc..7aeedd2 100644 --- a/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift @@ -27,28 +27,28 @@ @available(iOS 26, macOS 26, *) @Relayed 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 } } - + #if os(macOS) var platformStoredProperty = 123 @@ -56,19 +56,19 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed var observationIgnoredStoredProperty = 123 - + @PublisherSupressed var publisherIgnoredStoredProperty = 123 @@ -76,13 +76,13 @@ var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty @@ -93,31 +93,31 @@ #""" @available(iOS 26, macOS 26, *) public final class Person { - + static var user: Person? - + let id: UUID @RelayedProperty fileprivate(set) var age: Int @RelayedProperty var name: String @RelayedProperty - + 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 } } - + #if os(macOS) var platformStoredProperty = 123 @@ -125,20 +125,20 @@ var platformComputedProperty: Int { platformStoredProperty } - + @Memoized func makePlatformMemoizedProperty() -> Int { platformStoredProperty } #endif - + @ObservationSupressed @PublisherSupressed var ignoredStoredProperty = 123 - + @ObservationSupressed @RelayedProperty var observationIgnoredStoredProperty = 123 - + @PublisherSupressed @RelayedProperty var publisherIgnoredStoredProperty = 123 @@ -147,20 +147,20 @@ var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } - + @available(iOS 26, *) @Memoized(.private) func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - + @Memoized @PublisherSupressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } - + private final lazy var _publisher = PropertyPublisher(object: self) - + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -171,28 +171,28 @@ 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 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) _name.send(completion: .finished) @@ -202,7 +202,7 @@ #endif _observationIgnoredStoredProperty.send(completion: .finished) } - + fileprivate final let _age = PassthroughSubject() final var age: some Publisher { _storedPropertyPublisher(_age, for: \.age, object: object) @@ -225,7 +225,7 @@ final var observationIgnoredStoredProperty: some Publisher { _storedPropertyPublisher(_observationIgnoredStoredProperty, for: \.observationIgnoredStoredProperty, object: object) } - + internal final var fullName: some Publisher { _computedPropertyPublisher(for: \.fullName, object: object) } @@ -238,7 +238,7 @@ _computedPropertyPublisher(for: \.platformComputedProperty, object: object) } #endif - + #if os(macOS) final var platformMemoizedProperty: some Publisher { _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) @@ -249,30 +249,30 @@ _computedPropertyPublisher(for: \.memoizedProperty, object: object) } } - + private let _$observationRegistrar = Observation.ObservationRegistrar() - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { true } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs != rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs !== rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ @@ -280,10 +280,10 @@ lhs != rhs } } - + @available(iOS 26, macOS 26, *) extension Person: Relay.Publishable { } - + @available(iOS 26, macOS 26, *) extension Person: nonisolated Observation.Observable { } """#, diff --git a/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift b/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift index ad9c39e..bd490bb 100644 --- a/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift @@ -40,18 +40,18 @@ init(initialValue) { _name = initialValue } - + get { _$observationRegistrar.access(self, keyPath: \.name) return _name } - + set { guard shouldNotifyObservers(_name, newValue) else { _name = newValue return } - + publisher._beginModifications() _$observationRegistrar.willSet(self, keyPath: \.name) _name = newValue @@ -59,22 +59,22 @@ publisher._name.send(newValue) publisher._endModifications() } - + _modify { publisher._beginModifications() _$observationRegistrar.access(self, keyPath: \.name) _$observationRegistrar.willSet(self, keyPath: \.name) - + defer { _$observationRegistrar.didSet(self, keyPath: \.name) publisher._name.send(_name) publisher._endModifications() } - + yield &_name } } - + @MainActor private final var _name = 123 { didSet { _ = newValue @@ -108,35 +108,35 @@ init(initialValue) { _name = initialValue } - + get { _$observationRegistrar.access(self, keyPath: \.name) return _name } - + set { guard shouldNotifyObservers(_name, newValue) else { _name = newValue return } - + _$observationRegistrar.willSet(self, keyPath: \.name) _name = newValue _$observationRegistrar.didSet(self, keyPath: \.name) } - + _modify { _$observationRegistrar.access(self, keyPath: \.name) _$observationRegistrar.willSet(self, keyPath: \.name) - + defer { _$observationRegistrar.didSet(self, keyPath: \.name) } - + yield &_name } } - + @MainActor @PublisherSupressed private final var _name = 123 { didSet { _ = newValue @@ -170,35 +170,35 @@ init(initialValue) { _name = initialValue } - + get { return _name } - + set { guard shouldNotifyObservers(_name, newValue) else { _name = newValue return } - + publisher._beginModifications() _name = newValue publisher._name.send(newValue) publisher._endModifications() } - + _modify { publisher._beginModifications() - + defer { publisher._name.send(_name) publisher._endModifications() } - + yield &_name } } - + @MainActor @ObservationSupressed private final var _name = 123 { didSet { _ = newValue diff --git a/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift index 493aaf4..236a3b3 100644 --- a/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift @@ -49,7 +49,7 @@ @MainActor class Dog: Animal { @RelayedProperty - + var breed: String? var isBulldog: Bool { @@ -117,28 +117,28 @@ } private let _$observationRegistrar = Observation.ObservationRegistrar() - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { true } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs != rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs !== rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ diff --git a/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift index e408a06..fb3de8a 100644 --- a/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift @@ -26,7 +26,7 @@ #""" @Relayed class Dog: Animal { - + var breed: String? var isBulldog: Bool { @@ -48,7 +48,7 @@ #""" class Dog: Animal { @RelayedProperty - + var breed: String? var isBulldog: Bool { @@ -114,30 +114,30 @@ } - + private let _$observationRegistrar = Observation.ObservationRegistrar() - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { true } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs != rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ ) -> Bool { lhs !== rhs } - + private nonisolated func shouldNotifyObservers<__macro_local_1TfMu_: AnyObject & Equatable>( _ lhs: __macro_local_1TfMu_, _ rhs: __macro_local_1TfMu_ From f1ca04007dbc4cafc9c46df5cb195c0dfba82185 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 20:58:56 +0100 Subject: [PATCH 15/23] - --- .../Combine/Common/PublisherIgnored.swift | 21 ------------------- ...{PublishableMacros.md => CombineMacros.md} | 10 +++++++-- .../Documentation.docc/MemoizedMacros.md | 2 ++ Sources/Relay/Documentation.docc/Relay.md | 2 +- ...licitlyIsolatedPublishableMacroTests.swift | 0 ...licitlyIsolatedPublishableMacroTests.swift | 0 .../NonisolatedPublishableMacroTests.swift | 0 .../Publishable/PublishableMacroTests.swift | 0 ...licitlyIsolatedPublishableMacroTests.swift | 0 .../SubclassedPublishableMacroTests.swift | 0 .../ExplicitlyIsolatedRelayedMacroTests.swift | 0 .../ImplicitlyIsolatedRelayedMacroTests.swift | 0 .../NonisolatedRelayedMacroTests.swift | 0 .../Relayed/RelayedMacroTests.swift | 0 .../Relayed/RelayedPropertyMacroTests.swift | 0 ...dImplicitlyIsolatedRelayedMacroTests.swift | 0 .../Relayed/SubclassedRelayedMacroTests.swift | 0 17 files changed, 11 insertions(+), 24 deletions(-) delete mode 100644 Sources/Relay/Combine/Common/PublisherIgnored.swift rename Sources/Relay/Documentation.docc/{PublishableMacros.md => CombineMacros.md} (93%) rename Tests/RelayMacrosTests/{ => Combine}/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Publishable/NonisolatedPublishableMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Publishable/PublishableMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Publishable/SubclassedPublishableMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/NonisolatedRelayedMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/RelayedMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/RelayedPropertyMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift (100%) rename Tests/RelayMacrosTests/{ => Combine}/Relayed/SubclassedRelayedMacroTests.swift (100%) diff --git a/Sources/Relay/Combine/Common/PublisherIgnored.swift b/Sources/Relay/Combine/Common/PublisherIgnored.swift deleted file mode 100644 index 02a3101..0000000 --- a/Sources/Relay/Combine/Common/PublisherIgnored.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PublisherIgnored.swift -// Relay -// -// Created by Kamil Strzelecki on 23/11/2025. -// 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", - type: "PublisherIgnoredMacro" -) diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/CombineMacros.md similarity index 93% rename from Sources/Relay/Documentation.docc/PublishableMacros.md rename to Sources/Relay/Documentation.docc/CombineMacros.md index a4a4cb0..0c1d7bd 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/CombineMacros.md @@ -1,4 +1,4 @@ -# Publishable +# Combine Observe changes to `Observable` types synchronously with `Combine`. @@ -53,10 +53,16 @@ person.surname = "Strzelecki" ### Making Types Publishable +- +- ``Relayed()`` +- ``Relayed(isolation:)`` - ``Publishable()`` - ``Publishable(isolation:)`` + +### Customizing Generated Declarations + +- ``ObservationSupressed()`` - ``PublisherSupressed()`` -- ### Observing Changes with Combine diff --git a/Sources/Relay/Documentation.docc/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md index 237e060..41525fb 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -89,4 +89,6 @@ model.filteredData ### Customizing Generated Declarations +- ``ObservationSupressed()`` +- ``PublisherSupressed()`` - ``AccessControlLevel`` diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md index 6143a61..1b0a9c3 100644 --- a/Sources/Relay/Documentation.docc/Relay.md +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -4,7 +4,7 @@ Essential tools that extend the capabilities of `Observation`. ## Topics -- +- - ## Changelog diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/NonisolatedRelayedMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/RelayedMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/RelayedPropertyMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift diff --git a/Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift similarity index 100% rename from Tests/RelayMacrosTests/Relayed/SubclassedRelayedMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift From 732e203581221632e5529c527c355be16d26fa13 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 23:26:48 +0100 Subject: [PATCH 16/23] - --- ...swift => ObservationSuppressedMacro.swift} | 12 +++--- ...o.swift => PublisherSuppressedMacro.swift} | 14 +++---- .../Publishable/PublishableMacro.swift | 17 ++++++++ Macros/RelayMacros/Main/RelayPlugin.swift | 4 +- .../Common/ObservationSuppressed.swift | 23 +++++++++++ .../Combine/Common/ObservationSupressed.swift | 21 ---------- .../Combine/Common/PublisherSuppressed.swift | 22 ++++++++++ .../Combine/Common/PublisherSupressed.swift | 21 ---------- .../Combine/Publishable/Publishable.swift | 12 +++--- Sources/Relay/Combine/Relayed/Relayed.swift | 26 +++++------- .../ChoosingBetweenRelayedAndPublished.md | 40 +++++++++++++++++++ .../Relay/Documentation.docc/CombineMacros.md | 6 +-- .../Documentation.docc/HowPublishableWorks.md | 16 -------- .../Documentation.docc/MemoizedMacros.md | 4 +- ...licitlyIsolatedPublishableMacroTests.swift | 16 ++++---- ...licitlyIsolatedPublishableMacroTests.swift | 16 ++++---- .../NonisolatedPublishableMacroTests.swift | 16 ++++---- .../Publishable/PublishableMacroTests.swift | 16 ++++---- ...licitlyIsolatedPublishableMacroTests.swift | 4 +- .../SubclassedPublishableMacroTests.swift | 4 +- .../ExplicitlyIsolatedRelayedMacroTests.swift | 20 +++++----- .../ImplicitlyIsolatedRelayedMacroTests.swift | 20 +++++----- .../NonisolatedRelayedMacroTests.swift | 20 +++++----- .../Combine/Relayed/RelayedMacroTests.swift | 20 +++++----- .../Relayed/RelayedPropertyMacroTests.swift | 16 ++++---- ...rvationSuppressedMemoizedMacroTests.swift} | 12 +++--- ...rvationSuppressedMemoizedMacroTests.swift} | 12 +++--- ...blisherSuppressedMemoizedMacroTests.swift} | 12 +++--- .../MainActorPublishableTests.swift | 6 +-- .../ObservablePublishableTests.swift | 6 +-- .../Relayed/MainActorRelayedTests.swift | 10 ++--- .../Combine/Relayed/RelayedTests.swift | 10 ++--- .../Memoized/MainActorMemoizedTests.swift | 2 +- .../Memoized/ObservationMemoizedTests.swift | 2 +- .../Memoized/PublishableMemoizedTests.swift | 6 +-- .../Memoized/RelayedMemoizedTests.swift | 10 ++--- .../Memoized/SwiftDataMemoizedTests.swift | 2 +- 37 files changed, 267 insertions(+), 229 deletions(-) rename Macros/RelayMacros/Combine/Common/{ObservationSupressedMacro.swift => ObservationSuppressedMacro.swift} (74%) rename Macros/RelayMacros/Combine/Common/{PublisherSupressedMacro.swift => PublisherSuppressedMacro.swift} (75%) create mode 100644 Sources/Relay/Combine/Common/ObservationSuppressed.swift delete mode 100644 Sources/Relay/Combine/Common/ObservationSupressed.swift create mode 100644 Sources/Relay/Combine/Common/PublisherSuppressed.swift delete mode 100644 Sources/Relay/Combine/Common/PublisherSupressed.swift create mode 100644 Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublished.md delete mode 100644 Sources/Relay/Documentation.docc/HowPublishableWorks.md rename Tests/RelayMacrosTests/Memoized/{ObservationSupressedMemoizedMacroTests.swift => ObservationSuppressedMemoizedMacroTests.swift} (93%) rename Tests/RelayMacrosTests/Memoized/{RelayedObservationSupressedMemoizedMacroTests.swift => RelayedObservationSuppressedMemoizedMacroTests.swift} (93%) rename Tests/RelayMacrosTests/Memoized/{RelayedPublisherSupressedMemoizedMacroTests.swift => RelayedPublisherSuppressedMemoizedMacroTests.swift} (94%) diff --git a/Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationSuppressedMacro.swift similarity index 74% rename from Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift rename to Macros/RelayMacros/Combine/Common/ObservationSuppressedMacro.swift index 4f5c9e3..a9fb84d 100644 --- a/Macros/RelayMacros/Combine/Common/ObservationSupressedMacro.swift +++ b/Macros/RelayMacros/Combine/Common/ObservationSuppressedMacro.swift @@ -1,5 +1,5 @@ // -// ObservationSupressedMacro.swift +// ObservationSuppressedMacro.swift // Relay // // Created by Kamil Strzelecki on 01/12/2025. @@ -8,12 +8,12 @@ import SwiftSyntaxMacros -public enum ObservationSupressedMacro { +public enum ObservationSuppressedMacro { - static let attribute: AttributeSyntax = "@ObservationSupressed" + static let attribute: AttributeSyntax = "@ObservationSuppressed" } -extension ObservationSupressedMacro: PeerMacro { +extension ObservationSuppressedMacro: PeerMacro { public static func expansion( of _: AttributeSyntax, @@ -32,7 +32,7 @@ extension Property { && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil && !underlying.attributes.contains(like: ObservationIgnoredMacro.attribute) - && !underlying.attributes.contains(like: ObservationSupressedMacro.attribute) + && !underlying.attributes.contains(like: ObservationSuppressedMacro.attribute) } } @@ -40,6 +40,6 @@ extension FunctionDeclSyntax { var isObservationTracked: Bool { !attributes.contains(like: ObservationIgnoredMacro.attribute) - && !attributes.contains(like: ObservationSupressedMacro.attribute) + && !attributes.contains(like: ObservationSuppressedMacro.attribute) } } diff --git a/Macros/RelayMacros/Combine/Common/PublisherSupressedMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherSuppressedMacro.swift similarity index 75% rename from Macros/RelayMacros/Combine/Common/PublisherSupressedMacro.swift rename to Macros/RelayMacros/Combine/Common/PublisherSuppressedMacro.swift index 6cde9dc..24c59ea 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherSupressedMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherSuppressedMacro.swift @@ -1,5 +1,5 @@ // -// PublisherSupressedMacro.swift +// PublisherSuppressedMacro.swift // Relay // // Created by Kamil Strzelecki on 22/11/2025. @@ -8,12 +8,12 @@ import SwiftSyntaxMacros -public enum PublisherSupressedMacro { +public enum PublisherSuppressedMacro { - static let attribute: AttributeSyntax = "@PublisherSupressed" + static let attribute: AttributeSyntax = "@PublisherSuppressed" } -extension PublisherSupressedMacro: PeerMacro { +extension PublisherSuppressedMacro: PeerMacro { public static func expansion( of _: AttributeSyntax, @@ -31,20 +31,20 @@ extension Property { && mutability == .mutable && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherSupressedMacro.attribute) + && !underlying.attributes.contains(like: PublisherSuppressedMacro.attribute) } var isComputedPublisherTracked: Bool { kind == .computed && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherSupressedMacro.attribute) + && !underlying.attributes.contains(like: PublisherSuppressedMacro.attribute) } } extension FunctionDeclSyntax { var isPublisherTracked: Bool { - !attributes.contains(like: PublisherSupressedMacro.attribute) + !attributes.contains(like: PublisherSuppressedMacro.attribute) } } diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 43d5536..5029386 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -24,6 +24,23 @@ public enum PublishableMacro { ) } + if declaration.attributes.contains(like: ObservableMacro.attribute) { + context.diagnose( + node: declaration, + warningMessage: """ + @Publishable macro should be used with macros other than @Observable, \ + that supply their own Observable protocol conformance + """, + fixIts: [ + .replace( + message: MacroExpansionFixItMessage("Apply @Relayed macro"), + oldNode: node, + newNode: RelayedMacro.attribute.withTrivia(from: node) + ) + ] + ) + } + if declaration.attributes.contains(like: SwiftDataModelMacro.attribute) { context.diagnose( node: declaration, diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index 1a04053..ceebf1b 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -16,8 +16,8 @@ internal struct RelayPlugin: CompilerPlugin { PublishableMacro.self, RelayedMacro.self, RelayedPropertyMacro.self, - PublisherSupressedMacro.self, - ObservationSupressedMacro.self, + PublisherSuppressedMacro.self, + ObservationSuppressedMacro.self, MemoizedMacro.self ] } diff --git a/Sources/Relay/Combine/Common/ObservationSuppressed.swift b/Sources/Relay/Combine/Common/ObservationSuppressed.swift new file mode 100644 index 0000000..e089955 --- /dev/null +++ b/Sources/Relay/Combine/Common/ObservationSuppressed.swift @@ -0,0 +1,23 @@ +// +// PublisherSuppressed.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +/// Disables tracking of a property through `Observation`. +/// +/// - Note: This macro is functionally equivalent to the `@ObservationIgnored` macro, but can be used in contexts where that macro is not supported. +/// +/// By default, an object can track any property of an `Observable` type that is accessible to it. To prevent tracking of an accessible property +/// through `Observation`, attach this macro to the property of a ``Relayed()`` class or to a ``Memoized(_:_:)`` method. +/// +/// The `@ObservationSuppressed` macro is independent of the ``PublisherSuppressed()`` macro. +/// If you want to prevent tracking through `Combine` as well, apply both macros. +/// +@attached(peer) +public macro ObservationSuppressed() = #externalMacro( + module: "RelayMacros", + type: "ObservationSuppressedMacro" +) diff --git a/Sources/Relay/Combine/Common/ObservationSupressed.swift b/Sources/Relay/Combine/Common/ObservationSupressed.swift deleted file mode 100644 index 4e84a8f..0000000 --- a/Sources/Relay/Combine/Common/ObservationSupressed.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PublisherSupressed.swift -// Relay -// -// Created by Kamil Strzelecki on 23/11/2025. -// 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 `@PublisherSupressed` macro is independent of the `@ObservationIgnored` macro. -/// If you want to prevent tracking through `Observation` as well, apply both macros. -/// -@attached(peer) -public macro ObservationSupressed() = #externalMacro( - module: "RelayMacros", - type: "ObservationSupressedMacro" -) diff --git a/Sources/Relay/Combine/Common/PublisherSuppressed.swift b/Sources/Relay/Combine/Common/PublisherSuppressed.swift new file mode 100644 index 0000000..cc6e4cd --- /dev/null +++ b/Sources/Relay/Combine/Common/PublisherSuppressed.swift @@ -0,0 +1,22 @@ +// +// PublisherSuppressed.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +/// Disables tracking of a property through `Combine`. +/// +/// By default, an object can track any mutable or computed instance property of a ``Publishable`` type that is accessible to it. +/// To prevent tracking of an accessible property through `Combine`, attach this macro to the property of a ``Relayed()`` or ``Publishable()`` class, +/// or to a ``Memoized(_:_:)`` method. +/// +/// The `@PublisherSuppressed` macro is independent of the `@ObservationIgnored` and ``ObservationSuppressed()`` macros. +/// If you want to prevent tracking through `Observation` as well, apply both macros. +/// +@attached(peer) +public macro PublisherSuppressed() = #externalMacro( + module: "RelayMacros", + type: "PublisherSuppressedMacro" +) diff --git a/Sources/Relay/Combine/Common/PublisherSupressed.swift b/Sources/Relay/Combine/Common/PublisherSupressed.swift deleted file mode 100644 index 4442068..0000000 --- a/Sources/Relay/Combine/Common/PublisherSupressed.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// PublisherSupressed.swift -// Relay -// -// Created by Kamil Strzelecki on 23/11/2025. -// 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 `@PublisherSupressed` macro is independent of the `@ObservationIgnored` macro. -/// If you want to prevent tracking through `Observation` as well, apply both macros. -/// -@attached(peer) -public macro PublisherSupressed() = #externalMacro( - module: "RelayMacros", - type: "PublisherSupressedMacro" -) diff --git a/Sources/Relay/Combine/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift index d57cc07..ff915f4 100644 --- a/Sources/Relay/Combine/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -6,13 +6,13 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -/// A macro that adds ``Publishable`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` conformance to classes that are already `Observable`. /// /// - 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 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`. +/// To make the two compatible, apply another macro 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. @@ -21,7 +21,7 @@ /// 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. +/// 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. @@ -44,14 +44,14 @@ public macro Publishable() = #externalMacro( type: "PublishableMacro" ) -/// A macro that adds ``Publishable`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` conformance to classes that are already `Observable`. /// /// - 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 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`. +/// To make the two compatible, apply another macro 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. @@ -60,7 +60,7 @@ public macro Publishable() = #externalMacro( /// 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. +/// 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. diff --git a/Sources/Relay/Combine/Relayed/Relayed.swift b/Sources/Relay/Combine/Relayed/Relayed.swift index 7bba76e..2a488ec 100644 --- a/Sources/Relay/Combine/Relayed/Relayed.swift +++ b/Sources/Relay/Combine/Relayed/Relayed.swift @@ -8,22 +8,19 @@ import Observation -/// A macro that adds ``Publishable`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` and `Observable` conformance to classes. /// /// - 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. +/// If this causes compilation errors, use ``Relayed(isolation:)`` instead. /// -/// - 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, +/// The `@Relayed` 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. /// -/// 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. +/// Classes to which the `@Relayed` 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. @@ -52,23 +49,20 @@ public macro Relayed() = #externalMacro( type: "RelayedMacro" ) -/// A macro that adds ``Publishable`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` and `Observable` conformance to classes. /// /// - 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 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`. +/// To infer isolation automatically, use the ``Relayed()`` macro instead. /// -/// The `@Publishable` macro adds a new `publisher` property to your type, +/// The `@Relayed` 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. /// -/// 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. +/// Classes to which the `@Relayed` 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. diff --git a/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublished.md b/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublished.md new file mode 100644 index 0000000..ba0b9d7 --- /dev/null +++ b/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublished.md @@ -0,0 +1,40 @@ +# Choosing between @Relayed and @Publishable macros + +Determine which macro best fits your use cases. + +## Overview + +The ``Relayed()`` and ``Publishable()`` macros both enable observation of property updates through `Combine` +on your `Observable` classes, but they achieve this through different mechanisms. + +As a general rule: +- Prefer ``Relayed()`` macro when the class uses the `@Observable` macro. +- Prefer ``Publishable()`` macro when the class uses a different macro to provide `Observable` conformance. + +The table below highlights the key similarities and differences between the macros: + +Feature | `@Relayed` | `@Publishable` +--- | --- | --- +``Publishable`` conformance | Generated by the macro | Generated by the macro +Publishing changes through `Combine` | Direct | Uses key path comparison after each mutation (_O(n)_, where _n_ is the number of stored mutable properties) +`Observable` conformance | Generated by the macro | Provided by another `@Observable`-like macro +Publishing changes through `Observation` | Direct | Depends on the macro providing `Observable` conformance +Compatibility | Only applicable to classes using the `@Observable` macro directly | Should compile alongside macros that follow `@Observable`-like expansion pattern +Special considerations | Does not generate methods equivalent to `access` and `withMutation`, but otherwise replicates the `@Observable` macro expansion | Generates a custom `ObservationRegistrar` type, which might be unexpected for frameworks using reflection (e.g., `SwiftData` and its `@Model` macro) + +## Overloading ObservationRegistrar + +What makes the ``Publishable()`` macro compatible with other macros that follow the `@Observable`-like expansion pattern, +including the `@Observable` macro itself, stems from 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 by comparing key paths after each property mutation + +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. + +The ``Relayed()`` macro does not depend on this mechanism, making it potentially safer and more performant, +though incompatible with other macros generating `Observable` conformance. diff --git a/Sources/Relay/Documentation.docc/CombineMacros.md b/Sources/Relay/Documentation.docc/CombineMacros.md index 0c1d7bd..06affe6 100644 --- a/Sources/Relay/Documentation.docc/CombineMacros.md +++ b/Sources/Relay/Documentation.docc/CombineMacros.md @@ -53,7 +53,7 @@ person.surname = "Strzelecki" ### Making Types Publishable -- +- - ``Relayed()`` - ``Relayed(isolation:)`` - ``Publishable()`` @@ -61,8 +61,8 @@ person.surname = "Strzelecki" ### Customizing Generated Declarations -- ``ObservationSupressed()`` -- ``PublisherSupressed()`` +- ``ObservationSuppressed()`` +- ``PublisherSuppressed()`` ### Observing Changes with Combine diff --git a/Sources/Relay/Documentation.docc/HowPublishableWorks.md b/Sources/Relay/Documentation.docc/HowPublishableWorks.md deleted file mode 100644 index 3304b3a..0000000 --- a/Sources/Relay/Documentation.docc/HowPublishableWorks.md +++ /dev/null @@ -1,16 +0,0 @@ -# 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/MemoizedMacros.md b/Sources/Relay/Documentation.docc/MemoizedMacros.md index 41525fb..4e09bed 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -89,6 +89,6 @@ model.filteredData ### Customizing Generated Declarations -- ``ObservationSupressed()`` -- ``PublisherSupressed()`` +- ``ObservationSuppressed()`` +- ``PublisherSuppressed()`` - ``AccessControlLevel`` diff --git a/Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index fa3272a..9182aa9 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -25,7 +25,7 @@ assertMacroExpansion( #""" @available(iOS 26, macOS 26, *) - @CustomActor @Publishable(isolation: MainActor.self) @Observable + @CustomActor @Publishable(isolation: MainActor.self) @CustomObservable public final class Person { static var user: Person? @@ -63,10 +63,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -77,7 +77,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -86,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @CustomActor @Observable + @CustomActor @CustomObservable public final class Person { static var user: Person? @@ -124,10 +124,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -138,7 +138,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index 08768e3..9d2db28 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -25,7 +25,7 @@ assertMacroExpansion( #""" @available(iOS 26, macOS 26, *) - @MainActor @Publishable @Observable + @MainActor @Publishable @CustomObservable public final class Person { static var user: Person? @@ -63,10 +63,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -77,7 +77,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -86,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @MainActor @Observable + @MainActor @CustomObservable public final class Person { static var user: Person? @@ -124,10 +124,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -138,7 +138,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift index b0037b3..a2e9999 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift @@ -25,7 +25,7 @@ assertMacroExpansion( #""" @available(iOS 26, macOS 26, *) - @Publishable(isolation: nil) @Observable + @Publishable(isolation: nil) @CustomObservable public final class Person { static var user: Person? @@ -63,10 +63,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -77,7 +77,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -86,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @Observable + @CustomObservable public final class Person { static var user: Person? @@ -124,10 +124,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -138,7 +138,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift index db105b9..69808bf 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift @@ -25,7 +25,7 @@ assertMacroExpansion( #""" @available(iOS 26, macOS 26, *) - @Publishable @Observable + @Publishable @CustomObservable public final class Person { static var user: Person? @@ -63,10 +63,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -77,7 +77,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -86,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @Observable + @CustomObservable public final class Person { static var user: Person? @@ -124,10 +124,10 @@ } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -138,7 +138,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift index 296217b..e42d268 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift @@ -24,7 +24,7 @@ func testExpansion() { assertMacroExpansion( #""" - @MainActor @Publishable @Observable + @MainActor @Publishable @CustomObservable class Dog: Animal { var breed: String? @@ -47,7 +47,7 @@ """#, expandedSource: #""" - @MainActor @Observable + @MainActor @CustomObservable class Dog: Animal { var breed: String? diff --git a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift index abfa906..d757f66 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift @@ -24,7 +24,7 @@ func testExpansion() { assertMacroExpansion( #""" - @Publishable @Observable + @Publishable @CustomObservable class Dog: Animal { var breed: String? @@ -47,7 +47,7 @@ """#, expandedSource: #""" - @Observable + @CustomObservable class Dog: Animal { var breed: String? diff --git a/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift index 35bf383..6d7386a 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift @@ -63,16 +63,16 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -83,7 +83,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } @@ -133,18 +133,18 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed @RelayedProperty var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed @RelayedProperty var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -155,7 +155,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift index 7294f37..dd099a7 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift @@ -63,16 +63,16 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -83,7 +83,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } @@ -133,18 +133,18 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed @RelayedProperty var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed @RelayedProperty var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -155,7 +155,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift index 34d3f9e..22c071d 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift @@ -63,16 +63,16 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -83,7 +83,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } @@ -132,18 +132,18 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed @RelayedProperty var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed @RelayedProperty var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -154,7 +154,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift index 7aeedd2..a2e50e5 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift @@ -63,16 +63,16 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -83,7 +83,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } @@ -132,18 +132,18 @@ } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed @RelayedProperty var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed @RelayedProperty var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -154,7 +154,7 @@ "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { publisherIgnoredStoredProperty } diff --git a/Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift index bd490bb..59aa15c 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift @@ -85,11 +85,11 @@ ) } - func testPublisherSupressedExpansion() { + func testPublisherSuppressedExpansion() { assertMacroExpansion( #""" @MainActor - @RelayedProperty @PublisherSupressed + @RelayedProperty @PublisherSuppressed public internal(set) final var name = 123 { didSet { _ = newValue @@ -99,7 +99,7 @@ expandedSource: #""" @MainActor - @PublisherSupressed + @PublisherSuppressed public internal(set) final var name { didSet { _ = newValue @@ -137,7 +137,7 @@ } } - @MainActor @PublisherSupressed private final var _name = 123 { + @MainActor @PublisherSuppressed private final var _name = 123 { didSet { _ = newValue } @@ -147,11 +147,11 @@ ) } - func testObservationSupressedExpansion() { + func testObservationSuppressedExpansion() { assertMacroExpansion( #""" @MainActor - @RelayedProperty @ObservationSupressed + @RelayedProperty @ObservationSuppressed public internal(set) final var name = 123 { didSet { _ = newValue @@ -161,7 +161,7 @@ expandedSource: #""" @MainActor - @ObservationSupressed + @ObservationSuppressed public internal(set) final var name { didSet { _ = newValue @@ -199,7 +199,7 @@ } } - @MainActor @ObservationSupressed private final var _name = 123 { + @MainActor @ObservationSuppressed private final var _name = 123 { didSet { _ = newValue } diff --git a/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ObservationSuppressedMemoizedMacroTests.swift similarity index 93% rename from Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/ObservationSuppressedMemoizedMacroTests.swift index 1a03bb8..7befb8f 100644 --- a/Tests/RelayMacrosTests/Memoized/ObservationSupressedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ObservationSuppressedMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// ObservationSupressedMemoizedMacroTests.swift +// ObservationSuppressedMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,7 +11,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class ObservationSupressedMemoizedMacroTests: XCTestCase { + internal final class ObservationSuppressedMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self @@ -25,7 +25,7 @@ var side = 12.3 - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed private func calculateArea() -> Double { side * side } @@ -38,7 +38,7 @@ var side = 12.3 - @ObservationSupressed + @ObservationSuppressed private func calculateArea() -> Double { side * side } @@ -88,7 +88,7 @@ @available(macOS 26, *) @Memoized(.public, "customName") - @ObservationSupressed + @ObservationSuppressed private func calculateArea() -> Double { side * side } @@ -102,7 +102,7 @@ var side = 12.3 @available(macOS 26, *) - @ObservationSupressed + @ObservationSuppressed private func calculateArea() -> Double { side * side } diff --git a/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedObservationSuppressedMemoizedMacroTests.swift similarity index 93% rename from Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/RelayedObservationSuppressedMemoizedMacroTests.swift index aa39710..5241f0d 100644 --- a/Tests/RelayMacrosTests/Memoized/RelayedObservationSupressedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/RelayedObservationSuppressedMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// RelayedObservationSupressedMemoizedMacroTests.swift +// RelayedObservationSuppressedMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,7 +11,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class RelayedObservationSupressedMemoizedMacroTests: XCTestCase { + internal final class RelayedObservationSuppressedMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self @@ -25,7 +25,7 @@ var side = 12.3 - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed private func calculateArea() -> Double { side * side } @@ -38,7 +38,7 @@ var side = 12.3 - @ObservationSupressed + @ObservationSuppressed private func calculateArea() -> Double { side * side } @@ -90,7 +90,7 @@ @available(macOS 26, *) @Memoized(.public, "customName") - @ObservationSupressed + @ObservationSuppressed private func calculateArea() -> Double { side * side } @@ -104,7 +104,7 @@ var side = 12.3 @available(macOS 26, *) - @ObservationSupressed + @ObservationSuppressed private func calculateArea() -> Double { side * side } diff --git a/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSuppressedMemoizedMacroTests.swift similarity index 94% rename from Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/RelayedPublisherSuppressedMemoizedMacroTests.swift index 7c5b433..baf2979 100644 --- a/Tests/RelayMacrosTests/Memoized/RelayedPublisherSupressedMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSuppressedMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// RelayedPublisherSupressedMemoizedMacroTests.swift +// RelayedPublisherSuppressedMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,7 +11,7 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class RelayedPublisherSupressedMemoizedMacroTests: XCTestCase { + internal final class RelayedPublisherSuppressedMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self @@ -25,7 +25,7 @@ var side = 12.3 - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed private func calculateArea() -> Double { side * side } @@ -38,7 +38,7 @@ var side = 12.3 - @PublisherSupressed + @PublisherSuppressed private func calculateArea() -> Double { side * side } @@ -94,7 +94,7 @@ @available(macOS 26, *) @Memoized(.public, "customName") - @PublisherSupressed + @PublisherSuppressed private func calculateArea() -> Double { side * side } @@ -108,7 +108,7 @@ var side = 12.3 @available(macOS 26, *) - @PublisherSupressed + @PublisherSuppressed private func calculateArea() -> Double { side * side } diff --git a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift index d89ac7f..5abcee7 100644 --- a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift @@ -233,10 +233,10 @@ extension MainActorPublishableTests { } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -247,7 +247,7 @@ extension MainActorPublishableTests { "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift index 8c8aedd..b5881aa 100644 --- a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift @@ -232,10 +232,10 @@ extension ObservablePublishableTests { } #endif - @PublisherSupressed + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @@ -246,7 +246,7 @@ extension ObservablePublishableTests { "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift index 3252158..bf661b7 100644 --- a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift @@ -233,16 +233,16 @@ extension MainActorRelayedTests { } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -253,7 +253,7 @@ extension MainActorRelayedTests { "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift index 93614bb..a3f9aef 100644 --- a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift @@ -232,16 +232,16 @@ extension RelayedTests { } #endif - @ObservationSupressed @PublisherSupressed + @ObservationSuppressed @PublisherSuppressed var ignoredStoredProperty = 123 - @ObservationSupressed + @ObservationSuppressed var observationIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredStoredProperty = 123 - @PublisherSupressed + @PublisherSuppressed var publisherIgnoredComputedProperty: Int { publisherIgnoredStoredProperty } @@ -252,7 +252,7 @@ extension RelayedTests { "\(fullName), \(age)" } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index dcaa2b3..d3faf93 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -260,7 +260,7 @@ extension MainActorMemoizedTests { return baseArea * z } - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index fc6d1d5..8d61bae 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -258,7 +258,7 @@ extension ObservationMemoizedTests { return baseArea * z } - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 6b3cb8c..ab6aadb 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -192,17 +192,17 @@ extension PublishableMemoizedTests { return baseArea * z } - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed func calculateObservationIgnoredValue() -> Double { volume } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func calculatePublisherIgnoredValue() -> Double { volume } - @Memoized @ObservationSupressed @PublisherSupressed + @Memoized @ObservationSuppressed @PublisherSuppressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift index 5db1c01..5130cf9 100644 --- a/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift @@ -172,11 +172,11 @@ extension RelayedMemoizedTests { var y = 1.0 var z = 1.0 - @ObservationSupressed + @ObservationSuppressed private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } - @ObservationSupressed + @ObservationSuppressed private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } @@ -192,17 +192,17 @@ extension RelayedMemoizedTests { return baseArea * z } - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed func calculateObservationIgnoredValue() -> Double { volume } - @Memoized @PublisherSupressed + @Memoized @PublisherSuppressed func calculatePublisherIgnoredValue() -> Double { volume } - @Memoized @ObservationSupressed @PublisherSupressed + @Memoized @ObservationSuppressed @PublisherSuppressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index a328dbd..3ace195 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -263,7 +263,7 @@ extension SwiftDataMemoizedTests { return baseArea * z } - @Memoized @ObservationSupressed + @Memoized @ObservationSuppressed func calculateIgnoredValue() -> Double { volume } From 8478e8f9b47ca5168a5037c5cd9f8e4209ef4174 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Mon, 1 Dec 2025 23:58:42 +0100 Subject: [PATCH 17/23] - --- ...AnyPropertyPublisherPublishableTests.swift | 4 +-- .../MainActorPublishableTests.swift | 2 +- .../ObservablePublishableTests.swift | 2 +- .../SubclassedMainActorPublishableTests.swift | 6 ++-- .../SubclassedPublishableTests.swift | 6 ++-- Tests/RelayTests/Helpers/_Observable.swift | 28 +++++++++++++++++++ .../Memoized/PublishableMemoizedTests.swift | 2 +- 7 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 Tests/RelayTests/Helpers/_Observable.swift diff --git a/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift index 335128c..036de4f 100644 --- a/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift @@ -17,7 +17,7 @@ internal enum AnyPropertyPublisherPublishableTests { fileprivate final class NonEquatableClass {} - @Publishable @Observable + @Publishable @_Observable fileprivate final class Object { var unrelatedProperty = 0 @@ -212,7 +212,7 @@ extension AnyPropertyPublisherPublishableTests { } } - @Publishable @Observable + @Publishable @_Observable fileprivate final class Object { var unrelatedProperty = 0 diff --git a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift index 5abcee7..56584fe 100644 --- a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift @@ -202,7 +202,7 @@ extension MainActorPublishableTests { extension MainActorPublishableTests { - @MainActor @Publishable @Observable + @MainActor @Publishable @_Observable final class Person { let id = UUID() diff --git a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift index b5881aa..1763fd6 100644 --- a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift @@ -201,7 +201,7 @@ extension ObservablePublishableTests { extension ObservablePublishableTests { - @Publishable @Observable + @Publishable @_Observable final class Person { let id = UUID() diff --git a/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift index faeefc0..a7a8374 100644 --- a/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift @@ -289,7 +289,7 @@ extension SubclassedMainActorPublishableTests { extension SubclassedMainActorPublishableTests { - @MainActor @Publishable @Observable + @MainActor @Publishable @_Observable class PublishableAnimal { var name = "Unknown" @@ -300,7 +300,7 @@ extension SubclassedMainActorPublishableTests { } } - @MainActor @Publishable @Observable + @MainActor @Publishable @_Observable final class Dog: PublishableAnimal { var breed: String? @@ -335,7 +335,7 @@ extension SubclassedMainActorPublishableTests { } } - @MainActor @Publishable @Observable + @MainActor @Publishable @_Observable final class Cat: NonPublishableAnimal { var breed: String? diff --git a/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift index ca6761d..ee536f1 100644 --- a/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift @@ -288,7 +288,7 @@ extension SubclassedPublishableTests { extension SubclassedPublishableTests { - @Publishable @Observable + @Publishable @_Observable class PublishableAnimal { var name = "Unknown" @@ -299,7 +299,7 @@ extension SubclassedPublishableTests { } } - @Publishable @Observable + @Publishable @_Observable final class Dog: PublishableAnimal { var breed: String? @@ -333,7 +333,7 @@ extension SubclassedPublishableTests { } } - @Publishable @Observable + @Publishable @_Observable final class Cat: NonPublishableAnimal { var breed: String? diff --git a/Tests/RelayTests/Helpers/_Observable.swift b/Tests/RelayTests/Helpers/_Observable.swift new file mode 100644 index 0000000..6eb35ed --- /dev/null +++ b/Tests/RelayTests/Helpers/_Observable.swift @@ -0,0 +1,28 @@ +// +// _Observable.swift +// Relay +// +// Created by Kamil Strzelecki on 01/12/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Observation + +@attached( + member, + names: named(_$observationRegistrar), + named(access), + named(withMutation), + named(shouldNotifyObservers) +) +@attached( + extension, + conformances: Observable +) +@attached( + memberAttribute +) +macro _Observable() = #externalMacro( + module: "ObservationMacros", + type: "ObservableMacro" +) diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index ab6aadb..069d602 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -164,7 +164,7 @@ internal struct PublishableMemoizedTests { extension PublishableMemoizedTests { - @Publishable @Observable + @Publishable @_Observable final class Cube { var offset = 0.0 From c8b342cc8ac386c67dbbd5d005abce09e7484726 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 2 Dec 2025 00:37:27 +0100 Subject: [PATCH 18/23] - --- README.md | 27 +++++-------------- Sources/Relay/Documentation.docc/Changelog.md | 6 ++++- ...singBetweenRelayedAndPublishableMacros.md} | 0 Sources/Relay/Documentation.docc/Relay.md | 2 +- ...cros.md => RelayedAndPublishableMacros.md} | 12 ++++----- 5 files changed, 18 insertions(+), 29 deletions(-) rename Sources/Relay/Documentation.docc/{ChoosingBetweenRelayedAndPublished.md => ChoosingBetweenRelayedAndPublishableMacros.md} (100%) rename Sources/Relay/Documentation.docc/{CombineMacros.md => RelayedAndPublishableMacros.md} (80%) diff --git a/README.md b/README.md index a8a53d6..4e16ebd 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,12 @@ Essential tools that extend the capabilities of `Observation`. #### Contents -- [Publishable](#publishable) +- [Relayed and Publishable](#relayed-and-publishable) - [Memoized](#memoized) - [Documentation](#documentation) - [Installation](#installation) -## Publishable +## Relayed and Publishable
Observe changes to Observable types synchronously with Combine. @@ -24,14 +24,14 @@ 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 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`: +This is where the `Publishable` protocol 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. Classes can gain `Publishable` +conformance by attaching either the `@Relayed` or `@Publishable` macro: ```swift import Relay -@Publishable @Observable +@Relayed final class Person { var name = "John" var surname = "Doe" @@ -63,21 +63,6 @@ person.surname = "Strzelecki" // Full name - Kamil Strzelecki ``` -### How Publishable Works? - -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. -
## Memoized diff --git a/Sources/Relay/Documentation.docc/Changelog.md b/Sources/Relay/Documentation.docc/Changelog.md index 938a6e7..3033c2b 100644 --- a/Sources/Relay/Documentation.docc/Changelog.md +++ b/Sources/Relay/Documentation.docc/Changelog.md @@ -4,7 +4,11 @@ Summary of breaking changes between major releases. ## Version 3.0 -- All generated publishers now return an opaque `some Publisher` type instead of an erased `AnyPublisher`. +- All generated publishers now return an opaque `some Publisher` type +instead of an erased `AnyPublisher`. + +- When the ``Publishable()`` macro is used alongside the `@Observable` macro, a warning will be emitted +suggesting a switch to the ``Relayed()`` macro instead. - ``AnyPropertyPublisher`` is no longer generic, allowing subclassing of ``Publishable`` types. As a consequence, its ``AnyPropertyPublisher/willChange`` and ``AnyPropertyPublisher/didChange`` publishers now output `Void` diff --git a/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublished.md b/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublishableMacros.md similarity index 100% rename from Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublished.md rename to Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublishableMacros.md diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md index 1b0a9c3..a21e112 100644 --- a/Sources/Relay/Documentation.docc/Relay.md +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -4,7 +4,7 @@ Essential tools that extend the capabilities of `Observation`. ## Topics -- +- - ## Changelog diff --git a/Sources/Relay/Documentation.docc/CombineMacros.md b/Sources/Relay/Documentation.docc/RelayedAndPublishableMacros.md similarity index 80% rename from Sources/Relay/Documentation.docc/CombineMacros.md rename to Sources/Relay/Documentation.docc/RelayedAndPublishableMacros.md index 06affe6..cc07a4c 100644 --- a/Sources/Relay/Documentation.docc/CombineMacros.md +++ b/Sources/Relay/Documentation.docc/RelayedAndPublishableMacros.md @@ -1,4 +1,4 @@ -# Combine +# Relayed and Publishable Observe changes to `Observable` types synchronously with `Combine`. @@ -10,14 +10,14 @@ 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 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`: +This is where the ``Publishable`` protocol 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. Classes can gain ``Publishable`` +conformance by attaching either the ``Relayed()`` or ``Publishable()`` macro: ```swift import Relay -@Publishable @Observable +@Relayed final class Person { var name = "John" var surname = "Doe" @@ -53,7 +53,7 @@ person.surname = "Strzelecki" ### Making Types Publishable -- +- - ``Relayed()`` - ``Relayed(isolation:)`` - ``Publishable()`` From 2217d567ca3e2f167dce461b7b7a96606b0039b0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 2 Dec 2025 00:40:09 +0100 Subject: [PATCH 19/23] - --- Macros/Dependencies/PrincipleMacros | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index cc9d2e8..ed1067d 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit cc9d2e815f835417413e42be4043b7cc113515ac +Subproject commit ed1067d422089d8a833f2e358fcc55a2183bfb7d From 63315d7805e9214479b8f917c01d1d7513db7be0 Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 2 Dec 2025 00:45:29 +0100 Subject: [PATCH 20/23] - --- .../Combine/Relayed/RelayedPropertyMacro.swift | 4 ++-- .../Relay/Combine/Common/ObservationSuppressed.swift | 2 +- ...bclassedImplicitlyIsolatedRelayedMacroTests.swift | 4 +++- .../Relayed/SubclassedRelayedMacroTests.swift | 4 +++- .../Publishable/MainActorPublishableTests.swift | 12 +++++++----- .../Publishable/ObservablePublishableTests.swift | 12 +++++++----- .../Combine/Relayed/MainActorRelayedTests.swift | 12 +++++++----- Tests/RelayTests/Combine/Relayed/RelayedTests.swift | 12 +++++++----- 8 files changed, 37 insertions(+), 25 deletions(-) diff --git a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift index 9657d66..4cf2970 100644 --- a/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift @@ -64,8 +64,8 @@ extension RelayedPropertyMacro: PeerMacro { return [] } - let attributes = property.attributes.filter { - $0.attribute?.isLike(RelayedPropertyMacro.attribute) != true + let attributes = property.attributes.filter { attribute in + attribute.attribute?.isLike(RelayedPropertyMacro.attribute) != true } let modifiers = property.modifiers.withAccessControlLevel(.private) diff --git a/Sources/Relay/Combine/Common/ObservationSuppressed.swift b/Sources/Relay/Combine/Common/ObservationSuppressed.swift index e089955..e4ca0d4 100644 --- a/Sources/Relay/Combine/Common/ObservationSuppressed.swift +++ b/Sources/Relay/Combine/Common/ObservationSuppressed.swift @@ -1,5 +1,5 @@ // -// PublisherSuppressed.swift +// ObservationSuppressed.swift // Relay // // Created by Kamil Strzelecki on 23/11/2025. diff --git a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift index 236a3b3..b75ab2d 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift @@ -27,6 +27,7 @@ @MainActor @Relayed class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { @@ -48,8 +49,9 @@ #""" @MainActor class Dog: Animal { - @RelayedProperty + let id: UUID + @RelayedProperty var breed: String? var isBulldog: Bool { diff --git a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift index fb3de8a..5846d0d 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift @@ -27,6 +27,7 @@ @Relayed class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { @@ -47,8 +48,9 @@ expandedSource: #""" class Dog: Animal { + + let id: UUID @RelayedProperty - var breed: String? var isBulldog: Bool { diff --git a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift index 56584fe..6c5515d 100644 --- a/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift @@ -226,11 +226,6 @@ extension MainActorPublishableTests { var platformComputedProperty: Int { platformStoredProperty } - - @Memoized - func makePlatformMemoizedProperty() -> Int { - platformStoredProperty - } #endif @PublisherSuppressed @@ -251,5 +246,12 @@ extension MainActorPublishableTests { func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif } } diff --git a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift index 1763fd6..e8e3c4d 100644 --- a/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift +++ b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift @@ -225,11 +225,6 @@ extension ObservablePublishableTests { var platformComputedProperty: Int { platformStoredProperty } - - @Memoized - func makePlatformMemoizedProperty() -> Int { - platformStoredProperty - } #endif @PublisherSuppressed @@ -250,5 +245,12 @@ extension ObservablePublishableTests { func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif } } diff --git a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift index bf661b7..cf0d646 100644 --- a/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift @@ -226,11 +226,6 @@ extension MainActorRelayedTests { var platformComputedProperty: Int { platformStoredProperty } - - @Memoized - func makePlatformMemoizedProperty() -> Int { - platformStoredProperty - } #endif @ObservationSuppressed @PublisherSuppressed @@ -257,5 +252,12 @@ extension MainActorRelayedTests { func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif } } diff --git a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift index a3f9aef..3530d47 100644 --- a/Tests/RelayTests/Combine/Relayed/RelayedTests.swift +++ b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift @@ -225,11 +225,6 @@ extension RelayedTests { var platformComputedProperty: Int { platformStoredProperty } - - @Memoized - func makePlatformMemoizedProperty() -> Int { - platformStoredProperty - } #endif @ObservationSuppressed @PublisherSuppressed @@ -256,5 +251,12 @@ extension RelayedTests { func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif } } From a4989722d0d2208a2252ca56e7425cf7315dfb8f Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 2 Dec 2025 00:45:29 +0100 Subject: [PATCH 21/23] [SwiftFormat] Applied formatting --- .../Combine/Relayed/SubclassedRelayedMacroTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift index 5846d0d..240bf18 100644 --- a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift @@ -48,7 +48,7 @@ expandedSource: #""" class Dog: Animal { - + let id: UUID @RelayedProperty var breed: String? From a48a35a89f544b97ae8459e47971cf0167a141be Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 2 Dec 2025 00:49:18 +0100 Subject: [PATCH 22/23] - --- Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index 5029386..d31f4e4 100644 --- a/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -34,8 +34,8 @@ public enum PublishableMacro { fixIts: [ .replace( message: MacroExpansionFixItMessage("Apply @Relayed macro"), - oldNode: node, - newNode: RelayedMacro.attribute.withTrivia(from: node) + oldNode: node.attributeName, + newNode: RelayedMacro.attribute.attributeName.withTrivia(from: node.attributeName) ) ] ) From 4b4e30715c14ff8d05c6ef4868dc3bc63e58e96c Mon Sep 17 00:00:00 2001 From: Kamil Strzelecki Date: Tue, 2 Dec 2025 00:58:38 +0100 Subject: [PATCH 23/23] - --- .../SubclassedImplicitlyIsolatedPublishableMacroTests.swift | 2 ++ .../Combine/Publishable/SubclassedPublishableMacroTests.swift | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift index e42d268..aed624f 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift @@ -27,6 +27,7 @@ @MainActor @Publishable @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { @@ -50,6 +51,7 @@ @MainActor @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { diff --git a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift index d757f66..70bb615 100644 --- a/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift @@ -27,6 +27,7 @@ @Publishable @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { @@ -50,6 +51,7 @@ @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool {