Files
Brandon Williams 54eb417336 Make 'didSet' main actor. (#3206)
* Make 'didSet' main actor.

* tests for AppStorageKey

* wip

* tests

* clean up

* Update Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKey.swift

* Update Sources/ComposableArchitecture/SharedState/PersistenceKey/AppStorageKeyPathKey.swift

* try working around CI problem

* Added NB

* wip

* Update MigratingTo1.11.md

---------

Co-authored-by: Stephen Celis <stephen@stephencelis.com>
2024-07-17 08:57:43 -07:00

5.7 KiB

Migrating to 1.11

Update your code to use the new Shared/withLock(_:) method for mutating shared state from asynchronous contexts, rather than mutating the underlying wrapped value directly.

Overview

The Composable Architecture is under constant development, and we are always looking for ways to simplify the library, and make it more powerful. This version of the library introduced 2 new APIs and deprecated 1 API.

Important: Before following this migration guide be sure you have fully migrated to the newest tools of version 1.10. See doc:MigrationGuides for more information.

Mutating shared state concurrently

Version 1.10 of the Composable Architecture introduced a powerful tool for sharing state amongst your features. And you can mutate a piece of shared state directly, as if it were just a normal property on a value type:

case .incrementButtonTapped:
  state.count += 1
  return .none

And if you only ever mutate shared state from a reducer, then this is completely fine to do. However, because shared values are secretly references (that is how data is shared), it is possible to mutate shared values from effects, which means concurrently. And prior to 1.11, it was possible to do this directly:

case .delayedIncrementButtonTapped:
  return .run { _ in
    @Shared(.count) var count
    count += 1
  }

Now, Shared is Sendable, and is technically thread-safe in that it will not crash when writing to it from two different threads. However, allowing direct mutation does make the value susceptible to race conditions. If you were to perform count += 1 from 1,000 threads, it is possible for the final value to not be 1,000.

We wanted the @Shared type to be as ergonomic as possible, and that is why we make it directly mutable, but we should not be allowing these mutations to happen from asynchronous contexts. And so now the Shared/wrappedValue setter has been marked unavailable from asynchronous contexts, with a helpful message of how to fix:

case .delayedIncrementButtonTapped:
  return .run { _ in
    @Shared(.count) var count
    count += 1  // ⚠️ Use '$shared.withLock' instead of mutating directly.
  }

To fix this deprecation you can use the new Shared/withLock(_:) method on the projected value of @Shared:

case .delayedIncrementButtonTapped:
  return .run { _ in
    @Shared(.count) var count
    $count.withLock { $0 += 1 }
  }

This 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:

let currentCount = count
$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 Shared/withLock(_:). That will make sure that the full unit of work is guarded by a lock.

Supplying mock read-only state to previews

A new SharedReader/constant(_:) helper on SharedReader has been introduced to simplify supplying mock data to Xcode previews. It works like SwiftUI's Binding.constant, but for shared references:

#Preview {
  FeatureView(
    store: Store(
      initialState: Feature.State(count: .constant(42))
    ) {
      Feature()
    }
  )
)

Migrating to 1.11.2

A few bug fixes landed in 1.11.2 that may be source breaking. They are described below:

withLock is now @MainActor

In version 1.11 of the library we deprecated mutating shared state from asynchronous contexts, such as effects, and instead recommended using the new Shared/withLock(_:) method. Doing so made it possible to lock all mutations to the shared state and prevent race conditions (see the migration guide for more info).

However, this did leave open the possibility for deadlocks if shared state was read from and written to on different threads. To fix this we have now restricted Shared/withLock(_:) to the @MainActor, and so you will now need to await its usage:

-sharedCount.withLock { $0 += 1 }
+await sharedCount.withLock { $0 += 1 }

The compiler should suggest this fix-it for you.

Optional dynamic member lookup on Shared is deprecated/disfavored

When the Shared property wrapper was first introduced, its dynamic member lookup was overloaded to automatically unwrap optionals for ergonomic purposes:

if let sharedUnwrappedProperty = $shared.optionalProperty {
  // ...
}

This unfortunately made dynamic member lookup a little more difficult to understand:

$shared.optionalProperty  // Shared<Value>?, *not* Shared<Value?>

…and required casting and other tricks to transform shared values into what one might expect.

And so this dynamic member lookup is deprecated and has been disfavored, and will eventually be removed entirely. Instead, you can use Shared/init(_:) to explicitly unwrap a shared optional value.

Disfavoring it does have the consequence of being source breaking in the case of if let and guard let expressions, where Swift does not select the optional overload automatically. To migrate, use Shared/init(_:):

-if let sharedUnwrappedProperty = $shared.optionalProperty {
+if let sharedUnwrappedProperty = Shared($shared.optionalProperty) {
   // ...
 }