Files
Pyry Jahkola ae2515f93f Skip perception checks in Store.publisher (#3745)
* Add failing test using store.publisher in makeUIView

* Skip perception checks in store publisher

---------

Co-authored-by: Stephen Celis <stephen@stephencelis.com>
2025-10-15 09:57:18 -07:00

603 lines
19 KiB
Swift

import Combine
import CombineSchedulers
import Foundation
import SwiftUI
/// A store represents the runtime that powers the application. It is the object that you will pass
/// around to views that need to interact with the application.
///
/// You will typically construct a single one of these at the root of your application:
///
/// ```swift
/// @main
/// struct MyApp: App {
/// var body: some Scene {
/// WindowGroup {
/// RootView(
/// store: Store(initialState: AppFeature.State()) {
/// AppFeature()
/// }
/// )
/// }
/// }
/// }
/// ```
///
/// and then use the ``scope(state:action:)-90255`` method to derive more focused stores that can be
/// passed to subviews.
///
/// ### Scoping
///
/// The most important operation defined on ``Store`` is the ``scope(state:action:)-90255`` method,
/// which allows you to transform a store into one that deals with child state and actions. This is
/// necessary for passing stores to subviews that only care about a small portion of the entire
/// application's domain.
///
/// For example, if an application has a tab view at its root with tabs for activity, search, and
/// profile, then we can model the domain like this:
///
/// ```swift
/// @Reducer
/// struct AppFeature {
/// struct State {
/// var activity: Activity.State
/// var profile: Profile.State
/// var search: Search.State
/// }
///
/// enum Action {
/// case activity(Activity.Action)
/// case profile(Profile.Action)
/// case search(Search.Action)
/// }
///
/// // ...
/// }
/// ```
///
/// We can construct a view for each of these domains by applying ``scope(state:action:)-90255`` to
/// a store that holds onto the full app domain in order to transform it into a store for each
/// subdomain:
///
/// ```swift
/// struct AppView: View {
/// let store: StoreOf<AppFeature>
///
/// var body: some View {
/// TabView {
/// ActivityView(
/// store: store.scope(state: \.activity, action: \.activity)
/// )
/// .tabItem { Text("Activity") }
///
/// SearchView(
/// store: store.scope(state: \.search, action: \.search)
/// )
/// .tabItem { Text("Search") }
///
/// ProfileView(
/// store: store.scope(state: \.profile, action: \.profile)
/// )
/// .tabItem { Text("Profile") }
/// }
/// }
/// }
/// ```
///
/// ### 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)
#else
@preconcurrency@MainActor
#endif
public final class Store<State, Action>: _Store {
var children: [ScopeID<State, Action>: AnyObject] = [:]
private weak var parent: (any _Store)?
private let scopeID: AnyHashable?
func removeChild(scopeID: AnyHashable) {
children[scopeID as! ScopeID<State, Action>] = nil
}
let core: any Core<State, Action>
@_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] { core.effectCancellables }
#if !os(visionOS)
let _$observationRegistrar = PerceptionRegistrar(
isPerceptionCheckingEnabled: _isStorePerceptionCheckingEnabled
)
#else
let _$observationRegistrar = ObservationRegistrar()
#endif
private var parentCancellable: AnyCancellable?
/// Initializes a store from an initial state and a reducer.
///
/// - Parameters:
/// - initialState: The state to start the application in.
/// - reducer: The reducer that powers the business logic of the application.
/// - prepareDependencies: A closure that can be used to override dependencies that will be accessed
/// by the reducer.
public convenience init<R: Reducer<State, Action>>(
initialState: @autoclosure () -> R.State,
@ReducerBuilder<State, Action> reducer: () -> R,
withDependencies prepareDependencies: ((inout DependencyValues) -> Void)? = nil
) {
let (initialState, reducer, dependencies) = withDependencies(prepareDependencies ?? { _ in }) {
@Dependency(\.self) var dependencies
var updatedDependencies = dependencies
updatedDependencies.navigationIDPath.append(NavigationID())
return (initialState(), reducer(), updatedDependencies)
}
self.init(
initialState: initialState,
reducer: reducer.dependency(\.self, dependencies)
)
}
init() {
self.core = InvalidCore()
self.scopeID = nil
}
deinit {
guard Thread.isMainThread else { return }
MainActor._assumeIsolated {
Logger.shared.log("\(storeTypeName(of: self)).deinit")
}
}
/// Calls the given closure with a snapshot of the current state of the store.
///
/// A lightweight way of accessing store state when state is not observable and ``state-1qxwl`` is
/// unavailable.
///
/// - Parameter body: A closure that takes the current state of the store as its sole argument. If
/// the closure has a return value, that value is also used as the return value of the
/// `withState` method. The state argument reflects the current state of the store only for the
/// duration of the closure's execution, and is only observable over time, _e.g._ by SwiftUI, if
/// it conforms to ``ObservableState``.
/// - Returns: The return value, if any, of the `body` closure.
public func withState<R>(_ body: (_ state: State) -> R) -> R {
#if DEBUG
_PerceptionLocals.$skipPerceptionChecking.withValue(true) {
body(self.currentState)
}
#else
body(self.currentState)
#endif
}
/// Sends an action to the store.
///
/// This method returns a ``StoreTask``, which represents the lifecycle of the effect started from
/// sending an action. You can use this value to tie the effect's lifecycle _and_ cancellation to
/// an asynchronous context, such as SwiftUI's `task` view modifier:
///
/// ```swift
/// .task { await store.send(.task).finish() }
/// ```
///
/// - Parameter action: An action.
/// - Returns: A ``StoreTask`` that represents the lifecycle of the effect executed when
/// sending the action.
@discardableResult
public func send(_ action: Action) -> StoreTask {
.init(rawValue: self.send(action))
}
/// Sends an action to the store with a given animation.
///
/// See ``Store/send(_:)`` for more info.
///
/// - Parameters:
/// - action: An action.
/// - animation: An animation.
@discardableResult
public func send(_ action: Action, animation: Animation?) -> StoreTask {
send(action, transaction: Transaction(animation: animation))
}
/// Sends an action to the store with a given transaction.
///
/// See ``Store/send(_:)`` for more info.
///
/// - Parameters:
/// - action: An action.
/// - transaction: A transaction.
@discardableResult
public func send(_ action: Action, transaction: Transaction) -> StoreTask {
withTransaction(transaction) {
.init(rawValue: self.send(action))
}
}
/// Scopes the store to one that exposes child state and actions.
///
/// This can be useful for deriving new stores to hand to child views in an application. For
/// example:
///
/// ```swift
/// @Reducer
/// struct AppFeature {
/// @ObservableState
/// struct State {
/// var login: Login.State
/// // ...
/// }
/// enum Action {
/// case login(Login.Action)
/// // ...
/// }
/// // ...
/// }
///
/// // A store that runs the entire application.
/// let store = Store(initialState: AppFeature.State()) {
/// AppFeature()
/// }
///
/// // Construct a login view by scoping the store
/// // to one that works with only login domain.
/// LoginView(
/// store: store.scope(state: \.login, action: \.login)
/// )
/// ```
///
/// Scoping in this fashion allows you to better modularize your application. In this case,
/// `LoginView` could be extracted to a module that has no access to `AppFeature.State` or
/// `AppFeature.Action`.
///
/// - Parameters:
/// - state: A key path from `State` to `ChildState`.
/// - action: A case key path from `Action` to `ChildAction`.
/// - Returns: A new store with its domain (state and action) transformed.
public func scope<ChildState, ChildAction>(
state: KeyPath<State, ChildState>,
action: CaseKeyPath<Action, ChildAction>
) -> Store<ChildState, ChildAction> {
func open(_ core: some Core<State, Action>) -> any Core<ChildState, ChildAction> {
ScopedCore(base: core, stateKeyPath: state, actionKeyPath: action)
}
return scope(id: id(state: state, action: action), childCore: open(core))
}
func scope<ChildState, ChildAction>(
id: ScopeID<State, Action>?,
childCore: @autoclosure () -> any Core<ChildState, ChildAction>
) -> Store<ChildState, ChildAction> {
guard
core.canStoreCacheChildren,
let id,
let child = children[id] as? Store<ChildState, ChildAction>
else {
let child = Store<ChildState, ChildAction>(core: childCore(), scopeID: id, parent: self)
if core.canStoreCacheChildren, let id {
children[id] = child
}
return child
}
return child
}
@available(
*,
deprecated,
message:
"Pass 'state' a key path to child state and 'action' a case key path to child action, instead. For more information see the following migration guide: https://swiftpackageindex.com/pointfreeco/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.5#Store-scoping-with-key-paths"
)
public func scope<ChildState, ChildAction>(
state toChildState: @escaping (_ state: State) -> ChildState,
action fromChildAction: @escaping (_ childAction: ChildAction) -> Action
) -> Store<ChildState, ChildAction> {
_scope(state: toChildState, action: fromChildAction)
}
func _scope<ChildState, ChildAction>(
state toChildState: @escaping (_ state: State) -> ChildState,
action fromChildAction: @escaping (_ childAction: ChildAction) -> Action
) -> Store<ChildState, ChildAction> {
func open(_ core: some Core<State, Action>) -> any Core<ChildState, ChildAction> {
ClosureScopedCore(
base: core,
toState: toChildState,
fromAction: fromChildAction
)
}
return scope(id: nil, childCore: open(core))
}
@_spi(Internals)
public var currentState: State {
core.state
}
@_spi(Internals)
@_disfavoredOverload
public func send(_ action: Action) -> Task<Void, Never>? {
core.send(action)
}
private init(core: some Core<State, Action>, scopeID: AnyHashable?, parent: (any _Store)?) {
defer { Logger.shared.log("\(storeTypeName(of: self)).init") }
self.core = core
self.parent = parent
self.scopeID = scopeID
if let stateType = State.self as? any ObservableState.Type {
func subscribeToDidSet<T: ObservableState>(_ type: T.Type) -> AnyCancellable {
return core.didSet
.prefix { [weak self] _ in self?.core.isInvalid == false }
.compactMap { [weak self] in (self?.withState(\.self) as? T)?._$id }
.removeDuplicates()
.dropFirst()
.sink { [weak self, weak parent] _ in
guard let scopeID = self?.scopeID
else { return }
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
parent?.removeChild(scopeID: scopeID)
}
} receiveValue: { [weak self] _ in
guard let self else { return }
self._$observationRegistrar.withMutation(of: self, keyPath: \.currentState) {}
}
}
self.parentCancellable = subscribeToDidSet(stateType)
}
}
convenience init<R: Reducer<State, Action>>(
initialState: R.State,
reducer: R
) {
self.init(
core: RootCore(initialState: initialState, reducer: reducer),
scopeID: nil,
parent: nil
)
}
/// A publisher that emits when state changes.
///
/// This publisher supports dynamic member lookup so that you can pluck out a specific field in
/// the state:
///
/// ```swift
/// store.publisher.alert
/// .sink { ... }
/// ```
public var publisher: StorePublisher<State> {
StorePublisher(
store: self,
upstream: self.core.didSet.receive(on: UIScheduler.shared).map { self.withState(\.self) }
)
}
@_spi(Internals) public func id<ChildState, ChildAction>(
state: KeyPath<State, ChildState>,
action: CaseKeyPath<Action, ChildAction>
) -> ScopeID<State, Action> {
ScopeID(state: state, action: action)
}
}
@_spi(Internals) public struct ScopeID<State, Action>: Hashable {
let state: PartialKeyPath<State>
let action: PartialCaseKeyPath<Action>
}
extension Store: CustomDebugStringConvertible {
public nonisolated var debugDescription: String {
storeTypeName(of: self)
}
}
extension Store: ObservableObject {}
/// A convenience type alias for referring to a store of a given reducer's domain.
///
/// Instead of specifying two generics:
///
/// ```swift
/// let store: Store<Feature.State, Feature.Action>
/// ```
///
/// You can specify a single generic:
///
/// ```swift
/// let store: StoreOf<Feature>
/// ```
public typealias StoreOf<R: Reducer> = Store<R.State, R.Action>
/// A publisher of store state.
@dynamicMemberLookup
public struct StorePublisher<State>: Publisher {
public typealias Output = State
public typealias Failure = Never
let store: Any
let upstream: AnyPublisher<State, Never>
init(store: Any, upstream: some Publisher<Output, Failure>) {
self.store = store
self.upstream = upstream.eraseToAnyPublisher()
}
public func receive(subscriber: some Subscriber<Output, Failure>) {
self.upstream.subscribe(
AnySubscriber(
receiveSubscription: subscriber.receive(subscription:),
receiveValue: { value in
_PerceptionLocals.$skipPerceptionChecking.withValue(true) {
subscriber.receive(value)
}
},
receiveCompletion: { [store = self.store] in
subscriber.receive(completion: $0)
_ = store
}
)
)
}
/// Returns the resulting publisher of a given key path.
public subscript<Value: Equatable>(
dynamicMember keyPath: KeyPath<State, Value>
) -> StorePublisher<Value> {
.init(store: self.store, upstream: self.upstream.map(keyPath).removeDuplicates())
}
}
/// The type returned from ``Store/send(_:)`` that represents the lifecycle of the effect
/// started from sending an action.
///
/// You can use this value to tie the effect's lifecycle _and_ cancellation to an asynchronous
/// context, such as the `task` view modifier.
///
/// ```swift
/// .task { await store.send(.task).finish() }
/// ```
///
/// > Note: Unlike Swift's `Task` type, ``StoreTask`` automatically sets up a cancellation
/// > handler between the current async context and the task.
///
/// See ``TestStoreTask`` for the analog returned from ``TestStore``.
public struct StoreTask: Hashable, Sendable {
internal let rawValue: Task<Void, Never>?
internal init(rawValue: Task<Void, Never>?) {
self.rawValue = rawValue
}
/// Cancels the underlying task.
public func cancel() {
self.rawValue?.cancel()
}
/// Waits for the task to finish.
public func finish() async {
await self.rawValue?.cancellableValue
}
/// A Boolean value that indicates whether the task should stop executing.
///
/// After the value of this property becomes `true`, it remains `true` indefinitely. There is no
/// way to uncancel a task.
public var isCancelled: Bool {
self.rawValue?.isCancelled ?? true
}
}
func storeTypeName<State, Action>(of store: Store<State, Action>) -> String {
let stateType = typeName(State.self, genericsAbbreviated: false)
let actionType = typeName(Action.self, genericsAbbreviated: false)
if stateType.hasSuffix(".State"),
actionType.hasSuffix(".Action"),
stateType.dropLast(6) == actionType.dropLast(7)
{
return "StoreOf<\(stateType.dropLast(6))>"
} else if stateType.hasSuffix(".State?"),
actionType.hasSuffix(".Action"),
stateType.dropLast(7) == actionType.dropLast(7)
{
return "StoreOf<\(stateType.dropLast(7))?>"
} else if stateType.hasPrefix("IdentifiedArray<"),
actionType.hasPrefix("IdentifiedAction<"),
stateType.dropFirst(16).dropLast(7) == actionType.dropFirst(17).dropLast(8)
{
return "IdentifiedStoreOf<\(stateType.drop(while: { $0 != "," }).dropFirst(2).dropLast(7))>"
} else if stateType.hasPrefix("PresentationState<"),
actionType.hasPrefix("PresentationAction<"),
stateType.dropFirst(18).dropLast(7) == actionType.dropFirst(19).dropLast(8)
{
return "PresentationStoreOf<\(stateType.dropFirst(18).dropLast(7))>"
} else if stateType.hasPrefix("StackState<"),
actionType.hasPrefix("StackAction<"),
stateType.dropFirst(11).dropLast(7)
== actionType.dropFirst(12).prefix(while: { $0 != "," }).dropLast(6)
{
return "StackStoreOf<\(stateType.dropFirst(11).dropLast(7))>"
} else {
return "Store<\(stateType), \(actionType)>"
}
}
// NB: From swift-custom-dump. Consider publicizing interface in some way to keep things in sync.
@usableFromInline
func typeName(
_ type: Any.Type,
qualified: Bool = true,
genericsAbbreviated: Bool = true
) -> String {
var name = _typeName(type, qualified: qualified)
.replacingOccurrences(
of: #"\(unknown context at \$[[:xdigit:]]+\)\."#,
with: "",
options: .regularExpression
)
for _ in 1...10 { // NB: Only handle so much nesting
let abbreviated =
name
.replacingOccurrences(
of: #"\bSwift.Optional<([^><]+)>"#,
with: "$1?",
options: .regularExpression
)
.replacingOccurrences(
of: #"\bSwift.Array<([^><]+)>"#,
with: "[$1]",
options: .regularExpression
)
.replacingOccurrences(
of: #"\bSwift.Dictionary<([^,<]+), ([^><]+)>"#,
with: "[$1: $2]",
options: .regularExpression
)
if abbreviated == name { break }
name = abbreviated
}
name = name.replacingOccurrences(
of: #"\w+\.([\w.]+)"#,
with: "$1",
options: .regularExpression
)
if genericsAbbreviated {
name = name.replacingOccurrences(
of: #"<.+>"#,
with: "",
options: .regularExpression
)
}
return name
}
let _isStorePerceptionCheckingEnabled: Bool = {
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
return false
} else {
return true
}
}()
#if canImport(Observation)
// NB: This extension must be placed in the same file as 'class Store' due to either a bug
// in Swift, or very opaque and undocumented behavior of Swift.
// See https://github.com/tuist/tuist/issues/6320#issuecomment-2148554117
@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *)
extension Store: Observable {}
#endif
@MainActor
private protocol _Store: AnyObject {
func removeChild(scopeID: AnyHashable)
}