From a2c9901c1f345ad16c811586e49342587de92db0 Mon Sep 17 00:00:00 2001 From: Larry Atkin Date: Fri, 7 Nov 2025 16:54:04 -0600 Subject: [PATCH] Implement scope, where the closure to access the child state may return nil when the child state changes. --- Sources/ComposableArchitecture/Core.swift | 50 ++++++++++++++++++++++ Sources/ComposableArchitecture/Store.swift | 43 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/Sources/ComposableArchitecture/Core.swift b/Sources/ComposableArchitecture/Core.swift index 3c753fe5971d..85322e82b667 100644 --- a/Sources/ComposableArchitecture/Core.swift +++ b/Sources/ComposableArchitecture/Core.swift @@ -354,3 +354,53 @@ final class ClosureScopedCore: Core { base.effectCancellables } } + +final class OptionalClosureScopedCore: Core { + let base: Base + var cachedState: State + let toState: (Base.State) -> State? + let fromAction: (Action) -> Base.Action + init( + base: Base, + cachedState: State, + toState: @escaping (Base.State) -> State?, + fromAction: @escaping (Action) -> Base.Action + ) { + self.base = base + self.cachedState = cachedState + self.toState = toState + self.fromAction = fromAction + } + @inlinable + @inline(__always) + var state: State { + let state = toState(base.state) ?? cachedState + cachedState = state + return state + } + @inlinable + @inline(__always) + func send(_ action: Action) -> Task? { + base.send(fromAction(action)) + } + @inlinable + @inline(__always) + var canStoreCacheChildren: Bool { + true + } + @inlinable + @inline(__always) + var didSet: CurrentValueRelay { + base.didSet + } + @inlinable + @inline(__always) + var isInvalid: Bool { + base.isInvalid + } + @inlinable + @inline(__always) + var effectCancellables: [UUID: AnyCancellable] { + base.effectCancellables + } +} diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 183d9d716b6d..56350849870b 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -316,6 +316,49 @@ public final class Store: _Store { return scope(id: nil, childCore: open(core)) } + /// 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. + /// It is especially useful when a child state may be represented by multiple different + /// types, for example when it is defined using a protocol which may be implemented in + /// many different ways. When the child state changes, Observation may attempt to refresh + /// a view, but the observable reference is expecting the old child state. To handle + /// this situation, the toChildState closure should return nil if the actual child state + /// is not ChildState. This will result in using a ChildState that was previously cached. + /// + /// Users are cautioned that using this implementation of scope makes it possible for a + /// child reducer to be terminated without notification. If notification is desired, an + /// action with that purpose should be sent to the child before it is terminated. This is + /// different from the implementation for the .ifCaseLet reducer, which guarantees that the + /// child reducer is invoked before any parent reducer that might change the child state. + /// + /// - Parameters: + /// - toChildState: A function that optionally transforms `State` into `ChildState`. + /// - fromChildAction: A function that transforms `ChildAction` into `Action`. + /// - Returns: A new store with its domain (state and action) transformed. + + public func scope( + state toChildState: @escaping (_ state: State) -> ChildState?, + action fromChildAction: @escaping (_ childAction: ChildAction) -> Action + ) -> Store { + _scope(state: toChildState, action: fromChildAction) + } + + func _scope( + state toChildState: @escaping (_ state: State) -> ChildState?, + action fromChildAction: @escaping (_ childAction: ChildAction) -> Action + ) -> Store { + func open(_ core: some Core) -> any Core { + OptionalClosureScopedCore( + base: core, + cachedState: toChildState(core.state)!, + toState: toChildState, + fromAction: fromChildAction + ) + } + return scope(id: nil, childCore: open(core)) + } + @_spi(Internals) public var currentState: State { core.state