diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index b6e8af2..ac7ec49 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - XCODE_VERSION: "26.1" + XCODE_VERSION: "26.0" jobs: prepare: @@ -88,16 +88,16 @@ jobs: destination="platform=macOS,variant=Mac Catalyst" ;; ios) - destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.1" + destination="platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.0.1" ;; tvos) - destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.1" + destination="platform=tvOS Simulator,name=Apple TV 4K (3rd generation),OS=26.0" ;; watchos) - destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.1" + destination="platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.0" ;; visionos) - destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.1" + destination="platform=visionOS Simulator,name=Apple Vision Pro,OS=26.0" ;; *) echo "Unknown platform: ${{ matrix.platform }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b076f96..80556d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ on: - '*' env: - XCODE_VERSION: "26.1" + XCODE_VERSION: "26.0" jobs: release: diff --git a/.swiftlint.tests.yml b/.swiftlint.tests.yml index ee516cf..504622d 100644 --- a/.swiftlint.tests.yml +++ b/.swiftlint.tests.yml @@ -1,3 +1,4 @@ disabled_rules: - function_body_length + - type_body_length - no_magic_numbers \ No newline at end of file diff --git a/.swiftlint.yml b/.swiftlint.yml index 5bc3ecb..d7e50da 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -8,7 +8,7 @@ opt_in_rules: - anonymous_argument_in_multiline_closure - array_init - async_without_await - # attributes + # attributes (swiftformat) # balanced_xctest_lifecycle # closure_body_length - closure_end_indentation @@ -27,7 +27,7 @@ opt_in_rules: - discouraged_assert - discouraged_none_name - discouraged_object_literal - - discouraged_optional_boolean + # discouraged_optional_boolean - discouraged_optional_collection - empty_collection_literal - empty_count @@ -55,9 +55,9 @@ opt_in_rules: - ibinspectable_in_extension - identical_operands - implicit_return - # implicitly_unwrapped_optional + - implicitly_unwrapped_optional # incompatible_concurrency_annotation - # indentation_width + # indentation_width (swiftformat) - joined_default_parameter - last_where - legacy_multiple @@ -67,7 +67,7 @@ opt_in_rules: - local_doc_comment - lower_acl_than_parent # missing_docs - - modifier_order + # modifier_order (swiftformat) - multiline_arguments - multiline_arguments_brackets - multiline_function_chains @@ -144,7 +144,7 @@ opt_in_rules: # vertical_whitespace_opening_braces - weak_delegate - xct_specific_matcher - # yoda_condition + # yoda_condition (swiftformat) analyzer_rules: - capture_variable @@ -173,6 +173,7 @@ identifier_name: excluded: [id, ui, x, y, z, dx, dy, dz] line_length: + ignores_multiline_strings: true ignores_comments: true nesting: @@ -189,10 +190,6 @@ type_contents_order: order: [[case], [type_alias, associated_type], [subtype], [type_property], [instance_property], [ib_inspectable], [ib_outlet], [initializer], [deinitializer], [type_method], [view_life_cycle_method], [ib_action, ib_segue_action], [other_method], [subscript]] custom_rules: - global_actor_attribute_order: - name: "Global actor attribute order" - message: "Global actor should be the first attribute." - regex: "(?-s)(@.+[^,\\s]\\s+@.*Actor\\s)" sendable_attribute_order: name: "Sendable attribute order" message: "Sendable should be the first attribute." @@ -204,4 +201,4 @@ custom_rules: empty_line_after_type_declaration: name: "Empty line after type declaration" message: "Type declaration should start with an empty line." - regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\{]*? \\{(?!\\s*\\}) *\\n? *\\S" + regex: "( |^)(actor|class|struct|enum|protocol|extension) (?!var)[^\\n\\{]*? \\{(?!\\s*\\}) *\\n? *\\S" diff --git a/Macros/Dependencies/PrincipleMacros b/Macros/Dependencies/PrincipleMacros index 85aab93..cc9d2e8 160000 --- a/Macros/Dependencies/PrincipleMacros +++ b/Macros/Dependencies/PrincipleMacros @@ -1 +1 @@ -Subproject commit 85aab93496550f03b8888a96598fe06ad7685a53 +Subproject commit cc9d2e815f835417413e42be4043b7cc113515ac diff --git a/Macros/RelayMacros/Combine/Common/ObservableMacro.swift b/Macros/RelayMacros/Combine/Common/ObservableMacro.swift new file mode 100644 index 0000000..c36150f --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/ObservableMacro.swift @@ -0,0 +1,14 @@ +// +// ObservableMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal enum ObservableMacro { + + static let attribute: AttributeSyntax = "@Observable" +} diff --git a/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift new file mode 100644 index 0000000..b1ac4f8 --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/ObservationIgnoredMacro.swift @@ -0,0 +1,25 @@ +// +// ObservationIgnoredMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 22/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +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/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift new file mode 100644 index 0000000..c278392 --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/PropertyPublisherDeclBuilder.swift @@ -0,0 +1,187 @@ +// +// PropertyPublisherDeclBuilder.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { + + let declaration: ClassDeclSyntax + let properties: PropertiesList + let trimmedSuperclassType: TypeSyntax? + let preferredGlobalActorIsolation: GlobalActorIsolation? + + func build() -> [DeclSyntax] { + [ + """ + \(inheritedGlobalActorIsolation)\(inheritedAccessControlLevelAllowingOpen)\(inheritedFinalModifier)\ + class PropertyPublisher: \(inheritanceClause()) { + + private final unowned let object: \(trimmedType) + + \(objectWillChangeDidChangePublishers()) + + \(initializer()) + + \(deinitializer()) + + \(storedPropertiesPublishers().formatted()) + + \(computedPropertiesPublishers().formatted()) + + \(memoizedPropertiesPublishers().formatted()) + } + """ + ] + } + + private func inheritanceClause() -> TypeSyntax { + if let trimmedSuperclassType { + "\(trimmedSuperclassType).PropertyPublisher" + } else { + "Relay.AnyPropertyPublisher" + } + } + + private func objectWillChangeDidChangePublishers() -> MemberBlockItemListSyntax { + let notation = CamelCaseNotation(string: trimmedType.description) + let prefix = notation.joined(as: .lowerCamelCase) + + return """ + \(inheritedAccessControlLevel)final var \ + \(raw: prefix)WillChange: some Publisher<\(trimmedType), Never> { + willChange.map { [unowned object] _ in + object + } + } + + \(inheritedAccessControlLevel)final var \ + \(raw: prefix)DidChange: some Publisher<\(trimmedType), Never> { + didChange.map { [unowned object] _ in + object + } + } + """ + } + + private func initializer() -> MemberBlockItemListSyntax { + """ + \(inheritedAccessControlLevel)init(object: \(trimmedType)) { + self.object = object + super.init(object: object) + } + """ + } + + private func deinitializer() -> MemberBlockItemListSyntax { + """ + \(inheritedGlobalActorIsolation)deinit { + \(storedPropertiesSubjectsFinishCalls().formatted()) + } + """ + } + + @CodeBlockItemListBuilder + 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 + } + } + } + + private func storedPropertySubjectFinishCall(for property: Property) -> CodeBlockItemListSyntax { + "_\(property.trimmedName).send(completion: .finished)" + } + + @MemberBlockItemListBuilder + 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 + } + } + } + + private func storedPropertyPublisher(for property: Property) -> MemberBlockItemListSyntax { + // Stored properties cannot be made potentially unavailable + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) + let name = property.trimmedName + let type = property.inferredType + + return """ + fileprivate final let _\(name) = PassthroughSubject<\(type), Never>() + \(accessControlLevel)final var \(name): some Publisher<\(type), Never> { + _storedPropertyPublisher(_\(name), for: \\.\(name), object: object) + } + """ + } + + @MemberBlockItemListBuilder + private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { + for property in properties.all where property.isComputedPublisherTracked { + let publisher = computedPropertyPublisher(for: property) + if let ifConfigPublisher = property.underlying.applyingEnclosingIfConfig(to: publisher) { + ifConfigPublisher + } else { + publisher + } + } + } + + private func computedPropertyPublisher(for property: Property) -> MemberBlockItemListSyntax { + let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) + let availability = property.availability?.trimmed.withTrailingNewline + let name = property.trimmedName + let type = property.inferredType + + return """ + \(availability)\(accessControlLevel)final var \(name): some Publisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(name), object: object) + } + """ + } + + @MemberBlockItemListBuilder + private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { + for member in declaration.memberBlock.members { + if let extractionResult = MemoizedMacro.extract(from: member.decl) { + let declaration = extractionResult.declaration + + if !declaration.attributes.contains(like: PublisherIgnoredMacro.attribute) { + let publisher = memoizedPropertyPublisher(for: extractionResult) + if let ifConfigPublisher = declaration.applyingEnclosingIfConfig(to: publisher) { + ifConfigPublisher + } else { + publisher + } + } + } + } + } + + private func memoizedPropertyPublisher( + for extractionResult: MemoizedMacro.ExtractionResult + ) -> MemberBlockItemListSyntax { + let accessControlLevel = extractionResult.preferredAccessControlLevel?.inheritedBySibling() + let availability = extractionResult.declaration.availability?.trimmed.withTrailingNewline + let name = extractionResult.propertyName + let type = extractionResult.trimmedReturnType + + return """ + \(availability)\(accessControlLevel)final var \(raw: name): some Publisher<\(type), Never> { + _computedPropertyPublisher(for: \\.\(raw: name), object: object) + } + """ + } +} diff --git a/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift similarity index 72% rename from Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift rename to Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift index c5c70c4..d8040a0 100644 --- a/Macros/RelayMacros/Publishable/PublisherDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Common/PublisherDeclBuilder.swift @@ -11,10 +11,13 @@ import SwiftSyntaxMacros internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax - let properties: PropertiesList + let trimmedSuperclassType: TypeSyntax? func build() -> [DeclSyntax] { [ + """ + private final lazy var _publisher = PropertyPublisher(object: self) + """, """ /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. @@ -23,7 +26,9 @@ internal struct PublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - \(inheritedAccessControlLevel)private(set) lazy var publisher = PropertyPublisher(object: self) + \(inheritedOverrideModifier)\(inheritedAccessControlLevelAllowingOpen)var publisher: PropertyPublisher { + _publisher + } """ ] } diff --git a/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift new file mode 100644 index 0000000..b8d8d1a --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/PublisherIgnoredMacro.swift @@ -0,0 +1,43 @@ +// +// PublisherIgnoredMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 22/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +public enum PublisherIgnoredMacro { + + static let attribute: AttributeSyntax = "@PublisherIgnored" +} + +extension PublisherIgnoredMacro: PeerMacro { + + public static func expansion( + of _: AttributeSyntax, + providingPeersOf _: some DeclSyntaxProtocol, + in _: some MacroExpansionContext + ) -> [DeclSyntax] { + [] + } +} + +extension Property { + + var isStoredPublisherTracked: Bool { + kind == .stored + && mutability == .mutable + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + } + + var isComputedPublisherTracked: Bool { + kind == .computed + && underlying.typeScopeSpecifier == nil + && underlying.overrideSpecifier == nil + && !underlying.attributes.contains(like: PublisherIgnoredMacro.attribute) + } +} diff --git a/Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift b/Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift new file mode 100644 index 0000000..05c4f1e --- /dev/null +++ b/Macros/RelayMacros/Combine/Common/SwiftDataModelMacro.swift @@ -0,0 +1,14 @@ +// +// SwiftDataModelMacro.swift +// Relay +// +// Created by Kamil Strzelecki on 29/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import SwiftSyntaxMacros + +internal enum SwiftDataModelMacro { + + static let attribute: AttributeSyntax = "@Model" +} diff --git a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift similarity index 67% rename from Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift rename to Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift index f0551e0..0ff2d97 100644 --- a/Macros/RelayMacros/Publishable/ObservationRegistrarDeclBuilder.swift +++ b/Macros/RelayMacros/Combine/Publishable/ObservationRegistrarDeclBuilder.swift @@ -11,11 +11,20 @@ import SwiftSyntaxMacros internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuilding { let declaration: ClassDeclSyntax - let properties: PropertiesList let preferredGlobalActorIsolation: GlobalActorIsolation? - - private var registeredProperties: PropertiesList { - properties.stored.mutable.instance + private let trackedProperties: PropertiesList + private let genericParameter: TokenSyntax + + init( + declaration: ClassDeclSyntax, + properties: PropertiesList, + preferredGlobalActorIsolation: GlobalActorIsolation?, + context: some MacroExpansionContext + ) { + self.declaration = declaration + self.preferredGlobalActorIsolation = preferredGlobalActorIsolation + self.trackedProperties = properties.filter(\.isStoredPublisherTracked) + self.genericParameter = context.makeUniqueName("T") } func build() -> [DeclSyntax] { @@ -23,13 +32,11 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin """ private enum Observation { - struct ObservationRegistrar: \(inheritedGlobalActorIsolation)PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() - \(publishNewValueFunction()) - - \(subjectFunctions().formatted()) + \(publishFunction()) \(observationRegistrarWillSetDidSetAccessFunctions()) @@ -42,57 +49,39 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin ] } - private func publishNewValueFunction() -> MemberBlockItemListSyntax { + private func publishFunction() -> MemberBlockItemListSyntax { """ - \(inheritedGlobalActorIsolation)func publish( + \(inheritedGlobalActorIsolation)private func publish( _ object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any> ) { - \(publishNewValueKeyPathCasting().formatted()) + \(publishKeyPathLookups().formatted()) } """ } @CodeBlockItemListBuilder - private func publishNewValueKeyPathCasting() -> CodeBlockItemListSyntax { - for inferredType in registeredProperties.uniqueInferredTypes { - """ - if let keyPath = keyPath as? KeyPath<\(trimmedType), \(inferredType)>, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return + private func publishKeyPathLookups() -> CodeBlockItemListSyntax { + for property in trackedProperties { + let lookup = publishKeyPathLookup(for: property) + if let ifConfigLookup = property.underlying.applyingEnclosingIfConfig(to: lookup) { + ifConfigLookup + } else { + lookup } - """ } } - @MemberBlockItemListBuilder - private func subjectFunctions() -> MemberBlockItemListSyntax { - for inferredType in registeredProperties.uniqueInferredTypes { - """ - \(inheritedGlobalActorIsolation)private func subject( - for keyPath: KeyPath<\(trimmedType), \(inferredType)>, - on object: \(trimmedType) - ) -> PassthroughSubject<\(inferredType), Never>? { - \(subjectKeyPathCasting(for: inferredType).formatted()) - } - """ - } - } + private func publishKeyPathLookup(for property: Property) -> CodeBlockItemListSyntax { + // Stored properties cannot be made potentially unavailable + let name = property.trimmedName - @CodeBlockItemListBuilder - private func subjectKeyPathCasting(for inferredType: TypeSyntax) -> CodeBlockItemListSyntax { - for property in registeredProperties.withInferredType(like: inferredType).all { - let name = property.trimmedName - """ - if keyPath == \\.\(name) { - return object.publisher._\(name) - } - """ + return """ + if keyPath == \\.\(name) { + object.publisher._\(name).send(object[keyPath: \\.\(name)]) + return } """ - return nil - """ } private func observationRegistrarWillSetDidSetAccessFunctions() -> MemberBlockItemListSyntax { @@ -131,14 +120,14 @@ internal struct ObservationRegistrarDeclBuilder: ClassDeclBuilder, MemberBuildin private func observationRegistrarWithMutationFunction() -> MemberBlockItemListSyntax { """ - nonisolated func withMutation( + nonisolated func withMutation<\(genericParameter)>( of object: \(trimmedType), keyPath: KeyPath<\(trimmedType), some Any>, - _ mutation: () throws -> T - ) rethrows -> T { + _ mutation: () throws -> \(genericParameter) + ) rethrows -> \(genericParameter) { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: T! + nonisolated(unsafe) var result: \(genericParameter)! try assumeIsolatedIfNeeded { object.publisher._beginModifications() diff --git a/Macros/RelayMacros/Publishable/PublishableMacro.swift b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift similarity index 63% rename from Macros/RelayMacros/Publishable/PublishableMacro.swift rename to Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift index a8368d2..90f5554 100644 --- a/Macros/RelayMacros/Publishable/PublishableMacro.swift +++ b/Macros/RelayMacros/Combine/Publishable/PublishableMacro.swift @@ -10,20 +10,37 @@ import SwiftSyntaxMacros public enum PublishableMacro { + static let attribute: AttributeSyntax = "@Publishable" + private static func validate( - _ declaration: some DeclGroupSyntax, + _ node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, in context: some MacroExpansionContext - ) -> ClassDeclSyntax? { - guard let declaration = declaration.as(ClassDeclSyntax.self), - declaration.attributes.contains(likeOneOf: "@Observable", "@Model"), - declaration.isFinal - else { + ) throws -> ClassDeclSyntax { + guard let declaration = declaration.as(ClassDeclSyntax.self) else { + throw DiagnosticsError( + node: declaration, + message: "@Publishable macro can only be applied to Observable classes" + ) + } + + if declaration.attributes.contains(like: SwiftDataModelMacro.attribute) { context.diagnose( node: declaration, - errorMessage: "Publishable macro can only be applied to final @Observable or @Model classes" + warningMessage: """ + @Publishable macro compiles when applied to @Model classes, \ + but internals of SwiftData are incompatible with custom ObservationRegistrar + """, + fixIts: [ + .replace( + message: MacroExpansionFixItMessage("Remove @Publishable macro"), + oldNode: node, + newNode: "\(node.leadingTrivia)" as TokenSyntax + ) + ] ) - return nil } + return declaration } } @@ -33,33 +50,32 @@ extension PublishableMacro: MemberMacro { public static func expansion( of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, - conformingTo _: [TypeSyntax], + conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard let declaration = validate(declaration, in: context) else { - return [] - } - + let declaration = try validate(node, attachedTo: declaration, in: context) + let properties = try PropertiesParser.parse(memberBlock: declaration.memberBlock) let parameters = try Parameters(from: node) - let properties = PropertiesParser.parse( - memberBlock: declaration.memberBlock, - in: context - ) + + let hasPublishableSuperclass = protocols.isEmpty + let trimmedSuperclassType = hasPublishableSuperclass ? declaration.possibleSuperclassType : nil let builderTypes: [any ClassDeclBuilder] = [ PublisherDeclBuilder( declaration: declaration, - properties: properties + trimmedSuperclassType: trimmedSuperclassType ), PropertyPublisherDeclBuilder( declaration: declaration, properties: properties, + trimmedSuperclassType: trimmedSuperclassType, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation ), ObservationRegistrarDeclBuilder( declaration: declaration, properties: properties, - preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation + preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation, + context: context ) ] @@ -75,14 +91,16 @@ extension PublishableMacro: ExtensionMacro { of node: AttributeSyntax, attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, - conformingTo _: [TypeSyntax], + conformingTo protocols: [TypeSyntax], in context: some MacroExpansionContext ) throws -> [ExtensionDeclSyntax] { - guard let declaration = validate(declaration, in: context) else { + guard !protocols.isEmpty else { return [] } + let declaration = try validate(node, attachedTo: declaration, in: context) let parameters = try Parameters(from: node) + let globalActorIsolation = GlobalActorIsolation.resolved( for: declaration, preferred: parameters.preferredGlobalActorIsolation @@ -90,6 +108,7 @@ extension PublishableMacro: ExtensionMacro { return [ .init( + attributes: declaration.availability ?? [], extendedType: type, inheritanceClause: .init( inheritedTypes: [ diff --git a/Macros/RelayMacros/Main/RelayPlugin.swift b/Macros/RelayMacros/Main/RelayPlugin.swift index 9da9a12..bf089f6 100644 --- a/Macros/RelayMacros/Main/RelayPlugin.swift +++ b/Macros/RelayMacros/Main/RelayPlugin.swift @@ -14,6 +14,7 @@ internal struct RelayPlugin: CompilerPlugin { let providingMacros: [any Macro.Type] = [ PublishableMacro.self, + PublisherIgnoredMacro.self, MemoizedMacro.self ] } diff --git a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift index e889b5e..a5776a0 100644 --- a/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift +++ b/Macros/RelayMacros/Memoized/MemoizedDeclBuilder.swift @@ -21,11 +21,11 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { func build() -> [DeclSyntax] { [ """ - \(inheritedGlobalActorIsolation)private \ + \(raw: storedPropertyAvailabilityComment())\(inheritedGlobalActorIsolation)private final \ var _\(raw: propertyName): Optional<\(trimmedReturnType)> = nil """, """ - \(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)\ + \(inheritedAvailability)\(inheritedGlobalActorIsolation)\(preferredAccessControlLevel)final \ var \(raw: propertyName): \(trimmedReturnType) { if let cached = _\(raw: propertyName) { access(keyPath: \\._\(raw: propertyName)) @@ -44,6 +44,14 @@ internal struct MemoizedDeclBuilder: FunctionDeclBuilder, PeerBuilding { ] } + private func storedPropertyAvailabilityComment() -> String { + if inheritedAvailability != nil { + "// Stored properties cannot be made potentially unavailable\n" + } else { + "" + } + } + private func observationTrackingBlock() -> CodeBlockItemSyntax { """ return withObservationTracking { diff --git a/Macros/RelayMacros/Memoized/MemoizedMacro.swift b/Macros/RelayMacros/Memoized/MemoizedMacro.swift index c8d66fa..82e6668 100644 --- a/Macros/RelayMacros/Memoized/MemoizedMacro.swift +++ b/Macros/RelayMacros/Memoized/MemoizedMacro.swift @@ -10,58 +10,66 @@ import SwiftSyntaxMacros public enum MemoizedMacro { - private struct Input { + static let attribute: AttributeSyntax = "@Memoized" - let declaration: FunctionDeclSyntax - let trimmedReturnType: TypeSyntax - let propertyName: String + static func extract( + from declaration: DeclSyntax + ) -> ExtractionResult? { + 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) + else { + return nil + } + + return ExtractionResult( + validationResult: validationResult, + parameters: parameters + ) } +} - private static func validate( - _ declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext, +extension MemoizedMacro { + + private static func validateNode( + attachedTo declaration: some DeclSyntaxProtocol, + in context: (any MacroExpansionContext)?, with parameters: Parameters - ) -> Input? { + ) throws -> ValidationResult { guard let declaration = declaration.as(FunctionDeclSyntax.self), - let trimmedReturnType = trimmedReturnType(of: declaration), + let trimmedReturnType = declaration.signature.returnClause?.type.trimmed, declaration.signature.parameterClause.parameters.isEmpty, declaration.signature.effectSpecifiers == nil, declaration.typeScopeSpecifier == nil else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: """ - Memoized macro can only be applied to non-void, non-async, non-throwing \ + message: """ + @Memoized macro can only be applied to non-void, non-async, non-throwing \ methods that don't take any arguments """ ) - return nil } - guard let scope = context.lexicalContext.first?.as(ClassDeclSyntax.self), - scope.attributes.contains(likeOneOf: "@Observable", "@Model") - else { - context.diagnose( - node: declaration, - errorMessage: """ - Memoized macro can only be applied to methods declared in body (not extension) \ - of @Observable or @Model classes - """ - ) - return nil + 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 = validatePropertyName( + let propertyName = try validatePropertyName( for: declaration, - in: context, preferred: parameters.preferredPropertyName ) - guard let propertyName else { - return nil - } - - return Input( + return ValidationResult( declaration: declaration, trimmedReturnType: trimmedReturnType, propertyName: propertyName @@ -70,45 +78,36 @@ public enum MemoizedMacro { private static func validatePropertyName( for declaration: FunctionDeclSyntax, - in context: some MacroExpansionContext, preferred: String? - ) -> String? { + ) throws -> String { if let preferred { guard !preferred.isEmpty else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: "Memoized macro requires a non-empty property name" + message: "@Memoized macro requires a non-empty property name" ) - return nil } + return preferred } - let inferred = defaultPropertyName(for: declaration) + let functionName = declaration.name.trimmedDescription + var notation = CamelCaseNotation(string: functionName) + notation.removeFirst() + let inferred = notation.joined(as: .lowerCamelCase) + guard !inferred.isEmpty else { - context.diagnose( + throw DiagnosticsError( node: declaration, - errorMessage: """ - Memoized macro requires a method name with at least two words \ + message: """ + @Memoized macro requires a method name consisting of at least two words \ or explicit property name """ ) - return nil } return inferred } - - static func defaultPropertyName(for declaration: FunctionDeclSyntax) -> String { - let functionName = declaration.name.trimmedDescription - var notation = CamelCaseNotation(string: functionName) - notation.removeFirst() - return notation.joined(as: .lowerCamelCase) - } - - static func trimmedReturnType(of declaration: FunctionDeclSyntax) -> TypeSyntax? { - declaration.signature.returnClause?.type.trimmed - } } extension MemoizedMacro: PeerMacro { @@ -119,16 +118,12 @@ extension MemoizedMacro: PeerMacro { in context: some MacroExpansionContext ) throws -> [DeclSyntax] { let parameters = try Parameters(from: node) - let input = validate(declaration, in: context, with: parameters) - - guard let input else { - return [] - } + let validationResult = try validateNode(attachedTo: declaration, in: context, with: parameters) let builder = MemoizedDeclBuilder( - declaration: input.declaration, - trimmedReturnType: input.trimmedReturnType, - propertyName: input.propertyName, + declaration: validationResult.declaration, + trimmedReturnType: validationResult.trimmedReturnType, + propertyName: validationResult.propertyName, lexicalContext: context.lexicalContext, preferredAccessControlLevel: parameters.preferredAccessControlLevel, preferredGlobalActorIsolation: parameters.preferredGlobalActorIsolation @@ -140,6 +135,28 @@ extension MemoizedMacro: PeerMacro { extension MemoizedMacro { + @dynamicMemberLookup + struct ExtractionResult { + + let validationResult: ValidationResult + let parameters: Parameters + + subscript(dynamicMember keyPath: KeyPath) -> T { + validationResult[keyPath: keyPath] + } + + subscript(dynamicMember keyPath: KeyPath) -> T { + parameters[keyPath: keyPath] + } + } + + struct ValidationResult { + + let declaration: FunctionDeclSyntax + let trimmedReturnType: TypeSyntax + let propertyName: String + } + struct Parameters { let preferredAccessControlLevel: AccessControlLevel? diff --git a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift b/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift deleted file mode 100644 index de0564d..0000000 --- a/Macros/RelayMacros/Publishable/PropertyPublisherDeclBuilder.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// PropertyPublisherDeclBuilder.swift -// Relay -// -// Created by Kamil Strzelecki on 12/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import SwiftSyntaxMacros - -internal struct PropertyPublisherDeclBuilder: ClassDeclBuilder, MemberBuilding { - - let declaration: ClassDeclSyntax - let properties: PropertiesList - let preferredGlobalActorIsolation: GlobalActorIsolation? - - func build() -> [DeclSyntax] { - [ - """ - \(inheritedAccessControlLevel)final class PropertyPublisher: AnyPropertyPublisher<\(trimmedType)> { - - \(deinitializer()) - - \(storedPropertiesPublishers().formatted()) - - \(computedPropertiesPublishers().formatted()) - - \(memoizedPropertiesPublishers().formatted()) - } - """ - ] - } - - private func deinitializer() -> MemberBlockItemListSyntax { - """ - deinit { - \(storedPropertiesPublishersFinishCalls().formatted()) - } - """ - } - - @CodeBlockItemListBuilder - private func storedPropertiesPublishersFinishCalls() -> CodeBlockItemListSyntax { - for property in properties.stored.mutable.instance.all { - "_\(property.trimmedName).send(completion: .finished)" - } - } - - @MemberBlockItemListBuilder - private func storedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.stored.mutable.instance.all { - let globalActor = inheritedGlobalActorIsolation - let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) - let name = property.trimmedName - let type = property.inferredType - """ - fileprivate let _\(name) = PassthroughSubject<\(type), Never>() - \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { - _storedPropertyPublisher(_\(name), for: \\.\(name)) - } - """ - } - } - - @MemberBlockItemListBuilder - private func computedPropertiesPublishers() -> MemberBlockItemListSyntax { - for property in properties.computed.instance.all { - let globalActor = inheritedGlobalActorIsolation - let accessControlLevel = AccessControlLevel.forSibling(of: property.underlying) - let name = property.trimmedName - let type = property.inferredType - """ - \(globalActor)\(accessControlLevel)var \(name): AnyPublisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(name)) - } - """ - } - } - - @MemberBlockItemListBuilder - private func memoizedPropertiesPublishers() -> MemberBlockItemListSyntax { - for member in declaration.memberBlock.members { - if let functionDecl = member.decl.as(FunctionDeclSyntax.self), - let attribute = functionDecl.attributes.first(like: "@Memoized"), - let parameters = try? MemoizedMacro.Parameters(from: attribute), - let trimmedReturnType = MemoizedMacro.trimmedReturnType(of: functionDecl) { - let globalActor = parameters.preferredGlobalActorIsolation ?? inheritedGlobalActorIsolation - let accessControlLevel = parameters.preferredAccessControlLevel - let name = parameters.preferredPropertyName ?? MemoizedMacro.defaultPropertyName(for: functionDecl) - let type = trimmedReturnType - """ - \(globalActor)\(accessControlLevel)var \(raw: name): AnyPublisher<\(type), Never> { - _computedPropertyPublisher(for: \\.\(raw: name)) - } - """ - } - } - } -} diff --git a/README.md b/README.md index ded2b40..a8a53d6 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,9 @@ as it publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. -This is where `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you -take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates -with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: +This is where the `@Publishable` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features while processing changes synchronously when needed. It integrates with the `@Observable` +macro and is designed to be compatible with other macros built on top of `Observation`: ```swift import Relay @@ -76,7 +76,7 @@ By leveraging these facts, the `@Publishable` macro can overload the default `Ob While I acknowledge that this usage might not have been intended by the authors, I would refrain from calling it a hack. It relies solely on well-understood behaviors of Swift and its public APIs. -This approach has been carefully tested and verified to work with both `@Observable` and `@Model` macros. +This approach has been carefully tested and verified to work with the `@Observable` macro. diff --git a/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift new file mode 100644 index 0000000..882d3a7 --- /dev/null +++ b/Sources/Relay/Combine/Common/AnyPropertyPublisher.swift @@ -0,0 +1,141 @@ +// +// AnyPropertyPublisher.swift +// Relay +// +// Created by Kamil Strzelecki on 17/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Combine + +/// An object that exposes `Combine` publishers for ``willChange`` and ``didChange`` events. +/// +/// Subclasses of this class are generated by the ``Publishable()`` macro, and provide publishers +/// for all mutable or computed instance properties of the type the macro is applied to. +/// +open class AnyPropertyPublisher { + + private final let _willChange = PassthroughSubject() + private final let _didChange = PassthroughSubject() + + /// Emits **before** any of the ``Publishable`` object's stored properties are assigned a new value. + /// + /// Generated subclasses also expose specialized publisher that emits the ``Publishable`` object itself, + /// named using the class name as a prefix. For example, a class named `Person` will provide a `personWillChange` publisher. + /// + public final var willChange: some Publisher { + _willChange + } + + /// Emits **after** any of the ``Publishable`` object's stored properties are assigned a new value. + /// + /// Generated subclasses also expose specialized publisher that emits the ``Publishable`` object itself, + /// named using the class name as a prefix. For example, a class named `Person` will provide a `personDidChange` publisher. + /// + public final var didChange: some Publisher { + _didChange + } + + private final var pendingModifications = 0 + + @_documentation(visibility: private) + public init(object _: AnyObject) { + // Void + } + + deinit { + _willChange.send(completion: .finished) + _didChange.send(completion: .finished) + } +} + +// swiftlint:disable unowned_variable_capture +// swiftlint:disable identifier_name + +extension AnyPropertyPublisher { + + public final func _beginModifications() { + pendingModifications += 1 + if pendingModifications == 1 { + _willChange.send(()) + } + } + + public final func _endModifications() { + if pendingModifications == 1 { + _didChange.send(()) + } + pendingModifications -= 1 + } +} + +extension AnyPropertyPublisher { + + public final func _storedPropertyPublisher( + _ subject: PassthroughSubject, + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject { + subject.prepend(object[keyPath: keyPath]) + } +} + +extension AnyPropertyPublisher { + + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject { + _didChange + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } + } + + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject, T: Equatable { + _didChange + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } + .removeDuplicates() + } + + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject, T: AnyObject { + _didChange + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } + .removeDuplicates { lhs, rhs in + lhs === rhs + } + } + + public final func _computedPropertyPublisher( + for keyPath: KeyPath, + object: Object + ) -> some Publisher + where Object: AnyObject, T: AnyObject & Equatable { + _didChange + .prepend(()) + .map { [unowned object] in + object[keyPath: keyPath] + } + .removeDuplicates() + } +} + +// swiftlint:enable identifier_name +// swiftlint:enable unowned_variable_capture diff --git a/Sources/Relay/Combine/Common/PublishableProtocol.swift b/Sources/Relay/Combine/Common/PublishableProtocol.swift new file mode 100644 index 0000000..6e02c21 --- /dev/null +++ b/Sources/Relay/Combine/Common/PublishableProtocol.swift @@ -0,0 +1,30 @@ +// swiftlint:disable:this file_name +// +// PublishableProtocol.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +import Observation + +/// A type that can be observed using both the `Observation` and `Combine` frameworks. +/// +/// You don't need to declare conformance to this protocol yourself. +/// It is generated automatically when you apply the ``Publishable()`` macro to your type. +/// +public protocol Publishable: AnyObject, Observable { + + /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, + /// containing publishers for all mutable or computed instance properties of the type. + /// + associatedtype PropertyPublisher: AnyPropertyPublisher + + /// An instance that exposes `Combine` publishers for all mutable or computed instance properties of the type. + /// + /// - Important: Don't store this instance in an external property. Accessing it after the original object has been deallocated + /// may result in a crash. Always access it directly through the object that exposes it. + /// + var publisher: PropertyPublisher { get } +} diff --git a/Sources/Relay/Combine/Common/PublisherIgnored.swift b/Sources/Relay/Combine/Common/PublisherIgnored.swift new file mode 100644 index 0000000..02a3101 --- /dev/null +++ b/Sources/Relay/Combine/Common/PublisherIgnored.swift @@ -0,0 +1,21 @@ +// +// 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/Publishable/Publishable.swift b/Sources/Relay/Combine/Publishable/Publishable.swift similarity index 66% rename from Sources/Relay/Publishable/Publishable.swift rename to Sources/Relay/Combine/Publishable/Publishable.swift index 2c081db..d57cc07 100644 --- a/Sources/Relay/Publishable/Publishable.swift +++ b/Sources/Relay/Combine/Publishable/Publishable.swift @@ -6,14 +6,13 @@ // Copyright © 2025 Kamil Strzelecki. All rights reserved. // -import Observation - -/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` conformance to `Observable` types. /// /// - Note: This macro infers the global actor isolation of the type and applies it to the generated declarations. /// If this causes compilation errors, use ``Publishable(isolation:)`` instead. /// -/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied. +/// - Note: This macro works with `Observable` classes, but it does not generate `Observable` conformance by itself. +/// To make the two compatible, apply another macro - such as `@Observable` - to the type alongside `@Publishable`. /// /// The `@Publishable` macro adds a new `publisher` property to your type, /// which exposes `Combine` publishers for all mutable or computed instance properties. @@ -21,12 +20,16 @@ import Observation /// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. /// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. /// +/// Classes to which the `@Publishable` macro has been attached can be subclassed. To generate publishers for any properties added in a subclass, +/// the macro must be applied again to the subclass definition. Subclasses should either be isolated to the same global actor as their superclass or remain nonisolated. +/// /// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. /// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. /// However, this inference may fail in non-trivial cases. If the generated code fails to compile, explicitly specifying the type of the affected property should resolve the issue. /// @attached( member, + conformances: Publishable, names: named(_publisher), named(publisher), named(PropertyPublisher), @@ -41,13 +44,14 @@ public macro Publishable() = #externalMacro( type: "PublishableMacro" ) -/// A macro that adds ``Publishable-protocol`` conformance to `Observable` types. +/// A macro that adds ``Publishable`` conformance to `Observable` types. /// /// - Parameter isolation: The global actor to which the type is isolated. /// If set to `nil`, the generated members are `nonisolated`. /// To infer isolation automatically, use the ``Publishable()`` macro instead. /// -/// - Note: This macro works only with `final` classes to which the `@Observable` or `@Model` macro has been applied directly. +/// - Note: This macro works with `Observable` classes, but it does not generate `Observable` conformance by itself. +/// To make the two compatible, apply another macro - such as `@Observable` - to the type alongside `@Publishable`. /// /// The `@Publishable` macro adds a new `publisher` property to your type, /// which exposes `Combine` publishers for all mutable or computed instance properties. @@ -55,12 +59,16 @@ public macro Publishable() = #externalMacro( /// If a property’s type conforms to `Equatable`, its publisher automatically removes duplicate values. /// Just like the `Published` property wrapper, subscribing to any of the exposed publishers immediately emits the current value. /// +/// Classes to which the `@Publishable` macro has been attached can be subclassed. To generate publishers for any properties added in a subclass, +/// the macro must be applied again to the subclass definition. Subclasses should either be isolated to the same global actor as their superclass or remain nonisolated. +/// /// - Important: Swift Macros do not have access to full type information of expressions used in the code they’re applied to. /// Since working with `Combine` requires knowledge of concrete types, this macro attempts to infer the types of properties when they are not explicitly specified. /// 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, names: named(_publisher), named(publisher), named(PropertyPublisher), @@ -70,29 +78,9 @@ public macro Publishable() = #externalMacro( extension, conformances: Publishable ) -public macro Publishable( - isolation: Isolation.Type? +public macro Publishable( + isolation: (any GlobalActor.Type)? ) = #externalMacro( module: "RelayMacros", type: "PublishableMacro" ) - -/// A type that can be observed using both the `Observation` and `Combine` frameworks. -/// -/// You don't need to declare conformance to this protocol yourself. -/// It is generated automatically when you apply the ``Publishable()`` macro to your type. -/// -public protocol Publishable: AnyObject, Observable { - - /// A subclass of ``AnyPropertyPublisher`` generated by the ``Publishable()`` macro, - /// containing publishers for all mutable or computed instance properties of the type. - /// - associatedtype PropertyPublisher: AnyPropertyPublisher - - /// An instance that exposes `Combine` publishers for all mutable or computed instance properties of the type. - /// - /// - Important: Don't store this instance in an external property. Accessing it after the original object has been deallocated - /// may result in a crash. Always access it directly through the object that exposes it. - /// - var publisher: PropertyPublisher { get } -} diff --git a/Sources/Relay/Publishable/PublishableObservationRegistrar.swift b/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift similarity index 93% rename from Sources/Relay/Publishable/PublishableObservationRegistrar.swift rename to Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift index 10fe6fb..ea059d9 100644 --- a/Sources/Relay/Publishable/PublishableObservationRegistrar.swift +++ b/Sources/Relay/Combine/Publishable/PublishableObservationRegistrar.swift @@ -11,7 +11,7 @@ import Observation @_documentation(visibility: private) public protocol PublishableObservationRegistrar { - associatedtype Object: Publishable, Observable + associatedtype Object: Observable init() diff --git a/Sources/Relay/Publishable/SwiftObservationRegistrar.swift b/Sources/Relay/Combine/Publishable/SwiftObservationRegistrar.swift similarity index 100% rename from Sources/Relay/Publishable/SwiftObservationRegistrar.swift rename to Sources/Relay/Combine/Publishable/SwiftObservationRegistrar.swift diff --git a/Sources/Relay/Documentation.docc/Changelog.md b/Sources/Relay/Documentation.docc/Changelog.md new file mode 100644 index 0000000..938a6e7 --- /dev/null +++ b/Sources/Relay/Documentation.docc/Changelog.md @@ -0,0 +1,23 @@ +# Changelog + +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`. + +- ``AnyPropertyPublisher`` is no longer generic, allowing subclassing of ``Publishable`` types. +As a consequence, its ``AnyPropertyPublisher/willChange`` and ``AnyPropertyPublisher/didChange`` publishers now output `Void` +instead of the specialized `Object` type. Generated subclasses still expose specialized publishers using the class name as a prefix. +For example, a class named `Person` will provide `personWillChange` and `personDidChange` publishers. + +- Calling the `@Model` macro compatible with ``Publishable()`` turned out to be premature. `SwiftData` uses reflection +to find property named `_$observationRegistrar` and asserts if it cannot cast it to the default `ObservationRegistrar` type. +Although it's technically possible to bypass this assertion (for example, by making ``Publishable`` types conform to `CustomReflectable`), +the framework internals would still fail to send values through the generated publishers. Therefore, the ``Publishable()`` macro +now emits a warning when applied to `@Model` classes. + +## Version 2.0 + +- Renamed library from `Publishable` to `Relay`. +- `swift-tools-version` changed from 6.1 to 6.2. diff --git a/Sources/Relay/Documentation.docc/HowPublishableWorks.md b/Sources/Relay/Documentation.docc/HowPublishableWorks.md new file mode 100644 index 0000000..3304b3a --- /dev/null +++ b/Sources/Relay/Documentation.docc/HowPublishableWorks.md @@ -0,0 +1,16 @@ +# How Publishable Works? + +Learn how the ``Publishable()`` macro works under the hood. + +The ``Publishable()`` macro relies on two key properties of Swift Macros and `Observation` module: +- Macro expansions are compiled in the context of the module where they’re used. This allows references in the macro to be overloaded by locally available symbols. +- Swift exposes `ObservationRegistrar` as a documented, public API, making it possible to use it safely and directly. + +By leveraging these facts, the ``Publishable()`` macro can overload the default `ObservationRegistrar` with a custom one that: +- Forwards changes to Swift’s native `ObservationRegistrar` +- Simultaneously emits values through generated `Combine` publishers + +While I acknowledge that this usage might not have been intended by the authors, I would refrain from calling it a hack. +It relies solely on well-understood behaviors of Swift and its public APIs. + +This approach has been carefully tested and verified to work with the `@Observable` macro. diff --git a/Sources/Relay/Documentation.docc/PublishableMacros.md b/Sources/Relay/Documentation.docc/PublishableMacros.md index b50492f..017c048 100644 --- a/Sources/Relay/Documentation.docc/PublishableMacros.md +++ b/Sources/Relay/Documentation.docc/PublishableMacros.md @@ -10,9 +10,9 @@ as it publishes the updates via an `AsyncSequence`. In some scenarios, however, developers need to perform actions synchronously - immediately after a change occurs. -This is where ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you -take advantage of the latest `Observable` features, while processing changes synchronously when needed. It integrates -with both the `@Observable` and `@Model` macros and could be extended to support other types built on top of `Observation`: +This is where the ``Publishable()`` macro comes in. It allows `Observation` and `Combine` to coexist within a single type, letting you +take advantage of the latest `Observable` features while processing changes synchronously when needed. It integrates with the `@Observable` +macro and is designed to be compatible with other macros built on top of `Observation`: ```swift import Relay @@ -55,8 +55,10 @@ person.surname = "Strzelecki" - ``Publishable()`` - ``Publishable(isolation:)`` -- ``Publishable-protocol`` +- ``PublisherIgnored()`` +- ### Observing Changes with Combine +- ``Publishable-protocol`` - ``AnyPropertyPublisher`` diff --git a/Sources/Relay/Documentation.docc/Relay.md b/Sources/Relay/Documentation.docc/Relay.md index 57d66ce..6143a61 100644 --- a/Sources/Relay/Documentation.docc/Relay.md +++ b/Sources/Relay/Documentation.docc/Relay.md @@ -6,3 +6,7 @@ Essential tools that extend the capabilities of `Observation`. - - + +## Changelog + +- diff --git a/Sources/Relay/Publishable/AnyPropertyPublisher.swift b/Sources/Relay/Publishable/AnyPropertyPublisher.swift deleted file mode 100644 index 9cdd966..0000000 --- a/Sources/Relay/Publishable/AnyPropertyPublisher.swift +++ /dev/null @@ -1,110 +0,0 @@ -// -// AnyPropertyPublisher.swift -// Relay -// -// Created by Kamil Strzelecki on 17/01/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import Combine - -/// An object that exposes `Combine` publishers for ``willChange`` and ``didChange`` events. -/// -/// Subclasses of this class are generated by the ``Publishable()`` macro, and provide publishers -/// for all mutable or computed instance properties of the type the macro is applied to. -/// -open class AnyPropertyPublisher { - - private let _willChange = PassthroughSubject() - private let _didChange = PassthroughSubject() - - /// Emits the `Object` **before** any of its stored properties are assigned a new value. - /// - public var willChange: AnyPublisher { - _willChange.eraseToAnyPublisher() - } - - /// Emits the `Object` **after** any of its stored properties are assigned a new value. - /// - public var didChange: AnyPublisher { - _didChange.eraseToAnyPublisher() - } - - private var pendingModifications = 0 - private unowned let object: Object - - @_documentation(visibility: private) - public init(object: Object) { - self.object = object - } - - deinit { - _willChange.send(completion: .finished) - _didChange.send(completion: .finished) - } -} - -// swiftlint:disable identifier_name - -extension AnyPropertyPublisher { - - public func _beginModifications() { - pendingModifications += 1 - if pendingModifications == 1 { - _willChange.send(object) - } - } - - public func _endModifications() { - if pendingModifications == 1 { - _didChange.send(object) - } - pendingModifications -= 1 - } -} - -extension AnyPropertyPublisher { - - public func _storedPropertyPublisher( - _ subject: PassthroughSubject, - for keyPath: KeyPath - ) -> AnyPublisher { - subject - .prepend(object[keyPath: keyPath]) - .eraseToAnyPublisher() - } - - public func _storedPropertyPublisher( - _ subject: PassthroughSubject, - for keyPath: KeyPath - ) -> AnyPublisher { - subject - .prepend(object[keyPath: keyPath]) - .removeDuplicates() - .eraseToAnyPublisher() - } -} - -extension AnyPropertyPublisher { - - public func _computedPropertyPublisher( - for keyPath: KeyPath - ) -> AnyPublisher { - _didChange - .prepend(object) - .map { $0[keyPath: keyPath] } - .eraseToAnyPublisher() - } - - public func _computedPropertyPublisher( - for keyPath: KeyPath - ) -> AnyPublisher { - _didChange - .prepend(object) - .map { $0[keyPath: keyPath] } - .removeDuplicates() - .eraseToAnyPublisher() - } -} - -// swiftlint:enable identifier_name diff --git a/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift new file mode 100644 index 0000000..3f88ed2 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/ExplicitlyIsolatedMemoizedMacroTests.swift @@ -0,0 +1,161 @@ +// +// ExplicitlyIsolatedMemoizedMacroTests.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 ExplicitlyIsolatedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @CustomActor @Observable + public class Square { + + var side = 12.3 + + @Memoized(isolation: MainActor.self) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @CustomActor @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + @MainActor private final var _area: Optional = nil + + @MainActor final var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + MainActor.shared.assumeIsolated { _ in + rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @CustomActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName", isolation: MainActor.self) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @CustomActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + private func calculateArea() -> Double { + side * side + } + + // Stored properties cannot be made potentially unavailable + @MainActor private final var _customName: Optional = nil + + @available(macOS 26, *) + @MainActor public final var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () -> Void + ) { + withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + MainActor.shared.assumeIsolated { _ in + rawOperation() + } + } + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift similarity index 89% rename from Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift rename to Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift index d1597c7..bbe887b 100644 --- a/Tests/RelayMacrosTests/Memoized/MainActorMemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/ImplicitlyIsolatedMemoizedMacroTests.swift @@ -1,5 +1,5 @@ // -// MainActorMemoizedMacroTests.swift +// ImplicitlyIsolatedMemoizedMacroTests.swift // Relay // // Created by Kamil Strzelecki on 12/01/2025. @@ -11,14 +11,12 @@ import SwiftSyntaxMacrosTestSupport import XCTest - internal final class MainActorMemoizedMacroTests: XCTestCase { + internal final class ImplicitlyIsolatedMemoizedMacroTests: XCTestCase { private let macros: [String: any Macro.Type] = [ "Memoized": MemoizedMacro.self ] - // swiftlint:disable global_actor_attribute_order - func testExpansion() { assertMacroExpansion( #""" @@ -43,9 +41,9 @@ side * side } - @MainActor private var _area: Optional = nil + @MainActor private final var _area: Optional = nil - @MainActor var area: Double { + @MainActor final var area: Double { if let cached = _area { access(keyPath: \._area) return cached @@ -95,6 +93,7 @@ var side = 12.3 + @available(macOS 26, *) @Memoized(.public, "customName") private func calculateArea() -> Double { side * side @@ -107,13 +106,17 @@ public final class Square { var side = 12.3 + + @available(macOS 26, *) private func calculateArea() -> Double { side * side } - @MainActor private var _customName: Optional = nil + // Stored properties cannot be made potentially unavailable + @MainActor private final var _customName: Optional = nil - @MainActor public var customName: Double { + @available(macOS 26, *) + @MainActor public final var customName: Double { if let cached = _customName { access(keyPath: \._customName) return cached @@ -154,7 +157,5 @@ macros: macros ) } - - // swiftlint:enable global_actor_attribute_order } #endif diff --git a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift index afd9c82..fdaee85 100644 --- a/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift +++ b/Tests/RelayMacrosTests/Memoized/MemoizedMacroTests.swift @@ -41,9 +41,9 @@ side * side } - private var _area: Optional = nil + private final var _area: Optional = nil - var area: Double { + final var area: Double { if let cached = _area { access(keyPath: \._area) return cached @@ -87,6 +87,7 @@ var side = 12.3 + @available(macOS 26, *) @Memoized(.public, "customName") private func calculateArea() -> Double { side * side @@ -99,13 +100,17 @@ public final class Square { var side = 12.3 + + @available(macOS 26, *) private func calculateArea() -> Double { side * side } - private var _customName: Optional = nil + // Stored properties cannot be made potentially unavailable + private final var _customName: Optional = nil - public var customName: Double { + @available(macOS 26, *) + public final var customName: Double { if let cached = _customName { access(keyPath: \._customName) return cached diff --git a/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift new file mode 100644 index 0000000..f49cd85 --- /dev/null +++ b/Tests/RelayMacrosTests/Memoized/NonisolatedMemoizedMacroTests.swift @@ -0,0 +1,149 @@ +// +// NonisolatedMemoizedMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 12/01/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class NonisolatedMemoizedMacroTests: XCTestCase { + + private let macros: [String: any Macro.Type] = [ + "Memoized": MemoizedMacro.self + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + + @Memoized(isolation: nil) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public class Square { + + var side = 12.3 + private func calculateArea() -> Double { + side * side + } + + nonisolated private final var _area: Optional = nil + + nonisolated final var area: Double { + if let cached = _area { + access(keyPath: \._area) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._area) { + instance?._area = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _area = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + + func testExpansionWithParameters() { + assertMacroExpansion( + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + @Memoized(.public, "customName", isolation: nil) + private func calculateArea() -> Double { + side * side + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + public final class Square { + + var side = 12.3 + + @available(macOS 26, *) + private func calculateArea() -> Double { + side * side + } + + // Stored properties cannot be made potentially unavailable + nonisolated private final var _customName: Optional = nil + + @available(macOS 26, *) + nonisolated public final var customName: Double { + if let cached = _customName { + access(keyPath: \._customName) + return cached + } + + nonisolated(unsafe) weak var instance = self + + @Sendable nonisolated func assumeIsolatedIfNeeded( + _ operation: () -> Void + ) { + operation() + } + + @Sendable nonisolated func invalidateCache() { + assumeIsolatedIfNeeded { + instance?.withMutation(keyPath: \._customName) { + instance?._customName = nil + } + } + } + + return withObservationTracking { + let result = calculateArea() + _customName = result + return result + } onChange: { + invalidateCache() + } + } + } + """#, + macros: macros + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift new file mode 100644 index 0000000..4be04aa --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/ExplicitlyIsolatedPublishableMacroTests.swift @@ -0,0 +1,332 @@ +// +// ExplicitlyIsolatedPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + // swiftlint:disable:next type_body_length + internal final class ExplicitlyIsolatedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @available(iOS 26, macOS 26, *) + @CustomActor @Publishable(isolation: MainActor.self) @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + } + """#, + expandedSource: + #""" + @available(iOS 26, macOS 26, *) + @CustomActor @Observable + public final class Person { + + 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 + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// 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 + } + + 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 + + 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 + + @available(iOS 26, *) + fileprivate final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) + } + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor private func publish( + _ object: Person, + keyPath: KeyPath + ) { + if keyPath == \.age { + object.publisher._age.send(object[keyPath: \.age]) + return + } + if keyPath == \.name { + object.publisher._name.send(object[keyPath: \.name]) + return + } + if keyPath == \.surname { + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) + return + } + #endif + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + + @available(iOS 26, macOS 26, *) extension Person: @MainActor Publishable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift similarity index 56% rename from Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift rename to Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift index 9c9537e..2ef4147 100644 --- a/Tests/RelayMacrosTests/Publishable/MainActorPublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/ImplicitlyIsolatedPublishableMacroTests.swift @@ -1,5 +1,5 @@ // -// MainActorPublishableMacroTests.swift +// ImplicitlyIsolatedPublishableMacroTests.swift // Relay // // Created by Kamil Strzelecki on 24/08/2025. @@ -8,18 +8,24 @@ #if canImport(RelayMacros) import RelayMacros + import SwiftSyntaxMacroExpansion import SwiftSyntaxMacrosTestSupport import XCTest - internal final class MainActorPublishableMacroTests: XCTestCase { + // swiftlint:disable:next type_body_length + internal final class ImplicitlyIsolatedPublishableMacroTests: XCTestCase { - private let macros: [String: any Macro.Type] = [ - "Publishable": PublishableMacro.self + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) ] func testExpansion() { assertMacroExpansion( #""" + @available(iOS 26, macOS 26, *) @MainActor @Publishable @Observable public final class Person { @@ -44,14 +50,38 @@ set { _ = newValue } } - @Memoized(.public) + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } """#, expandedSource: #""" + @available(iOS 26, macOS 26, *) @MainActor @Observable public final class Person { @@ -76,11 +106,36 @@ set { _ = newValue } } - @Memoized(.public) + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + private final lazy var _publisher = PropertyPublisher(object: self) + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -88,83 +143,106 @@ /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - public private(set) lazy var publisher = PropertyPublisher(object: self) + public var publisher: PropertyPublisher { + _publisher + } + + @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 class PropertyPublisher: AnyPropertyPublisher { + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } - deinit { + 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 } - fileprivate let _age = PassthroughSubject() - @MainActor var age: AnyPublisher { - _storedPropertyPublisher(_age, for: \.age) + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) + } + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) } - fileprivate let _name = PassthroughSubject() - @MainActor var name: AnyPublisher { - _storedPropertyPublisher(_name, for: \.name) + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) } - fileprivate let _surname = PassthroughSubject() - @MainActor public var surname: AnyPublisher { - _storedPropertyPublisher(_surname, for: \.surname) + #if os(macOS) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) } + #endif - @MainActor internal var fullName: AnyPublisher { - _computedPropertyPublisher(for: \.fullName) + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) } - @MainActor fileprivate var initials: AnyPublisher { - _computedPropertyPublisher(for: \.initials) + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) } + #if os(macOS) + @available(macOS 26, *) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) + } + #endif - @MainActor public var label: AnyPublisher { - _computedPropertyPublisher(for: \.label) + @available(iOS 26, *) + fileprivate final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - struct ObservationRegistrar: @MainActor PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() - @MainActor func publish( + @MainActor private func publish( _ object: Person, keyPath: KeyPath ) { - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.age { - return object.publisher._age + object.publisher._age.send(object[keyPath: \.age]) + return } - return nil - } - @MainActor private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.name { - return object.publisher._name + object.publisher._name.send(object[keyPath: \.name]) + return } if keyPath == \.surname { - return object.publisher._surname + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) + return } - return nil + #endif } nonisolated func willSet( @@ -197,14 +275,14 @@ underlying.access(object, keyPath: keyPath) } - nonisolated func withMutation( + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Person, keyPath: KeyPath, - _ mutation: () throws -> T - ) rethrows -> T { + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: T! + nonisolated(unsafe) var result: __macro_local_1TfMu_! try assumeIsolatedIfNeeded { object.publisher._beginModifications() @@ -244,10 +322,10 @@ } } - extension Person: @MainActor Publishable { + @available(iOS 26, macOS 26, *) extension Person: @MainActor Publishable { } """#, - macros: macros + macroSpecs: macroSpecs ) } } diff --git a/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift new file mode 100644 index 0000000..b1569a5 --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/NonisolatedPublishableMacroTests.swift @@ -0,0 +1,318 @@ +// +// NonisolatedPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class NonisolatedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @available(iOS 26, macOS 26, *) + @Publishable(isolation: nil) @Observable + public final class Person { + + static var user: Person? + + let id: UUID + fileprivate(set) var age: Int + var name: String + + public var surname: String { + didSet { + print(oldValue) + } + } + + internal var fullName: String { + "\(name) \(surname)" + } + + private var initials: String { + get { "\(name.prefix(1))\(surname.prefix(1))" } + set { _ = newValue } + } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + } + """#, + expandedSource: + #""" + @available(iOS 26, macOS 26, *) + @Observable + public final class Person { + + 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 + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) + func makeLabel() -> String { + "\(fullName), \(age)" + } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// 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 + } + + 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 + + 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 + + @available(iOS 26, *) + fileprivate final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) + } + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + nonisolated private func publish( + _ object: Person, + keyPath: KeyPath + ) { + if keyPath == \.age { + object.publisher._age.send(object[keyPath: \.age]) + return + } + if keyPath == \.name { + object.publisher._name.send(object[keyPath: \.name]) + return + } + if keyPath == \.surname { + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) + return + } + #endif + } + + nonisolated func willSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Person, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Person, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Person, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + + @available(iOS 26, macOS 26, *) extension Person: nonisolated Publishable { + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift index a225b80..059536c 100644 --- a/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift +++ b/Tests/RelayMacrosTests/Publishable/PublishableMacroTests.swift @@ -8,18 +8,23 @@ #if canImport(RelayMacros) import RelayMacros + import SwiftSyntaxMacroExpansion import SwiftSyntaxMacrosTestSupport import XCTest internal final class PublishableMacroTests: XCTestCase { - private let macros: [String: any Macro.Type] = [ - "Publishable": PublishableMacro.self + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: ["Publishable"] + ) ] func testExpansion() { assertMacroExpansion( #""" + @available(iOS 26, macOS 26, *) @Publishable @Observable public final class Person { @@ -44,14 +49,38 @@ set { _ = newValue } } - @Memoized(.public) + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } } """#, expandedSource: #""" + @available(iOS 26, macOS 26, *) @Observable public final class Person { @@ -76,11 +105,36 @@ set { _ = newValue } } - @Memoized(.public) + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } + + @available(iOS 26, *) + @Memoized(.private) func makeLabel() -> String { "\(fullName), \(age)" } + @Memoized @PublisherIgnored + func makeIgnoredMemoizedProperty() -> Int { + ignoredStoredProperty + } + + private final lazy var _publisher = PropertyPublisher(object: self) + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable /// or computed instance properties of this object. /// @@ -88,83 +142,106 @@ /// the original object has been deallocated may result in a crash. Always access it directly /// through the object that exposes it. /// - public private(set) lazy var publisher = PropertyPublisher(object: self) + public var publisher: PropertyPublisher { + _publisher + } + + public final class PropertyPublisher: Relay.AnyPropertyPublisher { + + private final unowned let object: Person + + public final var personWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } - public final class PropertyPublisher: AnyPropertyPublisher { + public final var personDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + public init(object: Person) { + self.object = object + super.init(object: object) + } deinit { _age.send(completion: .finished) _name.send(completion: .finished) _surname.send(completion: .finished) + #if os(macOS) + _platformStoredProperty.send(completion: .finished) + #endif } - fileprivate let _age = PassthroughSubject() - var age: AnyPublisher { - _storedPropertyPublisher(_age, for: \.age) + fileprivate final let _age = PassthroughSubject() + final var age: some Publisher { + _storedPropertyPublisher(_age, for: \.age, object: object) + } + fileprivate final let _name = PassthroughSubject() + final var name: some Publisher { + _storedPropertyPublisher(_name, for: \.name, object: object) } - fileprivate let _name = PassthroughSubject() - var name: AnyPublisher { - _storedPropertyPublisher(_name, for: \.name) + fileprivate final let _surname = PassthroughSubject() + public final var surname: some Publisher { + _storedPropertyPublisher(_surname, for: \.surname, object: object) } - fileprivate let _surname = PassthroughSubject() - public var surname: AnyPublisher { - _storedPropertyPublisher(_surname, for: \.surname) + #if os(macOS) + fileprivate final let _platformStoredProperty = PassthroughSubject() + final var platformStoredProperty: some Publisher { + _storedPropertyPublisher(_platformStoredProperty, for: \.platformStoredProperty, object: object) } + #endif - internal var fullName: AnyPublisher { - _computedPropertyPublisher(for: \.fullName) + internal final var fullName: some Publisher { + _computedPropertyPublisher(for: \.fullName, object: object) } - fileprivate var initials: AnyPublisher { - _computedPropertyPublisher(for: \.initials) + fileprivate final var initials: some Publisher { + _computedPropertyPublisher(for: \.initials, object: object) } + #if os(macOS) + @available(macOS 26, *) + final var platformComputedProperty: some Publisher { + _computedPropertyPublisher(for: \.platformComputedProperty, object: object) + } + #endif - public var label: AnyPublisher { - _computedPropertyPublisher(for: \.label) + @available(iOS 26, *) + fileprivate final var label: some Publisher { + _computedPropertyPublisher(for: \.label, object: object) } } private enum Observation { - struct ObservationRegistrar: PublishableObservationRegistrar { + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { private let underlying = SwiftObservationRegistrar() - func publish( + private func publish( _ object: Person, keyPath: KeyPath ) { - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - if let keyPath = keyPath as? KeyPath, - let subject = subject(for: keyPath, on: object) { - subject.send(object[keyPath: keyPath]) - return - } - } - - private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.age { - return object.publisher._age + object.publisher._age.send(object[keyPath: \.age]) + return } - return nil - } - private func subject( - for keyPath: KeyPath, - on object: Person - ) -> PassthroughSubject? { if keyPath == \.name { - return object.publisher._name + object.publisher._name.send(object[keyPath: \.name]) + return } if keyPath == \.surname { - return object.publisher._surname + object.publisher._surname.send(object[keyPath: \.surname]) + return + } + #if os(macOS) + if keyPath == \.platformStoredProperty { + object.publisher._platformStoredProperty.send(object[keyPath: \.platformStoredProperty]) + return } - return nil + #endif } nonisolated func willSet( @@ -197,14 +274,14 @@ underlying.access(object, keyPath: keyPath) } - nonisolated func withMutation( + nonisolated func withMutation<__macro_local_1TfMu_>( of object: Person, keyPath: KeyPath, - _ mutation: () throws -> T - ) rethrows -> T { + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { nonisolated(unsafe) let mutation = mutation nonisolated(unsafe) let keyPath = keyPath - nonisolated(unsafe) var result: T! + nonisolated(unsafe) var result: __macro_local_1TfMu_! try assumeIsolatedIfNeeded { object.publisher._beginModifications() @@ -231,10 +308,10 @@ } } - extension Person: Publishable { + @available(iOS 26, macOS 26, *) extension Person: Publishable { } """#, - macros: macros + macroSpecs: macroSpecs ) } } diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift new file mode 100644 index 0000000..296217b --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/SubclassedImplicitlyIsolatedPublishableMacroTests.swift @@ -0,0 +1,217 @@ +// +// SubclassedImplicitlyIsolatedPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class SubclassedImplicitlyIsolatedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: [] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @MainActor @Publishable @Observable + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + } + """#, + expandedSource: + #""" + @MainActor @Observable + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// + override var publisher: PropertyPublisher { + _publisher + } + + @MainActor class PropertyPublisher: Animal.PropertyPublisher { + + private final unowned let object: Dog + + final var dogWillChange: some Publisher { + willChange.map { [unowned object] _ in + object + } + } + + final var dogDidChange: some Publisher { + didChange.map { [unowned object] _ in + object + } + } + + init(object: Dog) { + self.object = object + super.init(object: object) + } + + @MainActor deinit { + _breed.send(completion: .finished) + } + + fileprivate final let _breed = PassthroughSubject, Never>() + final var breed: some Publisher, Never> { + _storedPropertyPublisher(_breed, for: \.breed, object: object) + } + + final var isBulldog: some Publisher { + _computedPropertyPublisher(for: \.isBulldog, object: object) + } + + + } + + private enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + @MainActor private func publish( + _ object: Dog, + keyPath: KeyPath + ) { + if keyPath == \.breed { + object.publisher._breed.send(object[keyPath: \.breed]) + return + } + } + + nonisolated func willSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Dog, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Dog, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: @MainActor () throws -> Void, + file: StaticString = #fileID, + line: UInt = #line + ) rethrows { + try withoutActuallyEscaping(operation) { operation in + typealias Nonisolated = () throws -> Void + let rawOperation = unsafeBitCast(operation, to: Nonisolated.self) + + try MainActor.shared.assumeIsolated( + { _ in + try rawOperation() + }, + file: file, + line: line + ) + } + } + } + } + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift new file mode 100644 index 0000000..abfa906 --- /dev/null +++ b/Tests/RelayMacrosTests/Publishable/SubclassedPublishableMacroTests.swift @@ -0,0 +1,204 @@ +// +// SubclassedPublishableMacroTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +#if canImport(RelayMacros) + import RelayMacros + import SwiftSyntaxMacroExpansion + import SwiftSyntaxMacrosTestSupport + import XCTest + + internal final class SubclassedPublishableMacroTests: XCTestCase { + + private let macroSpecs: [String: MacroSpec] = [ + "Publishable": MacroSpec( + type: PublishableMacro.self, + conformances: [] + ) + ] + + func testExpansion() { + assertMacroExpansion( + #""" + @Publishable @Observable + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + } + """#, + expandedSource: + #""" + @Observable + class Dog: Animal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "Unknown"), \(age)" + } + + private final lazy var _publisher = PropertyPublisher(object: self) + + /// A ``PropertyPublisher`` which exposes `Combine` publishers for all mutable + /// or computed instance properties of this object. + /// + /// - Important: Don't store this instance in an external property. Accessing it after + /// the original object has been deallocated may result in a crash. Always access it directly + /// through the object that exposes it. + /// + override var publisher: PropertyPublisher { + _publisher + } + + 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 enum Observation { + + nonisolated struct ObservationRegistrar: PublishableObservationRegistrar { + + private let underlying = SwiftObservationRegistrar() + + private func publish( + _ object: Dog, + keyPath: KeyPath + ) { + if keyPath == \.breed { + object.publisher._breed.send(object[keyPath: \.breed]) + return + } + } + + nonisolated func willSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + object.publisher._beginModifications() + underlying.willSet(object, keyPath: keyPath) + } + } + + nonisolated func didSet( + _ object: Dog, + keyPath: KeyPath + ) { + nonisolated(unsafe) let keyPath = keyPath + assumeIsolatedIfNeeded { + underlying.didSet(object, keyPath: keyPath) + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + } + + nonisolated func access( + _ object: Dog, + keyPath: KeyPath + ) { + underlying.access(object, keyPath: keyPath) + } + + nonisolated func withMutation<__macro_local_1TfMu_>( + of object: Dog, + keyPath: KeyPath, + _ mutation: () throws -> __macro_local_1TfMu_ + ) rethrows -> __macro_local_1TfMu_ { + nonisolated(unsafe) let mutation = mutation + nonisolated(unsafe) let keyPath = keyPath + nonisolated(unsafe) var result: __macro_local_1TfMu_! + + try assumeIsolatedIfNeeded { + object.publisher._beginModifications() + defer { + publish(object, keyPath: keyPath) + object.publisher._endModifications() + } + result = try underlying.withMutation( + of: object, + keyPath: keyPath, + mutation + ) + } + + return result + } + + private nonisolated func assumeIsolatedIfNeeded( + _ operation: () throws -> Void + ) rethrows { + try operation() + } + } + } + } + """#, + macroSpecs: macroSpecs + ) + } + } +#endif diff --git a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift index 0331aef..50336d8 100644 --- a/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/MainActorMemoizedTests.swift @@ -107,58 +107,6 @@ internal enum MainActorMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(!cube.isBaseAreaCached) } - - @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.baseArea - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.y = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(!cube.isBaseAreaCached) - - cube.y = 4.0 - let access3 = cube.baseArea - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -279,70 +227,6 @@ extension MainActorMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(cube.isBaseAreaCached) } - - @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.volume - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 4.0 - let access3 = cube.volume - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -356,9 +240,11 @@ extension MainActorMemoizedTests { var y = 1.0 var z = 1.0 + @ObservationIgnored private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @ObservationIgnored private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } @@ -368,10 +254,18 @@ extension MainActorMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) + @Memoized + func calculatePlatformValue() -> Double { + volume + } + #endif } } diff --git a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift index 3e930b7..b691469 100644 --- a/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/ObservationMemoizedTests.swift @@ -106,58 +106,6 @@ internal enum ObservationMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(!cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.baseArea - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.y = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(!cube.isBaseAreaCached) - - cube.y = 4.0 - let access3 = cube.baseArea - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -277,70 +225,6 @@ extension ObservationMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.volume - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 4.0 - let access3 = cube.volume - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -354,9 +238,11 @@ extension ObservationMemoizedTests { var y = 1.0 var z = 1.0 + @ObservationIgnored private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @ObservationIgnored private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } @@ -366,10 +252,18 @@ extension ObservationMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) + @Memoized + func calculatePlatformValue() -> Double { + volume + } + #endif } } diff --git a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift index 2fa45ac..934060e 100644 --- a/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/PublishableMemoizedTests.swift @@ -172,9 +172,11 @@ extension PublishableMemoizedTests { var y = 1.0 var z = 1.0 + @ObservationIgnored private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @ObservationIgnored private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } @@ -184,10 +186,23 @@ extension PublishableMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + @Memoized @PublisherIgnored + 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 53eb56b..e2b8399 100644 --- a/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift +++ b/Tests/RelayTests/Memoized/SwiftDataMemoizedTests.swift @@ -107,58 +107,6 @@ internal enum SwiftDataMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(!cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.baseArea - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.y = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(!cube.isBaseAreaCached) - - cube.y = 4.0 - let access3 = cube.baseArea - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateBaseAreaCallsCount == 3) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -278,70 +226,6 @@ extension SwiftDataMemoizedTests { #expect(cube.calculateBaseAreaCallsCount == 1) #expect(cube.isBaseAreaCached) } - - @MainActor @Test - @available(macOS 26.0, macCatalyst 26.0, iOS 26.0, tvOS 26.0, watchOS 26.0, visionOS 26.0, *) - func observations() async { - let cube = Cube() - var queue = [Double]() - - let task = Task.immediate { - let observations = Observations { - cube.volume - } - for await area in observations { - queue.append(area) - } - } - - try? await Task.sleep(for: .microseconds(10)) // access1 - not cached - #expect(queue.popFirst() == 1.0) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(cube.isBaseAreaCached) - - cube.x = 2.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 1) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 1) - #expect(!cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access2 - not cached - #expect(queue.popFirst() == 2.0) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 3.0 - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 2) - #expect(!cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - cube.z = 4.0 - let access3 = cube.volume - #expect(access3 == 8.0) - #expect(queue.popFirst() == nil) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - try? await Task.sleep(for: .microseconds(10)) // access4 - cached - #expect(queue.popFirst() == 8.0) - #expect(cube.calculateVolumeCallsCount == 3) - #expect(cube.isVolumeCached) - #expect(cube.calculateBaseAreaCallsCount == 2) - #expect(cube.isBaseAreaCached) - - task.cancel() - await task.value - #expect(queue.isEmpty) - } } } @@ -355,9 +239,11 @@ extension SwiftDataMemoizedTests { var y = 1.0 var z = 1.0 + @Transient private(set) var calculateBaseAreaCallsCount = 0 var isBaseAreaCached: Bool { _baseArea != nil } + @Transient private(set) var calculateVolumeCallsCount = 0 var isVolumeCached: Bool { _volume != nil } @@ -371,10 +257,18 @@ extension SwiftDataMemoizedTests { return x * y } - @Memoized + @Memoized(.fileprivate, "volume") func calculateVolume() -> Double { calculateVolumeCallsCount += 1 return baseArea * z } + + #if os(macOS) + @available(macOS 26, *) + @Memoized + func calculatePlatformValue() -> Double { + volume + } + #endif } } diff --git a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift index dafca42..9997bca 100644 --- a/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift +++ b/Tests/RelayTests/Publishable/AnyPropertyPublisherTests.swift @@ -9,200 +9,399 @@ import Relay import Testing -internal struct AnyPropertyPublisherTests { +internal enum AnyPropertyPublisherTests { - fileprivate struct NonEquatableStruct {} + struct NonEquatableType { - @Publishable @Observable - fileprivate final class ObjectWithNonEquatableProperties { + fileprivate struct NonEquatableStruct {} - var storedProperty = NonEquatableStruct() - var unrelatedProperty = 0 + fileprivate final class NonEquatableClass {} - var computedProperty: NonEquatableStruct { - storedProperty + @Publishable @Observable + fileprivate final class Object { + + var unrelatedProperty = 0 + + var storedProperty = NonEquatableStruct() + var computedProperty: NonEquatableStruct { + storedProperty + } + + var referenceTypeStoredProperty = NonEquatableClass() + var referenceTypeComputedProperty: NonEquatableClass { + referenceTypeStoredProperty + } } - } - @Test - func nonEquatableStoredProperty() { - var object: ObjectWithNonEquatableProperties? = .init() - var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.storedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.storedProperty - } onChange: { - observationsQueue.append(true) + @Test + func storedProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableStruct]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.storedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.storedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = NonEquatableStruct() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeStoredProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeStoredProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeStoredProperty + } onChange: { + observationsQueue.append(true) + } + } - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = NonEquatableStruct() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == true) - observe() + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } + object?.referenceTypeStoredProperty = NonEquatableClass() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() - @Test - func nonEquatableComputedProperty() { - var object: ObjectWithNonEquatableProperties? = .init() - var publishableQueue = [NonEquatableStruct]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.computedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.computedProperty - } onChange: { - observationsQueue.append(true) + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func computedProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableStruct]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.computedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.computedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = NonEquatableStruct() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeComputedProperty() { + var object: Object? = .init() + var publishableQueue = [NonEquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeComputedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeComputedProperty + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == nil) - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == nil) + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = NonEquatableStruct() - #expect(publishableQueue.popFirst() != nil) - #expect(observationsQueue.popFirst() == true) - observe() + object?.referenceTypeStoredProperty = NonEquatableClass() + #expect(publishableQueue.popFirst() != nil) + #expect(observationsQueue.popFirst() == true) + observe() - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } } } extension AnyPropertyPublisherTests { - @Publishable @Observable - fileprivate final class ObjectWithEquatableProperties { + struct EquatableType { + + fileprivate final class EquatableClass: Equatable { + + let value: Int + + init(value: Int) { + self.value = value + } + + static func == (lhs: EquatableClass, rhs: EquatableClass) -> Bool { + lhs.value == rhs.value + } + } + + @Publishable @Observable + fileprivate final class Object { - var storedProperty = 0 - var unrelatedProperty = 0 + var unrelatedProperty = 0 - var computedProperty: Int { - storedProperty + var storedProperty = 0 + var computedProperty: Int { + storedProperty + } + + var referenceTypeStoredProperty = EquatableClass(value: 0) + var referenceTypeComputedProperty: EquatableClass { + referenceTypeStoredProperty + } } - } - @Test - func equatableStoredProperty() { - var object: ObjectWithEquatableProperties? = .init() - var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.storedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.storedProperty - } onChange: { - observationsQueue.append(true) + @Test + func storedProperty() { + var object: Object? = .init() + var publishableQueue = [Int]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.storedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.storedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() == 0) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = 0 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty += 1 + #expect(publishableQueue.popFirst() == 1) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() == 0) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeStoredProperty() { + var object: Object? = .init() + var publishableQueue = [EquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeStoredProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeStoredProperty + } onChange: { + observationsQueue.append(true) + } + } - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + observe() + #expect(publishableQueue.popFirst() == EquatableClass(value: 0)) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = 0 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty += 1 - #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() == true) - observe() + object?.referenceTypeStoredProperty = EquatableClass(value: 0) + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } + object?.referenceTypeStoredProperty = EquatableClass(value: 1) + #expect(publishableQueue.popFirst() == EquatableClass(value: 1)) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } - @Test - func equatableComputedProperty() { - var object: ObjectWithEquatableProperties? = .init() - var publishableQueue = [Int]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = object?.publisher.computedProperty.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = object?.computedProperty - } onChange: { - observationsQueue.append(true) + @Test + func computedProperty() { + var object: Object? = .init() + var publishableQueue = [Int]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.computedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.computedProperty + } onChange: { + observationsQueue.append(true) + } } + + observe() + #expect(publishableQueue.popFirst() == 0) + #expect(observationsQueue.popFirst() == nil) + + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty = 0 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + object?.storedProperty += 1 + #expect(publishableQueue.popFirst() == 1) + #expect(observationsQueue.popFirst() == true) + observe() + + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() } - observe() - #expect(publishableQueue.popFirst() == 0) - #expect(observationsQueue.popFirst() == nil) + @Test + func referenceTypeComputedProperty() { + var object: Object? = .init() + var publishableQueue = [EquatableClass]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = object?.publisher.referenceTypeComputedProperty.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = object?.referenceTypeComputedProperty + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == EquatableClass(value: 0)) + #expect(observationsQueue.popFirst() == nil) - object?.unrelatedProperty += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + object?.unrelatedProperty += 1 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty = 0 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) + object?.referenceTypeStoredProperty = EquatableClass(value: 0) + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) - object?.storedProperty += 1 - #expect(publishableQueue.popFirst() == 1) - #expect(observationsQueue.popFirst() == true) - observe() + object?.referenceTypeStoredProperty = EquatableClass(value: 1) + #expect(publishableQueue.popFirst() == EquatableClass(value: 1)) + #expect(observationsQueue.popFirst() == true) + observe() - object = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() + object = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } } } diff --git a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift index 8c86215..6217b38 100644 --- a/Tests/RelayTests/Publishable/MainActorPublishableTests.swift +++ b/Tests/RelayTests/Publishable/MainActorPublishableTests.swift @@ -108,7 +108,7 @@ extension MainActorPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.willChange.sink( + let cancellable = person?.publisher.personWillChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -157,7 +157,7 @@ extension MainActorPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.didChange.sink( + let cancellable = person?.publisher.personDidChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -218,5 +218,22 @@ extension MainActorPublishableTests { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift index 38f7cc2..00d1699 100644 --- a/Tests/RelayTests/Publishable/ObservationPublishableTests.swift +++ b/Tests/RelayTests/Publishable/ObservationPublishableTests.swift @@ -107,7 +107,7 @@ extension ObservationPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.willChange.sink( + let cancellable = person?.publisher.personWillChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -156,7 +156,7 @@ extension ObservationPublishableTests { nonisolated(unsafe) var observationsQueue = [Bool]() var completion: Subscribers.Completion? - let cancellable = person?.publisher.didChange.sink( + let cancellable = person?.publisher.personDidChange.sink( receiveCompletion: { completion = $0 }, receiveValue: { publishableQueue.append($0) } ) @@ -217,5 +217,22 @@ extension ObservationPublishableTests { get { "\(name.prefix(1))\(surname.prefix(1))" } set { _ = newValue } } + + #if os(macOS) + var platformStoredProperty = 123 + + @available(macOS 26, *) + var platformComputedProperty: Int { + platformStoredProperty + } + #endif + + @PublisherIgnored + var ignoredStoredProperty = 123 + + @PublisherIgnored + var ignoredComputedProperty: Int { + ignoredStoredProperty + } } } diff --git a/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift new file mode 100644 index 0000000..5ee1535 --- /dev/null +++ b/Tests/RelayTests/Publishable/SubclassedMainActorPublishableTests.swift @@ -0,0 +1,358 @@ +// +// SubclassedMainActorPublishableTests.swift +// Relay +// +// Created by Kamil Strzelecki on 29/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +@MainActor +internal struct SubclassedMainActorPublishableTests { + + @Test + func storedProperty() { + var dog: Dog? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.name.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.name + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "Unknown") + #expect(observationsQueue.popFirst() == nil) + + dog?.age = 5 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == "Paco") + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func 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 SubclassedMainActorPublishableTests { + + @Test + func computedProperty() { + var dog: Dog? = .init() + var publishableQueue = [Bool]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.isBulldog.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() == true) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = nil + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func 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 SubclassedMainActorPublishableTests { + + @Test + func willChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogWillChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func didChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogDidChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedMainActorPublishableTests { + + @MainActor @Publishable @Observable + class PublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @MainActor @Publishable @Observable + final class Dog: PublishableAnimal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} + +extension SubclassedMainActorPublishableTests { + + @MainActor + class NonPublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @MainActor @Publishable @Observable + final class Cat: NonPublishableAnimal { + + var breed: String? + + var isSphynx: Bool { + breed == "Sphynx" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} diff --git a/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift b/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift new file mode 100644 index 0000000..49fdeb0 --- /dev/null +++ b/Tests/RelayTests/Publishable/SubclassedPublishableTests.swift @@ -0,0 +1,356 @@ +// +// SubclassedPublishableTests.swift +// Relay +// +// Created by Kamil Strzelecki on 23/11/2025. +// Copyright © 2025 Kamil Strzelecki. All rights reserved. +// + +@testable import Relay +import Testing + +internal struct SubclassedPublishableTests { + + @Test + func storedProperty() { + var dog: Dog? = .init() + var publishableQueue = [String]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.name.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.name + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == "Unknown") + #expect(observationsQueue.popFirst() == nil) + + dog?.age = 5 + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == "Paco") + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func 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 SubclassedPublishableTests { + + @Test + func computedProperty() { + var dog: Dog? = .init() + var publishableQueue = [Bool]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.isBulldog.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() == true) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.breed = nil + #expect(publishableQueue.popFirst() == false) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func 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 SubclassedPublishableTests { + + @Test + func willChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogWillChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } + + @Test + func didChange() { + var dog: Dog? = .init() + var publishableQueue = [Dog]() + nonisolated(unsafe) var observationsQueue = [Bool]() + + var completion: Subscribers.Completion? + let cancellable = dog?.publisher.dogDidChange.sink( + receiveCompletion: { completion = $0 }, + receiveValue: { publishableQueue.append($0) } + ) + + func observe() { + withObservationTracking { + _ = dog?.age + _ = dog?.name + _ = dog?.breed + _ = dog?.isBulldog + } onChange: { + observationsQueue.append(true) + } + } + + observe() + #expect(publishableQueue.popFirst() == nil) + #expect(observationsQueue.popFirst() == nil) + + dog?.name = "Paco" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.age += 1 + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog?.breed = "Bulldog" + #expect(publishableQueue.popFirst() === dog) + #expect(observationsQueue.popFirst() == true) + observe() + + dog = nil + #expect(publishableQueue.isEmpty) + #expect(observationsQueue.isEmpty) + #expect(completion == .finished) + cancellable?.cancel() + } +} + +extension SubclassedPublishableTests { + + @Publishable @Observable + class PublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @Publishable @Observable + final class Dog: PublishableAnimal { + + var breed: String? + + var isBulldog: Bool { + breed == "Bulldog" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} + +extension SubclassedPublishableTests { + + class NonPublishableAnimal { + + var name = "Unknown" + var age = 0 + + var description: String { + "\(name), \(age)" + } + } + + @Publishable @Observable + final class Cat: NonPublishableAnimal { + + var breed: String? + + var isSphynx: Bool { + breed == "Sphynx" + } + + @ObservationIgnored + override var age: Int { + didSet { + _ = oldValue + } + } + + override var description: String { + "\(breed ?? "-"), \(age)" + } + } +} diff --git a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift b/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift deleted file mode 100644 index 8404c4b..0000000 --- a/Tests/RelayTests/Publishable/SwiftDataPublishableTests.swift +++ /dev/null @@ -1,231 +0,0 @@ -// -// SwiftDataPublishableTests.swift -// Relay -// -// Created by Kamil Strzelecki on 15/05/2025. -// Copyright © 2025 Kamil Strzelecki. All rights reserved. -// - -import Foundation -import Relay -import SwiftData -import Testing - -internal struct SwiftDataPublishableTests { - - @Test - func storedProperty() { - var person: Person? = .init() - var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.name.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.name - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == "John") - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() == "Kamil") - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } - - @Test - func computedProperty() { - var person: Person? = .init() - var publishableQueue = [String]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.fullName.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.fullName - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == "John Doe") - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() == "John Strzelecki") - #expect(observationsQueue.popFirst() == true) - observe() - - person?.age += 1 - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() == "Kamil Strzelecki") - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } -} - -extension SwiftDataPublishableTests { - - @Test - func willChange() { - var person: Person? = .init() - var publishableQueue = [Person]() - nonisolated(unsafe) var observationsQueue = [Bool]() - - var completion: Subscribers.Completion? - let cancellable = person?.publisher.willChange.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.didChange.sink( - receiveCompletion: { completion = $0 }, - receiveValue: { publishableQueue.append($0) } - ) - - func observe() { - withObservationTracking { - _ = person?.age - _ = person?.name - _ = person?.surname - _ = person?.fullName - } onChange: { - observationsQueue.append(true) - } - } - - observe() - #expect(publishableQueue.popFirst() == nil) - #expect(observationsQueue.popFirst() == nil) - - person?.surname = "Strzelecki" - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person?.age += 1 - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person?.name = "Kamil" - #expect(publishableQueue.popFirst() === person) - #expect(observationsQueue.popFirst() == true) - observe() - - person = nil - #expect(publishableQueue.isEmpty) - #expect(observationsQueue.isEmpty) - #expect(completion == .finished) - cancellable?.cancel() - } -} - -extension SwiftDataPublishableTests { - - @Publishable @Model - final class Person { - - var age: Int - fileprivate(set) var name: String - var surname: String - - internal var fullName: String { - "\(name) \(surname)" - } - - package var initials: String { - get { "\(name.prefix(1))\(surname.prefix(1))" } - set { _ = newValue } - } - - init( - age: Int = 25, - name: String = "John", - surname: String = "Doe" - ) { - self.age = age - self.name = name - self.surname = surname - } - } -}