ra1028 / swiftui-atom-properties

⚛️ Atomic approach state management and dependency injection for SwiftUI

Home Page:https://ra1028.github.io/swiftui-atom-properties/documentation/atoms

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

[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.

commented

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…

@majda107

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.