Sharing (observed) state through a computed property in combination with a didSet observer in the child causes an infinite loop
djangovanderheijden opened this issue · 1 comments
Description
Whilst converting my project to TCA 1.7 / Observation I encountered a situation that causes an infinite loop where this would previously work fine. I use a computed property on one of my parent reducers to share some state with a child reducer. The child is part of an enum of several different child types. In the child, I would respond to the state sharing through a didSet observer where I further synchronize the state change down to any children that may need access to the shared state.
A combination of the following is required for this infinite loop to happen:
- Parent and child use
@ObservableState
- Parent scopes child through a computed property where certain state is shared.
- Child is part of an enum of child types.
- Child observes changes to the shared state with a
didSet
observer. The shared state needs to be accessed in this observer. - Child accesses state in the view using
WithPerceptionTracking
(doesn't have to be the shared state).
Checklist
- I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
- If possible, I've reproduced the issue using the
main
branch of this package. - This issue hasn't been addressed in an existing GitHub issue or discussion.
Expected behavior
Pre-observation, this setup works fine. The addition of @ObservableState
and WithPerceptionTracking
to the child triggers the behavior.
Actual behavior
The application hangs and the message printed in the didSet
observer is repeated ad infinitum.
Steps to reproduce
@Reducer
struct Parent {
@ObservableState
struct State: Equatable {
var sharedState: String = "hello, world"
var _kind: Kind = .child(.init())
var kind: Kind {
get {
switch self._kind {
case var .child(state):
// Ensures child state matches that of the parent
state.sharedState = self.sharedState
return .child(state)
}
}
set {
// Normally I'd synchronize the shared state from the child back into the parent here
// but this is not relevant for the issue.
self._kind = newValue
}
}
@CasePathable
enum Kind: Equatable {
case child(Child.State)
}
}
enum Action {
case child(Child.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.kind, action: \.self) {
Scope(state: \.child, action: \.child) {
Child()
}
}
}
}
struct ParentView: View {
let store: StoreOf<Parent>
var body: some View {
WithPerceptionTracking {
switch self.store._kind {
case .child:
if let store = self.store.scope(state: \.kind[case: \.child], action: \.child) {
ChildView(store: store)
}
}
}
}
}
@Reducer
struct Child {
@ObservableState
struct State: Equatable {
var sharedState: String = "" {
didSet {
// This access of self.sharedState causes an infinite loop
guard self.sharedState != oldValue else {
return
}
// Normally I'd synchronize self.sharedState down to children that need access to it here
print("Shared state changed")
}
}
}
enum Action {
}
var body: some ReducerOf<Self> {
EmptyReducer()
}
}
struct ChildView: View {
@Perception.Bindable var store: StoreOf<Child>
var body: some View {
WithPerceptionTracking {
Text(self.store.sharedState)
}
}
}
The Composable Architecture version information
1.7.1
Destination operating system
iOS 17.2.2
Xcode version information
15.2 (15C500b)
Swift Compiler version information
swift-driver version: 1.87.3 Apple Swift version 5.9.2 (swiftlang-5.9.2.2.56 clang-1500.1.0.2.5)
Target: arm64-apple-macosx14.0
Hi @djangovanderheijden, this is not an infinite loop in the usual sense where the stack blows up. Instead, it's an infinite "render" loop, where a confluence of things is causing the view to invalidate and re-render over and over.
And the reason this is happening is because when doing guard self.sharedState …
in the child, that code is being executed in the context of the parent view's body (via the kind
getter), and so due to how observation works in Swift, the parent view now observes changes to sharedState
. And this is happening even though it seems as if the ParentView
doesn't access sharedState
at all. It secretly is. That sets up a chain reaction of over-observing state and view invalidation, causing an infinite "render" loop.
This isn't a bug in the library per-se. You could reproduce this problem in vanilla SwiftUI too. Sadly the fix is to be more conscience of how you are accessing inter-dependent state in this way. If you access sharedState
through its underscored property, it will prevent the parent from observing that state and fix the problem:
guard self._sharedState != oldValue else {
return
}
And I would say in general it's probably a good idea to only access the underscored property in didSet
s, and even in init
s. That helps prevent views from observing state that it doesn't actually care about.
Since this isn't an issue with the library I am going to convert it to a discussion. Feel free to discuss more over there.