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)
}
}