pointfreeco / swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
https://www.pointfree.co/collections/composable-architecture
MIT License
11.91k stars 1.37k forks source link

Over-Observation with @Shared-state #3164

Closed andtie closed 1 month ago

andtie commented 1 month ago

Description

When Scoping to a property annotated with @Shared, every action sent to the store triggers a re-render of SwiftUI-views.

The symptoms of this bug are similar to #3163 but the underlying problems seem separate.

Checklist

Expected behavior

When sending an action to a store that does not update the state, a SwiftUI-view should not be re-rendered.

Actual behavior

The SwiftUI-view is re-rendered for every action.

Steps to reproduce

Consider the following feature and view:

@Reducer
struct SharedBugFeature {

    struct Foo {
        var text = ""
    }

    @ObservableState
    struct State {
        @Shared(.inMemory("test"))
        var foo = Foo()
    }

    enum Action {
        case noop
    }

    var body: some Reducer<State, Action> {
        Scope(state: \.foo, action: \.self) {
            EmptyReducer()
        }
    }
}

struct SharedBugView: View {
    let store = StoreOf<SharedBugFeature>(initialState: .init()) {
        SharedBugFeature()
    }

    var body: some View {
        let _ = Self._printChanges()
        Button("Test \(store.foo.text)") {
            store.send(.noop)
        }
    }
}

Every press on the Button triggers a view update.

This is what I found debugging this issue

If a store is scoped to a shared-property, then it is passed as inout-parameter to the reducer closure. This necessarily triggers the setter of the Shared-struct, no matter whether the state was actually changed. This in turn triggers the setter of the underlying ValueReference which calls willSet and didSet of the perception registrar.

Bonus

In the following code, this bug actually triggers an infinite rerender-loop in SwiftUI:

struct InfiniteBugView: View {
    let store = StoreOf<SharedBugFeature>(initialState: .init()) {
        SharedBugFeature()
    }

    var body: some View {
        ZStack {
            Text("foo: \(store.foo.text)")
        }
        .fullScreenCover(isPresented: .constant(true)) {
            NavigationStack {
                Form {
                    Text("foo: \(store.foo.text)")
                        .sheet(isPresented: Binding(
                            get: { true },
                            set: { _ in }
                        )) {
                            NavigationStack {
                                ZStack {
                                    Form {
                                        Text(":: \(store.foo.text)")
                                    }
                                    .sheet(isPresented: .constant(true)) {
                                        Text("Leaf")
                                            .onAppear {
                                                print("onAppear", Date())
                                                store.send(.noop)
                                            }
                                    }
                                }
                            }
                        }
                }
            }
        }
    }
}

The Composable Architecture version information

1.11.1

Destination operating system

iOS 17.5

Xcode version information

Version 15.4 (15F31d)

Swift Compiler version information

Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-darwin23.5.0
stephencelis commented 1 month ago

This behavior is to be expected, since reducers pass the entire value as inout, and @Shared has a single observed wrappedValue property. While some of the coming ownership features may allow us to improve things here, we don't consider this behavior to be a bug, so I'm going to convert to a discussion.

Also, please note that we recommend using @Shared as small properties on a larger state, and do not suggest passing an entire @Shared value to a child reducer.

In the following code, this bug actually triggers an infinite rerender-loop in SwiftUI:

This appears to be a vanilla bug. I can swap out the store for a model and reproduce just fine:

@Observable class Model {
  var text = ""

  func send() {
    text = text
  }
}

struct InfiniteBugView: View {
  let model = Model()

  var body: some View {
    ZStack {
      Text("foo: \(model.text)")
    }
    .fullScreenCover(isPresented: .constant(true)) {
      NavigationStack {
        Form {
          Text("foo: \(model.text)")
            .sheet(isPresented: Binding(
              get: { true },
              set: { _ in }
            )) {
              NavigationStack {
                ZStack {
                  Form {
                    Text(":: \(model.text)")
                  }
                  .sheet(isPresented: .constant(true)) {
                    Text("Leaf")
                      .onAppear {
                        print("onAppear", Date())
                        model.send()
                      }
                  }
                }
              }
            }
        }
      }
    }
  }
}