SwiftUI Sheet race condition

Hi! While working on my Swift Student Challenge submission it seems that I found a race condition (TOCTOU) bug in SwiftUI when using sheets, and I'm not sure if this is expected behaviour or not.

Here's an example code:

import SwiftUI

struct ContentView: View {
    @State var myVar: Int?
    
    @State private var presentSheet: Bool = false
    
    var body: some View {
        VStack {
            // Uncommenting the following Text() view will "fix" the bug (kind of, see a better workaround below).
            // Text("The value is \(myVar == nil ? "nil" : "not nil")") 
            
            Button {
                myVar = nil
            } label: {
                Text("Set value to nil.")
            }
            
            Button {
                myVar = 1
                presentSheet.toggle()
            } label: {
                Text("Set value to 1 and open sheet.")
            }
        }
        .sheet(isPresented: $presentSheet, content: {
            if myVar == nil {
                Text("The value is nil")
                    .onAppear {
                       print(myVar) // prints Optional(1)
                    }
            } else {
                Text("The value is not nil")
            }
                
        })
    }
}

When opening the app and pressing the open sheet button, the sheet shows "The value is nil", even though the button sets myVar to 1 before the presentSheet Bool is toggled.

Thankfully, as a workaround to this bug, I found out you can change the sheet's view to this:

        .sheet(isPresented: $presentSheet, content: {
            if myVar == nil {
                Text("The value is nil")
                    .onAppear {
                        if myVar != nil {
                            print("Resetting View (TOCTOU found)")
                            let mySwap = myVar
                            myVar = nil
                            myVar = mySwap
                        }
                    }
            } else {
                Text("The value is not nil")
            }
                
        })

This triggers a view refresh by setting the variable to nil and then to its non-nil value again if the TOCTOU is found.

Do you think this is expected behaivor? Should I report a bug for this? This bug also affects .fullScreenCover() and .popover().

Answered by malc in 826281022

It's expected. body needs to call the myVar getter if you want body to be called when the myVar setter is used. And you need this to happen so that the .sheet computes a new version of the closure. You can fix it by putting the bool and the myVar in the same State, e.g.

struct Content {
    var myVar: Int? = nil
    var presentSheet: Bool = false
}
@State var content = Content()
.sheet(isPresented: $content.presentSheet) 

I think this problem appears because Optional unwrapping is not done. (If you check the actual printed value, the value is properly assigned to Optional(1)) How about modifying the code like below?

import SwiftUI

struct ContentView: View {
    @State var myVar: Int?
    @State private var presentSheet: Bool = false
    
    var body: some View {
        VStack {
            // Uncommenting the following Text() view will "fix" the bug (kind of, see a better workaround below).
            // Text("The value is \(myVar == nil ? "nil" : "not nil")")
            
            Button {
                myVar = nil
            } label: {
                Text("Set value to nil.")
            }
            
            Button {
                myVar = 1
                presentSheet.toggle()
            } label: {
                Text("Set value to 1 and open sheet.")
            }
        }
        .sheet(isPresented: $presentSheet, content: {
            if let myVar = myVar {
                Text("The value is nil")
                    .onAppear {
                       print(myVar) // prints Optional(1)
                    }
            } else {
                Text("The value is not nil")
            }
                
        })
    }
}

Indeed, that seems to be a fix for the nil case. But consider the following example:

struct ContentView: View {
    @State var myVar: Int = 0
    @State private var presentSheet: Bool = false
    
    var body: some View {
        VStack {
            Button {
                myVar = 0
            } label: {
                Text("Set value to nil.")
            }
            
            Button {
                myVar = 1
                presentSheet.toggle()
            } label: {
                Text("Set value to 1 and open sheet.")
            }
        }
        .sheet(isPresented: $presentSheet, content: {
            if myVar == 0 {
                Text("The value is nil")
                    .onAppear {
                       print(myVar) // prints 1
                    }
            } else {
                Text("The value is not nil")
            }
                
        })
    }
}

This seems to fail as well, kind of... At least if the first / only button I click is the "open sheet" one. Once I click the "Set value to nil." button as well it starts working properly.

It seems to me this might be some sort of race condition in which the sheet's View is "constructed" and presented to the user before / while (starts before and continues while) myVar becomes 1 without triggering another view refresh due to myVar's update in this process.

I filed a feedback, FB13660312, as I ran again into this issue when creating my Swift Student Challenge project - this time in a different scenario, but the issue is the same. I'd say it follows the same simplified example as before, except the value was used differently inside the sheet (it was passed to an UIViewRepresentable which used it - no if statements were directly used inside the sheet's trailing closure).

I'm curios to hear what others think, do you think this is a valid race condition bug?

Accepted Answer

It's expected. body needs to call the myVar getter if you want body to be called when the myVar setter is used. And you need this to happen so that the .sheet computes a new version of the closure. You can fix it by putting the bool and the myVar in the same State, e.g.

struct Content {
    var myVar: Int? = nil
    var presentSheet: Bool = false
}
@State var content = Content()
.sheet(isPresented: $content.presentSheet) 

@malc Thanks! To be honest, I forgot about this thread. I had to recall this issue from a year ago :)

Yeah, it makes sense now.

Just to be sure that I understood this correctly - the issue is that myVar's getter is never called by the View's body, and thus body doesn't refresh on its updates (and it makes sense now why that additional Text() fixed this issue).

And .sheet references external state (from the parent view) that isn’t part of the isPresented binding, so changes to myVar won’t automatically affect the sheet’s presentation unless they cause body to be re-evaluated.

And, given this, another solution (yours works great as well) that works would be to create a new View used by the .sheet with its own State& Binding variables, etc.

// ContentView body:
.sheet(isPresented: $presentSheet) {
    MySheetView(myVar: $myVar)
}

// Additional View:
struct MySheetView: View {
    @Binding var myVar: Int
    
    var body: some View {
        // ... (something that uses myVar getters)
        VStack {
            if myVar == 0 {
                Text("The value is nil")
            } else {
                Text("The value is 1")
                    .onAppear {
                        print(myVar) // prints 1
                    }
            }
            Text("The value is \(myVar)")
        }
    }
}

Now, myVar's change will invalidate MySheetView, causing it to be re-evaluated. Plus, either way, it's better to do it this way from a modularization perspective.

Now, for the last part, quoting @Engineer :

Since SwiftUI is declarative rather than procedural, and .onAppear performs its action asynchronously, I don’t think the value of myVar is guaranteed to match its outer context.

It definitely makes sense why the .onAppear used to give a different value (and this is actually why I used it; to check myVar's actual value). Now the answer is complete; I wasn't understanding why the .sheet wasn't being refreshed in the process.

Can you confirm that I understood this correctly? Thanks a lot!

SwiftUI Sheet race condition
 
 
Q