SwiftUI Navigation Destination setting for permanent transition

I'm running into a problem in an app that does not use full TCA, but it does use SwiftUI Navigation to, Dependencies, and some other PointFree libraries. I've followed the Modern SwiftUI series closely, studied the Standups app, and read through the docs but I cannot find a mention of this behavior.

I have an InitializationView that does some work during app initialization. If there is no data on disc, or it fails to parse the data on disc, it presents a warning to the user, allowing them to erase their data and start over. In this case, the destination leads to an AuthenticationView in the signUp state. If data is loaded, it jumps the user to a different view.

I've put the business logic of setting the destination in my button's dismiss function. On call, that function sets the destination to the AuthenticationView. However, in practice, I am seeing the alert disappear and the ProgressView of the InitializationView remains on top of the stack. I often get different (correct) behavior in the preview or on simulator when I use breakpoints so I'm assuming there is a buried concurrency issue, or I'm handling the order of my tasks incorrectly.

I've reproduced a minimal version of this error below. On simulator I still see the bug. I introduced spoken breakpoints to tell me the order things are happening, and the order I hear most frequently is AuthenticationViewModel initialized -> destination set to authenticate -> destination set to nil.

Any help is massively appreciated.


import SwiftUI
import SwiftUINavigation

@main
struct minimumtcaApp: App {
    var body: some Scene {
        WindowGroup {
            InitializationView(vm: InitializationViewModel(/*destination:.alert(.failedToInitialize)*/))
        }
    }
}

@MainActor
class InitializationViewModel: ObservableObject {
    
    @Published var destination: Destination? {
        didSet {
            print(String(describing: destination))
        }
    }
    
    enum Destination {
        case alert(AlertState<AlertAction>)
        case authentication(AuthenticationViewModel)
    }
    
    enum AlertAction: Equatable {
        case failedToInitialize
    }
    
    init(
        destination: Destination? = nil
    ) {
        self.destination = destination
        
        if self.destination == nil {
            self.destination = .authentication(AuthenticationViewModel())
        }
    }
    
    func dismissAlertButtonTapped(_ action: AlertAction?) {
        switch action {
        case .failedToInitialize:
            withAnimation {
                self.destination = .authentication(AuthenticationViewModel())
            }
            return
        case .none:
            return
        }
    }

}

extension AlertState where Action == InitializationViewModel.AlertAction {
    static let failedToInitialize = Self {
        TextState("Data failed to load")
    } actions: {
        ButtonState(action: .failedToInitialize) {
            TextState("Yes")
        }
    } message: {
        TextState(
          """
          Unfortunately your past data failed to load. Would you like to start the app as a new user?
          """
        )
    }
}

struct InitializationView: View {
    @ObservedObject var vm: InitializationViewModel
    
    var body: some View {
        NavigationStack {
            Rectangle()
                .foregroundColor(.blue)
                .navigationDestination(
                  unwrapping: self.$vm.destination,
                  case: /InitializationViewModel.Destination.authentication
                ) { $authModel in
                  AuthenticationView(vm: authModel)
                        .navigationBarBackButtonHidden(true)
                }
                .alert(
                    unwrapping: self.$vm.destination,
                    case: /InitializationViewModel.Destination.alert
                ) { action in
                    vm.dismissAlertButtonTapped(action)
                }
        }
    }
}

struct InitializationView_Previews: PreviewProvider {
    static var previews: some View {
        InitializationView(vm: InitializationViewModel())
            .previewDisplayName("Initialization of bare state")
        
        InitializationView(vm: InitializationViewModel(destination: .alert(.failedToInitialize)))
            .previewDisplayName("Deep link into alert state")
    }
}
class AuthenticationViewModel: ObservableObject {
}

struct AuthenticationView: View {
    @ObservedObject var vm: AuthenticationViewModel
    var body: some View {
        Rectangle()
            .foregroundColor(.red)
    }
}


Hi @ocapmycap, unfortunately I think you are just running into 2 different SwiftUI bugs. First, I think launching an app with the alert state already populated is causing SwiftUI to present the alert before the window/view hierarchy is ready. When I run the app in the simulator I see this:

