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().

Replies

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.

  • 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.

  • Alright, but what I wanted to highlight is that the value of myVar in the sheet’s View closure does not match the actual value in the outer ContentView, and it is never updated to reflect it’s new value (unless it is changed again). Is this expected as well?

    The .onAppear was just a way for me to check what value myVar actually had.

Add a Comment

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?

  • Thanks for filing FB13660312.

  • No problem! (:

Add a Comment