Skip to content

Cancellable issue in root store #3658

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
3 tasks done
arnauddorgans opened this issue Apr 16, 2025 · 5 comments · May be fixed by #3660
Open
3 tasks done

Cancellable issue in root store #3658

arnauddorgans opened this issue Apr 16, 2025 · 5 comments · May be fixed by #3660
Labels
bug Something isn't working due to a bug in the library.

Comments

@arnauddorgans
Copy link
Contributor

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
@arnauddorgans arnauddorgans added the bug Something isn't working due to a bug in the library. label Apr 16, 2025
@arnauddorgans
Copy link
Contributor Author

To workaround the issue I use this propertyWrapper to wrap the reducer inside an _IfLetReducer

/// A property wrapper that mimics `@StateObject` for root-level TCA features.
///
/// Useful when your feature has no parent (e.g. in your `App`, modal entry point, or top-level view).
/// Ensures the store is only initialized once.
///
/// ```swift
/// @RootStore(MyFeature(), state: MyFeature.State()) var store
/// ```
@propertyWrapper
public struct RootStore<StoreState, StoreAction>: DynamicProperty {
    @StateObject private var store: Store<StoreState?, StoreAction>
    public var wrappedValue: Store<StoreState, StoreAction> { store.scope(state: \.self!, action: \.self) }
    public var projectedValue: Perception.Bindable<Store<StoreState, StoreAction>> {
        .init(wrappedValue)
    }

    /// Creates the wrapped store using `.ifLet(\.self, action: \.self)` around the reducer,
    /// to isolate each root-level feature instance and ensure effect cancellation works correctly.
    ///
    /// This workaround is necessary when multiple instances of the same feature exist at the root level,
    /// to avoid shared cancellation IDs clashing across unrelated store trees.
    ///
    /// - seealso: https://github.com/pointfreeco/swift-composable-architecture/issues/3658
    public init<R: Reducer>(
        _ reducer: @escaping @autoclosure () -> R,
        state initialState: @escaping @autoclosure () -> StoreState,
        withDependencies prepareDependencies: ((inout DependencyValues) -> Void)? = nil
    ) where R.State == StoreState, R.Action == StoreAction {
        self._store = .init(wrappedValue: {
            .init(
                initialState: initialState(),
                reducer: {
                    EmptyReducer()
                        .ifLet(\.self, action: \.self, then: reducer)
                },
                withDependencies: prepareDependencies
            )
        }())
    }
}

@mbrandonw mbrandonw linked a pull request Apr 17, 2025 that will close this issue
@mbrandonw
Copy link
Member

Hi @arnauddorgans, thanks for reporting this. Can you give this PR #3660 a spin and let us know if it works for you?

@arnauddorgans
Copy link
Contributor Author

arnauddorgans commented Apr 17, 2025

@mbrandonw thanks, it fixes the issue, but it could be cool to add the same logic to Scope by adding the keyPath in the store id maybe?
this feature reproduces the bug with states

@Reducer
struct SuperFeature {
    @ObservableState
    struct State {
        var child1: Feature.State = .init(id: 1)
        var child2: Feature.State = .init(id: 2)
        var child3: Feature.State = .init(id: 3)
        var child4: Feature.State = .init(id: 4)
        var child5: Feature.State = .init(id: 5)
    }

    enum Action {
        case child1(Feature.Action)
        case child2(Feature.Action)
        case child3(Feature.Action)
        case child4(Feature.Action)
        case child5(Feature.Action)
    }

    var body: some ReducerOf<Self> {
        Scope(state: \.child1, action: \.child1) {
            Feature()
        }
        Scope(state: \.child2, action: \.child2) {
            Feature()
        }
        Scope(state: \.child3, action: \.child3) {
            Feature()
        }
        Scope(state: \.child4, action: \.child4) {
            Feature()
        }
        Scope(state: \.child5, action: \.child5) {
            Feature()
        }
    }
}
VStack {
                WithPerceptionTracking {
                    FeatureView(store: store.scope(state: \.child1, action: \.child1))
                    FeatureView(store: store.scope(state: \.child2, action: \.child2))
                    FeatureView(store: store.scope(state: \.child3, action: \.child3))
                    FeatureView(store: store.scope(state: \.child4, action: \.child4))
                    FeatureView(store: store.scope(state: \.child5, action: \.child5))
                }
            }

@mbrandonw
Copy link
Member

Hi @arnauddorgans, for that we are going to wait until 2.0. Introducing a navigation ID path for each scoping would be a bit too much with how things are modeled now, but will come for free with the changes we have planned for 2.0.

@arnauddorgans
Copy link
Contributor Author

@mbrandonw okay thanks! but it is weird because Scope and ifLet don't act the same, I don't have the issue with

EmptyReducer()
            .ifLet(\.child1, action: \.child1) {
                Feature()
            }
            .ifLet(\.child2, action: \.child2) {
                Feature()
            }
            .ifLet(\.child3, action: \.child3) {
                Feature()
            }
            .ifLet(\.child4, action: \.child4) {
                Feature()
            }
            .ifLet(\.child5, action: \.child5) {
                Feature()
            }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working due to a bug in the library.
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants