SwiftUI navigation pop-to-root and TCA optional reducers - nondeterministic behavior

I am trying to implement a SwiftUI application with NavigationView and an option to dismiss to the root view using Swift Composable Architecure.

FirstView → present SecondView → present ThirdView → dismiss to FirstView

State of the FirstView contains an optional state of the SecondView etc. Because of it, I am using the .optional() operator when combining reducers.

When dismissing from ThirdView to FirstView, I noticed a nondeterministic behavior, where sometimes the assertion in the .optional() operator fails, and sometimes not.

I need help with figuring out what is causing the issue. Any hints on how to implement the "dismiss to root" action would be appreciated.

I am using Xcode 12.2 with Swift 5.3.1, iOS 14.2 and Swift Composable Architecure 0.9.0.

Here is the exact code that I am using (copy&paste in Xcode, and it should run):

import ComposableArchitecture
import SwiftUI

@main
struct DemoApp: App {
  var body: some Scene {
    WindowGroup {
      NavigationView {
        FirstView(store: Store(
          initialState: FirstState(),
          reducer: firstReducer,
          environment: ()
        ))
      }
      .navigationViewStyle(StackNavigationViewStyle())
    }
  }
}

// MARK: - First

struct FirstState: Equatable {
  var second: SecondState?
}

enum FirstAction: Equatable {
  case presentSecond
  case dismissSecond
  case second(SecondAction)
}

let firstReducer = Reducer<FirstState, FirstAction, Void>.combine(
  secondReducer.optional().pullback(
    state: \.second,
    action: /FirstAction.second,
    environment: { _ in () }
  ),
  Reducer { state, action, _ in
    switch action {
    case .presentSecond:
      state.second = SecondState()
      return .none

    case .dismissSecond:
      state.second = nil
      return .none

    case .second(.third(.dismissToFirst)):
      return .init(value: .dismissSecond)

    case .second:
      return .none
    }
  }
)

struct FirstViewState: Equatable {
  let isPresentingSecond: Bool

  init(state: FirstState) {
    isPresentingSecond = state.second != nil
  }
}

struct FirstView: View {
  let store: Store<FirstState, FirstAction>

  var body: some View {
    WithViewStore(store.scope(state: FirstViewState.init(state:))) { viewStore in
      VStack {
        NavigationLink(
          destination: IfLetStore(
            store.scope(
              state: \.second,
              action: FirstAction.second
            ),
            then: SecondView.init(store:)
          ),
          isActive: viewStore.binding(
            get: \.isPresentingSecond,
            send: { $0 ? .presentSecond : .dismissSecond }
          ),
          label: { Text("Present Second") }
        )
      }
      .navigationTitle("First")
      .navigationBarTitleDisplayMode(.inline)
    }
  }
}

// MARK: - Second

struct SecondState: Equatable {
  var third: ThirdState?
}

enum SecondAction: Equatable {
  case presentThird
  case dismissThird
  case third(ThirdAction)
}

let secondReducer = Reducer<SecondState, SecondAction, Void>.combine(
  thirdReducer.optional().pullback(
    state: \.third,
    action: /SecondAction.third,
    environment: { _ in () }
  ),
  Reducer { state, action, _ in
    switch action {
    case .presentThird:
      state.third = ThirdState()
      return .none

    case .dismissThird:
      state.third = nil
      return .none

    case .third:
      return .none
    }
  }
)

struct SecondViewState: Equatable {
  let isPresentingThird: Bool

  init(state: SecondState) {
    isPresentingThird = state.third != nil
  }
}

struct SecondView: View {
  let store: Store<SecondState, SecondAction>

  var body: some View {
    WithViewStore(store.scope(state: SecondViewState.init(state:))) { viewStore in
      VStack {
        NavigationLink(
          destination: IfLetStore(
            store.scope(
              state: \.third,
              action: SecondAction.third
            ),
            then: ThirdView.init(store:)
          ),
          isActive: viewStore.binding(
            get: \.isPresentingThird,
            send: { $0 ? .presentThird : .dismissThird }
          ),
          label: { Text("Present Third") }
        )
      }
      .navigationTitle("Second")
      .navigationBarTitleDisplayMode(.inline)
    }
  }
}

// MARK: - Third

struct ThirdState: Equatable {}

enum ThirdAction: Equatable {
  case dismissToFirst
}

let thirdReducer = Reducer<ThirdState, ThirdAction, Void> { state, action, _ in
  switch action {
  case .dismissToFirst:
    return .none
  }
}

struct ThirdViewState: Equatable {
  init(state: ThirdState) {}
}

struct ThirdView: View {
  let store: Store<ThirdState, ThirdAction>

  var body: some View {
    WithViewStore(store.scope(state: ThirdViewState.init(state:))) { viewStore in
      VStack {
        Button(action: { viewStore.send(.dismissToFirst) }) {
          Text("Dismiss to First")
        }
      }
      .navigationTitle("Third")
      .navigationBarTitleDisplayMode(.inline)
    }
  }
}

I had some time to investigate the problem, and I found a solution. I have created a demo project that shows how navigation with pop-to-root can be implemented when using Composable Architecture.

The whole concept relies on separating the state of the view that is being presented from the state that determines if the corresponding SwiftUI NavigationLink is active. In addition, I have included the logic responsible for handling long-living effects on views that are being presented on the stack in the demo app. I hope this will help someone when struggling with similar issues.

@stephencelis @mbrandonw please let me know what do you think about my solution.

1 Like
Terms of Service

Privacy Policy

Cookie Policy