-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Fix docs for mutating shared state. #3580
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
Changes from 4 commits
ce17215
3c0d9a4
e24c094
a219f04
073d24b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Todo> | ||
| ``` | ||
|
|
||
| ## 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. | ||
|
Comment on lines
+457
to
+458
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And I'm linking out to the dedicated "Mutating Shared State" article over in the Sharing repo. |
||
|
|
||
| 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,15 +509,15 @@ struct Feature { | |
| Reduce { state, action in | ||
| switch action { | ||
| case .incrementButtonTapped: | ||
| state.count += 1 | ||
| state.$count.withLock { $0 += 1 } | ||
| return .none | ||
| } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| 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 | ||
|
|
@@ -494,7 +527,7 @@ func increment() async { | |
| } | ||
|
|
||
| await store.send(.incrementButtonTapped) { | ||
| $0.count = 1 | ||
| $0.$count.withLock { $0 = 1 } | ||
| } | ||
| } | ||
| ``` | ||
|
|
@@ -511,7 +544,7 @@ func increment() async { | |
| } | ||
|
|
||
| await store.send(.incrementButtonTapped) { | ||
| $0.count = 2 | ||
| $0.$count.withLock { $0 = 2 } | ||
| } | ||
| } | ||
| ``` | ||
|
|
@@ -590,7 +623,7 @@ func increment() async { | |
| } | ||
| await store.send(.incrementButtonTapped) | ||
| store.assert { | ||
| $0.count = 1 | ||
| $0.$count.withLock { $0 = 1 } | ||
| } | ||
| } | ||
| ``` | ||
|
|
@@ -964,36 +997,6 @@ struct FeatureView: View { | |
| } | ||
| ``` | ||
|
|
||
| ## Concurrent mutations to shared state | ||
|
|
||
| While the [`@Shared`](<doc: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. | ||
|
|
@@ -1047,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 | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe this now can go away since we have a workaround in Dependencies... |
||
| 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 <doc:Testing#Testing-gotchas>). 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`. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm moving this up higher because it's important to know this before the testing material.