Skip to content

Store: ObservableObject #3625

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

Merged
merged 7 commits into from
Apr 1, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ struct AppCoreTests {
$0.twoFactor?.isTwoFactorRequestInFlight = true
}
}
.finish()
await store.receive(\.login.twoFactor.twoFactorResponse.success) {
$0 = .newGame(NewGame.State())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ APIs, and these guides contain tips to do so.

## Topics

- <doc:MigratingTo1.19>
- <doc:MigratingTo1.18>
- <doc:MigratingTo1.17.1>
- <doc:MigratingTo1.17>
- <doc:MigratingTo1.16>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Migrating to 1.18

Stores now automatically cancel their in-flight effects when they deallocate. And another UIKit
navigation helper has been introduced.

## An effect lifecycle change

In previous versions of the Composable Architecture, a root store's effects continued to run even
after the store's lifetime. In 1.18, this leak has been fixed, and a root store's effects will be
cancelled when the store deallocates.

If you depend on a store's fire-and-forget effect to outlive the store, for example if you want to
ensure an analytics or persistence effect proceeds without cancellation, perform this work in an
unstructured task, instead:

```diff
return .run { _ in
- await analytics.track(/* ... */)
+ Task {
+ await analytics.track(/* ... */)
+ }
}
```

## A UIKit navigation helper

Our [Swift Navigation](https://github.com/pointfreeco/swift-navigation) library ships with many
UIKit tools, and the Composable Architecture integrates with many of them, but up till now it has
lacked support for trait-based navigation by pushing an element of ``StackState``.

This has been fixed with a new endpoint on the `push` trait that takes a `state` parameter:

```swift
traitCollection.push(state: Path.State.detail(/* ... */))
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Migrating to 1.19

Store internals have been rewritten for performance and future features, and are now compatible with
SwiftUI's `@StateObject` property wrapper.

## Overview

There are no steps needed to migrate to 1.19 of the Composable Architecture, but there are a number
of changes and improvements that have been made to the `Store` that one should be aware of.

## Store internals rewrite

The store's internals have been rewritten to improved performance and to pave the way for future
features. While this should not be a breaking change, with any rewrite it is important to thoroughly
test your application after upgrading.

## StateObject compatibility

SwiftUI's `@State` and `@StateObject` allow a view to own a value or object over time, ensuring that
when a parent view is recomputed, the view-local state isn't recreated from scratch.

One important difference between `@State` and `@StateObject` is that `@State`'s initializer is
eager, while `@StateObject`'s is lazy. Because of this, if you initialize a root `Store` to be held
in `@State`, stores will be initialized (and immediately discarded) whenever the parent view's body
is computed.

To avoid the creation of these stores, one can now assign the store to a `@StateObject`, instead:

```swift
struct FeatureView: View {
@StateObject var store: StoreOf<Feature>

init() {
_store = StateObject(
// This expression is only evaluated the first time the parent view is computed.
wrappedValue: Store(initialState: Feature.State()) {
Feature()
}
)
}

var body: some View { /* ... */ }
}
```

> Important: The store's `ObservableObject` conformance does not have any impact on the actual
> observability of the store. You should continue to rely on the ``ObservableState()`` macro for
> observation.

## Initial actions

A new `initialAction` has been introduced to the `Store` that will immediately kick off an initial
action when the store is created. This is an alternative to waiting for an `onAppear` or `task`
view modifier to evaluate.

```swift
Store(initialState: Feature.State(), initialAction: .initialize) {
Feature()
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ extension Binding {
}
}

extension ObservedObject.Wrapper {
@_disfavoredOverload
public subscript<State: ObservableState, Action, Member>(
dynamicMember keyPath: KeyPath<State, Member>
) -> _StoreObservedObject<State, Action, Member>
where ObjectType == Store<State, Action> {
_StoreObservedObject(wrapper: self, keyPath: keyPath)
}
}

extension UIBinding {
@_disfavoredOverload
public subscript<State: ObservableState, Action, Member>(
Expand Down Expand Up @@ -272,6 +282,34 @@ public struct _StoreBinding<State: ObservableState, Action, Value> {
}
}

@dynamicMemberLookup
public struct _StoreObservedObject<State: ObservableState, Action, Value> {
fileprivate let wrapper: ObservedObject<Store<State, Action>>.Wrapper
fileprivate let keyPath: KeyPath<State, Value>

public subscript<Member>(
dynamicMember keyPath: KeyPath<Value, Member>
) -> _StoreObservedObject<State, Action, Member> {
_StoreObservedObject<State, Action, Member>(
wrapper: wrapper,
keyPath: self.keyPath.appending(path: keyPath)
)
}

/// Creates a binding to the value by sending new values through the given action.
///
/// - Parameter action: An action for the binding to send values through.
/// - Returns: A binding.
#if swift(<5.10)
@MainActor(unsafe)
#else
@preconcurrency@MainActor
#endif
public func sending(_ action: CaseKeyPath<Action, Value>) -> Binding<Value> {
self.wrapper[state: self.keyPath, action: action]
}
}

@dynamicMemberLookup
public struct _StoreUIBinding<State: ObservableState, Action, Value> {
fileprivate let binding: UIBinding<Store<State, Action>>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ extension Binding {
}
}

extension ObservedObject.Wrapper {
#if swift(>=5.10)
@preconcurrency@MainActor
#else
@MainActor(unsafe)
#endif
public func scope<State: ObservableState, Action, ElementState, ElementAction>(
state: KeyPath<State, StackState<ElementState>>,
action: CaseKeyPath<Action, StackAction<ElementState, ElementAction>>
) -> Binding<Store<StackState<ElementState>, StackAction<ElementState, ElementAction>>>
where ObjectType == Store<State, Action> {
self[state: state, action: action]
}
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension SwiftUI.Bindable {
/// Derives a binding to a store focused on ``StackState`` and ``StackAction``.
Expand Down
39 changes: 39 additions & 0 deletions Sources/ComposableArchitecture/Observation/Store+Observation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,45 @@ extension Binding {
}
}

extension ObservedObject.Wrapper {
#if swift(>=5.10)
@preconcurrency@MainActor
#else
@MainActor(unsafe)
#endif
public func scope<State: ObservableState, Action, ChildState, ChildAction>(
state: KeyPath<State, ChildState?>,
action: CaseKeyPath<Action, PresentationAction<ChildAction>>,
fileID: StaticString = #fileID,
filePath: StaticString = #fileID,
line: UInt = #line,
column: UInt = #column
) -> Binding<Store<ChildState, ChildAction>?>
where ObjectType == Store<State, Action> {
self[
dynamicMember:
\.[
id: self[dynamicMember: \._currentState].wrappedValue[keyPath: state]
.flatMap(_identifiableID),
state: state,
action: action,
isInViewBody: _isInPerceptionTracking,
fileID: _HashableStaticString(rawValue: fileID),
filePath: _HashableStaticString(rawValue: filePath),
line: line,
column: column
]
]
}
}

extension Store {
fileprivate var _currentState: State {
get { currentState }
set {}
}
}

@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension SwiftUI.Bindable {
/// Scopes the binding of a store to a binding of an optional presentation store.
Expand Down
12 changes: 12 additions & 0 deletions Sources/ComposableArchitecture/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ import SwiftUI
/// to run only on the main thread, and so a check is executed immediately to make sure that is the
/// case. Further, all actions sent to the store and all scopes (see ``scope(state:action:)-90255``)
/// of the store are also checked to make sure that work is performed on the main thread.
///
/// ### ObservableObject conformance
///
/// The store conforms to `ObservableObject` but is _not_ observable via the `@ObservedObject`
/// property wrapper. This conformance is completely inert and its sole purpose is to allow stores
/// to be held in SwiftUI's `@StateObject` property wrapper.
///
/// Instead, stores should be observed through Swift's Observation framework (or the Perception
/// package when targeting iOS <17) by applying the ``ObservableState()`` macro to your feature's
/// state.
@dynamicMemberLookup
#if swift(<5.10)
@MainActor(unsafe)
Expand Down Expand Up @@ -416,6 +426,8 @@ extension Store: CustomDebugStringConvertible {
}
}

extension Store: ObservableObject {}

/// A convenience type alias for referring to a store of a given reducer's domain.
///
/// Instead of specifying two generics:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import XCTest
class BaseTCATestCase: XCTestCase {
override func tearDown() async throws {
try await super.tearDown()
_cancellationCancellables.withValue { [description = "\(self)"] in
let description = "\(self)"
_cancellationCancellables.withValue {
XCTAssertEqual($0.count, 0, description)
$0.removeAll()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2634,20 +2634,20 @@ final class PresentationReducerTests: BaseTCATestCase {
.ifLet(\.$alert, action: /Action.alert)
}
}
@MainActor
func testEphemeralBindingDismissal() async {
@Perception.Bindable var store = Store(
initialState: TestEphemeralBindingDismissalFeature.State(
alert: AlertState { TextState("Oops!") }
)
) {
TestEphemeralBindingDismissalFeature()
}

XCTAssertNotNil(store.alert)
$store.scope(state: \.alert, action: \.alert).wrappedValue = nil
XCTAssertNil(store.alert)
}
// @MainActor
// func testEphemeralBindingDismissal() async {
// @Perception.Bindable var store = Store(
// initialState: TestEphemeralBindingDismissalFeature.State(
// alert: AlertState { TextState("Oops!") }
// )
// ) {
// TestEphemeralBindingDismissalFeature()
// }
//
// XCTAssertNotNil(store.alert)
// $store.scope(state: \.alert, action: \.alert).wrappedValue = nil
// XCTAssertNil(store.alert)
// }
#endif
}

Expand Down
42 changes: 21 additions & 21 deletions Tests/ComposableArchitectureTests/StoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1103,27 +1103,27 @@ final class StoreTests: BaseTCATestCase {
var body: some ReducerOf<Self> { EmptyReducer() }
}

#if !os(visionOS)
@MainActor
func testInvalidatedStoreScope() async throws {
@Perception.Bindable var store = Store(
initialState: InvalidatedStoreScopeParentFeature.State(
child: InvalidatedStoreScopeChildFeature.State(
grandchild: InvalidatedStoreScopeGrandchildFeature.State()
)
)
) {
InvalidatedStoreScopeParentFeature()
}
store.send(.tap)

@Perception.Bindable var childStore = store.scope(state: \.child, action: \.child)!
let grandchildStoreBinding = $childStore.scope(state: \.grandchild, action: \.grandchild)

store.send(.child(.dismiss))
grandchildStoreBinding.wrappedValue = nil
}
#endif
// #if !os(visionOS)
// @MainActor
// func testInvalidatedStoreScope() async throws {
// @Perception.Bindable var store = Store(
// initialState: InvalidatedStoreScopeParentFeature.State(
// child: InvalidatedStoreScopeChildFeature.State(
// grandchild: InvalidatedStoreScopeGrandchildFeature.State()
// )
// )
// ) {
// InvalidatedStoreScopeParentFeature()
// }
// store.send(.tap)
//
// @Perception.Bindable var childStore = store.scope(state: \.child, action: \.child)!
// let grandchildStoreBinding = $childStore.scope(state: \.grandchild, action: \.grandchild)
//
// store.send(.child(.dismiss))
// grandchildStoreBinding.wrappedValue = nil
// }
// #endif

@MainActor
func testSurroundingDependencies() {
Expand Down
Loading