14.5 beta3 NavigationLink unexpected pop

I've run in to an issues on devices running the 14.5 beta with a couple of production apps (which use TCA) around views being unexpectedly popped from the nav stack.

I've published a repo with an overly simplified implementation that illustrates the problem.

In this example, immediately after pushing Text("Pushed"), it gets popped. This only happens when more than one NavigiationLink is declared on the pushing view (SecondView) AND state is changed. In this example I'm changing the state in an onAppear {} handler by setting loading.

This is definitely something new in 14.5 and I'm concerned something has changed in SwiftUI that TCA may not be handling correctly.

The only hint in the release notes that I could find was this:

The destination of NavigationLink that only differs by local state now resets that state when switching between links as expected. (72117345)

Please have a look at the example project and lmk if anything stands out as something that could be improved.

4 Likes

I came across this issue on the TCA repo, which is very similar to what I'm seeing.

The github issue suggests the problem occurs when there are exactly 2 NavigationLinks on the pushing view. This holds true in both my production apps and my test project.

My workaround for the time being is to add this to any view that has 2 navlinks:

NavigationLink(destination: EmptyView()) {
    EmptyView()
}

This seems absolutely crazy and for sure points to a bug in SwiftUI. Definitely hope it is addressed before 14.5 releases.
Nevertheless I am curious why this is happening. Is it related to 72117345 from the release notes? I don't really understand enough about that issue to say.

2 Likes

After reading you topic I downloaded the beta which broke my app. Thanks for the "fix".
This whole navigationLink thing definitely had issue since the beginning of SwiftUI, I hope Apple will fix this I'd rather not have this everywhere in the app.

I'm trying to reproduce the issue in vanilla rn.

Please post back what you find with vanilla SwiftUI, this is something I've not tried yet.

1 Like

I tried for a couple hours yesterday but I didn't manage to reproduce the issue without TCA. I'll give it another shot soon. From what I've tried, I think the issue lies into TCA.

edit: sorry my message was incomplete

@mbrandonw I was wondering if you've got any thoughts on this? Is there any change needed in TCA to accommodate w/e is going on in SwiftUI?

1 Like

Hi,
I am facing the same issue in my without using TCA. It seems like it's still not fixed in iOS 14.5 RC

The workaround of adding an empty NavigationView works for me as well. I would have preferred see it fixed in SwiftUI though as this seems a bit hacky.

1 Like

Interesting, thank you for this info. Looks like a difference that stands out is you only see it when using @Environment(\.presentationMode).

Did you try adding a StackNavigationViewStyle() to your NavigationView?

NavigationView {

}
.navigationViewStyle(StackNavigationViewStyle())

There were some problems in the past isActive bindings were incorrectly updated in NavigationLinks when pushing a screen on a screen and StackNavigationViewStyle mostly fixed this.

1 Like

Using StackNavigationStyle and also facing the same issue in 14.5 RC. After a state change the view pops back

1 Like

My issues persist too without the empty navigation trick, using StackNavigationViewStyle as well.

Strangely I have one place where the empty navigation trick didn't work.
I was dispatching multiple commands (pop, switch tab, push). I solved the issue by delaying each step.

I've been unable to reproduce it in a simple vanilla SwiftUI app.

I'm facing this issue with the public release of 14.5. I've tried the suggested workarounds with no success.

Has anyone come up with a working solution?

For us we do have the bug in the simulator but not on device.

For people experiencing this bug, are you triggering some state change that causes the original view (the one with the navigation links) to re-calculate it's view body? Perhaps the links are inside a WithViewStore whose store is triggering a body change on the WithViewStore view?

:wave: I'm seeing the same issue on simulator and device (Not using TCA).
For me it happens when my view has exactly two destinations so was able to fix it temporarily making sure I have at least three destinations.

Here's the simplest example I could manage that illustrates the bug. You'll notice that when navigating to screen 3 the view pops off as soon as it's presented

enum Screen: Hashable {
    case second
    case third
}

enum MyAction {
    case selectScreen(Screen?)
}

struct MyState: Equatable {
    var selectedScreen: Screen? = nil
}

struct Environment {}

let myReducer = Reducer<MyState, MyAction, Environment> { state, action, environment in
    switch action {
    case .selectScreen(let screen):
        state.selectedScreen = screen
        return .none
    }
}

struct FirstScreen: View {
    let store: Store<MyState, MyAction>
    
    public var body: some View {
        WithViewStore(self.store) { viewStore in
            NavigationView {
                VStack {
                    Button("Go to screen 2") {
                        viewStore.send(.selectScreen(.second))
                    }
                    
                    NavigationLink(
                        destination: SecondScreen(store: store),
                        tag: Screen.second,
                        selection: viewStore.binding(get: \.selectedScreen, send: .selectScreen(.second)),
                        label: {
                            EmptyView()
                        }
                    )
                }
            }
            .navigationViewStyle(StackNavigationViewStyle())
        }
    }
}

struct SecondScreen: View {
    let store: Store<MyState, MyAction>
    
    public var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack {
                Button("Go to screen 3") {
                    viewStore.send(.selectScreen(.third))
                }
                
                NavigationLink(
                    destination: Text("Third Screen"),
                    tag: Screen.third,
                    selection: viewStore.binding(get: \.selectedScreen, send: .selectScreen(.third)),
                    label: {
                        EmptyView()
                    }
                )
            }
        }
    }
}

Here's a working example of the same navigation just using local state. The pop doesn't happen when navigating to the third screen

struct FirstScreen: View {
    @State var secondScreenActive: Bool = false

    public var body: some View {
        NavigationView {
            VStack {
                Button("Go to screen 2") {
                    secondScreenActive = true
                }

                NavigationLink(
                    destination: SecondScreen(),
                    isActive: $secondScreenActive,
                    label: {
                        EmptyView()
                    }
                )
            }
        }
        .navigationViewStyle(StackNavigationViewStyle())
    }
}

struct SecondScreen: View {
    @State var thirdScreenActive: Bool = false

    public var body: some View {
        VStack {
            Button("Go to screen 3") {
                thirdScreenActive = true
            }

            NavigationLink(
                destination: Text("Third Screen"),
                isActive: $thirdScreenActive,
                label: {}
            )
        }
    }
}

Based on the example given I’d expect the behaviour you’re describing because you’re trying to bind two navigation links to the same piece of state. As soon as you try and navigate to screen three you’re going to trigger a pop on the original link as it’s state has now changed. This does not seem like a bug to me.

You don’t see this issue with the second example because you’re not sharing state.

Interesting, thanks for the feedback on that. I refactored the example to use discrete state for each screen and you're right, I don't see the pop happening. I'll try to retrofit this into my project to see if it fixes my issue

enum MyAction {
    case setSecondScreenVisible(Bool)
    case setThirdScreenVisible(Bool)
}

struct MyState: Equatable {
    var isSecondScreenVisible: Bool = false
    var isThirdScreenVisible: Bool = false
}

struct Environment {}

let myReducer = Reducer<MyState, MyAction, Environment> { state, action, environment in
    switch action {

    case .setSecondScreenVisible(let isVisible):
        state.isSecondScreenVisible = isVisible
        return .none

    case .setThirdScreenVisible(let isVisible):
        state.isThirdScreenVisible = isVisible
        return .none
    }
}

struct FirstScreen: View {
    let store: Store<MyState, MyAction>

    public var body: some View {
        WithViewStore(self.store) { viewStore in
            NavigationView {
                VStack {
                    Button("Go to screen 2") {
                        viewStore.send(.setSecondScreenVisible(true))
                    }

                    NavigationLink(
                        destination: SecondScreen(store: store),
                        isActive: viewStore.binding(get: \.isSecondScreenVisible, send: MyAction.setSecondScreenVisible(_:)),
                        label: {
                            EmptyView()
                        }
                    )
                }
            }
            .navigationViewStyle(StackNavigationViewStyle())
        }
    }
}

struct SecondScreen: View {
    let store: Store<MyState, MyAction>

    public var body: some View {
        WithViewStore(self.store) { viewStore in
            VStack {
                Button("Go to screen 3") {
                    viewStore.send(.setThirdScreenVisible(true))
                }

                NavigationLink(
                    destination: Text(verbatim: "Third Screen"),
                    isActive: viewStore.binding(get: \.isThirdScreenVisible, send: MyAction.setThirdScreenVisible(_:)),
                    label: {
                        EmptyView()
                    }
                )
            }
        }
    }
}

1 Like

A quick follow up as I've totally solved the problem on my project:

in my case I had 3 onboarding screens, all sharing OnboardingState, OnboardingActions. A navigation stack might look like

WelcomeScreen > LoginScreen > TwoFactorScreen

When on the TwoFactorScreen, any change to the onboarding state would cause a re-render in both the Login and Welcome Screens. During this re-render, the NavigationLinks would be either nilled, or read with the incorrect value (still not entirely sure which). This would lead to the TwoFactorScreen to be popped from the navigation stack and I would be taken back to the login screen.

To solve the issue I had to split any state in each of the screens to ensure that nothing was being shared, ie

    public var isLoginVisible: Bool = false
    public var isTwoFactorVisible: Bool = false

    public var isLoginRequestInProgress: Bool = false
    public var isTwoFactorRequestInProgress: Bool = false

And I also needed to scope my viewStores to only the state that the individual Screen cared about, ie

    let store: Store<OnboardingState, OnboardingAction>

    @ObservedObject var viewStore: ViewStore<ViewState, OnboardingAction>

    struct ViewState: Equatable {
        var email: String
        var password: String
        var isTwoFactorVisible: Bool
        var requestInProgress: Bool

        init(state: OnboardingState) {
            email = state.email
            password = state.password
            isTwoFactorVisible = state.isTwoFactorVisible
            requestInProgress = state.isLoginRequestInProgress
        }
    }

    public init(store: Store<OnboardingState, OnboardingAction>) {
        self.store = store
        self.viewStore = ViewStore(self.store.scope(state: ViewState.init))
    }

Hope that helps others in this thread. Not sure why this just started coming up in iOS 14.5, but this seems to be the solution

Glad you sorted it - some advice from somebody who has literally just implemented a near identical flow (Landing -> Login -> Maybe TFA) - it can be helpful to divide up your state into discrete domains, so your initial root state might be:

struct AppState: Equatable {
  var loginState: LoginState?
}

enum AppAction {
  case tappedStartLoginButton // reducer will initialise `LoginState`
}

You can use the presence of the login state to drive the navigation to the login screen.

Then you might have:

struct LoginState: Equatable {
  var twoFactorAuthState: TwoFactorAuthState?
}

Breaking your state up in this way makes it much easier to reason about discrete parts of your state, especially through a navigation hierarchy like this and will help avoid over-rendering your views.

In fact there's a good example of this in the TCA repo:
https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/TicTacToe/Sources/Core

1 Like