Odd `willSet` behavior for @BindableState

Look at the following demo code:

import ComposableArchitecture

struct DemoFeature: ReducerProtocol {
    struct State: Equatable {
        @BindableState var count: Int = 0 {
            willSet {
                print("Changing \(lastCount) to \(count)")
                lastCount = count
            }
        }
        var lastCount: Int = 0
    }
    
    enum Action: BindableAction, Equatable {
        case binding(BindingAction<State>)
    }
    
    var body: some ReducerProtocol<State, Action> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .binding(\.$count):
                print("Reducer changing \(state.lastCount) to \(state.count)")
                return .none
            case .binding:
                return .none
            }
        }
    }
}

And the following test cases:

func testCountDirectly() throws {
    var state = DemoFeature.State()

    XCTAssertEqual(state.count, 0)
    XCTAssertEqual(state.lastCount, 0)
        
    state.count = 10
    XCTAssertEqual(state.count, 10)
    XCTAssertEqual(state.lastCount, 0)

    state.count = 20
    XCTAssertEqual(state.count, 20)
    XCTAssertEqual(state.lastCount, 10)
}

This passes with the output:

Changing 0 to 0
Changing 0 to 10

Now I try to mutate the value through the store:

func testCount() throws {
    let store = TestStore(
        initialState: .init(),
        reducer: DemoFeature()
    )
    XCTAssertEqual(store.state.count, 0)
    XCTAssertEqual(store.state.lastCount, 0)
    
    _ = store.send(.set(\.$count, 10)) {
        $0.count = 10
    }
    _ = store.send(.set(\.$count, 20)) {
        $0.count = 20
        $0.lastCount = 10
    }
}

Which fails:

Reducer changing 0 to 10
Changing 0 to 0
Reducer changing 0 to 20
Changing 0 to 10
Test_SetDemo.swift:39: error: -[TestDemoFeature testCount] : A state change does not match expectation: …

      DemoFeature.State(
        _count: 20,
    −   lastCount: 10
    +   lastCount: 0
      )

(Expected: −, Actual: +)

Even weirder: I can remove $0.lastCount = 10 in the last test and I will still get the same output. So even if I don't tell the test, that I'm expecting lastCount to be 10 it would still fail, telling me it should be 10.

Does anyone know what's going on here?

(I know I can probably use onChange in a view to track changes to a binding, but in my real world use case I fear it would not track changes happening during setup outside of the view. And testing without the view would be hard.)

Because it references BindableState<Value>, the setter of BindingAction acts on the wrappedValue:

public static func set<Value: Equatable>(
  _ keyPath: WritableKeyPath<Root, BindableState<Value>>,
  _ value: Value
) -> Self {
  return .init(
    keyPath: keyPath,
    set: { $0[keyPath: keyPath].wrappedValue = value },
    value: value
  )
}

This doesn't trip the willSet observation, and the lastCount update. You can check this by rewriting your direct test as:

- state.count = 20
+ state.$count.wrappedValue = 20

and your test won't pass anymore.
I took the habit of avoiding willSet/didSet observers on wrapped properties in general for this reason, as you don't have guarantees that any mutation will always be spotted.
I don't know what it the correct solution here.

1 Like

I rewrote the direct test according to your suggestion but it still passes:

func testCountDirectly() throws {
    var state = DemoFeature.State()

    XCTAssertEqual(state.count, 0)
    XCTAssertEqual(state.lastCount, 0)
    
    state.count = 10
    XCTAssertEqual(state.count, 10)
    XCTAssertEqual(state.$count.wrappedValue, 10)
    XCTAssertEqual(state.lastCount, 0)

    state.count = 20
    XCTAssertEqual(state.count, 20)
    XCTAssertEqual(state.$count.wrappedValue, 20)
    XCTAssertEqual(state.lastCount, 10)
}

Ah, sorry. I misunderstood. What you meant was this, right:

func testCountDirectly() throws {
    var state = DemoFeature.State()

    XCTAssertEqual(state.count, 0)
    XCTAssertEqual(state.lastCount, 0)
    
    state.$count.wrappedValue = 10
    XCTAssertEqual(state.count, 10)
    XCTAssertEqual(state.$count.wrappedValue, 10)
    XCTAssertEqual(state.lastCount, 0)

    state.$count.wrappedValue = 20
    XCTAssertEqual(state.count, 20)
    XCTAssertEqual(state.$count.wrappedValue, 20)
    XCTAssertEqual(state.lastCount, 10)
}

This fails:

Test_SetDemo.swift:27: error: -[TestDemoFeature testCountDirectly] : XCTAssertEqual failed: ("0") is not equal to ("10")

I still can't wrap my head around what's going on. Here is a better output of the second test:

Reducer
lastCount: 0
count: 10

willSet
lastCount: 0
count (to be stored as new lastCount): 0

Reducer
lastCount: 0
count: 20

willSet
lastCount: 0
count (to be stored as new lastCount): 10

What I don't understand:

  1. willSet is executed every time, even though the binding itself is not changed, only the wrappedValue.
  2. willSet is executed after the reducer but when the reducer is running count has already been updated.
  3. How come the TestStore knows that lastCount should be 10 even though I removed the assertion?

Sorry if I wasn't clear. You can run the following code in a playground for example:

@propertyWrapper
struct Wrapped<Value> {
  var wrappedValue: Value
  var projectedValue: Self {
    get { self }
    set { self = newValue }
  }
}

struct Test {
  @Wrapped var count: Int = 0 {
    willSet {
      print("willSet \(newValue)")
    }
  }
}

var test = Test()
test.count = 10 // This will activate willSet
test.count == 10 // true

test.$count.wrappedValue = 20 // This will not activate willSet
test.count == 20 // true

test.count = 30 // This will activate willSet
test.count == 30 // true

You can check in the console that when you set the value through the wrappedValue, it doesn't trigger the willSet observer. This is also what is happening in the case of BindingAction, and thus, the logic that updates your lastCount doesn't run.

I'm not really sure why willSet is triggered in your case. I'll try to have a closer look at it later.

Ah yes, of course! You're triggering the willSet manually when you're defining your expected state:

_ = store.send(.set(\.$count, 10)) {
  $0.count = 10  // <- This triggers willSet!
}

So that explains why you're getting these surprising results. Again, I don't know the proper workaround, other than ditching theses observers. You can probably add an onChange higher order reducer to observe count from the reducer and automatically update lastCount when it changes.

Ah, that explains. Thank you!

I tried using onChange but it didn't work in my case as I need the last value inside .binding(\.$count) which seems to get executed before the action I send from the onChange closure.

So what I'm doing now is always setting count and lastCount to the same value so I have the lastCount when .binding(\.$count) is called. Feels a bit like a hack and very error-prone.

I like the idea with the higher order reducer. I will try to create one. Thanks again!