Nested Destination Reducer unexpected order of dismissal #3492
-
PrerequisiteI have a feature that can navigate to or present a variety of other features. Some of these are required to be presented via @Reducer
struct Destination {
@Reducer
enum Navigation {
case firstFeature(FirstFeature)
case secondFeature(SecondFeature)
}
@Reducer
enum Fullscreen {
case firstFeature(FirstFeature)
case secondFeature(SecondFeature)
}
enum State {
case navigation(Navigation.State)
case fullscreen(Fullscreen.State)
}
enum Action {
case navigation(Navigation.Action)
case fullscreen(Fullscreen.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.navigation.firstFeature, action: \.navigation.firstFeature) {
FirstFeature()
}
Scope(state: \.navigation.secondFeature, action: \.navigation.secondFeature) {
SecondFeature()
}
Scope(state: \.fullscreen.firstFeature, action: \.fullscreen.firstFeature) {
FirstFeature()
}
Scope(state: \.fullscreen.secondFeature, action: \.fullscreen.secondFeature) {
SecondFeature()
}
}
} This setup allows me to scope narrowly and exhaustively in the view: struct ParentFeatureView: View {
let store: StoreOf<ParentFeature>
var body: some View {
NavigationStack {
VStack {
// Parent view's body goes here
}
.navigationDestination(store: store.scope(
state: \.$destination.navigation, // Scopes exhaustively only to `navigation`
action: \.destination.navigation
)) { store in
switch store.case {
case .firstFeature(let firstStore):
FirstFeatureView(store: firstStore)
case .secondFeature(let secondStore):
SecondFeatureView(store: secondStore)
}
}
.fullScreenCover(store: store.scope(
state: \.$destination.fullscreen, // Scopes exhaustively only to `fullscreen`
action: \.destination.fullscreen
)) { store in
switch store.case {
case .firstFeature(let firstStore):
FirstFeatureView(store: firstStore)
case .secondFeature(let secondStore):
SecondFeatureView(store: secondStore)
}
}
}
}
} I’m also using the @Reducer
struct FirstFeature {
enum Action {
case onGoToSecondTap
}
@Dependency(\.dismiss) var dismiss
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onGoToSecondTap:
return .run { _ in
await dismiss()
}
}
}
} Problem DefinitionIn the parent reducer, when I have case .destination(.presented(.fullscreen(.firstFeature(.onGoToSecondTap)))):
state.destination = .fullscreen(.secondFeature(SecondFeature.State(count: 1)))
return .none It doesn’t work as expected—the expected behavior being that the first feature is dismissed and the second feature is presented. This is because the parent’s reducer seems to run before the This issue only occurs when I introduce nested reducers inside the Here is a minimal project that reproduces the issue: And a video for reference: For your convenience, here is the relevant code: Click to expand codeimport ComposableArchitecture
import Foundation
import SwiftUI
@Reducer
struct ParentFeature {
@Reducer
struct Destination {
@Reducer
enum Navigation {
case firstFeature(FirstFeature)
case secondFeature(SecondFeature)
}
@Reducer
enum Fullscreen {
case firstFeature(FirstFeature)
case secondFeature(SecondFeature)
}
enum State {
case navigation(Navigation.State)
case fullscreen(Fullscreen.State)
}
enum Action {
case navigation(Navigation.Action)
case fullscreen(Fullscreen.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.navigation.firstFeature, action: \.navigation.firstFeature) {
FirstFeature()
}
Scope(state: \.navigation.secondFeature, action: \.navigation.secondFeature) {
SecondFeature()
}
Scope(state: \.fullscreen.firstFeature, action: \.fullscreen.firstFeature) {
FirstFeature()
}
Scope(state: \.fullscreen.secondFeature, action: \.fullscreen.secondFeature) {
SecondFeature()
}
}
}
struct State {
@PresentationState var destination: Destination.State?
}
enum Action {
case onFullscreenFirstFeatureButtonTap
case onNavigateFirstFeatureButtonTap
case onNavigateSecondFeatureButtonTap
case destination(PresentationAction<Destination.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onFullscreenFirstFeatureButtonTap:
state.destination = .fullscreen(.firstFeature(FirstFeature.State()))
return .none
case .onNavigateFirstFeatureButtonTap:
state.destination = .navigation(.firstFeature(FirstFeature.State()))
return .none
case .onNavigateSecondFeatureButtonTap:
state.destination = .navigation(.secondFeature(SecondFeature.State(count: 10)))
return .none
case .destination(.presented(.fullscreen(.firstFeature(.onGoToSecondTap)))):
state.destination = .fullscreen(.secondFeature(SecondFeature.State(count: 1)))
print("### set second")
return .none
case .destination(.presented(.navigation(.firstFeature(.onGoToSecondTap)))):
state.destination = .navigation(.secondFeature(SecondFeature.State(count: 1)))
print("### set second")
return .none
case .destination(.dismiss):
print("### dismiss destination")
return .none
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination) {
Destination()
}
}
}
struct ParentFeatureView: View {
let store: StoreOf<ParentFeature>
var body: some View {
NavigationStack {
VStack {
Button {
store.send(.onFullscreenFirstFeatureButtonTap)
} label: {
Text("Fullscreen first feature")
}
Button {
store.send(.onNavigateFirstFeatureButtonTap)
} label: {
Text("Navigate to first feature")
}
Button {
store.send(.onNavigateSecondFeatureButtonTap)
} label: {
Text("Navigate to second feature")
}
}
.navigationDestination(store: store.scope(
state: \.$destination.navigation,
action: \.destination.navigation
)) { store in
switch store.case {
case .firstFeature(let firstStore):
FirstFeatureView(store: firstStore)
case .secondFeature(let secondStore):
SecondFeatureView(store: secondStore)
}
}
.fullScreenCover(store: store.scope(
state: \.$destination.fullscreen,
action: \.destination.fullscreen
)) { store in
switch store.case {
case .firstFeature(let firstStore):
FirstFeatureView(store: firstStore)
case .secondFeature(let secondStore):
SecondFeatureView(store: secondStore)
}
}
}
}
}
@Reducer
struct FirstFeature {
enum Action {
case onGoToSecondTap
}
@Dependency(\.dismiss) var dismiss
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onGoToSecondTap:
return .run { _ in
await dismiss()
print("### dismiss first")
}
}
}
}
struct FirstFeatureView: View {
let store: StoreOf<FirstFeature>
var body: some View {
VStack {
Text("First Feature")
.font(.headline)
Button {
store.send(.onGoToSecondTap)
} label: {
Text("Go to second")
}
}
}
}
@Reducer
struct SecondFeature {
struct State: Equatable {
var count: Int
}
enum Action {
case onExitButtonTap
}
@Dependency(\.dismiss) var dismiss
func reduce(into state: inout State, action: Action) -> Effect<Action> {
switch action {
case .onExitButtonTap:
return .run { _ in
await dismiss()
print("### dismiss second")
}
}
}
}
struct SecondFeatureView: View {
let store: StoreOf<SecondFeature>
var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
Text("Second Feature")
.font(.headline)
Text("\(viewStore.count)")
Button {
store.send(.onExitButtonTap)
} label: {
Text("Exit")
}
}
}
} I hope this clarifies the issue I’m experiencing. Any insights or solutions would be greatly appreciated. |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 3 replies
-
Hi @bohdany-cricut, thanks for the sample app! That's very helpful for us understand what you are trying to do. As far as I can tell this code is working as to be expected. When
Notice that after case .onGoToSecondTap:
return .run { _ in
//await dismiss()
print("### dismiss first")
} …it then works just fine. |
Beta Was this translation helpful? Give feedback.
Hi @bohdany-cricut,
Then you may want to provide some configuration to the child feature so that it knows if it is responsible for dismissing itself or not. That would get around the issue too.
That is happening because the act of changing
destination
to a different case cancels the effects…