Attempt to present <SwiftUI.PlatformAlertController: 0x13d89b600> on <SwiftUI.UIKitNavigationController: 0x13d81b200> (from <TtGC7SwiftUI32NavigationStackHostingControllerVS_7AnyView: 0x13d81ac00>) whose view is not in the window hierarchy.

The fix is hacky, but it's to wait a tick of the runloop before initializing the state:

init(
  destination: Destination? = nil
) {
  DispatchQueue.main.async {
    self.destination = destination
    if self.destination == nil {
      self.destination = .authentication(AuthenticationViewModel())
    }
  }
}

And second, SwiftUI also has problems with instantaneously swapping one kind of navigation for another. For example, simultaneously dismissing an alert and drilling down (or showing sheet, cover, another alert, etc.) tends to not really work.

Again the fix is to insert a small delay between the nil'ing out of navigation state and the re-populating of it:

case .failedToInitialize:
  DispatchQueue.main.async {
    self.destination = .authentication(AuthenticationViewModel())
  }

Doing both of those things fixed the problems for me. Also, if you do go this route, I would recommend using our @Dependency(\.mainQueue) instead of DispatchQueue.main that way you could use an immediately scheduler in tests and not have to wait around for that runloop tick.

Brandon, thanks so much for your help. I really appreciate it. This worked for me and forced me to learn how to use @Dependency(.mainQueue) which is a small benefit on the side.Thanks so much!

@brandonwilliams

In using mainQueue I am now running into an issue where the initialization produces an error in tests:

test_whenModelLoads_thenDestinationWillBeAuthentication(): @Dependency(\.mainQueue) - An unimplemented scheduler scheduled an action to run immediately.

Comments on UnimplementedScheduler notes this is ideal for testing code paths that should not rely on async functionality, but I am setting mainQueue in my test class. However, elsewhere in the codebase where I am following the pattern in PF tests (eg, mainQueue = DispatchQueue.test and setting the withDependencies model's mainQueue to mainQueue.eraseToAnyScheduler(), I do not get any errors. Might this be a product of relying on the scheduler in the model initializer?

@ocapmycap If the model initializer depends on the main queue, then you'll need to make sure you invoke the model's initializer inside a withDependencies block, otherwise it'll get the default test scheduler, which is the "unimplemented" one you're encountering.

I see what is happening now.

import SwiftUI
import SwiftUINavigation

@main
struct minimumtcaApp: App {
    var body: some Scene {
        WindowGroup {
            InitializationView(vm: InitializationViewModel(/*destination:.alert(.failedToInitialize)*/))
        }
    }
}

@MainActor
class InitializationViewModel: ObservableObject {
    
    @Dependency(\.mainQueue) var mainQueue
    @Published var destination: Destination?
    
    enum Destination {
        case alert(AlertState<AlertAction>)
        case authentication(AuthenticationViewModel)
    }
    
    enum AlertAction: Equatable {
        case failedToInitialize
    }
    
    init(
        destination: Destination? = nil
    ) {
        // ‼️ need to use mainQueue for hack
        mainQueue.schedule {
            self.destination = destination
            
            if self.destination == nil {
                // ‼️ AuthenticationViewModel initializer uses same hack as above
                self.destination = .authentication(AuthenticationViewModel())
            }
        }
    }
    
    /*
     ...
     */
}


struct InitializationView: View {
    @ObservedObject var vm: InitializationViewModel
    
    var body: some View {
        // body
    }
}

class AuthenticationViewModel: ObservableObject {
    @Dependency(\.mainQueue) var mainQueue
    @Published var vm: Destination? = nil
    init() {
        // ‼️ need to use mainQueue for hack
        mainQueue.schedule {
            // initialization work, including setting destination
        }
    }
}

struct AuthenticationView: View {
    @ObservedObject var vm: AuthenticationViewModel
    var body: some View {
        // body
    }
}

Using the hack discovered by Brandon above, I'm adding a tick of the runloop to ensure proper ordering of setting my destination in InitializationViewModel after the window hierarchy is set. However, that initializer will set the destination as another view whose model (AuthenticationViewModel) also uses that hack to get a tick of the run loop. And as that AuthenticationViewModel is set within the InitializationViewModel initializer, it is initializing as the unimplemented test value.

Short of using a customized version of the Dependencies package that sets testValue as immediate, or creating a custom mainQueue dependency is there a way to overwrite all uses of a dependency for an instance of a running test for situations like this?