Skip to content

Commit 54eb417

Browse files
Make 'didSet' main actor. (#3206)
* Make 'didSet' main actor. * tests for AppStorageKey * wip * tests * clean up * Update Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift * Update Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift * try working around CI problem * Added NB * wip * Update MigratingTo1.11.md --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent a200f6a commit 54eb417

File tree

10 files changed

+163
-8
lines changed

10 files changed

+163
-8
lines changed

Sources/ComposableArchitecture/Documentation.docc/Articles/MigrationGuides/MigratingTo1.11.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ APIs and deprecated 1 API.
1212
> Important: Before following this migration guide be sure you have fully migrated to the newest
1313
> tools of version 1.10. See <doc:MigrationGuides> for more information.
1414
15+
* [Mutating shared state concurrently](#Mutating-shared-state-concurrently)
16+
* [Supplying mock read-only state to previews](#Supplying-mock-read-only-state-to-previews)
17+
* [Migrating to 1.11.2](#Migrating-to-1112)
18+
1519
## Mutating shared state concurrently
1620

1721
Version 1.10 of the Composable Architecture introduced a powerful tool for
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import Dispatch
2+
3+
func mainActorASAP(execute block: @escaping @MainActor @Sendable () -> Void) {
4+
if DispatchQueue.getSpecific(key: key) == value {
5+
assumeMainActorIsolated {
6+
block()
7+
}
8+
} else {
9+
DispatchQueue.main.async {
10+
block()
11+
}
12+
}
13+
}
14+
15+
private let key: DispatchSpecificKey<UInt8> = {
16+
let key = DispatchSpecificKey<UInt8>()
17+
DispatchQueue.main.setSpecific(key: key, value: value)
18+
return key
19+
}()
20+
private let value: UInt8 = 0
21+
22+
// NB: Currently we can't use 'MainActor.assumeIsolated' on CI, but we can approximate this in
23+
// the meantime.
24+
@MainActor(unsafe)
25+
private func assumeMainActorIsolated(_ block: @escaping @MainActor @Sendable () -> Void) {
26+
block()
27+
}

Sources/ComposableArchitecture/SharedState/PersistenceKey.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ public protocol PersistenceReaderKey<Value> {
3535
/// deinitialized, the `didSet` closure will no longer be invoked.
3636
func subscribe(
3737
initialValue: Value?,
38-
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
38+
didSet: @escaping @Sendable (_ newValue: Value?) -> Void
3939
) -> Shared<Value>.Subscription
4040
}
4141

@@ -46,7 +46,7 @@ extension PersistenceReaderKey where ID == Self {
4646
extension PersistenceReaderKey {
4747
public func subscribe(
4848
initialValue: Value?,
49-
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
49+
didSet: @escaping @Sendable (_ newValue: Value?) -> Void
5050
) -> Shared<Value>.Subscription {
5151
Shared.Subscription {}
5252
}

Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ extension AppStorageKey: PersistenceKey {
287287

288288
public func subscribe(
289289
initialValue: Value?,
290-
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
290+
didSet: @escaping @Sendable (_ newValue: Value?) -> Void
291291
) -> Shared<Value>.Subscription {
292292
let previousValue = LockIsolated(initialValue)
293293
let userDefaultsDidChange = NotificationCenter.default.addObserver(

Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ extension AppStorageKeyPathKey: PersistenceKey, Hashable {
4848

4949
public func subscribe(
5050
initialValue: Value?,
51-
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
51+
didSet: @escaping @Sendable (_ newValue: Value?) -> Void
5252
) -> Shared<Value>.Subscription {
5353
let observer = self.store.observe(self.keyPath, options: .new) { _, change in
5454
guard

Sources/ComposableArchitecture/SharedState/PersistenceKey/FileStorageKey.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public final class FileStorageKey<Value: Codable & Sendable>: PersistenceKey, Se
7979

8080
public func subscribe(
8181
initialValue: Value?,
82-
didSet: @Sendable @escaping (_ newValue: Value?) -> Void
82+
didSet: @escaping @Sendable (_ newValue: Value?) -> Void
8383
) -> Shared<Value>.Subscription {
8484
let cancellable = LockIsolated<AnyCancellable?>(nil)
8585
@Sendable func setUpSources() {

Sources/ComposableArchitecture/SharedState/PersistenceKey/PersistenceKeyDefault.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ public struct PersistenceKeyDefault<Base: PersistenceReaderKey>: PersistenceRead
4141

4242
public func subscribe(
4343
initialValue: Base.Value?,
44-
didSet: @Sendable @escaping (Base.Value?) -> Void
44+
didSet: @escaping @Sendable (Base.Value?) -> Void
4545
) -> Shared<Base.Value>.Subscription {
4646
self.base.subscribe(initialValue: initialValue, didSet: didSet)
4747
}

Sources/ComposableArchitecture/SharedState/References/ValueReference.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -399,8 +399,10 @@ final class ValueReference<Value, Persistence: PersistenceReaderKey<Value>>: Ref
399399
self._$perceptionRegistrar.willSet(self, keyPath: \.value)
400400
defer { self._$perceptionRegistrar.didSet(self, keyPath: \.value) }
401401
#endif
402-
self.lock.withLock {
403-
self._value = value ?? initialValue
402+
mainActorASAP {
403+
self.lock.withLock {
404+
self._value = value ?? initialValue
405+
}
404406
}
405407
}
406408
}

Tests/ComposableArchitectureTests/AppStorageTests.swift

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,100 @@ final class AppStorageTests: XCTestCase {
200200
store.setValue(2, forKey: "other-count")
201201
XCTAssertEqual(values.value, [1])
202202
}
203+
204+
@MainActor
205+
func testUpdateStoreFromBackgroundThread() async throws {
206+
@Dependency(\.defaultAppStorage) var store
207+
@Shared(.appStorage("count")) var count = 0
208+
209+
let publisherExpectation = expectation(description: "publisher")
210+
let cancellable = $count.publisher.sink { _ in
211+
XCTAssertTrue(Thread.isMainThread)
212+
publisherExpectation.fulfill()
213+
}
214+
defer { _ = cancellable }
215+
216+
await withUnsafeContinuation { continuation in
217+
DispatchQueue.global().async { [store = UncheckedSendable(store)] in
218+
XCTAssertFalse(Thread.isMainThread)
219+
store.wrappedValue.setValue(1, forKey: "count")
220+
continuation.resume()
221+
}
222+
}
223+
224+
await fulfillment(of: [publisherExpectation], timeout: 0)
225+
}
226+
227+
@MainActor
228+
func testUpdateStoreFromMainThread() async throws {
229+
@Dependency(\.defaultAppStorage) var store
230+
@Shared(.appStorage("count")) var count = 0
231+
let isInStackFrame = LockIsolated(false)
232+
233+
let publisherExpectation = expectation(description: "publisher")
234+
let cancellable = $count.publisher.sink { _ in
235+
XCTAssertTrue(Thread.isMainThread)
236+
XCTAssertTrue(isInStackFrame.value)
237+
publisherExpectation.fulfill()
238+
}
239+
defer { _ = cancellable }
240+
241+
await withUnsafeContinuation { continuation in
242+
XCTAssertTrue(Thread.isMainThread)
243+
isInStackFrame.withValue { $0 = true }
244+
store.setValue(1, forKey: "count")
245+
isInStackFrame.withValue { $0 = false }
246+
continuation.resume()
247+
}
248+
249+
await fulfillment(of: [publisherExpectation], timeout: 0)
250+
}
251+
252+
@MainActor
253+
func testWillEnterForegroundFromBackgroundThread() async throws {
254+
@Shared(.appStorage("count")) var count = 0
255+
256+
let publisherExpectation = expectation(description: "publisher")
257+
let cancellable = $count.publisher.sink { _ in
258+
XCTAssertTrue(Thread.isMainThread)
259+
publisherExpectation.fulfill()
260+
}
261+
defer { _ = cancellable }
262+
263+
await withUnsafeContinuation { continuation in
264+
DispatchQueue.global().async {
265+
XCTAssertFalse(Thread.isMainThread)
266+
NotificationCenter.default.post(name: willEnterForegroundNotificationName!, object: nil)
267+
continuation.resume()
268+
}
269+
}
270+
271+
await fulfillment(of: [publisherExpectation], timeout: 0)
272+
}
273+
274+
@MainActor
275+
func testUpdateStoreFromBackgroundThread_KeyPath() async throws {
276+
@Dependency(\.defaultAppStorage) var store
277+
@Shared(.appStorage(\.count)) var count = 0
278+
279+
let publisherExpectation = expectation(description: "publisher")
280+
publisherExpectation.expectedFulfillmentCount = 2
281+
let cancellable = $count.publisher.sink { _ in
282+
XCTAssertTrue(Thread.isMainThread)
283+
publisherExpectation.fulfill()
284+
}
285+
defer { _ = cancellable }
286+
287+
await withUnsafeContinuation { continuation in
288+
DispatchQueue.global().async { [store = UncheckedSendable(store)] in
289+
XCTAssertFalse(Thread.isMainThread)
290+
store.wrappedValue.count = 1
291+
continuation.resume()
292+
}
293+
}
294+
295+
await fulfillment(of: [publisherExpectation], timeout: 0)
296+
}
203297
}
204298

205299
extension UserDefaults {

Tests/ComposableArchitectureTests/FileStorageTests.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,34 @@ final class FileStorageTests: XCTestCase {
434434
XCTAssertEqual(count, max * (max + 1) / 2)
435435
}
436436
}
437+
438+
@MainActor
439+
func testUpdateFileSystemFromBackgroundThread() async throws {
440+
await withDependencies {
441+
$0.defaultFileStorage = .fileSystem
442+
} operation: {
443+
try? FileManager.default.removeItem(at: .fileURL)
444+
445+
@Shared(.fileStorage(.fileURL)) var count = 0
446+
447+
let publisherExpectation = expectation(description: "publisher")
448+
let cancellable = $count.publisher.sink { _ in
449+
XCTAssertTrue(Thread.isMainThread)
450+
publisherExpectation.fulfill()
451+
}
452+
defer { _ = cancellable }
453+
454+
await withUnsafeContinuation { continuation in
455+
DispatchQueue.global().async {
456+
XCTAssertFalse(Thread.isMainThread)
457+
try! Data("1".utf8).write(to: .fileURL)
458+
continuation.resume()
459+
}
460+
}
461+
462+
await fulfillment(of: [publisherExpectation], timeout: 0.1)
463+
}
464+
}
437465
}
438466

439467
extension URL {

0 commit comments

Comments
 (0)