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 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/ObservationSuppressedMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationSuppressedMacro.swift new file mode 100644 index 0000000..a9fb84d --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/ObservationSuppressedMacro.swift @@ -0,0 +1,45 @@ +// +// ObservationSuppressedMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 01/12/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +public enum ObservationSuppressedMacro { + + static let attribute: AttributeSyntax = "@ObservationSuppressed" +} + +extension ObservationSuppressedMacro: 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: ObservationSuppressedMacro.attribute) + } +} + +extension FunctionDeclSyntax { + + var isObservationTracked: Bool { + !attributes.contains(like: ObservationIgnoredMacro.attribute) + && !attributes.contains(like: ObservationSuppressedMacro.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/PublisherSuppressedMacro.swift similarity index 60% rename from Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift rename to Macros/RelayMacros/Combine/Common/PublisherSuppressedMacro.swift index b8d8d1a..24c59ea 100644 --- a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherSuppressedMacro.swift @@ -1,5 +1,5 @@ // -// PublisherIgnoredMacro.swift +// PublisherSuppressedMacro.swift // Relay // // Created by Kamil Strzelecki on 22/11/2025. @@ -8,12 +8,12 @@ import SwiftSyntaxMacros -public enum PublisherIgnoredMacro { +public enum PublisherSuppressedMacro { - static let attribute: AttributeSyntax = "@PublisherIgnored" + static let attribute: AttributeSyntax = "@PublisherSuppressed" } -extension PublisherIgnoredMacro: PeerMacro { +extension PublisherSuppressedMacro: 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: PublisherSuppressedMacro.attribute) } var isComputedPublisherTracked: Bool { kind == .computed && underlying.typeScopeSpecifier == nil && underlying.overrideSpecifier == nil - && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + && !underlying.attributes.contains(like: PublisherSuppressedMacro.attribute) + } +} + +extension FunctionDeclSyntax { + + var isPublisherTracked: Bool { + !attributes.contains(like: PublisherSuppressedMacro.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 90f5554..d31f4e4 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 @@ -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.attributeName, + newNode: RelayedMacro.attribute.attributeName.withTrivia(from: node.attributeName) + ) + ] + ) + } + if declaration.attributes.contains(like: SwiftDataModelMacro.attribute) { context.diagnose( node: declaration, @@ -32,10 +49,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,14 +69,14 @@ extension PublishableMacro: MemberMacro { conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - let declaration = try validate(node, attachedTo: declaration, in: context) - let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) + let declaration = try validateNode(node, attachedTo: declaration, in: context) + let properties = try PropertiesParser.parse(declarationGroup: declaration) let parameters = try Parameters(from: node) let hasPublishableSuperclass = protocols.isEmpty let trimmedSuperclassType = hasPublishableSuperclass ? declaration.possibleSuperclassType : nil - let builderTypes: [any ClassDeclBuilder] = [ + let builders: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, trimmedSuperclassType: trimmedSuperclassType @@ -79,8 +95,8 @@ extension PublishableMacro: MemberMacro { ) ] - return try builderTypes.flatMap { builderType in - try builderType.build() + return try builders.flatMap { builder in + try builder.build() } } } @@ -98,7 +114,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 +123,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/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..82f414b --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/RelayedMacro.swift @@ -0,0 +1,161 @@ +// +// 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 _: 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(declarationGroup: declaration) + 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 _: AttributeSyntax, + attachedTo _: some DeclGroupSyntax, + providingAttributesFor member: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) throws -> [AttributeSyntax] { + if try RelayedPropertyMacro.shouldAttach(to: member) { + [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..8f5c7dd --- /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 { + 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..4cf2970 --- /dev/null +++ b/Macros/RelayMacros/Combine/Relayed/RelayedPropertyMacro.swift @@ -0,0 +1,90 @@ +// +// 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: attribute) + } else { + false + } + } +} + +extension RelayedPropertyMacro { + + private static func validateNode( + attachedTo declaration: some DeclSyntaxProtocol + ) throws -> Property? { + if let property = try PropertiesParser.parseStandalone(declaration: declaration), + property.isStoredObservationTracked || property.isStoredPublisherTracked { + property + } else { + nil + } + } +} + +extension RelayedPropertyMacro: AccessorMacro { + + public static func expansion( + of _: AttributeSyntax, + providingAccessorsOf declaration: some DeclSyntaxProtocol, + in _: 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 _: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let property = try validateNode(attachedTo: declaration) else { + return [] + } + + let attributes = property.attributes.filter { attribute in + attribute.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.trimmed + .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..ceebf1b 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -14,7 +14,10 @@ internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ PublishableMacro.self, - PublisherIgnoredMacro.self, + RelayedMacro.self, + RelayedPropertyMacro.self, + PublisherSuppressedMacro.self, + ObservationSuppressedMacro.self, MemoizedMacro.self ] } 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 82e6668..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 ) } @@ -100,8 +119,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 """ ) } @@ -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 } 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/Combine/Common/ObservationSuppressed.swift b/Sources/Relay/Combine/Common/ObservationSuppressed.swift new file mode 100644 index 0000000..e4ca0d4 --- /dev/null +++ b/Sources/Relay/Combine/Common/ObservationSuppressed.swift @@ -0,0 +1,23 @@ +// +// ObservationSuppressed.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/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/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/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 new file mode 100644 index 0000000..2a488ec --- /dev/null +++ b/Sources/Relay/Combine/Relayed/Relayed.swift @@ -0,0 +1,94 @@ +// +// 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`` 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 ``Relayed(isolation:)`` instead. +/// +/// 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 `@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. +/// 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`` 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 ``Relayed()`` macro instead. +/// +/// 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 `@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. +/// 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" +) 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/ChoosingBetweenRelayedAndPublishableMacros.md b/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublishableMacros.md new file mode 100644 index 0000000..ba0b9d7 --- /dev/null +++ b/Sources/Relay/Documentation.docc/ChoosingBetweenRelayedAndPublishableMacros.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/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 237e060..4e09bed 100644 --- a/Sources/Relay/Documentation.docc/MemoizedMacros.md +++ b/Sources/Relay/Documentation.docc/MemoizedMacros.md @@ -89,4 +89,6 @@ model.filteredData ### Customizing Generated Declarations +- ``ObservationSuppressed()`` +- ``PublisherSuppressed()`` - ``AccessControlLevel`` diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md index 6143a61..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/PublishableMacros.md b/Sources/Relay/Documentation.docc/RelayedAndPublishableMacros.md similarity index 72% rename from Sources/Relay/Documentation.docc/PublishableMacros.md rename to Sources/Relay/Documentation.docc/RelayedAndPublishableMacros.md index 017c048..cc07a4c 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/RelayedAndPublishableMacros.md @@ -1,4 +1,4 @@ -# Publishable +# 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,10 +53,16 @@ person.surname = "Strzelecki" ### Making Types Publishable +- +- ``Relayed()`` +- ``Relayed(isolation:)`` - ``Publishable()`` - ``Publishable(isolation:)`` -- ``PublisherIgnored()`` -- + +### Customizing Generated Declarations + +- ``ObservationSuppressed()`` +- ``PublisherSuppressed()`` ### Observing Changes with Combine diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift similarity index 91% rename from Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift index 4be04aa..9182aa9 100644 --- a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/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] = [ @@ -26,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? @@ -57,23 +56,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -82,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @CustomActor @Observable + @CustomActor @CustomObservable public final class Person { static var user: Person? @@ -113,23 +117,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -209,9 +218,14 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #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) } } @@ -322,7 +336,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/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift similarity index 91% rename from Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index 2ef4147..9d2db28 100644 --- a/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/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] = [ @@ -26,7 +25,7 @@ assertMacroExpansion( #""" @available(iOS 26, macOS 26, *) - @MainActor @Publishable @Observable + @MainActor @Publishable @CustomObservable public final class Person { static var user: Person? @@ -57,23 +56,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -82,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @MainActor @Observable + @MainActor @CustomObservable public final class Person { static var user: Person? @@ -113,23 +117,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -209,9 +218,14 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #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) } } @@ -322,7 +336,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/Combine/Publishable/NonisolatedPublishableMacroTests.swift similarity index 90% rename from Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/NonisolatedPublishableMacroTests.swift index b1569a5..a2e9999 100644 --- a/Tests/RelayMacrosTests/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? @@ -56,23 +56,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -81,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @Observable + @CustomObservable public final class Person { static var user: Person? @@ -112,23 +117,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -208,9 +218,14 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #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) } } @@ -308,7 +323,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/Combine/Publishable/PublishableMacroTests.swift similarity index 90% rename from Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/PublishableMacroTests.swift index 059536c..69808bf 100644 --- a/Tests/RelayMacrosTests/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? @@ -56,23 +56,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -81,7 +86,7 @@ expandedSource: #""" @available(iOS 26, macOS 26, *) - @Observable + @CustomObservable public final class Person { static var user: Person? @@ -112,23 +117,28 @@ var platformComputedProperty: Int { platformStoredProperty } + + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } @available(iOS 26, *) @Memoized(.private) - func makeLabel() -> String { + func makeMemoizedProperty() -> String { "\(fullName), \(age)" } - @Memoized @PublisherIgnored + @Memoized @PublisherSuppressed func makeIgnoredMemoizedProperty() -> Int { ignoredStoredProperty } @@ -208,9 +218,14 @@ } #endif + #if os(macOS) + final var platformMemoizedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformMemoizedProperty, object: object) + } + #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) } } @@ -308,7 +323,7 @@ } } - @available(iOS 26, macOS 26, *) extension Person: Publishable { + @available(iOS 26, macOS 26, *) extension Person: Relay.Publishable { } """#, macroSpecs: macroSpecs diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift similarity index 98% rename from Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift index 296217b..aed624f 100644 --- a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift @@ -24,9 +24,10 @@ func testExpansion() { assertMacroExpansion( #""" - @MainActor @Publishable @Observable + @MainActor @Publishable @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { @@ -47,9 +48,10 @@ """#, expandedSource: #""" - @MainActor @Observable + @MainActor @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift similarity index 98% rename from Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift index abfa906..70bb615 100644 --- a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Combine/Publishable/SubclassedPublishableMacroTests.swift @@ -24,9 +24,10 @@ func testExpansion() { assertMacroExpansion( #""" - @Publishable @Observable + @Publishable @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { @@ -47,9 +48,10 @@ """#, expandedSource: #""" - @Observable + @CustomObservable class Dog: Animal { + let id: UUID var breed: String? var isBulldog: Bool { diff --git a/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/ExplicitlyIsolatedRelayedMacroTests.swift new file mode 100644 index 0000000..6d7386a --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/ImplicitlyIsolatedRelayedMacroTests.swift new file mode 100644 index 0000000..dd099a7 --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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/Combine/Relayed/NonisolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/NonisolatedRelayedMacroTests.swift new file mode 100644 index 0000000..22c071d --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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/Combine/Relayed/RelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/RelayedMacroTests.swift new file mode 100644 index 0000000..a2e50e5 --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + @RelayedProperty + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + @RelayedProperty + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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/Combine/Relayed/RelayedPropertyMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/RelayedPropertyMacroTests.swift new file mode 100644 index 0000000..59aa15c --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/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 testPublisherSuppressedExpansion() { + assertMacroExpansion( + #""" + @MainActor + @RelayedProperty @PublisherSuppressed + public internal(set) final var name = 123 { + didSet { + _ = newValue + } + } + """#, + expandedSource: + #""" + @MainActor + @PublisherSuppressed + 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 @PublisherSuppressed private final var _name = 123 { + didSet { + _ = newValue + } + } + """#, + macros: macros + ) + } + + func testObservationSuppressedExpansion() { + assertMacroExpansion( + #""" + @MainActor + @RelayedProperty @ObservationSuppressed + public internal(set) final var name = 123 { + didSet { + _ = newValue + } + } + """#, + expandedSource: + #""" + @MainActor + @ObservationSuppressed + 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 @ObservationSuppressed private final var _name = 123 { + didSet { + _ = newValue + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift new file mode 100644 index 0000000..b75ab2d --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedImplicitlyIsolatedRelayedMacroTests.swift @@ -0,0 +1,156 @@ +// +// 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 { + + let id: UUID + 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 { + + let id: UUID + @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/Combine/Relayed/SubclassedRelayedMacroTests.swift b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift new file mode 100644 index 0000000..240bf18 --- /dev/null +++ b/Tests/RelayMacrosTests/Combine/Relayed/SubclassedRelayedMacroTests.swift @@ -0,0 +1,155 @@ +// +// 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 { + + let id: UUID + 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 { + + let id: UUID + @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/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/Memoized/ObservationSuppressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ObservationSuppressedMemoizedMacroTests.swift new file mode 100644 index 0000000..7befb8f --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/ObservationSuppressedMemoizedMacroTests.swift @@ -0,0 +1,147 @@ +// +// ObservationSuppressedMemoizedMacroTests.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 ObservationSuppressedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Observable + public class Square { + + var side = 12.3 + + @Memoized @ObservationSuppressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public class Square { + + var side = 12.3 + + @ObservationSuppressed + 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") + @ObservationSuppressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @ObservationSuppressed + 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..45f97fc --- /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/RelayedObservationSuppressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedObservationSuppressedMemoizedMacroTests.swift new file mode 100644 index 0000000..5241f0d --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/RelayedObservationSuppressedMemoizedMacroTests.swift @@ -0,0 +1,151 @@ +// +// RelayedObservationSuppressedMemoizedMacroTests.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 RelayedObservationSuppressedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Relayed + public class Square { + + var side = 12.3 + + @Memoized @ObservationSuppressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public class Square { + + var side = 12.3 + + @ObservationSuppressed + 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") + @ObservationSuppressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @ObservationSuppressed + 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/RelayedPublisherSuppressedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSuppressedMemoizedMacroTests.swift new file mode 100644 index 0000000..baf2979 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/RelayedPublisherSuppressedMemoizedMacroTests.swift @@ -0,0 +1,159 @@ +// +// RelayedPublisherSuppressedMemoizedMacroTests.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 RelayedPublisherSuppressedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Relayed + public class Square { + + var side = 12.3 + + @Memoized @PublisherSuppressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public class Square { + + var side = 12.3 + + @PublisherSuppressed + 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") + @PublisherSuppressed + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @Relayed + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @PublisherSuppressed + 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 diff --git a/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/AnyPropertyPublisherPublishableTests.swift new file mode 100644 index 0000000..036de4f --- /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/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift similarity index 92% rename from Tests/RelayTests/Publishable/MainActorPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/MainActorPublishableTests.swift index 6217b38..6c5515d 100644 --- a/Tests/RelayTests/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() @@ -228,12 +228,30 @@ extension MainActorPublishableTests { } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif } } diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift similarity index 90% rename from Tests/RelayTests/Publishable/ObservationPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/ObservablePublishableTests.swift index 00d1699..e8e3c4d 100644 --- a/Tests/RelayTests/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,9 +199,9 @@ extension ObservationPublishableTests { } } -extension ObservationPublishableTests { +extension ObservablePublishableTests { - @Publishable @Observable + @Publishable @_Observable final class Person { let id = UUID() @@ -227,12 +227,30 @@ extension ObservationPublishableTests { } #endif - @PublisherIgnored + @PublisherSuppressed var ignoredStoredProperty = 123 - @PublisherIgnored + @PublisherSuppressed var ignoredComputedProperty: Int { ignoredStoredProperty } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif } } diff --git a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift similarity index 98% rename from Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/SubclassedMainActorPublishableTests.swift index 5ee1535..a7a8374 100644 --- a/Tests/RelayTests/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 @@ -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/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift similarity index 98% rename from Tests/RelayTests/Publishable/SubclassedPublishableTests.swift rename to Tests/RelayTests/Combine/Publishable/SubclassedPublishableTests.swift index 49fdeb0..ee536f1 100644 --- a/Tests/RelayTests/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 { @@ -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/Publishable/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Combine/Relayed/AnyPropertyPublisherRelayedTests.swift similarity index 98% rename from Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift rename to Tests/RelayTests/Combine/Relayed/AnyPropertyPublisherRelayedTests.swift index 9997bca..406fb94 100644 --- a/Tests/RelayTests/Publishable/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/Combine/Relayed/MainActorRelayedTests.swift b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift new file mode 100644 index 0000000..cf0d646 --- /dev/null +++ b/Tests/RelayTests/Combine/Relayed/MainActorRelayedTests.swift @@ -0,0 +1,263 @@ +// +// 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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + 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 new file mode 100644 index 0000000..3530d47 --- /dev/null +++ b/Tests/RelayTests/Combine/Relayed/RelayedTests.swift @@ -0,0 +1,262 @@ +// +// 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 + + @ObservationSuppressed @PublisherSuppressed + var ignoredStoredProperty = 123 + + @ObservationSuppressed + var observationIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredStoredProperty = 123 + + @PublisherSuppressed + var publisherIgnoredComputedProperty: Int { + publisherIgnoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeMemoizedProperty() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherSuppressed + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + #if os(macOS) + @Memoized + func makePlatformMemoizedProperty() -> Int { + platformStoredProperty + } + #endif + } +} 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/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/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 50336d8..d3faf93 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -260,6 +260,11 @@ extension MainActorMemoizedTests { return baseArea * z } + @Memoized @ObservationSuppressed + 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..8d61bae 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -258,6 +258,11 @@ extension ObservationMemoizedTests { return baseArea * z } + @Memoized @ObservationSuppressed + 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 934060e..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 @@ -192,7 +192,17 @@ extension PublishableMemoizedTests { return baseArea * z } - @Memoized @PublisherIgnored + @Memoized @ObservationSuppressed + func calculateObservationIgnoredValue() -> Double { + volume + } + + @Memoized @PublisherSuppressed + func calculatePublisherIgnoredValue() -> Double { + volume + } + + @Memoized @ObservationSuppressed @PublisherSuppressed func calculateIgnoredValue() -> Double { volume } diff --git a/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift new file mode 100644 index 0000000..5130cf9 --- /dev/null +++ b/Tests/RelayTests/Memoized/RelayedMemoizedTests.swift @@ -0,0 +1,218 @@ +// +// 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 + + @ObservationSuppressed + private(set) var calculateBaseAreaCallsCount = 0 + var isBaseAreaCached: Bool { _baseArea != nil } + + @ObservationSuppressed + 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 @ObservationSuppressed + func calculateObservationIgnoredValue() -> Double { + volume + } + + @Memoized @PublisherSuppressed + func calculatePublisherIgnoredValue() -> Double { + volume + } + + @Memoized @ObservationSuppressed @PublisherSuppressed + func calculateIgnoredValue() -> Double { + volume + } + + #if os(macOS) + @available(macOS 26, *) + @Memoized + func calculatePlatformValue() -> Double { + volume + } + #endif + } +} diff --git a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift index e2b8399..3ace195 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -263,6 +263,11 @@ extension SwiftDataMemoizedTests { return baseArea * z } + @Memoized @ObservationSuppressed + func calculateIgnoredValue() -> Double { + volume + } + #if os(macOS) @available(macOS 26, *) @Memoized