[Bug]: AtomScope .override() does not update dependent atoms (atoms that watch or select the overridden atom)
majda107 opened this issue · comments
Checklist
- This is not a bug caused by platform.
- Reviewed the README and documentation.
- Checked existing issues & PRs to ensure not duplicated.
What happened?
Based on this issue:
#77
I've implemented scope loading atoms to inject dynamic entites in page scope (based on navigation).
By some time I have noticed uneven reactive updates, so I found that .override() does not trigger watch (observing atoms) update.
I think this is an unexpected behavior and it leads to improper dynamic/reactive updates, based on the atom lifetime. Briefly inspecting source code of the library I have found no logic that would call update on observers (watching atoms).
Expected Behavior
All atoms that read out / select id of the trip entity should dynamically update and stay in sync. Currently they are out of sync, and only some of them update.
Reproduction Steps
Minimal reproduction sample (read the comments):
import SwiftUI
import Atoms
struct TestTrip {
var id: String
}
struct TestScopeAtom: ValueAtom, Hashable {
func value(context: Context) -> TestTrip? {
nil
}
}
struct TestScopeReadIdAtom: ValueAtom, Hashable {
func value(context: Context) -> String {
// we watch the scope atom in order to propagate changes with reactivity
let scopeAtom = context.watch(TestScopeAtom())
return scopeAtom?.id ?? "no id"
}
}
struct TestScopeSelectIdAtom: ValueAtom, Hashable {
func value(context: Context) -> String {
context.watch(TestScopeAtom().select(\.?.id)) ?? "no id"
}
}
struct AtomsPlaygroundScreen: View {
@State private var newTripId: String? = nil // state for current trip (id), simulates path input...
var body: some View {
AtomScope {
VStack {
Text("STATE TRIP ID - \(newTripId ?? "no id")") // id read directly from state (simulates input path)
AtomsPlaygroundScreenInner()
VStack {
Button {
newTripId = nil
} label: {
Text("set id = nil")
}
Button {
newTripId = "1"
} label: {
Text("set id = 1")
}
Button {
newTripId = "2"
} label: {
Text("set id = 2")
}
Button {
newTripId = "abc-453281"
} label: {
Text("set id = 'abc-453281'")
}
}
}
}
.override(TestScopeAtom()) { _ in
if let tripId = newTripId {
return TestTrip(id: tripId)
} else {
return nil
}
}
.id(newTripId ?? "no id") // .id ensures to refresh the view and call .override() modifier!
}
}
struct AtomsPlaygroundScreenInner: View {
@Watch(TestScopeAtom())
var scope
@Watch(TestScopeAtom().select(\.?.id))
var selectScopeId
@Watch(TestScopeReadIdAtom())
var readId
@Watch(TestScopeSelectIdAtom())
var selectIdAtom
var body: some View {
VStack(alignment: .leading) {
Text("SCOPE ATOM TRIP ID - \(scope?.id ?? "no id")") // id read from overridden scope atom
Text("SCOPE ATOM TRIP ID select - \(selectScopeId ?? "no id")") // id selected from overridden scope atom
Text("READ ATOM TRIP ID - \(readId)") // id read from 'read atom' that uses watch to get newest data
Text("SELECT ATOM TRIP ID - \(selectIdAtom)") // id selected from scope atom, using another atom and watch!
}
.stretchLeading()
.padding(.all, 20)
}
}
Swift Version
Apple Swift version 5.8.1 (swiftlang-5.8.0.124.5 clang-1403.0.22.11.100) Target: arm64-apple-darwin22.5.0
Library Version
newest main branch
Platform
iOS
Scrrenshot/Video/Gif
Video of unexpected behavior and breakpoints (scope atom really does update, rest of the atoms do not).
Screen.Recording.2023-08-31.at.13.29.47.1.mp4
Here I've created temporary solution by resetting old children cache...
https://github.com/majda107/swiftui-atom-properties/tree/main
Please check it out, in case of interest I can clean up / refactor the changes and create PR.
This is quite interesting use case though, I didn't expect override
to be used on a State change
@ra1028 What do you think?
This is quite interesting use case though, I didn't expect
override
to be used on a State change @ra1028 What do you think?
It was suggested this way here: #77
by @ra1028 directly, so I would say it’s expected to update the state graph.
By the implementation, it’s clear that it does not update because its missing feature (not a bug) because of immutable store pattern. It also is kinda tricky to implement because of key scoping. See my fork for reference…
Unfortunately, the behavior of updating dependent atoms when AtomScope.override() is called again is not supported and is not currently planned to be supported.
AtomScope.override() overrides the creation of the value when the atom is first accessed by the views in scope, thereby not disposing of the already cached value. This is because updating a view in a declarative UI cannot be completely restricted in its timing, and disposing of the cache every time the parent view is updated would have a significant negative impact on performance.
Apologies that my advice in #77 was only one of the ideas I could provide based on limited information and didn't cover the requirement that tripId be updated by the parent view while the child view is being displayed.
You can call ViewContext.reset() to clear the cache of the overridden atom, but I personally recommend you stop the tricky approach and switch to a more straight forward implementation.
To avoid Prop drilling, I think you can go with my first idea in #77 or using EnvironmentValues in conjunction with atoms would be a good idea.