@@ -33,6 +33,7 @@ system, such as SQLite.
3333* [ Observing changes to shared state] ( #Observing-changes-to-shared-state )
3434* [ Initialization rules] ( #Initialization-rules )
3535* [ Deriving shared state] ( #Deriving-shared-state )
36+ * [ Concurrent mutations to shared state] ( #Concurrent-mutations-to-shared-state )
3637* [ Testing shared state] ( #Testing-shared-state )
3738 * [ Testing when using persistence] ( #Testing-when-using-persistence )
3839 * [ Testing when using custom persistence strategies] ( #Testing-when-using-custom-persistence-strategies )
@@ -41,7 +42,6 @@ system, such as SQLite.
4142 * [ Testing tips] ( #Testing-tips )
4243* [ Read-only shared state] ( #Read-only-shared-state )
4344* [ Type-safe keys] ( #Type-safe-keys )
44- * [ Concurrent mutations to shared state] ( #Concurrent-mutations-to-shared-state )
4545* [ Shared state in pre-observation apps] ( #Shared-state-in-pre-observation-apps )
4646* [ Gotchas of @Shared ] ( #Gotchas-of-Shared )
4747
@@ -446,13 +446,46 @@ else { return }
446446todo // Shared<Todo>
447447```
448448
449+ ## Concurrent mutations to shared state
450+
451+ [mutating - shared- state- article]: https: // swiftpackageindex.com/pointfreeco/swift-sharing/main/documentation/sharing/mutatingsharedstate
452+
453+ While the `@Shared ` property wrapper makes it possible to treat shared state
454+ _mostly_ like regular state, you do have to perform some extra steps to mutate shared state.
455+ This is because shared state is technically a reference deep down, even
456+ though we take extra steps to make it appear value- like. And this means it's possible to mutate the
457+ same piece of shared state from multiple threads, and hence race conditions are possible. See
458+ [Mutating Shared State][mutating - shared- state- article] for a more in - depth explanation.
459+
460+ To mutate a piece of shared state in an isolated fashion, use the `withLock` method
461+ defined on the `@Shared ` projected value:
462+
463+ ```swift
464+ state.$count.withLock { $0 += 1 }
465+ ```
466+
467+ That locks the entire unit of work of reading the current count, incrementing it, and storing it
468+ back in the reference.
469+
470+ Technically it is still possible to write code that has race conditions, such as this silly example:
471+
472+ ```swift
473+ let currentCount = state.count
474+ state.$count.withLock { $0 = currentCount + 1 }
475+ ```
476+
477+ But there is no way to 100 % prevent race conditions in code. Even actors are susceptible to
478+ problems due to re- entrancy. To avoid problems like the above we recommend wrapping as many
479+ mutations of the shared state as possible in a single `withLock`. That will make
480+ sure that the full unit of work is guarded by a lock.
481+
449482## Testing shared state
450483
451484Shared state behaves quite a bit different from the regular state held in Composable Architecture
452485features. It is capable of being changed by any part of the application, not just when an action is
453486sent to the store, and it has reference semantics rather than value semantics. Typically references
454487cause serious problems with testing, especially exhaustive testing that the library prefers (see
455- < doc: Testing > ), because references cannot be copied and so one cannot inspect the changes
488+ < doc: TestingTCA > ), because references cannot be copied and so one cannot inspect the changes
456489before and after an action is sent.
457490
458491For this reason, the `@Shared ` property wrapper does extra work during testing to preserve a
@@ -476,15 +509,15 @@ struct Feature {
476509 Reduce { state, action in
477510 switch action {
478511 case .incrementButtonTapped :
479- state.count += 1
512+ state.$ count. withLock { $0 += 1 }
480513 return .none
481514 }
482515 }
483516 }
484517}
485518```
486519
487- This feature can be tested in exactly the same way as when you are using non- shared state:
520+ This feature can be tested in a similar same way as when you are using non- shared state:
488521
489522```swift
490523@Test
@@ -494,7 +527,7 @@ func increment() async {
494527 }
495528
496529 await store.send (.incrementButtonTapped ) {
497- $0 .count = 1
530+ $0 .$ count. withLock { $0 = 1 }
498531 }
499532}
500533```
@@ -511,7 +544,7 @@ func increment() async {
511544 }
512545
513546 await store.send (.incrementButtonTapped ) {
514- $0 .count = 2
547+ $0 .$ count. withLock { $0 = 2 }
515548 }
516549}
517550```
@@ -590,7 +623,7 @@ func increment() async {
590623 }
591624 await store.send (.incrementButtonTapped )
592625 store.assert {
593- $0 .count = 1
626+ $0 .$ count. withLock { $0 = 1 }
594627 }
595628}
596629```
@@ -658,11 +691,11 @@ func basics() {
658691However, if your test suite is a part of an app target, then the entry point of the app will execute
659692and potentially cause an early access of `@Shared `, thus capturing a different default value than
660693what is specified above. This quirk of tests in app targets is documented in
661- < doc: Testing #Testing - gotchas> of the < doc: Testing > article, and a similar quirk
694+ < doc: TestingTCA #Testing - gotchas> of the < doc: TestingTCA > article, and a similar quirk
662695exists for Xcode previews and is discussed below in < doc: SharingState#Gotchas - of- Shared> .
663696
664697The most robust workaround to this issue is to simply not execute your app's entry point when tests
665- are running, which we detail in < doc: Testing #Testing - host- application> . This makes it so that you
698+ are running, which we detail in < doc: TestingTCA #Testing - host- application> . This makes it so that you
666699are not accidentally execute network requests, tracking analytics, etc. while running tests.
667700
668701You can also work around this issue by simply setting the shared state again after initializing
@@ -964,36 +997,6 @@ struct FeatureView: View {
964997}
965998```
966999
967- ## Concurrent mutations to shared state
968-
969- While the [`@Shared `](< doc: Shared> ) property wrapper makes it possible to treat shared state
970- _mostly_ like regular state, you do have to perform some extra steps to mutate shared state.
971- This is because shared state is technically a reference deep down, even
972- though we take extra steps to make it appear value- like. And this means it's possible to mutate the
973- same piece of shared state from multiple threads, and hence race conditions are possible.
974-
975- To mutate a piece of shared state in an isolated fashion, use the `withLock` method
976- defined on the `@Shared ` projected value:
977-
978- ```swift
979- state.$count.withLock { $0 += 1 }
980- ```
981-
982- That locks the entire unit of work of reading the current count, incrementing it, and storing it
983- back in the reference.
984-
985- Technically it is still possible to write code that has race conditions, such as this silly example:
986-
987- ```swift
988- let currentCount = state.count
989- state.$count.withLock { $0 = currentCount + 1 }
990- ```
991-
992- But there is no way to 100 % prevent race conditions in code. Even actors are susceptible to
993- problems due to re- entrancy. To avoid problems like the above we recommend wrapping as many
994- mutations of the shared state as possible in a single `withLock`. That will make
995- sure that the full unit of work is guarded by a lock.
996-
9971000## Gotchas of @Shared
9981001
9991002There are a few gotchas to be aware of when using shared state in the Composable Architecture.
@@ -1048,61 +1051,73 @@ extension AppState: Codable {
10481051}
10491052```
10501053
1051- #### Previews
1054+ #### Tests
10521055
1053- When a preview is run in an app target, the entry point is also created. This means if your entry
1054- point looks something like this :
1056+ While shared properties are compatible with the Composable Architecture's testing tools, assertions
1057+ may not correspond directly to a particular action when several actions are received by effects.
10551058
1056- ```swift
1057- @main
1058- struct MainApp: App {
1059- let store = Store (… )
1059+ Take this simple example, in which a `tap` action kicks off an effect that returns a `response`,
1060+ which finally mutates some shared state:
10601061
1061- var body: some Scene {
1062- …
1062+ ```swift
1063+ @Reducer
1064+ struct Feature {
1065+ struct State : Equatable {
1066+ @Shared (value: false ) var bool
1067+ }
1068+ enum Action {
1069+ case tap
1070+ case response
1071+ }
1072+ var body: some ReducerOf<Self > {
1073+ Reduce { state, action in
1074+ switch action {
1075+ case .tap :
1076+ return .run { send in
1077+ await send (.response )
1078+ }
1079+ case .response :
1080+ state.$bool.withLock { $0 .toggle () }
1081+ return .none
1082+ }
1083+ }
10631084 }
10641085}
10651086```
10661087
1067- … then a store will be created each time you run your preview. This can be problematic with `@Shared `
1068- and persistence strategies because the first access of a `@Shared ` property will use the default
1069- value provided, and that will cause `@Shared `'s created later to ignore the default . That will mean
1070- you cannot override shared state in previews.
1071-
1072- The fix is to delay creation of the store until the entry point's `body` is executed. Further, it
1073- can be a good idea to also not run the `body` when in tests because that can also interfere with
1074- tests (as documented in < doc: Testing#Testing - gotchas> ). Here is one way this can be accomplished:
1088+ We would expect to assert against this mutation when the test store receives the `response` action,
1089+ but this will fail:
10751090
10761091```swift
1077- import ComposableArchitecture
1078- import SwiftUI
1079-
1080- @main
1081- struct MainApp: App {
1082- @MainActor
1083- static let store = Store (… )
1084-
1085- var body: some Scene {
1086- WindowGroup {
1087- if isTesting {
1088- // NB: Don't run application in tests to avoid interference
1089- // between the app and the test.
1090- EmptyView ()
1091- } else {
1092- AppView (store : Self .store )
1093- }
1094- }
1095- }
1092+ // ❌ State was not expected to change, but a change occurred: …
1093+ //
1094+ // Feature.State(
1095+ // - _shared: #1 false
1096+ // + _shared: #1 true
1097+ // )
1098+ //
1099+ // (Expected: −, Actual: +)
1100+ await store.send (.tap )
1101+
1102+ // ❌ Expected state to change, but no change occurred.
1103+ await store.receive (.response ) {
1104+ $0 .$shared.withLock { $0 = true }
10961105}
10971106```
10981107
1099- Alternatively you can take an extra step to override shared state in your previews:
1108+ This is due to an implementation detail of the `TestStore` that predates `@Shared `, in which the
1109+ test store eagerly processes all actions received _before_ you have asserted on them. As such, you
1110+ must always assert against shared state mutations in the first action:
11001111
11011112```swift
1102- #Preview {
1103- @Shared (.appStorage (" isOn" )) var isOn = true
1104- isOn = true
1113+ await store.send (.tap ) { // ✅
1114+ $0 .$shared.withLock { $0 = true }
11051115}
1116+
1117+ // ❌ Expected state to change, but no change occurred.
1118+ await store.receive (.response ) // ✅
11061119```
11071120
1108- The second assignment of `isOn` will guarantee that it holds a value of `true `.
1121+ In a future major version of the Composable Architecture, we will be able to introduce a breaking
1122+ change that allows you to assert against shared state mutations in the action that performed the
1123+ mutation.
0 commit comments