Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Member Author

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.

* [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)
Expand All @@ -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)

Expand Down Expand Up @@ -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`](<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. See
[Mutating Shared State][mutating-shared-state-article] for a more in-depth explanation.
Comment on lines +457 to +458
Copy link
Member Author

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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
Expand All @@ -494,7 +527,7 @@ func increment() async {
}

await store.send(.incrementButtonTapped) {
$0.count = 1
$0.$count.withLock { $0 = 1 }
}
}
```
Expand All @@ -511,7 +544,7 @@ func increment() async {
}

await store.send(.incrementButtonTapped) {
$0.count = 2
$0.$count.withLock { $0 = 2 }
}
}
```
Expand Down Expand Up @@ -590,7 +623,7 @@ func increment() async {
}
await store.send(.incrementButtonTapped)
store.assert {
$0.count = 1
$0.$count.withLock { $0 = 1 }
}
}
```
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Copy link
Member Author

Choose a reason for hiding this comment

The 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`.
Loading