From ce17215f72428d3e9c40a835db3ca4ebb5632f04 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 1 Feb 2025 12:41:17 -0800 Subject: [PATCH 1/4] Fix docs for mutating shared state. --- .../Articles/SharingState.md | 67 ++++++++++--------- 1 file changed, 35 insertions(+), 32 deletions(-) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md index 6066d29887d5..35eb2360836a 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md @@ -33,6 +33,7 @@ system, such as SQLite. * [Observing changes to shared state](#Observing-changes-to-shared-state) * [Initialization rules](#Initialization-rules) * [Deriving shared state](#Deriving-shared-state) +* [Concurrent mutations to shared state](#Concurrent-mutations-to-shared-state) * [Testing shared state](#Testing-shared-state) * [Testing when using persistence](#Testing-when-using-persistence) * [Testing when using custom persistence strategies](#Testing-when-using-custom-persistence-strategies) @@ -41,7 +42,6 @@ system, such as SQLite. * [Testing tips](#Testing-tips) * [Read-only shared state](#Read-only-shared-state) * [Type-safe keys](#Type-safe-keys) -* [Concurrent mutations to shared state](#Concurrent-mutations-to-shared-state) * [Shared state in pre-observation apps](#Shared-state-in-pre-observation-apps) * [Gotchas of @Shared](#Gotchas-of-Shared) @@ -446,6 +446,39 @@ else { return } todo // Shared ``` +## Concurrent mutations to shared state + +[mutating-shared-state-article]: https://swiftpackageindex.com/pointfreeco/swift-sharing/main/documentation/sharing/mutatingsharedstate + +While the [`@Shared`]() property wrapper makes it possible to treat shared state +_mostly_ like regular state, you do have to perform some extra steps to mutate shared state. +This is because shared state is technically a reference deep down, even +though we take extra steps to make it appear value-like. And this means it's possible to mutate the +same piece of shared state from multiple threads, and hence race conditions are possible. See +[Mutating Shared State][mutating-shared-state-article] for a more in-depth explanation. + +To mutate a piece of shared state in an isolated fashion, use the `withLock` method +defined on the `@Shared` projected value: + +```swift +state.$count.withLock { $0 += 1 } +``` + +That locks the entire unit of work of reading the current count, incrementing it, and storing it +back in the reference. + +Technically it is still possible to write code that has race conditions, such as this silly example: + +```swift +let currentCount = state.count +state.$count.withLock { $0 = currentCount + 1 } +``` + +But there is no way to 100% prevent race conditions in code. Even actors are susceptible to +problems due to re-entrancy. To avoid problems like the above we recommend wrapping as many +mutations of the shared state as possible in a single `withLock`. That will make +sure that the full unit of work is guarded by a lock. + ## Testing shared state Shared state behaves quite a bit different from the regular state held in Composable Architecture @@ -476,7 +509,7 @@ struct Feature { Reduce { state, action in switch action { case .incrementButtonTapped: - state.count += 1 + state.$count.withLock { $0 += 1 } return .none } } @@ -964,36 +997,6 @@ struct FeatureView: View { } ``` -## Concurrent mutations to shared state - -While the [`@Shared`]() property wrapper makes it possible to treat shared state -_mostly_ like regular state, you do have to perform some extra steps to mutate shared state. -This is because shared state is technically a reference deep down, even -though we take extra steps to make it appear value-like. And this means it's possible to mutate the -same piece of shared state from multiple threads, and hence race conditions are possible. - -To mutate a piece of shared state in an isolated fashion, use the `withLock` method -defined on the `@Shared` projected value: - -```swift -state.$count.withLock { $0 += 1 } -``` - -That locks the entire unit of work of reading the current count, incrementing it, and storing it -back in the reference. - -Technically it is still possible to write code that has race conditions, such as this silly example: - -```swift -let currentCount = state.count -state.$count.withLock { $0 = currentCount + 1 } -``` - -But there is no way to 100% prevent race conditions in code. Even actors are susceptible to -problems due to re-entrancy. To avoid problems like the above we recommend wrapping as many -mutations of the shared state as possible in a single `withLock`. That will make -sure that the full unit of work is guarded by a lock. - ## Gotchas of @Shared There are a few gotchas to be aware of when using shared state in the Composable Architecture. From 3c0d9a4d21edc50958a00532bfd0f343437c2cc7 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 1 Feb 2025 12:58:08 -0800 Subject: [PATCH 2/4] A few more fixes --- .../Articles/SharingState.md | 67 ++----------------- 1 file changed, 4 insertions(+), 63 deletions(-) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md index 35eb2360836a..75acee0e1959 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md @@ -517,7 +517,7 @@ struct Feature { } ``` -This feature can be tested in exactly the same way as when you are using non-shared state: +This feature can be tested in a similar same way as when you are using non-shared state: ```swift @Test @@ -527,7 +527,7 @@ func increment() async { } await store.send(.incrementButtonTapped) { - $0.count = 1 + $0.$count.withLock { $0 = 1 } } } ``` @@ -544,7 +544,7 @@ func increment() async { } await store.send(.incrementButtonTapped) { - $0.count = 2 + $0.$count.withLock { $0 = 2 } } } ``` @@ -623,7 +623,7 @@ func increment() async { } await store.send(.incrementButtonTapped) store.assert { - $0.count = 1 + $0.$count.withLock { $0 = 1 } } } ``` @@ -1050,62 +1050,3 @@ extension AppState: Codable { } } ``` - -#### Previews - -When a preview is run in an app target, the entry point is also created. This means if your entry -point looks something like this: - -```swift -@main -struct MainApp: App { - let store = Store(…) - - var body: some Scene { - … - } -} -``` - -…then a store will be created each time you run your preview. This can be problematic with `@Shared` -and persistence strategies because the first access of a `@Shared` property will use the default -value provided, and that will cause `@Shared`'s created later to ignore the default. That will mean -you cannot override shared state in previews. - -The fix is to delay creation of the store until the entry point's `body` is executed. Further, it -can be a good idea to also not run the `body` when in tests because that can also interfere with -tests (as documented in ). Here is one way this can be accomplished: - -```swift -import ComposableArchitecture -import SwiftUI - -@main -struct MainApp: App { - @MainActor - static let store = Store(…) - - var body: some Scene { - WindowGroup { - if isTesting { - // NB: Don't run application in tests to avoid interference - // between the app and the test. - EmptyView() - } else { - AppView(store: Self.store) - } - } - } -} -``` - -Alternatively you can take an extra step to override shared state in your previews: - -```swift -#Preview { - @Shared(.appStorage("isOn")) var isOn = true - isOn = true -} -``` - -The second assignment of `isOn` will guarantee that it holds a value of `true`. From e24c094c73b8f892bb4f8604525b6f3120187e81 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 1 Feb 2025 13:33:17 -0800 Subject: [PATCH 3/4] Add FAQ to table of contents. --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 3f514961c785..784a08fd82b7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ SwiftUI, UIKit, and more, and on any Apple platform (iOS, macOS, iPadOS, visionO * [Examples](#examples) * [Basic usage](#basic-usage) * [Documentation](#documentation) +* [FAQ](#faq) * [Community](#community) * [Installation](#installation) * [Translations](#translations) @@ -575,6 +576,11 @@ comfortable with the library: * [Concurrency][concurrency-article] * [Bindings][bindings-article] +## FAQ + +We have a [dedicated article][faq-article] for all of the most frequently asked questions and +comments people have concerning the library. + ## Community If you want to discuss the Composable Architecture or have a question about how to use it to solve @@ -639,11 +645,6 @@ If you'd like to contribute a translation, please [open a PR](https://github.com/pointfreeco/swift-composable-architecture/edit/main/README.md) with a link to a [Gist](https://gist.github.com)! -## FAQ - -We have a [dedicated article][faq-article] for all of the most frequently asked questions and -comments people have concerning the library. - ## Credits and thanks The following people gave feedback on the library at its early stages and helped make the library From a219f0412e270bb39a32da81f90d57519c7e0545 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sun, 2 Feb 2025 10:28:42 -0800 Subject: [PATCH 4/4] Update Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md --- .../Documentation.docc/Articles/SharingState.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md index 75acee0e1959..e01105b62444 100644 --- a/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md +++ b/Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md @@ -450,7 +450,7 @@ todo // Shared [mutating-shared-state-article]: https://swiftpackageindex.com/pointfreeco/swift-sharing/main/documentation/sharing/mutatingsharedstate -While the [`@Shared`]() property wrapper makes it possible to treat shared state +While the `@Shared` property wrapper makes it possible to treat shared state _mostly_ like regular state, you do have to perform some extra steps to mutate shared state. This is because shared state is technically a reference deep down, even though we take extra steps to make it appear value-like. And this means it's possible to mutate the