Skip to content

Commit 1eeca17

Browse files
Make Shared.wrappedValue setter unavailable from async and introduce Shared.withLock (#3136)
* Add withValue to Shared, deprecate direct mutation. * updates * wip * wip * wip * wip * Available noasync * withLock * clean up * wip * wip * Update SyncUpsListTests.swift * wip * wip * wip * wip * wip --------- Co-authored-by: Stephen Celis <[email protected]>
1 parent 9670a86 commit 1eeca17

File tree

17 files changed

+464
-70
lines changed

17 files changed

+464
-70
lines changed

Examples/SyncUps/SyncUps/AppFeature.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ struct AppFeature {
3030
}
3131
Reduce { state, action in
3232
switch action {
33-
case let .path(.element(id, .detail(.delegate(delegateAction)))):
33+
case let .path(.element(_, .detail(.delegate(delegateAction)))):
3434
switch delegateAction {
3535
case let .startMeeting(sharedSyncUp):
3636
state.path.append(.record(RecordMeeting.State(syncUp: sharedSyncUp)))

Examples/SyncUps/SyncUpsTests/AppFeatureTests.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ final class AppFeatureTests: XCTestCase {
1212
AppFeature()
1313
}
1414

15-
let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id])
15+
let sharedSyncUp = try XCTUnwrap(Shared($syncUps[id: syncUp.id]))
1616

1717
await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: sharedSyncUp)))) {
1818
$0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: sharedSyncUp))
@@ -44,7 +44,7 @@ final class AppFeatureTests: XCTestCase {
4444
AppFeature()
4545
}
4646

47-
let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id])
47+
let sharedSyncUp = try XCTUnwrap(Shared($syncUps[id: syncUp.id]))
4848

