-
-
Notifications
You must be signed in to change notification settings - Fork 121
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement @ObservableDefault macro #189
base: main
Are you sure you want to change the base?
Conversation
This addresses sindresorhus#142 by creating a new macro that can be used inside `@Observable` classes. The macro is implemented in a new `DefaultsMacros` module. The decision to do so is based on the introduction of a new dependency on `swift-syntax`, and to keep the main module dependency-free.
Thanks for working on this 🙏 |
👍 |
Tests for the macro declaration were in the wrong test target and the test method names were not accurate. Added another test target to actually test that the macro works when used in an @observable class.
This is to be consistent with the `@Default` property wrapper.
In the macro tests, I had to move the @observable classes out of the test because linter was asking to mark them as private but doing so was causing the @observable macro to error.
I think this is now ready for review. Please, let me know about any issues/concerns. BTW I renamed the macro to |
I don't think the |
The macro is also missing an important feature; it does not trigger updates to the model if a |
Thank you for fixing the typos.
Ah, this makes sense in hindsight. Will rename 👍🏽
Yeah, this is indeed an important shortcoming. I've been fiddling to see if I could come up with something that is easy to use and works well. This is what I arrived at (expanded version), let me know what you think @sindresorhus: @Observable
final class TestModelWithoutMacro {
@ObservationIgnored
var testValue: String {
get {
_ = _testValueObserver // <--- Make sure the lazy var is initialized
access(keyPath: \.testValue)
return Defaults[Defaults.Keys.test]
}
set {
withMutation(keyPath: \.testValue) {
Defaults[Defaults.Keys.test] = newValue
}
}
}
// Use a lazy var so we can access self
@ObservationIgnored
private lazy var _testValueObserver: Defaults.Observation =
Defaults.observe(Defaults.Keys.test) { keyChange in
self.testValue = keyChange.newValue
}
} |
We cannot overload @default, otherwise it is impossible to use the property wrapper version in the same source file as the macro as the later takes precedence.
Yeah, that looks good. Should be |
of node: AttributeSyntax, | ||
providingAccessorsOf declaration: some DeclSyntaxProtocol, | ||
in context: some MacroExpansionContext | ||
) throws -> [AccessorDeclSyntax] { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
) throws -> [AccessorDeclSyntax] { | |
) throws(ObservableDefaultMacroError) -> [AccessorDeclSyntax] { |
And then you can remove ObservableDefaultMacroError
from the throw statements.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah! typed throws are here and I forgot about them. Neat.
Not sure. |
Don't forget to make the self access weak in the closure though. |
Seems like the compiler is straight up crashing when generating a Will ask there and try to come up with other alternatives in the meantime. |
67e35fb
to
1f693cd
Compare
While there's been no activity about the I was fiddling around with other alternatives, and an issue came up that also applies when using the Will keep thinking about other ways, but I'm no longer sure it's possible to do with the current macro capabilities. I may be missing some clever trick to it. |
Use a lock and mark the variable with |
Thank you for the suggestion, a lock may work. But did you mean to mark the We could generate the code below and warn users that if they need the model to be /* This source generates the model below.
@Observable
final class TestModel: @unchecked Sendable {
@ObservableDefault(Defaults.Keys.test)
@ObservationIgnored
var testValue: String
}
*/
@Observable
final class TestModel: @unchecked Sendable {
@ObservationIgnored
var testValue: String {
get {
// Ensure the observation is set up, using the lock to prevent data races
_testValueLock.lock()
if _testValueObservation == nil {
_testValueObservation = Defaults.observe(Defaults.Keys.test, options: []) { [weak self] keyChange in
self?.testValue = keyChange.newValue
}
}
_testValueLock.unlock()
access(keyPath: \.testValue)
return Defaults[Defaults.Keys.test]
}
set {
withMutation(keyPath: \.testValue) {
Defaults[Defaults.Keys.test] = newValue
}
}
}
@ObservationIgnored
private let _testValueLock: Lock = .make()
@ObservationIgnored
private var _testValueObservation: Defaults.Observation?
} |
I was thinking there was a keyword for ignoring sendable, but what I meant was wrap it in an unchecked sendable box: final class LockedSendableValue<Value>: @unchecked Sendable {
private var _value: Value
private let lock = Lock()
init(_ value: @autoclosure @Sendable () throws -> Value) rethrows {
self._value = try value()
}
func withValue<T: Sendable>(
_ operation: @Sendable (inout Value) throws -> T
) rethrows -> T {
try self.lock.withLock {
var value = self._value
defer { self._value = value }
return try operation(&value)
}
}
}
No |
I've been running the suggestion in my head and I think I'm missing something. I'll try to write down the full issue:
Now, here are the options that come to my mind:
I can see that neither of these is a great option, but so far I haven't been able to come up with anything better. Let me know if I've been missing anything from the suggestions, maybe I'm not fully understanding what you meant! |
How about something like this? This also correctly destroys the event subscription when the class deinits: extension Defaults.Keys {
static let test = Key<String>("test", default: "")
}
@Observable
final class TestModelWithoutMacro {
private enum AssociatedKeys {
static var observer = 1
}
@ObservationIgnored
var testValue: String {
get {
if objc_getAssociatedObject(self, &AssociatedKeys.observer) == nil {
let cancellable = Defaults.publisher(.test)
.sink { [weak self] in
self?.testValue = $0.newValue
}
objc_setAssociatedObject(self, &AssociatedKeys.observer, cancellable, .OBJC_ASSOCIATION_RETAIN)
}
access(keyPath: \.testValue)
return Defaults[.test]
}
set {
withMutation(keyPath: \.testValue) {
Defaults[.test] = newValue
}
}
}
} |
Friendly bump in case you forgot about this :) |
Oh hi there, I had not forgotten but I seem to have missed your previous comment with the associated object suggestion, so thanks for the ping. I'll take a look at it this weekend 👍🏽 |
Hi there! Thought I'd pitch in and contribute to this great package. This addresses #142 by creating a new macro that can be used inside
@Observable
classes.The macro is implemented in a new
DefaultsMacros
module. The decision to do so is based on the introduction of a new dependency onswift-syntax
, and to keep the main module dependency-free (specially since the syntax package adds quite a bit to build times). Please, let me know if you'd rather move it to the main module.Settings the PR as draft for early review, but there are at least a couple things I want to do to get it ready. Also, I haven't put much thought into the naming; advice on this is appreciated.
TODO
Figure out if@ObservationIgnored
can be added automatically as part of the macro.