Expose observe overloads for separate tracking and application of changes#286
Expose observe overloads for separate tracking and application of changes#286maximkrouk wants to merge 16 commits intopointfreeco:mainfrom
observe overloads for separate tracking and application of changes#286Conversation
|
@stephencelis @mbrandonw Looking forward for your review 🫠 |
|
Hi @maximkrouk, I'm sorry but I still don't really understand what you're are trying to achieve here, and why it is any better than just using Also, have you tried implementing these tools outside of the library? If not, can you try? And if you run into problems can you let us know what those are? |
|
Here is a simplified example in an executable swift package maximkrouk/swift-navigation-test-repo:simplified The issue is that nested observe applictions are triggering parent ones. This behavior is expected because the Redundant updates are present for any amount of nested changes so it'll be a pretty bit problem in a larger application structure like this (pseudocode) If we set appView.setModel(appModel)where func setModel(_ model:) {
observe {
childView.setModel(model.child) // will call observe for child props and probably `setModel` for child.child etc.
}
}then AppView { // ← this
MainView { // ← this
Header { // ← this
Labels { // ← and this update will be triggered
UsernameLabel() // ← for a simple text change here
}
}
}
}Implementing smth similar outside the lib requires jumping through hoops, I did smth similar utilizing And last but not least I genuinely believe that adding Some examples of what people might do for convenience // autoclosure-based observation
func observe<Value>(
_ value: @Sendable @escaping @autoclosure () -> Value,
onChange: @Sendable @escaping (Value) -> Void
) -> ObserveToken {
SwiftNavigation.observe { _ = value() } onChange: {
onChange(value())
}
}
observe(myModel.text) { label.text = $0 }// KeyPath-based observation with weak capture
func observeWeak<Object: AnyObject & Perceptible & Sendable, Value>(
_ object: Object,
_ keyPath: KeyPath<Object, Value> & Sendable,
onChange: @Sendable @escaping (Value) -> Void
) -> ObserveToken {
SwiftNavigation.observe { [weak object] in
_ = object?[keyPath: keyPath]
} onChange: { [weak object] in
object.map { onChange($0[keyPath: keyPath]) }
}
}
observeWeak(myModel, \.text) { label.text = $0 }Oh and
it's not just better, it allows to correctly process nested observables (this is possible with pure |
|
I was trying to cover the issue extensively, but tldr is:
|
|
@maximkrouk Revisiting this I think we're down to merge this! I merged |
| _ tracking: @escaping @MainActor @Sendable () -> Void, | ||
| onChange apply: @escaping @MainActor @Sendable () -> Void | ||
| ) -> ObserveToken { | ||
| observe { _ in apply() } |
There was a problem hiding this comment.
Why doesn't this function body ignore the tracking parameter? Could we have a unit test to check that this actually works?
There was a problem hiding this comment.
Indeed it has to be used and I fixed it in my local version a while ago, but didn't push it, I'll push an update tomorrow
Wow, it's been a while, but I'm glad anyway 😅 I'll take a look and push an update tomorrow |
- Fix Xcode26 warnings related to redundant use of @_inheritActorContext - Fix NSObject.observe
|
I left a comment related to this branch here #306 (comment) |
|
@maximkrouk Replied, but the short of it is you can ignore those warnings! |
|
I fixed comments and updated tests but now there are 2 other issues:
I ran out of time today, so I'll take a look on it tomorrow, but from high level overview of my changes it doesn't look like it should break anything (especially for the old signature with one closure) 🤔 |
…pose-observe-function # Conflicts: # Sources/SwiftNavigation/Observe.swift
2886926 to
684393d
Compare
| XCTAssertEqual( | ||
| MockTracker.shared.entries.withValue(\.count), | ||
| 13 | ||
| ) |
There was a problem hiding this comment.
The order of logs is not consistent here, so I decided to assert by presence of "ParentObject.childUpdate" which is stable across runs and indicates the problem of unscoped observation
|
UPD:
|
| Task { | ||
| await operation() | ||
| } | ||
| call(operation) |
There was a problem hiding this comment.
The task here was breaking the isolation, maybe marking everything as @_inheritActorContext + @isolated(any) wasn't required, I'll check if it works without it later 🫠
There was a problem hiding this comment.
Btw Task.yield() is still not sufficient for awaiting for changes to be applied 😔
There was a problem hiding this comment.
Btw Task.yield() is still not sufficient for awaiting for changes to be applied 😔
I also noticed that old observe(_:) should use a separate version of onChange/withRecursivePerceptionTracking, current one is calling this shared apply closure too often I'll investigate it a bit later 🫠
| Task { | ||
| await operation() | ||
| } | ||
| call(operation) |
There was a problem hiding this comment.
Btw Task.yield() is still not sufficient for awaiting for changes to be applied 😔
| Task { | ||
| await operation() | ||
| } | ||
| call(operation) |
There was a problem hiding this comment.
Btw Task.yield() is still not sufficient for awaiting for changes to be applied 😔
I also noticed that old observe(_:) should use a separate version of onChange/withRecursivePerceptionTracking, current one is calling this shared apply closure too often I'll investigate it a bit later 🫠
| @Sendable | ||
| private func call(_ f: @escaping @Sendable () -> Void) { | ||
| f() | ||
| } |
There was a problem hiding this comment.
Workarounds to make @escaping @isolated(any) @Sendable () -> Void functions callable from sync contexts
TODO: remove
@escaping
It does look like we need to bring it back (#316) but we still want |
# Conflicts: # Sources/SwiftNavigation/Observe.swift
|
Tested on Xcode 26.0.1 (17A400) and looks like everything works and in sync with |
| apply(transaction) | ||
| } | ||
| } task: { transaction, work in | ||
| DispatchQueue.main.async { |
There was a problem hiding this comment.
Probably an important change, I used a higher-level API here, to avoid duplication of derived observation handling and removed DispatchQueue.main.async in favor of ActorProxy isolation on explicitly passed MainActor.shared, today I'm too tired to evaluate the impact, maybe I should fallback to duplication, please take a look 🙏
|
Added the following overload: observe(observableObject.property) { propertyValue in
// handling
}
Note Didn't test multiple properties, but I'm pretty sure this should work as well observe((obj.prop1, obj.prop2)) { tuple in
print(tuple.0, tuple.1)
}But both properties will be read on each change, but it's still an improvement |
|
And an additional simple example for closure-based
Note There is still no way to implement it outside of the library without marking a bunch of stuff available with |
|
I’m a bit late to the party, but I recently opened a discussion proposing the introduction of an identity-based observation strategy. Is this something you considered when designing this @maximkrouk? With the API introduced in this PR, it's already possible to scope the observed state down to the bare minimum, which is great. However, I think allowing updates to be further constrained so they only trigger when the identity of the observed state changes could make the behavior even safer and more predictable. Take this example: class RootViewController: UIViewController {
let model = Model()
override func viewDidLoad() {
super.viewDidLoad()
observe { [weak self] in
guard let self else {
return
}
switch model.destination {
case .detail(let detailModel):
print(detailModel)
}
}
}
}
@Observable
class Model {
var destination: Destination {
.detail(detailModel)
}
@ObservationIgnored
private lazy var detailModel = DetailModel(string: "test")
enum Destination: Hashable, Identifiable {
var id: Self { self }
case detail(DetailModel)
}
}
@Observable
class DetailModel: HashableObject {
var string: String
init(string: String) {
self.string = string
_ = self.string.count
}
}As you can see, As you can see, This could be avoided with a dedicated
Please refer to my discussion for more details. |
|
I'm not sure if particualrly
should be a part of observe(model) { model in
// this will never be called since users
// have to observe properties, not just an observable object
}Maybe smth like observe<Object: Perceptible, each Value>(
_ model: Object,
_ scopes: repeat each (Object) -> Value, // should accept KeyPaths
onChange: (repeat each Value) -> Void // will provide pure args, without "tupling" them
) -> ObserveTokenwill be more suitable for library-level 🤔
Note present(
item: $model.detail,
onDismiss: { ... },
content: { DetailController(model: $0) }
)Currently if |
| /// observable model in order to populate your view, and also automatically track changes to | ||
| /// any fields accessed in the tracking parameter so that the view is always up-to-date. | ||
| /// | ||
| /// - Parameter tracking: A closure that contains properties to track |
There was a problem hiding this comment.
| /// - Parameter tracking: A closure that contains properties to track | |
| /// - Parameter context: A closure that contains properties to track |
| @discardableResult | ||
| public func observe<T>( | ||
| _ context: @escaping @MainActor @Sendable @autoclosure () -> T, | ||
| onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction, T) -> Void |
There was a problem hiding this comment.
| onChange apply: @escaping @MainActor @Sendable (_ transaction: UITransaction, T) -> Void | |
| onChange apply: @escaping @MainActor @Sendable (T, _ transaction: UITransaction) -> Void |
I think it makes more sense to have the object of the observation as the first argument of the apply closure.
There was a problem hiding this comment.
Won't be possible if we introduce variadic generics, but otherwise I think I agree
UPD: I changed the order as proposed since variadic generic won't work with @autoclosure attribute
| /// A version of ``observe(_:onChange:)-(()->Void,_)`` that is passed updated value. | ||
| /// | ||
| /// - Parameter tracking: A closure that contains properties to track | ||
| /// - Parameter onChange: Invoked when the value of a property changes |
There was a problem hiding this comment.
| /// - Parameter onChange: Invoked when the value of a property changes | |
| /// - Parameter apply: Invoked when the value of a property changes |
There was a problem hiding this comment.
though, in general, I would promote the label onChange to be the parameter name and get rid of apply. That's more similar to withObservationTracking
|
I still think this flavor of observation would be worth adding to the core library. I see the current version of Regarding |
But navigation helpers use all the parameters, And For example my old local API looked kinda like this: updateBindings([ // array of Cancellable
model.observe(\.count.description, onChange: assign(to: \.countLabel.text)),
...
])where And I think such stuff should be local. Note Btw I had to conform
Not sure about that, I mentioned ergonomics perspective, users would be able to pass an But from the memory management perspective users still get a closure-based version to capture objects weakly explicitly, in general |
|
Oh, I just figured that we're basically talking about the same thing :D It's basically the same but:
This method is
One more thing to thing about is base function tracking context signature, currently it's // MARK: - Current
// Pros:
// - no unused types/params
// Cons:
// - users have to discard result for single properties manually
// - users have to discard result for multiple properties manually
// Note:
// - single props can be tracked with `observe(model, \.property) { print($0 }`
observe { _ = model.property } onChange: { print(model.value) }
// MARK: - Proposed
// Pros:
// - users don't have to discard result for single properties manually
// Cons:
// - value returned from tracking context is implicitly discarded
// - users have to discard result for multiple properties manually
// Note:
// - single props can be tracked with `observe(model, \.property) { print($0 }`
observe { model.property } onChange: { print(model.value) }Options:
|
|
Again taking inspiration from the two type two types of navigation functions: // No id filtering (similar to what you propose)
@discardableResult
public func destination<Item>(
item: UIBinding<Item?>,
content: @escaping (UIBinding<Item>) -> UIViewController,
present: @escaping (UIViewController, UITransaction) -> Void,
dismiss:
@escaping (
_ child: UIViewController,
_ transaction: UITransaction
) -> Void
) -> ObserveToken {
destination(
item: item,
id: { _ in nil },
content: content,
present: present,
dismiss: dismiss
)
}
// Id filtering
@discardableResult
public func destination<Item, ID: Hashable>(
item: UIBinding<Item?>,
id: KeyPath<Item, ID>,
content: @escaping (UIBinding<Item>) -> UIViewController,
present:
@escaping (
_ child: UIViewController,
_ transaction: UITransaction
) -> Void,
dismiss:
@escaping (
_ child: UIViewController,
_ transaction: UITransaction
) -> Void
) -> ObserveToken {
destination(
item: item,
id: { $0[keyPath: id] },
content: content,
present: present,
dismiss: dismiss
)
}Given how high-level and generic this tool is, I think identity-based observation is worth including in the core library. Maybe I could just PR it after this on gets merged. |
|
Well, I don't think it's necessary, and you could just make a But maybe it's just me and the feature is more useful than I perceive it 💁♂️ |
Follow-up PR for #281
The issue:
In code like this:
where
call stack will look kinda like this
And since child props access is nested in
observe { // #1, parent will applyself.childComponent.bind(model.child)on any child props change even tho only child should be updated in that case viaobserve { // #2Proposed solution
Provide an
observeoverload that will track and apply changes separately so the pseudocode from above will look like this:call stack will look kinda like this
Final note
Basically the library adds a cool handling of UITransactions and resubscription to
withPerceptionTrackingbut cuts down the ability to separate tracking and updates which is present inwithPerceptionTracking. The solution cannot be replaced with a simple use ofwithPerceptionTrackingsince UITransaction-related stuff is library implementation detail, this PR keeps existing functionality, but brings back a lower-levelwithPerceptionTracking-like API keeping the cool stuff related toUITransaction. The API is not as ergonomic as a basicobservebut it handles an important edgecase and it's sufficient for users of the library to build their own APIs based on the new method.