pointfreeco/swift-composable-architecture

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 didSets, and even in inits. 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.