Files
swift-composable-architectu…/Sources/ComposableArchitecture/Reducer/Reducers/StackReducer.swift
2023-11-15 09:49:11 -08:00

658 lines
24 KiB
Swift

@_spi(Reflection) import CasePaths
import Combine
import Foundation
import OrderedCollections
/// A list of data representing the content of a navigation stack.
///
/// Use this type for modeling a feature's domain that needs to present child features using
/// ``Reducer/forEach(_:action:destination:fileID:line:)-yz3v``.
///
/// See the dedicated article on <doc:Navigation> for more information on the library's navigation
/// tools, and in particular see <doc:StackBasedNavigation> for information on modeling navigation
/// using ``StackState`` for navigation stacks. Also see
/// <doc:StackBasedNavigation#StackState-vs-NavigationPath> to understand how ``StackState``
/// compares to SwiftUI's `NavigationPath` type.
public struct StackState<Element> {
var _dictionary: OrderedDictionary<StackElementID, Element>
fileprivate var _mounted: OrderedSet<StackElementID> = []
@Dependency(\.stackElementID) private var stackElementID
/// An ordered set of identifiers, one for each stack element.
///
/// You can use this set to iterate over stack elements along with their associated identifiers.
///
/// ```swift
/// for (id, element) in zip(state.path.ids, state.path) {
/// if element.isDeleted {
/// state.path.pop(from: id)
/// break
/// }
/// }
/// ```
public var ids: OrderedSet<StackElementID> {
self._dictionary.keys
}
/// Accesses the value associated with the given id for reading and writing.
public subscript(id id: StackElementID) -> Element? {
_read { yield self._dictionary[id] }
_modify { yield &self._dictionary[id] }
set {
switch (self.ids.contains(id), newValue, _XCTIsTesting) {
case (true, _, _), (false, .some, true):
self._dictionary[id] = newValue
case (false, .some, false):
if !_XCTIsTesting {
runtimeWarn("Can't assign element at missing ID.")
}
case (false, .none, _):
break
}
}
}
/// Accesses the value associated with the given id and case for reading and writing.
///
/// > Note: Accessing the wrong case will result in a runtime warning.
public subscript<Case>(id id: StackElementID, case path: CaseKeyPath<Element, Case>) -> Case?
where Element: CasePathable {
_read { yield self[id: id, case: AnyCasePath(path)] }
_modify { yield &self[id: id, case: AnyCasePath(path)] }
}
@available(
iOS,
deprecated: 9999,
message:
"Use the version of this subscript with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@available(
macOS,
deprecated: 9999,
message:
"Use the version of this subscript with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@available(
tvOS,
deprecated: 9999,
message:
"Use the version of this subscript with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@available(
watchOS,
deprecated: 9999,
message:
"Use the version of this subscript with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
public subscript<Case>(id id: StackElementID, case path: AnyCasePath<Element, Case>) -> Case? {
_read { yield self[id: id].flatMap(path.extract) }
_modify {
let root = self[id: id]
var value = root.flatMap(path.extract)
let success = value != nil
yield &value
guard success else {
var description: String?
if let root = root,
let metadata = EnumMetadata(Element.self),
let caseName = metadata.caseName(forTag: metadata.tag(of: root))
{
description = caseName
}
runtimeWarn(
"""
Can't modify unrelated case\(description.map { " \($0.debugDescription)" } ?? "")
"""
)
return
}
self[id: id] = value.map(path.embed)
}
}
/// Pops the element corresponding to `id` from the stack, and all elements after it.
///
/// - Parameter id: The identifier of an element in the stack.
public mutating func pop(from id: StackElementID) {
guard let index = self.ids.firstIndex(of: id)
else { return }
self.removeSubrange(index...)
}
/// Pops all elements that come after the element corresponding to `id` in the stack.
///
/// - Parameter id: The identifier of an element in the stack.
public mutating func pop(to id: StackElementID) {
guard let index = self.ids.firstIndex(of: id)
else { return }
self.removeSubrange(index.advanced(by: 1)...)
}
}
extension StackState: RandomAccessCollection, RangeReplaceableCollection {
public var startIndex: Int { self._dictionary.keys.startIndex }
public var endIndex: Int { self._dictionary.keys.endIndex }
public func index(after i: Int) -> Int { self._dictionary.keys.index(after: i) }
public func index(before i: Int) -> Int { self._dictionary.keys.index(before: i) }
public subscript(position: Int) -> Element { self._dictionary.values[position] }
public init() {
self._dictionary = [:]
}
public mutating func removeAll(keepingCapacity keepCapacity: Bool = false) {
self._dictionary.removeAll(keepingCapacity: keepCapacity)
}
public mutating func replaceSubrange<C: Collection>(_ subrange: Range<Int>, with newElements: C)
where C.Element == Element {
self._dictionary.removeSubrange(subrange)
for (offset, element) in zip(subrange.lowerBound..., newElements) {
self._dictionary.updateValue(element, forKey: self.stackElementID.next(), insertingAt: offset)
}
}
}
extension StackState: Equatable where Element: Equatable {
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs._dictionary == rhs._dictionary
}
}
extension StackState: Hashable where Element: Hashable {
public func hash(into hasher: inout Hasher) {
hasher.combine(self._dictionary)
}
}
// NB: We can remove `@unchecked` when swift-collections 1.1 is released.
extension StackState: @unchecked Sendable where Element: Sendable {}
extension StackState: Decodable where Element: Decodable {
public init(from decoder: Decoder) throws {
let elements = try [Element](from: decoder)
self.init(elements)
}
}
extension StackState: Encodable where Element: Encodable {
public func encode(to encoder: Encoder) throws {
try [Element](self).encode(to: encoder)
}
}
extension StackState: CustomStringConvertible {
public var description: String {
self._dictionary.values.elements.description
}
}
extension StackState: CustomDebugStringConvertible {
public var debugDescription: String {
"\(Self.self)(\(self._dictionary.description))"
}
}
extension StackState: CustomDumpReflectable {
public var customDumpMirror: Mirror {
Mirror(self, unlabeledChildren: Array(zip(self.ids, self)), displayStyle: .dictionary)
}
}
/// A wrapper type for actions that can be presented in a navigation stack.
///
/// Use this type for modeling a feature's domain that needs to present child features using
/// ``Reducer/forEach(_:action:destination:fileID:line:)-yz3v``.
///
/// See the dedicated article on <doc:Navigation> for more information on the library's navigation
/// tools, and in particular see <doc:StackBasedNavigation> for information on modeling navigation
/// using ``StackAction`` for navigation stacks.
public enum StackAction<State, Action>: CasePathable {
/// An action sent to the associated stack element at a given identifier.
indirect case element(id: StackElementID, action: Action)
/// An action sent to dismiss the associated stack element at a given identifier.
case popFrom(id: StackElementID)
/// An action sent to present the given state at a given identifier in a navigation stack. This
/// action is typically sent from the view via the `NavigationLink(value:)` initializer.
case push(id: StackElementID, state: State)
public static var allCasePaths: AllCasePaths {
AllCasePaths()
}
public struct AllCasePaths {
public var element: AnyCasePath<StackAction, (id: StackElementID, action: Action)> {
AnyCasePath(
embed: StackAction.element,
extract: {
guard case let .element(id, action) = $0 else { return nil }
return (id: id, action: action)
}
)
}
public var popFrom: AnyCasePath<StackAction, StackElementID> {
AnyCasePath(
embed: StackAction.popFrom,
extract: {
guard case let .popFrom(id) = $0 else { return nil }
return id
}
)
}
public var push: AnyCasePath<StackAction, (id: StackElementID, state: State)> {
AnyCasePath(
embed: StackAction.push,
extract: {
guard case let .push(id, state) = $0 else { return nil }
return (id: id, state: state)
}
)
}
public subscript(id id: StackElementID) -> AnyCasePath<StackAction, Action> {
AnyCasePath(
embed: { .element(id: id, action: $0) },
extract: {
guard case .element(id, let action) = $0 else { return nil }
return action
}
)
}
}
}
extension StackAction: Equatable where State: Equatable, Action: Equatable {}
extension StackAction: Hashable where State: Hashable, Action: Hashable {}
extension StackAction: Sendable where State: Sendable, Action: Sendable {}
extension Reducer {
/// Embeds a child reducer in a parent domain that works on elements of a navigation stack in
/// parent state.
///
/// This version of `forEach` works when the parent domain holds onto the child domain using
/// ``StackState`` and ``StackAction``.
///
/// For example, if a parent feature models a navigation stack of child features using the
/// ``StackState`` and ``StackAction`` types, then it can perform its core logic _and_ the logic
/// of each child feature using the `forEach` operator:
///
/// ```swift
/// @Reducer
/// struct ParentFeature {
/// struct State {
/// var path = StackState<Path.State>()
/// // ...
/// }
/// enum Action {
/// case path(StackAction<Path.State, Path.Action>)
/// // ...
/// }
/// var body: some ReducerOf<Self> {
/// Reduce { state, action in
/// // Core parent logic
/// }
/// .forEach(\.path, action: \.path) {
/// Path()
/// }
/// }
/// }
/// ```
///
/// The `forEach` operator does a number of things to make integrating parent and child features
/// ergonomic and enforce correctness:
///
/// * It forces a specific order of operations for the child and parent features:
/// * When a ``StackAction/element(id:action:)`` action is sent it runs the
/// child first, and then the parent. If the order was reversed, then it would be possible
/// for the parent feature to `nil` out the child state, in which case the child feature
/// would not be able to react to that action. That can cause subtle bugs.
/// * When a ``StackAction/popFrom(id:)`` action is sent it runs the parent feature
/// before the child state is popped off the stack. This gives the parent feature an
/// opportunity to inspect the child state one last time before the state is removed.
/// * When a ``StackAction/push(id:state:)`` action is sent it runs the parent feature
/// after the child state is appended to the stack. This gives the parent feature an
/// opportunity to make extra mutations to the state after it has been added.
///
/// * It automatically cancels all child effects when it detects the child's state is removed
/// from the stack
///
/// * It gives the child feature access to the ``DismissEffect`` dependency, which allows the
/// child feature to dismiss itself without communicating with the parent.
///
/// - Parameters:
/// - toStackState: A writable key path from parent state to a stack of destination state.
/// - toStackAction: A case path from parent action to a stack action.
/// - destination: A reducer that will be invoked with destination actions against elements of
/// destination state.
/// - Returns: A reducer that combines the destination reducer with the parent reducer.
@inlinable
@warn_unqualified_access
public func forEach<DestinationState, DestinationAction, Destination: Reducer>(
_ toStackState: WritableKeyPath<State, StackState<DestinationState>>,
action toStackAction: CaseKeyPath<Action, StackAction<DestinationState, DestinationAction>>,
@ReducerBuilder<DestinationState, DestinationAction> destination: () -> Destination,
fileID: StaticString = #fileID,
line: UInt = #line
) -> _StackReducer<Self, Destination>
where Destination.State == DestinationState, Destination.Action == DestinationAction {
_StackReducer(
base: self,
toStackState: toStackState,
toStackAction: AnyCasePath(toStackAction),
destination: destination(),
fileID: fileID,
line: line
)
}
@available(
iOS,
deprecated: 9999,
message:
"Use the version of this operator with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@available(
macOS,
deprecated: 9999,
message:
"Use the version of this operator with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@available(
tvOS,
deprecated: 9999,
message:
"Use the version of this operator with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@available(
watchOS,
deprecated: 9999,
message:
"Use the version of this operator with case key paths, instead. See the following migration guide for more information:\n\nhttps://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.4#Using-case-key-paths"
)
@inlinable
@warn_unqualified_access
public func forEach<DestinationState, DestinationAction, Destination: Reducer>(
_ toStackState: WritableKeyPath<State, StackState<DestinationState>>,
action toStackAction: AnyCasePath<Action, StackAction<DestinationState, DestinationAction>>,
@ReducerBuilder<DestinationState, DestinationAction> destination: () -> Destination,
fileID: StaticString = #fileID,
line: UInt = #line
) -> _StackReducer<Self, Destination>
where Destination.State == DestinationState, Destination.Action == DestinationAction {
_StackReducer(
base: self,
toStackState: toStackState,
toStackAction: toStackAction,
destination: destination(),
fileID: fileID,
line: line
)
}
}
public struct _StackReducer<Base: Reducer, Destination: Reducer>: Reducer {
let base: Base
let toStackState: WritableKeyPath<Base.State, StackState<Destination.State>>
let toStackAction: AnyCasePath<Base.Action, StackAction<Destination.State, Destination.Action>>
let destination: Destination
let fileID: StaticString
let line: UInt
@Dependency(\.navigationIDPath) var navigationIDPath
@usableFromInline
init(
base: Base,
toStackState: WritableKeyPath<Base.State, StackState<Destination.State>>,
toStackAction: AnyCasePath<Base.Action, StackAction<Destination.State, Destination.Action>>,
destination: Destination,
fileID: StaticString,
line: UInt
) {
self.base = base
self.toStackState = toStackState
self.toStackAction = toStackAction
self.destination = destination
self.fileID = fileID
self.line = line
}
public func reduce(into state: inout Base.State, action: Base.Action) -> Effect<Base.Action> {
let idsBefore = state[keyPath: self.toStackState]._mounted
let destinationEffects: Effect<Base.Action>
let baseEffects: Effect<Base.Action>
switch self.toStackAction.extract(from: action) {
case let .element(elementID, destinationAction):
if state[keyPath: self.toStackState][id: elementID] != nil {
let elementNavigationIDPath = self.navigationIDPath(for: elementID)
destinationEffects = self.destination
.dependency(
\.dismiss,
DismissEffect { @MainActor in
Task._cancel(
id: NavigationDismissID(elementID: elementID),
navigationID: elementNavigationIDPath
)
}
)
.dependency(\.navigationIDPath, elementNavigationIDPath)
.reduce(
into: &state[keyPath: self.toStackState][id: elementID]!,
action: destinationAction
)
.map { toStackAction.embed(.element(id: elementID, action: $0)) }
._cancellable(navigationIDPath: elementNavigationIDPath)
} else {
runtimeWarn(
"""
A "forEach" at "\(self.fileID):\(self.line)" received an action for a missing element. …
Action:
\(debugCaseOutput(destinationAction))
This is generally considered an application logic error, and can happen for a few reasons:
• A parent reducer removed an element with this ID before this reducer ran. This reducer \
must run before any other reducer removes an element, which ensures that element \
reducers can handle their actions while their state is still available.
• An in-flight effect emitted this action when state contained no element at this ID. \
While it may be perfectly reasonable to ignore this action, consider canceling the \
associated effect before an element is removed, especially if it is a long-living effect.
• This action was sent to the store while its state contained no element at this ID. To \
fix this make sure that actions for this reducer can only be sent from a view store when \
its state contains an element at this id. In SwiftUI applications, use \
"NavigationStackStore".
"""
)
destinationEffects = .none
}
baseEffects = self.base.reduce(into: &state, action: action)
case let .popFrom(id):
destinationEffects = .none
let canPop = state[keyPath: self.toStackState].ids.contains(id)
baseEffects = self.base.reduce(into: &state, action: action)
if canPop {
state[keyPath: self.toStackState].pop(from: id)
} else {
runtimeWarn(
"""
A "forEach" at "\(self.fileID):\(self.line)" received a "popFrom" action for a missing \
element. …
ID:
\(id)
Path IDs:
\(state[keyPath: self.toStackState].ids)
"""
)
}
case let .push(id, element):
destinationEffects = .none
if state[keyPath: self.toStackState].ids.contains(id) {
runtimeWarn(
"""
A "forEach" at "\(self.fileID):\(self.line)" received a "push" action for an element it \
already contains. …
ID:
\(id)
Path IDs:
\(state[keyPath: self.toStackState].ids)
"""
)
baseEffects = self.base.reduce(into: &state, action: action)
break
} else if DependencyValues._current.context == .test {
let nextID = DependencyValues._current.stackElementID.peek()
if id.generation > nextID.generation {
runtimeWarn(
"""
A "forEach" at "\(self.fileID):\(self.line)" received a "push" action with an \
unexpected generational ID. …
Received ID:
\(id)
Expected ID:
\(nextID)
"""
)
} else if id.generation == nextID.generation {
_ = DependencyValues._current.stackElementID.next()
}
}
state[keyPath: self.toStackState]._dictionary[id] = element
baseEffects = self.base.reduce(into: &state, action: action)
case .none:
destinationEffects = .none
baseEffects = self.base.reduce(into: &state, action: action)
}
let idsAfter = state[keyPath: self.toStackState].ids
let cancelEffects: Effect<Base.Action> =
areOrderedSetsDuplicates(idsBefore, idsAfter)
? .none
: .merge(
idsBefore.subtracting(idsAfter).map {
._cancel(navigationID: self.navigationIDPath(for: $0))
}
)
let presentEffects: Effect<Base.Action> =
areOrderedSetsDuplicates(idsBefore, idsAfter)
? .none
: .merge(
idsAfter.subtracting(idsBefore).map { elementID in
let navigationDestinationID = self.navigationIDPath(for: elementID)
return .concatenate(
.publisher { Empty(completeImmediately: false) }
._cancellable(
id: NavigationDismissID(elementID: elementID),
navigationIDPath: navigationDestinationID
),
.publisher { Just(self.toStackAction.embed(.popFrom(id: elementID))) }
)
._cancellable(navigationIDPath: navigationDestinationID)
._cancellable(id: OnFirstAppearID(), navigationIDPath: .init())
}
)
state[keyPath: self.toStackState]._mounted = idsAfter
return .merge(
destinationEffects,
baseEffects,
cancelEffects,
presentEffects
)
}
private func navigationIDPath(for elementID: StackElementID) -> NavigationIDPath {
self.navigationIDPath.appending(
NavigationID(
id: elementID,
keyPath: self.toStackState
)
)
}
}
/// An opaque type that identifies an element of ``StackState``.
///
/// The ``StackState`` type creates instances of this identifier when new elements are added to
/// the stack. This makes it possible to easily look up specific elements in the stack without
/// resorting to positional indices, which can be error prone, especially when dealing with async
/// effects.
///
/// In production environments (e.g. in Xcode previews, simulators and on devices) the identifier
/// is backed by a randomly generated UUID, but in tests a deterministic, generational ID is used.
/// This allows you to predict how IDs will be created and allows you to write tests for how
/// features behave in the stack.
///
/// ```swift
/// func testBasics() {
/// var path = StackState<Int>()
/// path.append(42)
/// XCTAssertEqual(path[id: 0], 42)
/// path.append(1729)
/// XCTAssertEqual(path[id: 1], 1729)
///
/// path.removeAll()
/// path.append(-1)
/// XCTAssertEqual(path[id: 2], -1)
/// }
/// ```
///
/// Notice that after removing all elements and appending a new element, the ID generated was 2 and
/// did not go back to 0. This is because in tests the IDs are _generational_, which means they
/// keep counting up, even if you remove elements from the stack.
public struct StackElementID: Hashable, Sendable {
@_spi(Internals) public var generation: Int
@_spi(Internals) public init(generation: Int) {
self.generation = generation
}
}
extension StackElementID: CustomDebugStringConvertible {
public var debugDescription: String {
"#\(self.generation)"
}
}
extension StackElementID: CustomDumpStringConvertible {
public var customDumpDescription: String {
self.debugDescription
}
}
extension StackElementID: ExpressibleByIntegerLiteral {
public init(integerLiteral value: Int) {
if !_XCTIsTesting {
fatalError(
"""
Specifying stack element IDs by integer literal is not allowed outside of tests.
In tests, integer literal stack element IDs can be used as a shorthand to the \
auto-incrementing generation of the current dependency context. This can be useful when \
asserting against actions received by a specific element.
"""
)
}
self.init(generation: value)
}
}
private struct NavigationDismissID: Hashable {
let elementID: AnyHashable
}