4949
await store.send(\.path.push, (id: 0, .detail(SyncUpDetail.State(syncUp: sharedSyncUp)))) {
5050
$0.path[id: 0] = .detail(SyncUpDetail.State(syncUp: sharedSyncUp))

Examples/SyncUps/SyncUpsTests/SyncUpDetailTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ final class SyncUpDetailTests: XCTestCase {
118118
// TODO: Can this exhaustively be caught?
119119
defer { XCTAssertEqual([], syncUps) }
120120

121-
let sharedSyncUp = try XCTUnwrap($syncUps[id: syncUp.id])
121+
let sharedSyncUp = try XCTUnwrap(Shared($syncUps[id: syncUp.id]))
122122
let store = TestStore(initialState: SyncUpDetail.State(syncUp: sharedSyncUp)) {
123123
SyncUpDetail()
124124
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# Migrating to 1.11
2+
3+
Update your code to use the new ``Shared/withLock(_:)`` method for mutating shared state from
4+
asynchronous contexts, rather than mutating the underlying wrapped value directly.
5+
6+
## Overview
7+
8+
The Composable Architecture is under constant development, and we are always looking for ways to
9+
simplify the library, and make it more powerful. This version of the library introduced 2 new
10+
APIs and deprecated 1 API.
11+
12+
> Important: Before following this migration guide be sure you have fully migrated to the newest
13+
> tools of version 1.10. See <doc:MigrationGuides> for more information.
14+
15+
## Mutating shared state concurrently
16+
17+
Version 1.10 of the Composable Architecture introduced a powerful tool for
18+
[sharing state](<doc:SharingState>) amongst your features. And you can mutate a piece of shared
19+
state directly, as if it were just a normal property on a value type:
20+
21+
```swift
22+
case .incrementButtonTapped:
23+
state.count += 1
24+
return .none
25+
```
26+
27+
And if you only ever mutate shared state from a reducer, then this is completely fine to do.
28+
However, because shared values are secretly references (that is how data is shared), it is possible
29+
to mutate shared values from effects, which means concurrently. And prior to 1.11, it was possible
30+
to do this directly:
31+
32+
```swift
33+
case .delayedIncrementButtonTapped:
34+
return .run { _ in
35+
@Shared(.count) var count
36+
count += 1
37+
}
38+
39+
Now, `Shared` is `Sendable`, and is technically thread-safe in that it will not crash when writing
40+
to it from two different threads. However, allowing direct mutation does make the value susceptible
41+
to race conditions. If you were to perform `count += 1` from 1,000 threads, it is possible for
42+
the final value to not be 1,000.
43+
44+
We wanted the [`@Shared`](<doc:Shared>) type to be as ergonomic as possible, and that is why we make
45+
it directly mutable, but we should not be allowing these mutations to happen from asynchronous
46+
contexts. And so now the ``Shared/wrappedValue`` setter has been marked unavailable from
47+
asynchronous contexts, with a helpful message of how to fix:
48+
49+
```swift
50+
case .delayedIncrementButtonTapped:
51+
return .run { _ in
52+
@Shared(.count) var count
53+
count += 1 // ⚠️ Use '$shared.withLock' instead of mutating directly.
54+
}
55+
```
56+
57+
To fix this deprecation you can use the new ``Shared/withLock(_:)`` method on the projected value of
58+
`@Shared`:
59+
60+
```swift
61+
case .delayedIncrementButtonTapped:
62+
return .run { _ in
63+
@Shared(.count) var count
64+
$count.withLock { $0 += 1 }
65+
}
66+
```
67+
68+
This locks the entire unit of work of reading the current count, incrementing it, and storing it
69+
back in the reference.
70+
71+
Technically it is still possible to write code that has race conditions, such as this silly example:
72+
73+
```swift
74+
let currentCount = count
75+
$count.withLock { $0 = currentCount + 1 }
76+
```
77+
78+
But there is no way to 100% prevent race conditions in code. Even actors are susceptible to problems
79+
due to re-entrancy. To avoid problems like the above we recommend wrapping as many mutations of the
80+
shared state as possible in a single ``Shared/withLock(_:)``. That will make sure that the full unit
81+
of work is guarded by a lock.
82+
83+
## Supplying mock read-only state to previews
84+
85+
A new ``SharedReader/constant(_:)`` helper on ``SharedReader`` has been introduced to simplify
86+
supplying mock data to Xcode previews. It works like SwiftUI's `Binding.constant`, but for shared
87+
references:
88+
89+
```swift
90+
#Preview {
91+
FeatureView(
92+
store: Store(
93+
initialState: Feature.State(count: .constant(42))
94+
) {
95+
Feature()
96+
}
97+
)
98+
)
99+
```

Sources/ComposableArchitecture/Documentation.docc/Articles/SharingState.md

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ as SQLite.
3838
* [Testing tips](#Testing-tips)
3939
* [Read-only shared state](#Read-only-shared-state)
4040
* [Type-safe keys](#Type-safe-keys)
41+
* [Concurrent mutations to shared state](#Concurrent-mutations-to-shared-state)
4142
* [Shared state in pre-observation apps](#Shared-state-in-pre-observation-apps)
4243
* [Gotchas of @Shared](#Gotchas-of-Shared)
4344

@@ -269,8 +270,6 @@ case .countUpdated(let count):
269270
If `count` changes, then `$count.publisher` emits, causing the `countUpdated` action to be sent,
270271
causing the shared `count` to be mutated, causing `$count.publisher` to emit, and so on.
271272

272-
273-
274273
## Initialization rules
275274

276275
Because the state sharing tools use property wrappers there are special rules that must be followed
@@ -570,8 +569,8 @@ shared state in an effect, and then increments from the effect:
570569

571570
```swift
572571
case .incrementButtonTapped:
573-
return .run { [count = state.$count] _ in
574-
count.wrappedValue += 1
572+
return .run { [sharedCount = state.$count] _ in
573+
sharedCount.withLock { $0 += 1 }
575574
}
576575
```
577576

@@ -810,7 +809,7 @@ func testIncrement() async {
810809
}
811810
```
812811

813-
This will fail if you accidetally remove a `@Shared` from one of your features.
812+
This will fail if you accidentally remove a `@Shared` from one of your features.
814813

815814
Further, you can enforce this pattern in your codebase by making all `@Shared` properties
816815
`fileprivate` so that they can never be mutated outside their file scope:
@@ -991,6 +990,36 @@ struct FeatureView: View {
991990
}
992991
```
993992

993+
## Concurrent mutations to shared state
994+
995+
While the [`@Shared`](<doc:Shared>) property wrapper makes it possible to treat shared state
996+
_mostly_ like regular state, you do have to perform some extra steps to mutate shared state from
997+
an asynchronous context. This is because shared state is technically a reference deep down, even
998+
though we take extra steps to make it appear value-like. And this means it's possible to mutate the
999+
same piece of shared state from multiple threads, and hence race conditions are possible.
1000+
1001+
To mutate a piece of shared state in an isolated fashion, use the ``Shared/withLock(_:)`` method
1002+
defined on the `@Shared` projected value:
1003+
1004+
```swift
1005+
state.$count.withLock { $0 += 1 }
1006+
```
1007+
1008+
That locks the entire unit of work of reading the current count, incrementing it, and storing it
1009+
back in the reference.
1010+
1011+
Technically it is still possible to write code that has race conditions, such as this silly example:
1012+
1013+
```swift
1014+
let currentCount = state.count
1015+
state.$count.withLock { $0 = currentCount + 1 }
1016+
```
1017+
1018+
But there is no way to 100% prevent race conditions in code. Even actors are susceptible to
1019+
problems due to re-entrancy. To avoid problems like the above we recommend wrapping as many
1020+
mutations of the shared state as possible in a single ``Shared/withLock(_:)``. That will make
1021+
sure that the full unit of work is guarded by a lock.
1022+
9941023
## Gotchas of @Shared
9951024

9961025
There are a few gotchas to be aware of when using shared state in the Composable Architecture.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ``ComposableArchitecture/Shared``
2+
3+
## Topics
4+
5+
### Creating a shared value
6+
7+
- ``init(_:fileID:line:)-9d3q``
8+
- ``init(_:)``
9+
- ``init(projectedValue:)``
10+
11+
### Creating a persisted value
12+
13+
- ``init(wrappedValue:_:fileID:line:)-512rh``
14+
- ``init(wrappedValue:_:fileID:line:)-7a80y``
15+
- ``init(_:fileID:line:)-8zcy1``
16+
- ``init(_:fileID:line:)-8jqg5``
17+
- ``init(_:fileID:line:)-gluj``
18+
19+
### Accessing the value
20+
21+
- ``wrappedValue``
22+
- ``projectedValue``
23+
- ``reader``
24+
- ``subscript(dynamicMember:)-6kmzm``
25+
- ``subscript(dynamicMember:)-22ga9``
26+
27+
### Isolating the value
28+
29+
- ``withLock(_:)``
30+
31+
### Unit testing the value
32+
33+
- ``assert(_:file:line:)``
34+
35+
### SwiftUI integration
36+
37+
- ``elements``
38+
39+
### Combine integration
40+
41+
- ``publisher``
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# ``ComposableArchitecture/SharedReader``
2+
3+
## Topics
4+
5+
### Creating a shared value
6+
7+
- ``init(_:)-3a38z``
8+
- ``init(_:)-42f43``
9+
- ``init(projectedValue:)``
10+
- ``constant(_:)``
11+
12+
### Creating a persisted value
13+
14+
- ``init(wrappedValue:_:fileID:line:)-7q52``
15+
- ``init(wrappedValue:_:fileID:line:)-6asu2``
16+
- ``init(_:fileID:line:)-41rb8``
17+
- ``init(_:fileID:line:)-3lxyf``
18+
- ``init(_:fileID:line:)-hzp``
19+
20+
### Getting the value
21+
22+
- ``wrappedValue``
23+
- ``projectedValue``
24+
- ``subscript(dynamicMember:)-34wfb``
25+
26+
### SwiftUI integration
27+
28+
- ``elements``
29+
30+
### Combine integration
31+
32+
- ``publisher``
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import ComposableArchitecture
2+
import SwiftUI
3+
4+
@main
5+
struct SyncUpsApp: App {
6+
@MainActor
7+
static let store = Store(initialState: SyncUpsList.State()) {
8+
SyncUpsList()
9+
}
10+
11+
var body: some Scene {
12+
WindowGroup {
13+
NavigationStack {
14+
SyncUpsListView(store: Self.store)
15+
}
16+
}
17+
}
18+
}

0 commit comments

Comments
 (0)