Skip to content

Isolate cancellation in root stores. #3660

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

Merged
merged 5 commits into from
May 7, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Sources/ComposableArchitecture/Core.swift
Original file line number Diff line number Diff line change
@@ -61,11 +61,11 @@ final class RootCore<Root: Reducer>: Core {
self.reducer = reducer
}
func send(_ action: Root.Action) -> Task<Void, Never>? {
_withoutPerceptionChecking {
_send(action)
}
_withoutPerceptionChecking {
_send(action)
}
}
func _send(_ action: Root.Action) -> Task<Void, Never>? {
private func _send(_ action: Root.Action) -> Task<Void, Never>? {
self.bufferedActions.append(action)
guard !self.isSending else { return nil }

13 changes: 12 additions & 1 deletion Sources/ComposableArchitecture/Internal/NavigationID.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Foundation
@_spi(Reflection) import CasePaths

extension DependencyValues {
@@ -22,14 +23,18 @@ struct NavigationIDPath: Hashable, Sendable {

var prefixes: [NavigationIDPath] {
(0...self.path.count).map { index in
NavigationIDPath(path: Array(self.path.dropFirst(index)))
NavigationIDPath(path: Array(self.path.prefix(self.path.count - index)))
}
}

func appending(_ element: NavigationID) -> Self {
.init(path: self.path + [element])
}

mutating func append(_ element: NavigationID) {
self.path.append(element)
}

public var id: Self { self }
}

@@ -111,6 +116,12 @@ struct NavigationID: Hashable, @unchecked Sendable {
}
}

init() {
self.kind = .keyPath(\Void.self)
self.identifier = UUID()
self.tag = nil
}

static func == (lhs: Self, rhs: Self) -> Bool {
lhs.kind == rhs.kind
&& lhs.identifier == rhs.identifier
Original file line number Diff line number Diff line change
@@ -76,7 +76,9 @@ extension Reducer {
// specialization defined below from being called, which fuses chained calls.
-> _DependencyKeyWritingReducer<Self>
{
_DependencyKeyWritingReducer(base: self) { $0[keyPath: keyPath] = value }
_DependencyKeyWritingReducer(base: self) {
$0[keyPath: keyPath] = value
}
}

/// Places a value in the reducer's dependencies.
@@ -144,7 +146,9 @@ extension Reducer {
// specialization defined below from being called, which fuses chained calls.
-> _DependencyKeyWritingReducer<Self>
{
_DependencyKeyWritingReducer(base: self) { transform(&$0[keyPath: keyPath]) }
_DependencyKeyWritingReducer(base: self) {
transform(&$0[keyPath: keyPath])
}
}
}

7 changes: 5 additions & 2 deletions Sources/ComposableArchitecture/Store.swift
Original file line number Diff line number Diff line change
@@ -176,7 +176,9 @@ public final class Store<State, Action> {
) {
let (initialState, reducer, dependencies) = withDependencies(prepareDependencies ?? { _ in }) {
@Dependency(\.self) var dependencies
return (initialState(), reducer(), dependencies)
var updatedDependencies = dependencies
updatedDependencies.navigationIDPath.append(NavigationID())
return (initialState(), reducer(), updatedDependencies)
}
self.init(
initialState: initialState,
@@ -329,7 +331,8 @@ public final class Store<State, Action> {
}

@available(
*, deprecated,
*,
deprecated,
message:
"Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide: https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths"
)
1 change: 1 addition & 0 deletions Sources/ComposableArchitecture/TestStore.swift
Original file line number Diff line number Diff line change
@@ -541,6 +541,7 @@ public final class TestStore<State: Equatable, Action> {
let reducer = Dependencies.withDependencies {
prepareDependencies(&$0)
sharedChangeTracker.track(&$0)
$0.navigationIDPath.append(NavigationID())
} operation: {
TestReducer(Reduce(reducer()), initialState: initialState())
}
Original file line number Diff line number Diff line change
@@ -1109,7 +1109,7 @@ final class PresentationReducerTests: BaseTCATestCase {
await presentationTask.cancel()
}

func testNavigation_cancelID_parentCancellation() async {
func testNavigation_cancelID_parentCancellation() async throws {
struct Grandchild: Reducer {
struct State: Equatable {}
enum Action: Equatable {
@@ -1193,17 +1193,15 @@ final class PresentationReducerTests: BaseTCATestCase {
let store = await TestStore(initialState: Parent.State()) {
Parent()
}
let childPresentationTask = await store.send(.presentChild) {
await store.send(.presentChild) {
$0.child = Child.State()
}
let grandchildPresentationTask = await store.send(.child(.presented(.presentGrandchild))) {
await store.send(.child(.presented(.presentGrandchild))) {
$0.child?.grandchild = Grandchild.State()
}
await store.send(.child(.presented(.startButtonTapped)))
await store.send(.child(.presented(.grandchild(.presented(.startButtonTapped)))))
await store.send(.stopButtonTapped)
await grandchildPresentationTask.cancel()
await childPresentationTask.cancel()
}

func testNavigation_cancelID_parentCancelTwoChildren() async {
75 changes: 75 additions & 0 deletions Tests/ComposableArchitectureTests/StoreTests.swift
Original file line number Diff line number Diff line change
@@ -1213,6 +1213,81 @@ final class StoreTests: BaseTCATestCase {
}
}
}

@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
@MainActor func testRootStoreCancellationIsolation() async throws {
let clock = TestClock()
let store1 = Store(initialState: RootStoreCancellationIsolation.State()) {
RootStoreCancellationIsolation()
} withDependencies: {
$0.continuousClock = clock
}
let store2 = Store(initialState: RootStoreCancellationIsolation.State()) {
RootStoreCancellationIsolation()
} withDependencies: {
$0.continuousClock = clock
}
store1.send(.tap)
store2.send(.tap)
try await Task.sleep(for: .seconds(1))
store2.send(.cancelButtonTapped)
await clock.run(timeout: .seconds(1))
XCTAssertEqual(store1.count, 42)
XCTAssertEqual(store2.count, 0)
}

@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
@MainActor func testRootStoreCancellationIsolation_TestStore() async throws {
let clock = TestClock()
let store1 = TestStore(initialState: RootStoreCancellationIsolation.State()) {
RootStoreCancellationIsolation()
} withDependencies: {
$0.continuousClock = clock
}
let store2 = TestStore(initialState: RootStoreCancellationIsolation.State()) {
RootStoreCancellationIsolation()
} withDependencies: {
$0.continuousClock = clock
}
await store1.send(.tap)
await store2.send(.tap)
await store2.send(.cancelButtonTapped)
await clock.run()
await store1.receive(\.response) {
$0.count = 42
}
}

@available(iOS 16, macOS 13, tvOS 16, watchOS 9, *)
@Reducer struct RootStoreCancellationIsolation {
@ObservableState struct State: Equatable {
var count = 0
}
enum Action {
case cancelButtonTapped
case response(Int)
case tap
}
@Dependency(\.continuousClock) var clock
enum CancelID { case effect }
var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
case .cancelButtonTapped:
return .cancel(id: CancelID.effect)
case .response(let value):
state.count = value
return .none
case .tap:
return .run { send in
try await clock.sleep(for: .seconds(1))
await send(.response(42))
}
.cancellable(id: CancelID.effect)
}
}
}
}
}

#if canImport(Testing)

Unchanged files with check annotations Beta

///
/// Read <doc:Bindings> for more information.
public struct BindingAction<Root>: CasePathable, Equatable, Sendable {
public let keyPath: _SendablePartialKeyPath<Root>

Check warning on line 147 in Sources/ComposableArchitecture/SwiftUI/Binding.swift

GitHub Actions / xcodebuild (15) (test, IOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'BindingAction' has non-sendable type '_SendablePartialKeyPath<Root>' (aka 'PartialKeyPath<Root>')

Check warning on line 147 in Sources/ComposableArchitecture/SwiftUI/Binding.swift

GitHub Actions / xcodebuild (15) (test, IOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'BindingAction' has non-sendable type '_SendablePartialKeyPath<Root>' (aka 'PartialKeyPath<Root>')

Check warning on line 147 in Sources/ComposableArchitecture/SwiftUI/Binding.swift

GitHub Actions / xcodebuild (15) (test, WATCHOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'BindingAction' has non-sendable type '_SendablePartialKeyPath<Root>' (aka 'PartialKeyPath<Root>')

Check warning on line 147 in Sources/ComposableArchitecture/SwiftUI/Binding.swift

GitHub Actions / xcodebuild (15) (test, WATCHOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'BindingAction' has non-sendable type '_SendablePartialKeyPath<Root>' (aka 'PartialKeyPath<Root>')
@usableFromInline
let set: @Sendable (inout Root) -> Void
/// See ``Sharing/SharedReaderKey/appStorage(_:)`` to create values of this type.
@available(*, deprecated, message: "Use an 'AppStorageKey', instead")
public struct AppStorageKeyPathKey<Value: Sendable>: Sendable {
private let keyPath: _SendableReferenceWritableKeyPath<UserDefaults, Value>

Check warning on line 30 in Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift

GitHub Actions / xcodebuild (15) (test, IOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'AppStorageKeyPathKey' has non-sendable type '_SendableReferenceWritableKeyPath<UserDefaults, Value>' (aka 'ReferenceWritableKeyPath<UserDefaults, Value>')

Check warning on line 30 in Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift

GitHub Actions / xcodebuild (15) (test, IOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'AppStorageKeyPathKey' has non-sendable type '_SendableReferenceWritableKeyPath<UserDefaults, Value>' (aka 'ReferenceWritableKeyPath<UserDefaults, Value>')

Check warning on line 30 in Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift

GitHub Actions / xcodebuild (15) (test, WATCHOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'AppStorageKeyPathKey' has non-sendable type '_SendableReferenceWritableKeyPath<UserDefaults, Value>' (aka 'ReferenceWritableKeyPath<UserDefaults, Value>')

Check warning on line 30 in Sources/ComposableArchitecture/Sharing/AppStorageKeyPathKey.swift

GitHub Actions / xcodebuild (15) (test, WATCHOS, 15.4)

stored property 'keyPath' of 'Sendable'-conforming generic struct 'AppStorageKeyPathKey' has non-sendable type '_SendableReferenceWritableKeyPath<UserDefaults, Value>' (aka 'ReferenceWritableKeyPath<UserDefaults, Value>')
private let store: UncheckedSendable<UserDefaults>
public init(_ keyPath: _SendableReferenceWritableKeyPath<UserDefaults, Value>) {