Scott Wood
Scott Wood

Reputation: 404

Why does setting property in didSet of published property cause infinite loop?

I'm attempting to use SwiftUI Toggle to control the state of a boolean stored on the server.

On the didSet of the @Published property, I'm making a network call to persist the state. Successful network calls work great. On failure, I am attempting to set the property back to its previous state. This causes an infinite loop. This seemingly only happens if the property is wrapped (@Published, @State, @Binding).

When a property is not using a wrapper, the dev has full control to programmatically determine what the value of the property should be and can set it without the didSet being called infinitely. This is intentional - it's a primary example of why didSet even exists - to allow the user to validate, filter, restrict, etc. the result and then set it to what is allowable.

Presumably this has to do with the property wrappers using Combine and listening to any state changes and triggering the property observers endlessly.

Is there a way to stop this behavior? It feels like a bug. If not, any proposals on how to handle my request?

Here is a simple playground that shows the issue:

import SwiftUI
import PlaygroundSupport

class VM: ObservableObject {
    var loopBreaker = 0
    var networkSuccess = false
    @Published var isOn: Bool = false
    {
        didSet {
            print("isOn: \(isOn), oldValue \(oldValue)")
            
            // Code only to break loop
            loopBreaker += 1
            if loopBreaker > 4 {
                print("break loop!")
                networkSuccess.toggle()
                loopBreaker = 0
            }
            ///////////////////////////////////////////////
            
            // call server to store state
            guard networkSuccess else {
                // ENDLESS LOOP!
                isOn = oldValue
                return
            }
        }
    }
    
    var enabled: Bool = false
    {
        didSet {
            print("enabled: \(enabled), oldValue \(oldValue)")
            enabled = oldValue
            print("enabled: \(enabled), oldValue \(oldValue)")
        }
    }
}

struct ContentView: View {
    @ObservedObject var vm = VM()
    var body: some View {
        Toggle("Hello World", isOn: $vm.isOn)
            .onChange(of: vm.isOn) {
                vm.enabled = $0
            }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Output:

isOn: true, oldValue: false
isOn: false, oldValue: true
isOn: true, oldValue: false
isOn: false, oldValue: true
isOn: true, oldValue: false
break loop!
enabled: true, oldValue: false
enabled: false, oldValue: false

Note that I show both cases here, an @Published property: isOn and an unwrapped, generic swift property: enabled.

I also added a way to break the loop so your entire Xcode doesn't crash or become unresponsive.

Additional info:

@DalijaPrasnikar Pointed me to an answer that may provide a hint as to the problem. According to this, you can only set the property in a didSet if you have direct memory access. Perhaps I don't have that when these properties are wrapped by these types? But how do I gain direct memory access of a wrapped property

Here is a link to an answer that extracts out the swift documentation that leads me to believe I should be able to do this. This same documentation alludes to the fact that a property can not be set in the willSet observer.

Below is an even more concise playground to show the differences:

class VM: ObservableObject {
    @Published var publishedBool: Bool = false { didSet {
        publishedBool = oldValue // ENDLESS LOOP - Will need to comment out to see nonWrappedBool functionality
    } }
    var nonWrappedBool: Bool = false { didSet {
        nonWrappedBool = oldValue   // WORKS AS EXPECTED - Must comment out publishedBool `didSet` in order for this to get hit
    } }
}

struct ContentView: View {
    @ObservedObject var vm = VM()
    var body: some View {
        Toggle("Persist this state on server", isOn: $vm.publishedBool)
            .onChange(of: vm.publishedBool) {
                vm.nonWrappedBool = $0  // OnChange won't be called unless publishedBool `didSet` is commented out. Endless loop occurs first
            }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Upvotes: -1

Views: 1123

Answers (3)

Scott Wood
Scott Wood

Reputation: 404

The solution we went with is very similar to @vadian answer (but without the binding) and effectively what @DalijaPrasnikar proposed in the comments - add another property to know the state of the system. This feels inherently anti-SwiftUI (declarative) UI development. Every solution proposed has created a state machine.

It feels like I should be able to write code that will restrict/modify/filter the UI without needing additional properties to maintain state - but seemingly that can't be done.

With that being said, here is our solution:

import SwiftUI
import PlaygroundSupport
class VM: ObservableObject {
    private let networkFailed = true
    @Published var isSaving: Bool = false
    @Published var publishedBool: Bool = false
    {
        didSet {
            guard !isSaving else {
                print("ignore didSet")
                return
            }
            isSaving = true
            Task {
                try! await Task.sleep(for: .seconds(2))
                if networkFailed {
                    print("revert state")
                    publishedBool = oldValue
                }
                isSaving = false
            }
        }
    }
}

struct ContentView: View {
    @ObservedObject var vm = VM()
    var body: some View {
        Toggle("Persist this state on server", isOn: $vm.publishedBool)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

The key to this code is the isSaving property. We set it to true before the asynchronous code, set the property to our desired value (if need be) - this will call didSet again but the guard breaks the loop, then set isSaving to false at the end of the function.

Upvotes: -1

vadian
vadian

Reputation: 285082

The cause of the endless loop is pretty clear: You modify isOn in its didSet property observer

  • which calls didSet again
  • which modifies isOn
  • which calls didSet
  • which modifies isOn
  • which calls didSet
  • which modifies isOn
  • and so on … and so on … and so on ...

Conclusion: The Toggle cannot be reset in a @Published property observer.


A reliable alternative is a custom Binding in the view.

In VM create an additional Bool property disableToggle.
Create also a function which first disables the Toggle, then performs the network request (here a sleeping Swift Concurrency Task) and on failure switches the Toggle back. Finally it re-enables the Toggle

@MainActor
class VM: ObservableObject {
    var networkSuccess = false
    @Published var isOn: Bool = false
    @Published var disableToggle: Bool = false
    
    public func networkRequest() {
        disableToggle = true
        Task {
            try! await Task.sleep(for: .seconds(2))
            networkSuccess = [true, false].randomElement()!
            if !networkSuccess { isOn.toggle() }
            disableToggle = false
        }
    }
}

In the view add the custom Binding which just calls the network function in the set scope

struct ContentView: View {
    @StateObject var vm = VM()
    
    var body: some View {
        let toggleBinding = Binding(
            get: { return vm.isOn },
            set: {
                vm.isOn = $0
                vm.networkRequest()
            }
        )
        Toggle("Persist this state on server", isOn: toggleBinding)
            .disabled(vm.disableToggle)
    }
}

Upvotes: 2

thedp
thedp

Reputation: 8508

Here is an even simpler code example to demonstrate the issue you're experiencing.

import SwiftUI
import PlaygroundSupport

class VM: ObservableObject {
    @Published var publishedBool: Bool = false {
        didSet {
            print("publishedBool didSet \(oldValue) -> \(publishedBool)")
            publishedBool = oldValue
        }
    }
}

struct ContentView: View {
    @ObservedObject var vm = VM()  // BTW, this should be @StateObject because you init it here, and not injecting the VM.

    var body: some View {
        Button("press") {
            vm.publishedBool = true
        }
    }
}

PlaygroundPage.current.setLiveView(ContentView())

When you press the button, the endless loop starts.

I think this simpler example demonstrates better what's going on. Notice the output:

publishedBool didSet false -> true
publishedBool didSet true -> false
publishedBool didSet false -> true
publishedBool didSet true -> false
publishedBool didSet false -> true
publishedBool didSet true -> false
...
...
...

And btw, if you try to use willSet, you will end up with the same endless loop.

The @Published is the reason for this odd behavior; If you remove @Published it doesn't happen. Interesting!

Without the @Published it's just a property without the "magical" logic behind the scenes, so it works as expected.

I'm not 100% sure why this enters into this endless loop because of @Published, but I assume it's probably because this property wrapper has an internal logic that causes this continues update. The didSet you placed is not on the value change, but the property wrapper struct change. It could be a bug in @Published implementation.

So you need to consider alternative solutions, which are a better approach anyway. You tried to set a side effect in the VM from the UI onChanged. Instead, you should listen to changes in the VM and update the VM.

import SwiftUI
import PlaygroundSupport
import Combine

class VM: ObservableObject {
    private var cancellables = Set<AnyCancellable>()
    var loopBreaker = 0
    var networkSuccess = false
    @Published var isOn: Bool = false

    var enabled: Bool = false
    {
        didSet {
            print("enabled: \(enabled), oldValue \(oldValue)")
            enabled = oldValue
            print("enabled: \(enabled), oldValue \(oldValue)")
        }
    }

    init() {
        $isOn.sink { [weak self] isOn in
            print("isOn changed: \(isOn)")

            guard let self else { return }

            self.enabled = isOn  // changed inside the VM

            guard isOn else { return }

            // Code only to break loop
            self.loopBreaker += 1
            if self.loopBreaker > 4 {
                print("break loop!")
                self.networkSuccess.toggle()
                self.loopBreaker = 0
            }
            ///////////////////////////////////////////////

            // call server to store state
            guard self.networkSuccess else {
                self.isOn = false
                return
            }
        }
        .store(in: &cancellables)
    }
}

struct ContentView: View {
    @StateObject var vm = VM()

    var body: some View {
        Toggle("Hello World", isOn: $vm.isOn)
    }
}

PlaygroundPage.current.setLiveView(ContentView())

Upvotes: 1

Related Questions