Hi! I'm currently (5.10 and 5.11) unable to manually attach an ObservationTracked macro to an Observable class:
import Observation
@available(macOS 14.0, *)
@available(iOS 17.0, *)
@Observable class Person {
@ObservationTracked var name: String?
}
This expands:
@available(macOS 14.0, *)
class Person {
var name: String?{
@storageRestrictions(initializes: _name) // Cannot find type '_name' in scope
init(initialValue) {
_name = initialValue // Cannot find type '_name' in scope
}
get {
access(keyPath: \.name)
return _name // Cannot find type '_name' in scope
}
set {
withMutation(keyPath: \.name) { // Cannot find type '_name' in scope
_name = newValue
}
}
}
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<Person , Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<Person , Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
@available(macOS 14.0, *) extension Person: Observation.Observable {
}
Attaching the ObservationTracked macro manually seems to be blocking the Observable macro on synthesizing the _name stored variable.[1]
Removing the ObservationTracked macro gets things working again… so I'm not blocked on anything… but is this the intended behavior? The ObservationTracked docs tell us[2]:
The [Observable macro] uses this macro. Its use outside of the framework isn’t necessary.
It's ambiguous to me whether or not that means (to an engineer) "you can use this but you shouldn't have to" or "we don't expect you to use this". Is this a "bug" worth fixing or is this just something that works as intended for now?
I haven't used @Observable in my project, but I think this is the intended behavior. @Observable macro adds ObservationTracked macro to all stored properties unless they have ObservationNotTracked macro (I forgot the exact name) applied. So you are not supposed to add ObservationTracked macro manually.
How about think like this: it's an internal macro and should only be used by @Observable macro.
If you use it manually you must preform ALL of the tasks the macro does - e.g. creating a storage ivar. It is there to be a marker such that other macros can utilize it. In common usage I would not expect folks to ever type @ObservationTracked in their source code, but I would expect that other macros that utilize observation to emit @ObservationTracked to prevent recursion and identify the tracked variables such that it can synthesize parameters later in the application of the macro.
Hmm… I'm actually not completely sure I follow exactly. I can see the ObservationTrackedMacro macro following two roles: PeerMacro and AccessorMacro. For the PeerMacro role, ObservationTrackedMacro does skip out early when it detects ObservationTracked (or ObservationIgnored) was added.[1] For the AccessorMacro role, ObservationTrackedMacrodoes not skip out early when it detects ObservationTracked.[2] This matches up with what I saw originally… adding the macro by hand in code led to synthesized getter and setter but no synthesized ivar.
ObservableMacro follows the MemberAttributeMacro role and skips out on attaching ObservationTracked when it detects the property is already ObservationTracked.[3]
Given that ObservableMacro is not attaching ObservationTracked when it detects ObservationTracked has already been added… in what way would it be undesirable for the check inside the ObservationTrackedMacro implementation of PeerMacro to not return early if ObservationTracked was detected? And why would it not be undesirable for the check inside the ObservationTrackedMacro implementation of AccessorMacro to not return early if ObservationTracked was detected?
There's a meta-question brewing here that is agnostic of Observable… which is what patterns should engineers practice when writing their own macros that attach (publicly available) property macros directly through MemberAttributeMacro. What (if any) edge-casey or race-conditioney behavior from the macro system should we need to defend against (where stateless codegen functions don't have a simple way to save state and know whether an arbitrary property macro came "from inside" or "from outside").
I too wondered about this issue and came across this thread.
Indeed, the behavior when ObsrevationTracked is explicit is odd. The reason why only AccessorMacro works correctly and PeerMacro does not, and the fact that the Observable macro assumes the case where ObsrevationTracked is already attached, are also reasons why I cannot be sure that this usage is not expected.
My personal opinion is that adding an ObservableTracked macro is syntactically the same as adding an ObservableIgnored macro, so I think it should work the same way. I also believe that if we are using a library that extends Observation, it will create a case for adding the ObsrevationTracked macro manually.
I'm using machine translation, so I'm sorry if some parts are hard to understand.
If we modify the ObsrevationTracked so that it can be attached manually, I think we would have to slightly modify the value of the Array<DeclSyntax> returned by the PeerMacro of ObsrevationTracked. If this is not modified, a variable with both the ObsrevationTracked macro and the ObsrevationIgnored macro may be created at the same time.
It seems in the current implementation of SwiftSyntax, the DeclSyntaxProtocol parameter of the secondary macro implementation type (in this case ObservationTrackedMacro) will not include the attribute syntax (in this case @ObservationTracked) generated from the primary macro implementation type (ObservationMacro).
Though a little bit magic, this surely is handy in some way...