diff --git a/Sources/ComposableArchitecture/Internal/DispatchQueue.swift b/Sources/ComposableArchitecture/Internal/DispatchQueue.swift index 5a3f314783f7..33eedad7b9e0 100644 --- a/Sources/ComposableArchitecture/Internal/DispatchQueue.swift +++ b/Sources/ComposableArchitecture/Internal/DispatchQueue.swift @@ -1,36 +1,24 @@ -import Dispatch +#if compiler(<6.2) + import Dispatch -func mainActorNow(execute block: @MainActor @Sendable () -> R) -> R { - if DispatchQueue.getSpecific(key: key) == value { - return MainActor._assumeIsolated { - block() - } - } else { - return DispatchQueue.main.sync { - MainActor._assumeIsolated { + func mainActorNow(execute block: @MainActor @Sendable () -> R) -> R { + if DispatchQueue.getSpecific(key: key) == value { + return MainActor._assumeIsolated { block() } - } - } -} - -func mainActorASAP(execute block: @escaping @MainActor @Sendable () -> Void) { - if DispatchQueue.getSpecific(key: key) == value { - MainActor._assumeIsolated { - block() - } - } else { - DispatchQueue.main.async { - MainActor._assumeIsolated { - block() + } else { + return DispatchQueue.main.sync { + MainActor._assumeIsolated { + block() + } } } } -} -private let key: DispatchSpecificKey = { - let key = DispatchSpecificKey() - DispatchQueue.main.setSpecific(key: key, value: value) - return key -}() -private let value: UInt8 = 0 + private let key: DispatchSpecificKey = { + let key = DispatchSpecificKey() + DispatchQueue.main.setSpecific(key: key, value: value) + return key + }() + private let value: UInt8 = 0 +#endif diff --git a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift index 47b72720e3a0..ab7ff112211e 100644 --- a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift @@ -100,25 +100,25 @@ extension BindingAction { self.isInvalidated = isInvalidated } deinit { - let isInvalidated = mainActorNow(execute: isInvalidated) - guard !isInvalidated else { return } - guard wasCalled.value else { + guard !wasCalled.value + else { return } + Task { @MainActor [value, isInvalidated] in + guard !isInvalidated() else { return } var valueDump: String { var valueDump = "" - customDump(self.value, to: &valueDump, maxDepth: 0) + customDump(value, to: &valueDump, maxDepth: 0) return valueDump } reportIssue( """ A binding action sent from a store was not handled. … - + Action: \(typeName(Action.self)).binding(.set(_, \(valueDump))) - + To fix this, invoke "BindingReducer()" from your feature reducer's "body". """ ) - return } } } diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index ae6e071319b4..20d5a900b84d 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -770,22 +770,28 @@ extension WithViewStore where ViewState: Equatable, Content: View { } deinit { - let isInvalidated = mainActorNow(execute: isInvalidated) - guard !isInvalidated else { return } - guard self.wasCalled.value else { + guard !self.wasCalled.value + else { return } + + Task { + @MainActor [ + context, fileID, filePath, line, column, value, bindableActionType, isInvalidated + ] in + let tmp = isInvalidated() + guard !tmp else { return } var valueDump: String { var valueDump = "" - customDump(self.value, to: &valueDump, maxDepth: 0) + customDump(value, to: &valueDump, maxDepth: 0) return valueDump } reportIssue( """ A binding action sent from a store \ - \(self.context == .bindingState ? "for binding state defined " : "")at \ - "\(self.fileID):\(self.line)" was not handled. … + \(context == .bindingState ? "for binding state defined " : "")at \ + "\(fileID):\(line)" was not handled. … Action: - \(typeName(self.bindableActionType)).binding(.set(_, \(valueDump))) + \(typeName(bindableActionType)).binding(.set(_, \(valueDump))) To fix this, invoke "BindingReducer()" from your feature reducer's "body". """, @@ -794,7 +800,6 @@ extension WithViewStore where ViewState: Equatable, Content: View { line: line, column: column ) - return } } } diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index aae8a40d1565..12e0568c9509 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -429,7 +429,7 @@ import IssueReporting #if swift(<5.10) @MainActor(unsafe) #else - @preconcurrency@MainActor + @preconcurrency @MainActor #endif public final class TestStore { /// The current dependencies of the test store. @@ -576,7 +576,11 @@ public final class TestStore { column: UInt = #column ) async { await self.finish( - timeout: duration.nanoseconds, fileID: fileID, file: filePath, line: line, column: column + timeout: duration.nanoseconds, + fileID: fileID, + file: filePath, + line: line, + column: column ) } @@ -649,7 +653,10 @@ public final class TestStore { func completed() { self.assertNoReceivedActions( - fileID: self.fileID, filePath: self.filePath, line: self.line, column: self.column + fileID: self.fileID, + filePath: self.filePath, + line: self.line, + column: self.column ) Task.cancel(id: OnFirstAppearID()) for effect in self.reducer.inFlightEffects { @@ -983,7 +990,11 @@ extension TestStore { let previousStackElementID = self.reducer.dependencies.stackElementID.incrementingCopy() let task = self.store.send( .init( - origin: .send(action), fileID: fileID, filePath: filePath, line: line, column: column + origin: .send(action), + fileID: fileID, + filePath: filePath, + line: line, + column: column ) ) if uncheckedUseMainSerialExecutor { @@ -2379,7 +2390,11 @@ extension TestStore { await Task.megaYield() _ = { self._skipReceivedActions( - strict: strict, fileID: fileID, file: filePath, line: line, column: column + strict: strict, + fileID: fileID, + file: filePath, + line: line, + column: column ) }() } @@ -2464,7 +2479,11 @@ extension TestStore { await Task.megaYield() _ = { self._skipInFlightEffects( - strict: strict, fileID: fileID, filePath: filePath, line: line, column: column + strict: strict, + fileID: fileID, + filePath: filePath, + line: line, + column: column ) }() } @@ -2526,7 +2545,7 @@ extension TestStore { switch exhaustivity { case .on: reportIssue(message, fileID: fileID, filePath: filePath, line: line, column: column) - case let .off(showSkippedAssertions): + case .off(let showSkippedAssertions): if showSkippedAssertions { withExpectedIssue { reportIssue( @@ -2833,11 +2852,11 @@ class TestReducer: Reducer { let effects: Effect switch action.origin { - case let .send(action): + case .send(let action): effects = reducer.reduce(into: &state, action: action) self.state = state - case let .receive(action): + case .receive(let action): effects = reducer.reduce(into: &state, action: action) self.receivedActions.append((action, state)) } @@ -2908,7 +2927,7 @@ class TestReducer: Reducer { case send(Action) fileprivate var action: Action { switch self { - case let .receive(action), let .send(action): + case .receive(let action), .send(let action): return action } } diff --git a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift index 11f96aa95c6c..98dab93e89c4 100644 --- a/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift +++ b/Tests/ComposableArchitectureTests/RuntimeWarningTests.swift @@ -5,7 +5,7 @@ final class RuntimeWarningTests: BaseTCATestCase { @MainActor - func testBindingUnhandledAction() { + func testBindingUnhandledAction() async throws { let line = #line + 2 struct State: Equatable { @BindingState var value = 0 @@ -16,8 +16,6 @@ let store = Store(initialState: State()) {} XCTExpectFailure { - ViewStore(store, observe: { $0 }).$value.wrappedValue = 42 - } issueMatcher: { $0.compactDescription == """ failed - A binding action sent from a store for binding state defined at \ "\(#fileID):\(line)" was not handled. … @@ -28,6 +26,11 @@ To fix this, invoke "BindingReducer()" from your feature reducer's "body". """ } + + let viewStore = ViewStore(store, observe: { $0 }) + viewStore.$value.wrappedValue = 42 + try await Task.sleep(nanoseconds: 1_000_000) + } @ObservableState @@ -35,7 +38,7 @@ var count = 0 } @MainActor - func testObservableBindingUnhandledAction() { + func testObservableBindingUnhandledAction() async throws { typealias State = TestObservableBindingUnhandledActionState enum Action: BindableAction, Equatable { case binding(BindingAction) @@ -43,8 +46,6 @@ let store = Store(initialState: State()) {} XCTExpectFailure { - store.count = 42 - } issueMatcher: { $0.compactDescription == """ failed - A binding action sent from a store was not handled. … @@ -54,10 +55,13 @@ To fix this, invoke "BindingReducer()" from your feature reducer's "body". """ } + + store.count = 42 + try await Task.sleep(nanoseconds: 1_000_000) } @MainActor - func testBindingUnhandledAction_BindingState() { + func testBindingUnhandledAction_BindingState() async throws { struct State: Equatable { @BindingState var value = 0 } @@ -68,8 +72,6 @@ let store = Store(initialState: State()) {} XCTExpectFailure { - ViewStore(store, observe: { $0 }).$value.wrappedValue = 42 - } issueMatcher: { $0.compactDescription == """ failed - A binding action sent from a store for binding state defined at \ "\(#fileID):\(line)" was not handled. … @@ -80,6 +82,10 @@ To fix this, invoke "BindingReducer()" from your feature reducer's "body". """ } + + let viewStore = ViewStore(store, observe: { $0 }) + viewStore.$value.wrappedValue = 42 + try await Task.sleep(nanoseconds: 1_000_000) } @Reducer @@ -100,7 +106,10 @@ XCTExpectFailure { store.scope(state: \.path, action: \.path)[ - fileID: "file.swift", filePath: "/file.swift", line: 1, column: 1 + fileID: "file.swift", + filePath: "/file.swift", + line: 1, + column: 1 ] = .init() } issueMatcher: { $0.compactDescription == """