Skip to content

StackState: Dismiss action no longer works after cancelling interactive pop gesture on iOS 16.0 #3673

@takasek

Description

@takasek

Description

When using StackState-based navigation on iOS 16.0, edge swipe back-and-cancel causes the state to become inconsistent: after cancelling, the screen stays visible but its dismiss effect no longer works.

To Reproduce

  1. Build and run this minimal TCA sample (see code below)
  2. Tap 'Go to screen A' to push ScreenA
  3. On ScreenA, start an edge swipe pop, but cancel before finishing (do not complete the pop)
  4. Try tapping the "Dismiss" button: it no longer works.
Simulator.Screen.Recording.-.iPhone.X.-.2025-05-03.at.04.40.06.mp4

Minimal Sample

referred to Examples/CaseStudies/SwiftUICaseStudies/04-NavigationStack.swift

@Reducer
struct NavigationDemo {
  @Reducer
  enum Path {
    case screenA(ScreenA)
  }
  @ObservableState
  struct State: Equatable {
    var path = StackState<Path.State>()
  }
  enum Action {
    case path(StackActionOf<Path>)
  }
  var body: some Reducer<State, Action> {
    Reduce { _, _ in .none }
      .forEach(\.path, action: \.path)
  }
}
extension NavigationDemo.Path.State: Equatable {}
struct NavigationDemoView: View {
  @Perception.Bindable var store: StoreOf<NavigationDemo>
  var body: some View {
    WithPerceptionTracking {
      NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
        Form {
          Section {
            NavigationLink(
              "Go to screen A",
              state: NavigationDemo.Path.State.screenA(ScreenA.State())
            )
            .navigationTitle("Root")
          }
        }
      } destination: { store in
        WithPerceptionTracking {
          switch store.case {
          case let .screenA(store):
            ScreenAView(store: store)
          }
        }
      }
    }
  }
}

@Reducer
struct ScreenA {
  @ObservableState
  struct State: Equatable {}
  enum Action {
    case dismissButtonTapped
  }
  @Dependency(\.dismiss) var dismiss
  var body: some Reducer<State, Action> {
    Reduce { state, action in
      switch action {
      case .dismissButtonTapped:
        return .run { _ in
          await self.dismiss()
        }
      }
    }
  }
}
struct ScreenAView: View {
  let store: StoreOf<ScreenA>
  var body: some View {
    WithPerceptionTracking {
      Form {
        Section {
          Button("Dismiss") {
            store.send(.dismissButtonTapped)
          }
        }
        Section {
          NavigationLink(
            "Go to screen A",
            state: NavigationDemo.Path.State.screenA(ScreenA.State())
          )
        }
      }
    }
    .navigationTitle("Screen A")
  }
}

Additional context

Doing the same with pure SwiftUI (without TCA) does NOT reproduce the issue,
so it appears to be specific to TCA's handling of StackState and navigation state recovery.

struct SwiftUINavigationDemoView: View {
  enum Screen: Hashable {
    case screenA
  }
  @State private var navigationPath: [Screen] = []
  var body: some View {
    NavigationStack(path: $navigationPath) {
      Form {
        Section {
          NavigationLink("Go to screen A", value: Screen.screenA)
            .navigationTitle("Root")
        }
      }
      .navigationDestination(for: Screen.self) { screen in
        switch screen {
        case .screenA:
          SwiftUIScreenAView(navigationPath: $navigationPath)
        }
      }
    }
  }
}
struct SwiftUIScreenAView: View {
  @Binding var navigationPath: [SwiftUINavigationDemoView.Screen]
  @Environment(\.dismiss) private var dismiss
  var body: some View {
    Form {
      Section {
        Button("Dismiss") {
          dismiss()
        }
      }
      Section {
        NavigationLink(
          "Go to screen A",
          value: SwiftUINavigationDemoView.Screen.screenA
        )
      }
    }
    .navigationTitle("Screen A")
  }
}

Checklist

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

Expected behavior

After cancelling the pop gesture, the screen's effects (e.g. dismiss effect) should keep working as before.

Actual behavior

the screen stays visible but its dismiss effect no longer works.

Reproducing project

TCABugExample.zip

The Composable Architecture version information

at least since 1.18.0 until dd34949

Destination operating system

Simulator 16.0, Device 16.0.3. Simulator 16.1 does not reproduce the bug

Xcode version information

Version 16.2 (16C5032a)

Swift Compiler version information

swift-driver version: 1.115.1 Apple Swift version 6.0.3 (swiftlang-6.0.3.1.10 clang-1600.0.30.1)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working due to a bug in the library.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions