Skip to content

Cancellable issue in root store #3658

@arnauddorgans

Description

@arnauddorgans

Description

If you have multiple root stores of the same feature and return a .cancel(id:) effect in one of those, it will cancel the effect in all other root stores.

Note: It is not the case when you wrap the feature inside an _IfLetReducer.

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

It cancels only the effect of the current store

Simulator.Screen.Recording.-.iPhone.SE.3rd.generation.-.2025-04-16.at.18.10.01.mp4

Actual behavior

Cancels all effects of all stores

Simulator.Screen.Recording.-.iPhone.SE.3rd.generation.-.2025-04-16.at.18.09.30.mp4

Reproducing project

@main
struct TCAApp: App {
    @State private var count = 5
    var body: some Scene {
        WindowGroup {
            VStack {
                Button("-") {
                    count -= 1
                }
                ForEach(0..<count, id: \.self) {
                    FeatureView(id: $0)
                }
                Button("+") {
                    count += 1
                }
            }
        }
    }
}


@ViewAction(for: Feature.self)
struct FeatureView: View {
    @StateObject var store: Store<Feature.State?, Feature.Action>
    @State private var isLoading = false

    init(id: Int) {
        self._store = .init(wrappedValue: {
            .init(initialState: .init(id: id), reducer: {
                Scope(state: \.self!, action: \.self) {
                    Feature()
                }
//                The following code fixes the issue
//                EmptyReducer()
//                    .ifLet(\.self, action: \.self) {
//                        Feature()
//                    }
            })
        }())
    }

    var body: some View {
        WithPerceptionTracking {
            HStack {
                if isLoading {
                    Button("Stop") {
                        send(.buttonTapped)
                    }
                    ProgressView()
                } else {
                    Text("")
                }
            }
            .task {
                isLoading = true
                await send(.task).finish()
                isLoading = false
            }
        }
    }
}

@Reducer
struct Feature {
    @ObservableState
    struct State: Identifiable {
        let id: Int
    }

    enum Action: ViewAction {
        case view(ViewAction)
        
        enum ViewAction {
            case task
            case buttonTapped
        }
    }

    enum CancelID {
        case task
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .view(.task):
                return .run { _ in
                    while !Task.isCancelled {
                        try await Task.sleep(nanoseconds: 1_000_000_000)
                    }
                }
                .cancellable(id: CancelID.task)
            case .view(.buttonTapped):
                return .cancel(id: CancelID.task)
            }
        }
    }
}

The Composable Architecture version information

1.19.0

Destination operating system

iOS 16 - 18

Xcode version information

Xcode 16.3

Swift Compiler version information

swift-driver version: 1.120.5 Apple Swift version 6.1 (swiftlang-6.1.0.110.21 clang-1700.0.13.3)
Target: arm64-apple-macosx15.0

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