diff --git a/Sources/ComposableArchitecture/Internal/DispatchQueue.swift b/Sources/ComposableArchitecture/Internal/DispatchQueue.swift index db1e7409e7c1..5a3f314783f7 100644 --- a/Sources/ComposableArchitecture/Internal/DispatchQueue.swift +++ b/Sources/ComposableArchitecture/Internal/DispatchQueue.swift @@ -14,6 +14,20 @@ func mainActorNow(execute block: @MainActor @Sendable () -> R) -> R } } +func mainActorASAP(execute block: @escaping @MainActor @Sendable () -> Void) { + if DispatchQueue.getSpecific(key: key) == value { + MainActor._assumeIsolated { + block() + } + } else { + DispatchQueue.main.async { + MainActor._assumeIsolated { + block() + } + } + } +} + private let key: DispatchSpecificKey = { let key = DispatchSpecificKey() DispatchQueue.main.setSpecific(key: key, value: value) diff --git a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift index a66bbc5c7173..2efc9f9d8b74 100644 --- a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift @@ -78,6 +78,7 @@ } } +#if DEBUG private final class BindableActionDebugger: Sendable { let isInvalidated: @MainActor @Sendable () -> Bool let value: any Sendable @@ -112,6 +113,7 @@ } } } +#endif extension BindableAction where State: ObservableState { fileprivate static func set( diff --git a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift index a2e6ac5f1a46..45d89afd48aa 100644 --- a/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift +++ b/Sources/ComposableArchitecture/SharedState/References/ValueReference.swift @@ -395,7 +395,7 @@ final class ValueReference>: Ref initialValue: initialValue ) { [weak self] value in guard let self else { return } - mainActorNow { + mainActorASAP { #if canImport(Perception) self._$perceptionRegistrar.willSet(self, keyPath: \.value) defer { self._$perceptionRegistrar.didSet(self, keyPath: \.value) } diff --git a/Tests/ComposableArchitectureTests/SharedTests.swift b/Tests/ComposableArchitectureTests/SharedTests.swift index 90b5784a0b8a..218fd9e2cf36 100644 --- a/Tests/ComposableArchitectureTests/SharedTests.swift +++ b/Tests/ComposableArchitectureTests/SharedTests.swift @@ -1002,6 +1002,47 @@ final class SharedTests: XCTestCase { } } } + + func testReEntrantSharedSubscriptionDependencyResolution() async throws { + for _ in 1...100 { + try await withDependencies { + $0 = DependencyValues() + } operation: { + @Shared(.appStorage("count")) var count = 0 + + struct Client: TestDependencyKey { + init() { + @Dependency(\.defaultAppStorage) var userDefaults + userDefaults.set(42, forKey: "count") + } + static var testValue: Self { Self() } + } + + withEscapedDependencies { dependencies in + DispatchQueue.global().async { + dependencies.yield { + XCTAssertEqual({ Thread.isMainThread }(), false) + @Dependency(Client.self) var client + _ = client + } + } + DispatchQueue.main.async { [sharedCount = $count] in + dependencies.yield { + XCTAssertEqual({ Thread.isMainThread }(), true) + _ = sharedCount.wrappedValue + } + } + } + + try await Task.sleep(nanoseconds: 10_000_000) + XCTAssertEqual(count, 42) + } + } + } +} + +@globalActor actor GA: GlobalActor { + static let shared = GA() } @Reducer