diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 637b5a7a87e1..ceedeba3e615 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Update xcbeautify + run: brew upgrade xcbeautify - name: List available devices run: xcrun simctl list devices available - name: Cache derived data @@ -65,6 +67,8 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode ${{ matrix.xcode }} run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + - name: Update xcbeautify + run: brew upgrade xcbeautify - name: Install visionOS runtime if: matrix.platform == 'visionOS' run: | @@ -100,6 +104,8 @@ jobs: - uses: actions/checkout@v4 - name: Select Xcode 15.4 run: sudo xcode-select -s /Applications/Xcode_15.4.app + - name: Update xcbeautify + run: brew upgrade xcbeautify - name: Build for library evolution run: make build-for-library-evolution @@ -118,6 +124,8 @@ jobs: deriveddata-examples- - name: Select Xcode 16 run: sudo xcode-select -s /Applications/Xcode_16.2.app + - name: Update xcbeautify + run: brew upgrade xcbeautify - name: Set IgnoreFileSystemDeviceInodeChanges flag run: defaults write com.apple.dt.XCBuild IgnoreFileSystemDeviceInodeChanges -bool YES - name: Update mtime for incremental builds diff --git a/Sources/ComposableArchitecture/Core.swift b/Sources/ComposableArchitecture/Core.swift new file mode 100644 index 000000000000..57af08d7e800 --- /dev/null +++ b/Sources/ComposableArchitecture/Core.swift @@ -0,0 +1,343 @@ +import Combine +import Foundation + +@MainActor +protocol Core: AnyObject, Sendable { + associatedtype State + associatedtype Action + var state: State { get } + func send(_ action: Action) -> Task? + + var canStoreCacheChildren: Bool { get } + var didSet: CurrentValueRelay { get } + var isInvalid: Bool { get } + + var effectCancellables: [UUID: AnyCancellable] { get } +} + +final class InvalidCore: Core { + var state: State { + get { fatalError() } + set { fatalError() } + } + func send(_ action: Action) -> Task? { nil } + + @inlinable + @inline(__always) + var canStoreCacheChildren: Bool { false } + let didSet = CurrentValueRelay(()) + @inlinable + @inline(__always) + var isInvalid: Bool { true } + @inlinable + @inline(__always) + var effectCancellables: [UUID: AnyCancellable] { [:] } +} + +final class RootCore: Core { + var state: Root.State { + didSet { + didSet.send(()) + } + } + let reducer: Root + + @inlinable + @inline(__always) + var canStoreCacheChildren: Bool { true } + let didSet = CurrentValueRelay(()) + @inlinable + @inline(__always) + var isInvalid: Bool { false } + + private var bufferedActions: [Root.Action] = [] + var effectCancellables: [UUID: AnyCancellable] = [:] + private var isSending = false + init( + initialState: Root.State, + reducer: Root + ) { + self.state = initialState + self.reducer = reducer + } + func send(_ action: Root.Action) -> Task? { + _withoutPerceptionChecking { + _send(action) + } + } + func _send(_ action: Root.Action) -> Task? { + self.bufferedActions.append(action) + guard !self.isSending else { return nil } + + self.isSending = true + var currentState = self.state + let tasks = LockIsolated<[Task]>([]) + defer { + withExtendedLifetime(self.bufferedActions) { + self.bufferedActions.removeAll() + } + self.state = currentState + self.isSending = false + if !self.bufferedActions.isEmpty { + if let task = self.send( + self.bufferedActions.removeLast() + ) { + tasks.withValue { $0.append(task) } + } + } + } + + var index = self.bufferedActions.startIndex + while index < self.bufferedActions.endIndex { + defer { index += 1 } + let action = self.bufferedActions[index] + let effect = reducer.reduce(into: ¤tState, action: action) + let uuid = UUID() + + switch effect.operation { + case .none: + break + case let .publisher(publisher): + var didComplete = false + let boxedTask = Box?>(wrappedValue: nil) + let effectCancellable = withEscapedDependencies { continuation in + publisher + .receive(on: UIScheduler.shared) + .handleEvents(receiveCancel: { [weak self] in self?.effectCancellables[uuid] = nil }) + .sink( + receiveCompletion: { [weak self] _ in + boxedTask.wrappedValue?.cancel() + didComplete = true + self?.effectCancellables[uuid] = nil + }, + receiveValue: { [weak self] effectAction in + guard let self else { return } + if let task = continuation.yield({ + self.send(effectAction) + }) { + tasks.withValue { $0.append(task) } + } + } + ) + } + + if !didComplete { + let task = Task { @MainActor in + for await _ in AsyncStream.never {} + effectCancellable.cancel() + } + boxedTask.wrappedValue = task + tasks.withValue { $0.append(task) } + self.effectCancellables[uuid] = AnyCancellable { + task.cancel() + } + } + case let .run(priority, operation): + withEscapedDependencies { continuation in + let task = Task(priority: priority) { @MainActor [weak self] in + let isCompleted = LockIsolated(false) + defer { isCompleted.setValue(true) } + await operation( + Send { effectAction in + if isCompleted.value { + reportIssue( + """ + An action was sent from a completed effect: + + Action: + \(debugCaseOutput(effectAction)) + + Effect returned from: + \(debugCaseOutput(action)) + + Avoid sending actions using the 'send' argument from 'Effect.run' after \ + the effect has completed. This can happen if you escape the 'send' \ + argument in an unstructured context. + + To fix this, make sure that your 'run' closure does not return until \ + you're done calling 'send'. + """ + ) + } + if let task = continuation.yield({ + self?.send(effectAction) + }) { + tasks.withValue { $0.append(task) } + } + } + ) + self?.effectCancellables[uuid] = nil + } + tasks.withValue { $0.append(task) } + self.effectCancellables[uuid] = AnyCancellable { + task.cancel() + } + } + } + } + + guard !tasks.isEmpty else { return nil } + return Task { @MainActor in + await withTaskCancellationHandler { + var index = tasks.startIndex + while index < tasks.endIndex { + defer { index += 1 } + await tasks[index].value + } + } onCancel: { + var index = tasks.startIndex + while index < tasks.endIndex { + defer { index += 1 } + tasks[index].cancel() + } + } + } + } + private actor DefaultIsolation {} +} + +final class ScopedCore: Core { + let base: Base + let stateKeyPath: KeyPath + let actionKeyPath: CaseKeyPath + init( + base: Base, + stateKeyPath: KeyPath, + actionKeyPath: CaseKeyPath + ) { + self.base = base + self.stateKeyPath = stateKeyPath + self.actionKeyPath = actionKeyPath + } + @inlinable + @inline(__always) + var state: State { + base.state[keyPath: stateKeyPath] + } + @inlinable + @inline(__always) + func send(_ action: Action) -> Task? { + base.send(actionKeyPath(action)) + } + @inlinable + @inline(__always) + var canStoreCacheChildren: Bool { + base.canStoreCacheChildren + } + @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 + } +} + +final class IfLetCore: Core { + let base: Base + var cachedState: State + let stateKeyPath: KeyPath + let actionKeyPath: CaseKeyPath + var parentCancellable: AnyCancellable? + init( + base: Base, + cachedState: State, + stateKeyPath: KeyPath, + actionKeyPath: CaseKeyPath + ) { + self.base = base + self.cachedState = cachedState + self.stateKeyPath = stateKeyPath + self.actionKeyPath = actionKeyPath + } + @inlinable + @inline(__always) + var state: State { + let state = base.state[keyPath: stateKeyPath] ?? cachedState + cachedState = state + return state + } + @inlinable + @inline(__always) + func send(_ action: Action) -> Task? { + #if DEBUG + if BindingLocal.isActive && isInvalid { + return nil + } + #endif + return base.send(actionKeyPath(action)) + } + @inlinable + @inline(__always) + var canStoreCacheChildren: Bool { + base.canStoreCacheChildren + } + @inlinable + @inline(__always) + var didSet: CurrentValueRelay { + base.didSet + } + @inlinable + @inline(__always) + var isInvalid: Bool { + base.state[keyPath: stateKeyPath] == nil || base.isInvalid + } + @inlinable + @inline(__always) + var effectCancellables: [UUID: AnyCancellable] { + base.effectCancellables + } +} + +final class ClosureScopedCore: Core { + let base: Base + let toState: (Base.State) -> State + let fromAction: (Action) -> Base.Action + init( + base: Base, + toState: @escaping (Base.State) -> State, + fromAction: @escaping (Action) -> Base.Action + ) { + self.base = base + self.toState = toState + self.fromAction = fromAction + } + @inlinable + @inline(__always) + var state: State { + toState(base.state) + } + @inlinable + @inline(__always) + func send(_ action: Action) -> Task? { + base.send(fromAction(action)) + } + @inlinable + @inline(__always) + var canStoreCacheChildren: Bool { + false + } + @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/Internal/CurrentValueRelay.swift b/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift index bb09e396e08b..071049c4e51b 100644 --- a/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift +++ b/Sources/ComposableArchitecture/Internal/CurrentValueRelay.swift @@ -1,7 +1,7 @@ import Combine import Foundation -final class CurrentValueRelay: Publisher { +final class CurrentValueRelay: Publisher, @unchecked Sendable { typealias Failure = Never private var currentValue: Output diff --git a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift index 03d504769d22..8634d1552cce 100644 --- a/Sources/ComposableArchitecture/Observation/Binding+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Binding+Observation.swift @@ -164,7 +164,13 @@ extension Store where State: ObservableState, Action: BindableAction, Action.Sta get { self.state[keyPath: keyPath] } set { BindingLocal.$isActive.withValue(true) { - self.send(.set(keyPath.unsafeSendable(), newValue, isInvalidated: _isInvalidated)) + self.send( + .set( + keyPath.unsafeSendable(), + newValue, + isInvalidated: { [weak self] in self?.core.isInvalid ?? true } + ) + ) } } } @@ -181,7 +187,9 @@ where get { self.observableState } set { BindingLocal.$isActive.withValue(true) { - self.send(.set(\.self, newValue, isInvalidated: _isInvalidated)) + self.send( + .set(\.self, newValue, isInvalidated: { [weak self] in self?.core.isInvalid ?? true }) + ) } } } @@ -200,7 +208,15 @@ where get { self.state[keyPath: keyPath] } set { BindingLocal.$isActive.withValue(true) { - self.send(.view(.set(keyPath.unsafeSendable(), newValue, isInvalidated: _isInvalidated))) + self.send( + .view( + .set( + keyPath.unsafeSendable(), + newValue, + isInvalidated: { [weak self] in self?.core.isInvalid ?? true } + ) + ) + ) } } } @@ -218,7 +234,11 @@ where get { self.observableState } set { BindingLocal.$isActive.withValue(true) { - self.send(.view(.set(\.self, newValue, isInvalidated: _isInvalidated))) + self.send( + .view( + .set(\.self, newValue, isInvalidated: { [weak self] in self?.core.isInvalid ?? true }) + ) + ) } } } diff --git a/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift b/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift index 8966058683be..d2e912a98077 100644 --- a/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/IdentifiedArray+Observation.swift @@ -76,7 +76,7 @@ extension Store where State: ObservableState { line: UInt = #line, column: UInt = #column ) -> some RandomAccessCollection> { - if !self.canCacheChildren { + if !core.canStoreCacheChildren { reportIssue( uncachedStoreWarning(self), fileID: fileID, @@ -124,17 +124,24 @@ public struct _StoreCollection: RandomAc else { return Store() } - let id = self.data.ids[position] - var element = self.data[position] - return self.store.scope( - id: self.store.id(state: \.[id: id]!, action: \.[id: id]), - state: ToState { - element = $0[id: id] ?? element - return element - }, - action: { .element(id: id, action: $0) }, - isInvalid: { !$0.ids.contains(id) } - ) + let elementID = self.data.ids[position] + let scopeID = self.store.id(state: \.[id: elementID], action: \.[id: elementID]) + guard let child = self.store.children[scopeID] as? Store + else { + @MainActor + func open( + _ core: some Core, IdentifiedAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: self.data[position], + stateKeyPath: \.[id:elementID], + actionKeyPath: \.[id:elementID] + ) + } + return self.store.scope(id: scopeID, childCore: open(self.store.core)) + } + return child } } } diff --git a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift index 6a6e7d4a3d00..f998513643c7 100644 --- a/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/NavigationStack+Observation.swift @@ -187,32 +187,43 @@ public struct _NavigationDestinationViewModifier< content .environment(\.navigationDestinationType, State.self) .navigationDestination(for: StackState.Component.self) { component in - var element = component.element - self - .destination( - self.store.scope( - id: self.store.id( - state: - \.[ - id: component.id, - fileID: _HashableStaticString( - rawValue: fileID), - filePath: _HashableStaticString( - rawValue: filePath), line: line, column: column - ], - action: \.[id: component.id] - ), - state: ToState { - element = $0[id: component.id] ?? element - return element - }, - action: { .element(id: component.id, action: $0) }, - isInvalid: { !$0.ids.contains(component.id) } - ) - ) + navigationDestination(component: component) .environment(\.navigationDestinationType, State.self) } } + + @MainActor + private func navigationDestination(component: StackState.Component) -> Destination { + let id = store.id( + state: + \.[ + id:component.id, + fileID:_HashableStaticString(rawValue: fileID), + filePath:_HashableStaticString(rawValue: filePath), + line:line, + column:column + ], + action: \.[id:component.id] + ) + @MainActor + func open( + _ core: some Core, StackAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: component.element, + stateKeyPath: \.[ + id:component.id, + fileID:_HashableStaticString(rawValue: fileID), + filePath:_HashableStaticString(rawValue: filePath), + line:line, + column:column + ], + actionKeyPath: \.[id:component.id] + ) + } + return destination(store.scope(id: id, childCore: open(store.core))) + } } @available(iOS 16, macOS 13, tvOS 16, watchOS 9, *) diff --git a/Sources/ComposableArchitecture/Observation/Store+Observation.swift b/Sources/ComposableArchitecture/Observation/Store+Observation.swift index b27745160387..de5b3db45a29 100644 --- a/Sources/ComposableArchitecture/Observation/Store+Observation.swift +++ b/Sources/ComposableArchitecture/Observation/Store+Observation.swift @@ -73,22 +73,22 @@ extension Store where State: ObservableState { /// > observed. /// /// - Parameters: - /// - state: A key path to optional child state. - /// - action: A case key path to child actions. - /// - fileID: The fileID. - /// - filePath: The filePath. - /// - line: The line. - /// - column: The column. + /// - stateKeyPath: A key path to optional child state. + /// - actionKeyPath: A case key path to child actions. + /// - fileID: The source `#fileID` associated with the scoping. + /// - filePath: The source `#filePath` associated with the scoping. + /// - line: The source `#line` associated with the scoping. + /// - column: The source `#column` associated with the scoping. /// - Returns: An optional store of non-optional child state and actions. public func scope( - state: KeyPath, - action: CaseKeyPath, + state stateKeyPath: KeyPath, + action actionKeyPath: CaseKeyPath, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, column: UInt = #column ) -> Store? { - if !self.canCacheChildren { + if !core.canStoreCacheChildren { reportIssue( uncachedStoreWarning(self), fileID: fileID, @@ -97,17 +97,21 @@ extension Store where State: ObservableState { column: column ) } - guard var childState = self.state[keyPath: state] - else { return nil } - return self.scope( - id: self.id(state: state.appending(path: \.!), action: action), - state: ToState { - childState = $0[keyPath: state] ?? childState - return childState - }, - action: { action($0) }, - isInvalid: { $0[keyPath: state] == nil } - ) + let id = id(state: stateKeyPath, action: actionKeyPath) + guard let childState = state[keyPath: stateKeyPath] + else { + children[id] = nil // TODO: Eager? + return nil + } + func open(_ core: some Core) -> any Core { + IfLetCore( + base: core, + cachedState: childState, + stateKeyPath: stateKeyPath, + actionKeyPath: actionKeyPath + ) + } + return scope(id: id, childCore: open(core)) } } @@ -412,7 +416,7 @@ extension Store where State: ObservableState { if newValue == nil, let childState = self.state[keyPath: state], id == _identifiableID(childState), - !self._isInvalidated() + !self.core.isInvalid { self.send(action(.dismiss)) if self.state[keyPath: state] != nil { diff --git a/Sources/ComposableArchitecture/RootStore.swift b/Sources/ComposableArchitecture/RootStore.swift deleted file mode 100644 index 378deabfa9a9..000000000000 --- a/Sources/ComposableArchitecture/RootStore.swift +++ /dev/null @@ -1,160 +0,0 @@ -import Combine -import Foundation - -@_spi(Internals) -@MainActor -public final class RootStore { - private var bufferedActions: [Any] = [] - let didSet = CurrentValueRelay(()) - @_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] = [:] - private var isSending = false - private let reducer: any Reducer - private(set) var state: Any { - didSet { - didSet.send(()) - } - } - - init( - initialState: State, - reducer: some Reducer - ) { - self.state = initialState - self.reducer = reducer - } - - func send(_ action: Any, originatingFrom originatingAction: Any? = nil) -> Task? { - func open(reducer: some Reducer) -> Task? { - self.bufferedActions.append(action) - guard !self.isSending else { return nil } - - self.isSending = true - var currentState = self.state as! State - let tasks = LockIsolated<[Task]>([]) - defer { - withExtendedLifetime(self.bufferedActions) { - self.bufferedActions.removeAll() - } - self.state = currentState - self.isSending = false - if !self.bufferedActions.isEmpty { - if let task = self.send( - self.bufferedActions.removeLast(), - originatingFrom: originatingAction - ) { - tasks.withValue { $0.append(task) } - } - } - } - - var index = self.bufferedActions.startIndex - while index < self.bufferedActions.endIndex { - defer { index += 1 } - let action = self.bufferedActions[index] as! Action - let effect = reducer.reduce(into: ¤tState, action: action) - let uuid = UUID() - - switch effect.operation { - case .none: - break - case let .publisher(publisher): - var didComplete = false - let boxedTask = Box?>(wrappedValue: nil) - let effectCancellable = withEscapedDependencies { continuation in - publisher - .receive(on: UIScheduler.shared) - .handleEvents(receiveCancel: { [weak self] in self?.effectCancellables[uuid] = nil }) - .sink( - receiveCompletion: { [weak self] _ in - boxedTask.wrappedValue?.cancel() - didComplete = true - self?.effectCancellables[uuid] = nil - }, - receiveValue: { [weak self] effectAction in - guard let self else { return } - if let task = continuation.yield({ - self.send(effectAction, originatingFrom: action) - }) { - tasks.withValue { $0.append(task) } - } - } - ) - } - - if !didComplete { - let task = Task { @MainActor in - for await _ in AsyncStream.never {} - effectCancellable.cancel() - } - boxedTask.wrappedValue = task - tasks.withValue { $0.append(task) } - self.effectCancellables[uuid] = AnyCancellable { - task.cancel() - } - } - case let .run(priority, operation): - withEscapedDependencies { continuation in - let task = Task(priority: priority) { @MainActor [weak self] in - let isCompleted = LockIsolated(false) - defer { isCompleted.setValue(true) } - await operation( - Send { effectAction in - if isCompleted.value { - reportIssue( - """ - An action was sent from a completed effect: - - Action: - \(debugCaseOutput(effectAction)) - - Effect returned from: - \(debugCaseOutput(action)) - - Avoid sending actions using the 'send' argument from 'Effect.run' after \ - the effect has completed. This can happen if you escape the 'send' \ - argument in an unstructured context. - - To fix this, make sure that your 'run' closure does not return until \ - you're done calling 'send'. - """ - ) - } - if let task = continuation.yield({ - self?.send(effectAction, originatingFrom: action) - }) { - tasks.withValue { $0.append(task) } - } - } - ) - self?.effectCancellables[uuid] = nil - } - tasks.withValue { $0.append(task) } - self.effectCancellables[uuid] = AnyCancellable { - task.cancel() - } - } - } - } - - guard !tasks.isEmpty else { return nil } - return Task { @MainActor in - await withTaskCancellationHandler { - var index = tasks.startIndex - while index < tasks.endIndex { - defer { index += 1 } - await tasks[index].value - } - } onCancel: { - var index = tasks.startIndex - while index < tasks.endIndex { - defer { index += 1 } - tasks[index].cancel() - } - } - } - } - return _withoutPerceptionChecking { - open(reducer: self.reducer) - } - } -} diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 397a8ea9307b..0c4ffa960d36 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -138,13 +138,10 @@ import SwiftUI @preconcurrency@MainActor #endif public final class Store { - var canCacheChildren = true - private var children: [ScopeID: AnyObject] = [:] - var _isInvalidated: @MainActor @Sendable () -> Bool = { false } + var children: [ScopeID: AnyObject] = [:] - @_spi(Internals) public let rootStore: RootStore - private let toState: PartialToState - private let fromAction: (Action) -> Any + let core: any Core + @_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] { core.effectCancellables } #if !os(visionOS) let _$observationRegistrar = PerceptionRegistrar( @@ -178,10 +175,7 @@ public final class Store { } init() { - self._isInvalidated = { true } - self.rootStore = RootStore(initialState: (), reducer: EmptyReducer()) - self.toState = .keyPath(\State.self) - self.fromAction = { $0 } + self.core = InvalidCore() } deinit { @@ -227,7 +221,7 @@ public final class Store { /// sending the action. @discardableResult public func send(_ action: Action) -> StoreTask { - .init(rawValue: self.send(action, originatingFrom: nil)) + .init(rawValue: self.send(action)) } /// Sends an action to the store with a given animation. @@ -252,7 +246,7 @@ public final class Store { @discardableResult public func send(_ action: Action, transaction: Transaction) -> StoreTask { withTransaction(transaction) { - .init(rawValue: self.send(action, originatingFrom: nil)) + .init(rawValue: self.send(action)) } } @@ -300,12 +294,28 @@ public final class Store { state: KeyPath, action: CaseKeyPath ) -> Store { - self.scope( - id: self.id(state: state, action: action), - state: ToState(state), - action: { action($0) }, - isInvalid: nil - ) + func open(_ core: some Core) -> any Core { + ScopedCore(base: core, stateKeyPath: state, actionKeyPath: action) + } + return scope(id: id(state: state, action: action), childCore: open(core)) + } + + func scope( + id: ScopeID?, + childCore: @autoclosure () -> any Core + ) -> Store { + guard + core.canStoreCacheChildren, + let id, + let child = children[id] as? Store + else { + let child = Store(core: childCore()) + if core.canStoreCacheChildren, let id { + children[id] = child + } + return child + } + return child } @available( @@ -317,108 +327,58 @@ public final class Store { state toChildState: @escaping (_ state: State) -> ChildState, action fromChildAction: @escaping (_ childAction: ChildAction) -> Action ) -> Store { - self.scope( - id: nil, - state: ToState(toChildState), - action: fromChildAction, - isInvalid: nil - ) + _scope(state: toChildState, action: fromChildAction) } - @_spi(Internals) - public var currentState: State { - self.toState(self.rootStore.state) + func _scope( + state toChildState: @escaping (_ state: State) -> ChildState, + action fromChildAction: @escaping (_ childAction: ChildAction) -> Action + ) -> Store { + func open(_ core: some Core) -> any Core { + ClosureScopedCore( + base: core, + toState: toChildState, + fromAction: fromChildAction + ) + } + return scope(id: nil, childCore: open(core)) } @_spi(Internals) - public func scope( - id: ScopeID?, - state: ToState, - action fromChildAction: @escaping (ChildAction) -> Action, - isInvalid: ((State) -> Bool)? - ) -> Store { - if self.canCacheChildren, - let id = id, - let childStore = self.children[id] as? Store - { - return childStore - } - let childStore = Store( - rootStore: self.rootStore, - toState: self.toState.appending(state.base), - fromAction: { [fromAction] in fromAction(fromChildAction($0)) } - ) - childStore._isInvalidated = - id == nil || !self.canCacheChildren - ? { @MainActor @Sendable in - isInvalid?(self.currentState) == true || self._isInvalidated() - } - : { @MainActor @Sendable [weak self] in - guard let self else { return true } - return isInvalid?(self.currentState) == true || self._isInvalidated() - } - childStore.canCacheChildren = self.canCacheChildren && id != nil - if let id = id, self.canCacheChildren { - self.children[id] = childStore - } - return childStore + public var currentState: State { + core.state } @_spi(Internals) - public func send( - _ action: Action, - originatingFrom originatingAction: Action? - ) -> Task? { - #if DEBUG - if BindingLocal.isActive && self._isInvalidated() { - return .none - } - #endif - return self.rootStore.send(self.fromAction(action)) + @_disfavoredOverload + public func send(_ action: Action) -> Task? { + core.send(action) } - private init( - rootStore: RootStore, - toState: PartialToState, - fromAction: @escaping (Action) -> Any - ) { + private init(core: some Core) { defer { Logger.shared.log("\(storeTypeName(of: self)).init") } - self.rootStore = rootStore - self.toState = toState - self.fromAction = fromAction - - func subscribeToDidSet(_ type: T.Type) -> AnyCancellable { - let toState = toState as! PartialToState - return rootStore.didSet - .compactMap { [weak rootStore] in - rootStore.map { toState($0.state) }?._$id - } - .removeDuplicates() - .dropFirst() - .sink { [weak self] _ in - guard let self else { return } - self._$observationRegistrar.withMutation(of: self, keyPath: \.currentState) {} - } - } + self.core = core if let stateType = State.self as? any ObservableState.Type { + func subscribeToDidSet(_ type: T.Type) -> AnyCancellable { + core.didSet + .compactMap { [weak self] in (self?.currentState as? T)?._$id } + .removeDuplicates() + .dropFirst() + .sink { [weak self] _ in + guard let self else { return } + self._$observationRegistrar.withMutation(of: self, keyPath: \.currentState) {} + } + } self.parentCancellable = subscribeToDidSet(stateType) } } - - convenience init( + + convenience init>( initialState: R.State, reducer: R - ) - where - R.State == State, - R.Action == Action - { - self.init( - rootStore: RootStore(initialState: initialState, reducer: reducer), - toState: .keyPath(\State.self), - fromAction: { $0 } - ) + ) { + self.init(core: RootCore(initialState: initialState, reducer: reducer)) } /// A publisher that emits when state changes. @@ -433,7 +393,7 @@ public final class Store { public var publisher: StorePublisher { StorePublisher( store: self, - upstream: self.rootStore.didSet.map { self.currentState } + upstream: self.core.didSet.map { self.currentState } ) } @@ -546,10 +506,6 @@ public struct StoreTask: Hashable, Sendable { } } -private protocol _OptionalProtocol {} -extension Optional: _OptionalProtocol {} -extension PresentationState: _OptionalProtocol {} - func storeTypeName(of store: Store) -> String { let stateType = typeName(State.self, genericsAbbreviated: false) let actionType = typeName(Action.self, genericsAbbreviated: false) @@ -633,47 +589,6 @@ func typeName( return name } -@_spi(Internals) -public struct ToState { - fileprivate let base: PartialToState - @_spi(Internals) - public init(_ closure: @escaping (State) -> ChildState) { - self.base = .closure { closure($0 as! State) } - } - @_spi(Internals) - public init(_ keyPath: KeyPath) { - self.base = .keyPath(keyPath) - } -} - -private enum PartialToState { - case closure((Any) -> State) - case keyPath(AnyKeyPath) - case appended((Any) -> Any, AnyKeyPath) - func callAsFunction(_ state: Any) -> State { - switch self { - case let .closure(closure): - return closure(state) - case let .keyPath(keyPath): - return state[keyPath: keyPath] as! State - case let .appended(closure, keyPath): - return closure(state)[keyPath: keyPath] as! State - } - } - func appending(_ state: PartialToState) -> PartialToState { - switch (self, state) { - case let (.keyPath(lhs), .keyPath(rhs)): - return .keyPath(lhs.appending(path: rhs)!) - case let (.closure(lhs), .keyPath(rhs)): - return .appended(lhs, rhs) - case let (.appended(lhsClosure, lhsKeyPath), .keyPath(rhs)): - return .appended(lhsClosure, lhsKeyPath.appending(path: rhs)!) - default: - return .closure { state(self($0)) } - } - } -} - let _isStorePerceptionCheckingEnabled: Bool = { if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { return false diff --git a/Sources/ComposableArchitecture/SwiftUI/Binding.swift b/Sources/ComposableArchitecture/SwiftUI/Binding.swift index 1df56e2ec570..e2d7dbff5e0b 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Binding.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Binding.swift @@ -313,7 +313,7 @@ extension ViewStore where ViewAction: BindableAction, ViewAction.State == ViewSt value: value, bindableActionType: ViewAction.self, context: .bindingState, - isInvalidated: self.store._isInvalidated, + isInvalidated: { [weak self] in self?.store.core.isInvalid ?? true }, fileID: bindingState.fileID, filePath: bindingState.filePath, line: bindingState.line, @@ -424,12 +424,7 @@ public struct BindingViewStore { line: UInt = #line, column: UInt = #column ) { - self.store = store.scope( - id: nil, - state: ToState(\.self), - action: Action.binding, - isInvalid: nil - ) + self.store = store._scope(state: { $0 }, action: { .binding($0) }) #if DEBUG self.bindableActionType = type(of: Action.self) self.fileID = fileID @@ -469,7 +464,7 @@ public struct BindingViewStore { value: value, bindableActionType: self.bindableActionType, context: .bindingStore, - isInvalidated: self.store._isInvalidated, + isInvalidated: { [weak store] in store?.core.isInvalid ?? true }, fileID: self.fileID, filePath: self.filePath, line: self.line, @@ -515,12 +510,7 @@ extension ViewStore { observe: { (_: State) in toViewState( BindingViewStore( - store: store.scope( - id: nil, - state: ToState(\.self), - action: fromViewAction, - isInvalid: nil - ) + store: store._scope(state: { $0 }, action: fromViewAction) ) ) }, @@ -631,16 +621,7 @@ extension WithViewStore where Content: View { self.init( store, observe: { (_: State) in - toViewState( - BindingViewStore( - store: store.scope( - id: nil, - state: ToState(\.self), - action: fromViewAction, - isInvalid: nil - ) - ) - ) + toViewState(BindingViewStore(store: store._scope(state: { $0 }, action: fromViewAction))) }, send: fromViewAction, removeDuplicates: isDuplicate, diff --git a/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift b/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift index 9a59c73f08cf..283b7165d8b3 100644 --- a/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/Deprecated/NavigationLinkStore.swift @@ -1,3 +1,4 @@ +import Combine import SwiftUI /// A view that controls a navigation presentation. @@ -71,19 +72,20 @@ public struct NavigationLinkStore< Destination, @ViewBuilder label: () -> Label ) { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + PresentationCore(base: core, toDestinationState: toDestinationState) + } let store = store.scope( - id: nil, - state: ToState(\.self), - action: { $0 }, - isInvalid: { $0.wrappedValue.flatMap(toDestinationState) == nil } + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) ) self.store = store self.viewStore = ViewStore( - store.scope( - id: nil, - state: ToState { $0.wrappedValue.flatMap(toDestinationState) != nil }, - action: { $0 }, - isInvalid: nil + store._scope( + state: { $0.wrappedValue.flatMap(toDestinationState) != nil }, + action: { $0 } ), observe: { $0 } ) @@ -122,19 +124,20 @@ public struct NavigationLinkStore< Destination, @ViewBuilder label: () -> Label ) where DestinationState: Identifiable { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + NavigationLinkCore(base: core, id: id, toDestinationState: toDestinationState) + } let store = store.scope( - id: nil, - state: ToState(\.self), - action: { $0 }, - isInvalid: { $0.wrappedValue.flatMap(toDestinationState)?.id != id } + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) ) self.store = store self.viewStore = ViewStore( - store.scope( - id: nil, - state: ToState { $0.wrappedValue.flatMap(toDestinationState)?.id == id }, - action: { $0 }, - isInvalid: nil + store._scope( + state: { $0.wrappedValue.flatMap(toDestinationState)?.id == id }, + action: { $0 } ), observe: { $0 } ) @@ -159,13 +162,9 @@ public struct NavigationLinkStore< ) ) { IfLetStore( - self.store.scope( - id: nil, - state: ToState( - returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) } - ), - action: { .presented(self.fromDestinationAction($0)) }, - isInvalid: nil + self.store._scope( + state: returningLastNonNilValue { $0.wrappedValue.flatMap(self.toDestinationState) }, + action: { .presented(self.fromDestinationAction($0)) } ), then: self.destination ) @@ -186,3 +185,33 @@ public struct NavigationLinkStore< return link } } + +private final class NavigationLinkCore< + Base: Core, PresentationAction>, + State, + Action, + DestinationState: Identifiable +>: Core { + let base: Base + let id: DestinationState.ID + let toDestinationState: (State) -> DestinationState? + init( + base: Base, + id: DestinationState.ID, + toDestinationState: @escaping (State) -> DestinationState? + ) { + self.base = base + self.id = id + self.toDestinationState = toDestinationState + } + var state: Base.State { + base.state + } + func send(_ action: Base.Action) -> Task? { + base.send(action) + } + var canStoreCacheChildren: Bool { base.canStoreCacheChildren } + var didSet: CurrentValueRelay { base.didSet } + var isInvalid: Bool { state.wrappedValue.flatMap(toDestinationState)?.id != id || base.isInvalid } + var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } +} diff --git a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift index c95bbd4512a0..f49604ea3b26 100644 --- a/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/ForEachStore.swift @@ -133,6 +133,20 @@ public struct ForEachStore< > { self.data = store.withState { $0 } + + func open( + _ core: some Core, IdentifiedAction>, + element: EachState, + id: ID + ) -> any Core { + IfLetCore( + base: core, + cachedState: element, + stateKeyPath: \.[id:id], + actionKeyPath: \.[id:id] + ) + } + self.content = WithViewStore( store, observe: { $0 }, @@ -140,16 +154,10 @@ public struct ForEachStore< ) { viewStore in ForEach(viewStore.state, id: viewStore.state.id) { element in let id = element[keyPath: viewStore.state.id] - var element = element content( store.scope( id: store.id(state: \.[id: id]!, action: \.[id: id]), - state: ToState { - element = $0[id: id] ?? element - return element - }, - action: { .element(id: id, action: $0) }, - isInvalid: { !$0.ids.contains(id) } + childCore: open(store.core, element: element, id: id) ) ) } @@ -197,23 +205,31 @@ public struct ForEachStore< > { self.data = store.withState { $0 } + + func open( + _ core: some Core, (id: ID, action: EachAction)>, + element: EachState, + id: ID + ) -> any Core { + IfLetCore( + base: core, + cachedState: element, + stateKeyPath: \.[id:id], + actionKeyPath: \.[id:id] + ) + } + self.content = WithViewStore( store, observe: { $0 }, removeDuplicates: { areOrderedSetsDuplicates($0.ids, $1.ids) } ) { viewStore in ForEach(viewStore.state, id: viewStore.state.id) { element in - var element = element let id = element[keyPath: viewStore.state.id] content( store.scope( id: store.id(state: \.[id: id]!, action: \.[id: id]), - state: ToState { - element = $0[id: id] ?? element - return element - }, - action: { (id, $0) }, - isInvalid: { !$0.ids.contains(id) } + childCore: open(store.core, element: element, id: id) ) ) } diff --git a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift index 5cb33a2a7341..fef4ec46c0b0 100644 --- a/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/IfLetStore.swift @@ -1,3 +1,4 @@ +import Combine import SwiftUI /// A view that safely unwraps a store of optional state in order to show one of two views. @@ -59,26 +60,26 @@ public struct IfLetStore: View { @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent, @ViewBuilder else elseContent: () -> ElseContent ) where Content == _ConditionalContent { + func open(_ core: some Core) -> any Core { + _IfLetCore(base: core) + } let store = store.scope( id: store.id(state: \.self, action: \.self), - state: ToState(\.self), - action: { $0 }, - isInvalid: { $0 == nil } + childCore: open(store.core) ) self.store = store let elseContent = elseContent() self.content = { viewStore in - if var state = viewStore.state { + if let state = viewStore.state { + @MainActor + func open(_ core: some Core) -> any Core { + IfLetCore(base: core, cachedState: state, stateKeyPath: \.self, actionKeyPath: \.self) + } return ViewBuilder.buildEither( first: ifContent( store.scope( id: store.id(state: \.!, action: \.self), - state: ToState { - state = $0 ?? state - return state - }, - action: { $0 }, - isInvalid: { $0 == nil } + childCore: open(store.core) ) ) ) @@ -104,24 +105,24 @@ public struct IfLetStore: View { _ store: Store, @ViewBuilder then ifContent: @escaping (_ store: Store) -> IfContent ) where Content == IfContent? { + func open(_ core: some Core) -> any Core { + _IfLetCore(base: core) + } let store = store.scope( id: store.id(state: \.self, action: \.self), - state: ToState(\.self), - action: { $0 }, - isInvalid: { $0 == nil } + childCore: open(store.core) ) self.store = store self.content = { viewStore in - if var state = viewStore.state { + if let state = viewStore.state { + @MainActor + func open(_ core: some Core) -> any Core { + IfLetCore(base: core, cachedState: state, stateKeyPath: \.self, actionKeyPath: \.self) + } return ifContent( store.scope( id: store.id(state: \.!, action: \.self), - state: ToState { - state = $0 ?? state - return state - }, - action: { $0 }, - isInvalid: { $0 == nil } + childCore: open(store.core) ) ) } else { @@ -304,3 +305,16 @@ public struct IfLetStore: View { ) } } + +private final class _IfLetCore, Wrapped, Action>: Core { + let base: Base + init(base: Base) { + self.base = base + } + var state: Base.State { base.state } + func send(_ action: Action) -> Task? { base.send(action) } + var canStoreCacheChildren: Bool { base.canStoreCacheChildren } + var didSet: CurrentValueRelay { base.didSet } + var isInvalid: Bool { state == nil || base.isInvalid } + var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } +} diff --git a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift index ed10dafe3220..c2ebf1197b7a 100644 --- a/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/NavigationStackStore.swift @@ -55,32 +55,41 @@ public struct NavigationStackStore line: UInt = #line, column: UInt = #column ) { - self.root = root() - self.destination = { component in - var element = component.element - return destination( - store - .scope( - id: store.id( - state: - \.[ - id: component.id, - fileID: _HashableStaticString( - rawValue: fileID), - filePath: _HashableStaticString( - rawValue: filePath), line: line, column: column - ]!, - action: \.[id: component.id] - ), - state: ToState { - element = $0[id: component.id] ?? element - return element - }, - action: { .element(id: component.id, action: $0) }, - isInvalid: { !$0.ids.contains(component.id) } - ) + func navigationDestination( + component: StackState.Component + ) -> Destination { + let id = store.id( + state: + \.[ + id :component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + action: \.[id: component.id] ) + @MainActor + func open( + _ core: some Core, StackAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: component.element, + stateKeyPath: \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + actionKeyPath: \.[id: component.id] + ) + } + return destination(store.scope(id: id, childCore: open(store.core))) } + self.root = root() + self.destination = navigationDestination(component:) self._viewStore = ObservedObject( wrappedValue: ViewStore( store, @@ -112,34 +121,46 @@ public struct NavigationStackStore line: UInt = #line, column: UInt = #column ) where Destination == SwitchStore { - self.root = root() - self.destination = { component in - var element = component.element - return SwitchStore( - store - .scope( - id: store.id( - state: - \.[ - id: component.id, - fileID: _HashableStaticString( - rawValue: fileID), - filePath: _HashableStaticString( - rawValue: filePath), line: line, column: column - ]!, - action: \.[id: component.id] - ), - state: ToState { - element = $0[id: component.id] ?? element - return element - }, - action: { .element(id: component.id, action: $0) }, - isInvalid: { !$0.ids.contains(component.id) } + func navigationDestination( + component: StackState.Component + ) -> Destination { + let id = store.id( + state: + \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + action: \.[id: component.id] + ) + if let child = store.children[id] as? Store { + return SwitchStore(child, content: destination) + } else { + @MainActor + func open( + _ core: some Core, StackAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: component.element, + stateKeyPath: \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + actionKeyPath: \.[id: component.id] ) - ) { _ in - destination(component.element) + } + return SwitchStore(store.scope(id: id, childCore: open(store.core)), content: destination) } } + + self.root = root() + self.destination = navigationDestination(component:) self._viewStore = ObservedObject( wrappedValue: ViewStore( store, diff --git a/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift b/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift index df8ed127de5f..0bf535b27471 100644 --- a/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift +++ b/Sources/ComposableArchitecture/SwiftUI/PresentationModifier.swift @@ -1,3 +1,4 @@ +import Combine import SwiftUI extension View { @@ -244,11 +245,14 @@ public struct PresentationStore< _ destination: DestinationContent ) -> Content ) where State == DestinationState, Action == DestinationAction { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + PresentationCore(base: core, toDestinationState: { $0 }) + } let store = store.scope( id: store.id(state: \.self, action: \.self), - state: ToState(\.self), - action: { $0 }, - isInvalid: { $0.wrappedValue == nil } + childCore: open(store.core) ) let viewStore = ViewStore( store, @@ -260,12 +264,7 @@ public struct PresentationStore< self.toDestinationState = { $0 } self.toID = toID self.fromDestinationAction = { $0 } - self.destinationStore = store.scope( - id: store.id(state: \.wrappedValue, action: \.presented), - state: ToState(\.wrappedValue), - action: { .presented($0) }, - isInvalid: nil - ) + self.destinationStore = store.scope(state: \.wrappedValue, action: \.presented) self.content = content self.viewStore = viewStore } @@ -280,11 +279,14 @@ public struct PresentationStore< _ destination: DestinationContent ) -> Content ) { + func open( + _ core: some Core, PresentationAction> + ) -> any Core, PresentationAction> { + PresentationCore(base: core, toDestinationState: toDestinationState) + } let store = store.scope( - id: nil, - state: ToState(\.self), - action: { $0 }, - isInvalid: { $0.wrappedValue.flatMap(toDestinationState) == nil } + id: store.id(state: \.self, action: \.self), + childCore: open(store.core) ) let viewStore = ViewStore(store, observe: { $0 }, removeDuplicates: { toID($0) == toID($1) }) @@ -292,11 +294,9 @@ public struct PresentationStore< self.toDestinationState = toDestinationState self.toID = toID self.fromDestinationAction = fromDestinationAction - self.destinationStore = store.scope( - id: nil, - state: ToState { $0.wrappedValue.flatMap(toDestinationState) }, - action: { .presented(fromDestinationAction($0)) }, - isInvalid: nil + self.destinationStore = store._scope( + state: { $0.wrappedValue.flatMap(toDestinationState) }, + action: { .presented(fromDestinationAction($0)) } ) self.content = content self.viewStore = viewStore @@ -326,6 +326,33 @@ public struct PresentationStore< } } +final class PresentationCore< + Base: Core, PresentationAction>, + State, + Action, + DestinationState +>: Core { + let base: Base + let toDestinationState: (State) -> DestinationState? + init( + base: Base, + toDestinationState: @escaping (State) -> DestinationState? + ) { + self.base = base + self.toDestinationState = toDestinationState + } + var state: Base.State { + base.state + } + func send(_ action: Base.Action) -> Task? { + base.send(action) + } + var canStoreCacheChildren: Bool { base.canStoreCacheChildren } + var didSet: CurrentValueRelay { base.didSet } + var isInvalid: Bool { state.wrappedValue.flatMap(toDestinationState) == nil || base.isInvalid } + var effectCancellables: [UUID: AnyCancellable] { base.effectCancellables } +} + @_spi(Presentation) public struct AnyIdentifiable: Identifiable { public let id: AnyHashable @@ -347,14 +374,6 @@ public struct DestinationContent { public func callAsFunction( @ViewBuilder _ body: @escaping (_ store: Store) -> Content ) -> some View { - IfLetStore( - self.store.scope( - id: self.store.id(state: \.self, action: \.self), - state: ToState(returningLastNonNilValue { $0 }), - action: { $0 }, - isInvalid: nil - ), - then: body - ) + IfLetStore(self.store, then: body) } } diff --git a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift index 9acee2a9442d..370d1d533fbc 100644 --- a/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/SwitchStore.swift @@ -161,12 +161,7 @@ public struct CaseLet( diff --git a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift index e49ad967dc74..e9f748a1c23a 100644 --- a/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift +++ b/Sources/ComposableArchitecture/SwiftUI/WithViewStore.swift @@ -493,12 +493,7 @@ public struct WithViewStore: View { line: UInt = #line ) { self.init( - store: store.scope( - id: nil, - state: ToState(toViewState), - action: fromViewAction, - isInvalid: nil - ), + store: store._scope(state: toViewState, action: fromViewAction), removeDuplicates: isDuplicate, content: content, file: file, @@ -589,12 +584,7 @@ public struct WithViewStore: View { line: UInt = #line ) { self.init( - store: store.scope( - id: nil, - state: ToState(toViewState), - action: { $0 }, - isInvalid: nil - ), + store: store._scope(state: toViewState, action: { $0 }), removeDuplicates: isDuplicate, content: content, file: file, @@ -686,12 +676,7 @@ extension WithViewStore where ViewState: Equatable, Content: View { line: UInt = #line ) { self.init( - store: store.scope( - id: nil, - state: ToState(toViewState), - action: fromViewAction, - isInvalid: nil - ), + store: store._scope(state: toViewState, action: fromViewAction), removeDuplicates: ==, content: content, file: file, @@ -779,12 +764,7 @@ extension WithViewStore where ViewState: Equatable, Content: View { line: UInt = #line ) { self.init( - store: store.scope( - id: nil, - state: ToState(toViewState), - action: { $0 }, - isInvalid: nil - ), + store: store._scope(state: toViewState, action: { $0 }), removeDuplicates: ==, content: content, file: file, diff --git a/Sources/ComposableArchitecture/TestStore.swift b/Sources/ComposableArchitecture/TestStore.swift index 783333873104..ecc1c1201268 100644 --- a/Sources/ComposableArchitecture/TestStore.swift +++ b/Sources/ComposableArchitecture/TestStore.swift @@ -983,8 +983,7 @@ extension TestStore { let task = self.store.send( .init( origin: .send(action), fileID: fileID, filePath: filePath, line: line, column: column - ), - originatingFrom: nil + ) ) if uncheckedUseMainSerialExecutor { await Task.yield() @@ -1024,7 +1023,7 @@ extension TestStore { // NB: Give concurrency runtime more time to kick off effects so users don't need to manually // instrument their effects. await Task.megaYield(count: 20) - return .init(rawValue: task, timeout: self.timeout) + return .init(rawValue: task.rawValue, timeout: self.timeout) } } @@ -2617,12 +2616,7 @@ extension TestStore { store: Store(initialState: self.state) { BindingReducer(action: toViewAction.extract(from:)) } - .scope( - id: nil, - state: ToState(\.self), - action: toViewAction.embed, - isInvalid: nil - ) + ._scope(state: { $0 }, action: toViewAction.embed) ) } } diff --git a/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift b/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift index 57b9d7adf1f1..b6b2d3c3040a 100644 --- a/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/IfLetUIKit.swift @@ -56,19 +56,18 @@ extension Store { return self .publisher .removeDuplicates(by: { ($0 != nil) == ($1 != nil) }) - .sink { state in - if var state = state { - unwrap( - self.scope( - id: self.id(state: \.!, action: \.self), - state: ToState { - state = $0 ?? state - return state - }, - action: { $0 }, - isInvalid: { $0 == nil } + .sink { [weak self] state in + if let self, let state { + @MainActor + func open(_ core: some Core) -> any Core { + IfLetCore( + base: core, + cachedState: state, + stateKeyPath: \.self, + actionKeyPath: \.self ) - ) + } + unwrap(self.scope(id: nil, childCore: open(self.core))) } else { `else`() } diff --git a/Sources/ComposableArchitecture/UIKit/NavigationStackControllerUIKit.swift b/Sources/ComposableArchitecture/UIKit/NavigationStackControllerUIKit.swift index bf4c29703ce1..2589e04db642 100644 --- a/Sources/ComposableArchitecture/UIKit/NavigationStackControllerUIKit.swift +++ b/Sources/ComposableArchitecture/UIKit/NavigationStackControllerUIKit.swift @@ -43,28 +43,35 @@ root: root ) navigationDestination(for: StackState.Component.self) { component in - var element = component.element - return destination( - path.wrappedValue.scope( - id: path.wrappedValue.id( - state: - \.[ - id: component.id, - fileID: _HashableStaticString( - rawValue: fileID), - filePath: _HashableStaticString( - rawValue: filePath), line: line, column: column - ], - action: \.[id: component.id] - ), - state: ToState { - element = $0[id: component.id] ?? element - return element - }, - action: { .element(id: component.id, action: $0) }, - isInvalid: { !$0.ids.contains(component.id) } - ) + let id = path.wrappedValue.id( + state: + \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + action: \.[id: component.id] ) + @MainActor + func open( + _ core: some Core, StackAction> + ) -> any Core { + IfLetCore( + base: core, + cachedState: component.element, + stateKeyPath: \.[ + id: component.id, + fileID: _HashableStaticString(rawValue: fileID), + filePath: _HashableStaticString(rawValue: filePath), + line: line, + column: column + ], + actionKeyPath: \.[id: component.id] + ) + } + return destination(path.wrappedValue.scope(id: id, childCore: open(path.wrappedValue.core))) } } } diff --git a/Sources/ComposableArchitecture/ViewStore.swift b/Sources/ComposableArchitecture/ViewStore.swift index 064231780fe1..6fbb362698df 100644 --- a/Sources/ComposableArchitecture/ViewStore.swift +++ b/Sources/ComposableArchitecture/ViewStore.swift @@ -155,14 +155,9 @@ public final class ViewStore: ObservableObject { self.storeTypeName = ComposableArchitecture.storeTypeName(of: store) Logger.shared.log("View\(self.storeTypeName).init") #endif - self.store = store.scope( - id: nil, - state: ToState(toViewState), - action: fromViewAction, - isInvalid: nil - ) + self.store = store._scope(state: toViewState, action: fromViewAction) self._state = CurrentValueRelay(self.store.withState { $0 }) - self.viewCancellable = self.store.rootStore.didSet + self.viewCancellable = self.store.core.didSet .compactMap { [weak self] in self?.store.withState { $0 } } .removeDuplicates(by: isDuplicate) .dropFirst() diff --git a/Tests/ComposableArchitectureTests/StoreFilterTests.swift b/Tests/ComposableArchitectureTests/StoreFilterTests.swift deleted file mode 100644 index 5781116a6cc3..000000000000 --- a/Tests/ComposableArchitectureTests/StoreFilterTests.swift +++ /dev/null @@ -1,29 +0,0 @@ -import Combine -@_spi(Internals) import ComposableArchitecture -import XCTest - -final class StoreInvalidationTests: BaseTCATestCase { - @MainActor - func testInvalidation() { - var cancellables: Set = [] - - let store = Store(initialState: nil) {} - .scope( - id: nil, - state: ToState { $0 }, - action: { $0 }, - isInvalid: { $0 != nil } - ) - let viewStore = ViewStore(store, observe: { $0 }) - var count = 0 - viewStore.publisher - .sink { _ in count += 1 } - .store(in: &cancellables) - - XCTAssertEqual(count, 1) - viewStore.send(()) - XCTAssertEqual(count, 1) - viewStore.send(()) - XCTAssertEqual(count, 1) - } -} diff --git a/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift b/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift index be246622091c..92ff21294242 100644 --- a/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift +++ b/Tests/ComposableArchitectureTests/StoreLifetimeTests.swift @@ -140,7 +140,7 @@ final class StoreLifetimeTests: BaseTCATestCase { child.send(.start) XCTAssertEqual(store.withState(\.child.count), 1) } - await clock.run() + await clock.run(timeout: .seconds(5)) XCTAssertEqual(store.withState(\.child.count), 2) } } diff --git a/Tests/ComposableArchitectureTests/StoreTests.swift b/Tests/ComposableArchitectureTests/StoreTests.swift index 5d0c4a04418e..010010e1ce30 100644 --- a/Tests/ComposableArchitectureTests/StoreTests.swift +++ b/Tests/ComposableArchitectureTests/StoreTests.swift @@ -13,11 +13,11 @@ final class StoreTests: BaseTCATestCase { func testCancellableIsRemovedOnImmediatelyCompletingEffect() { let store = Store(initialState: ()) {} - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + XCTAssertEqual(store.effectCancellables.count, 0) store.send(()) - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + XCTAssertEqual(store.effectCancellables.count, 0) } @MainActor @@ -39,15 +39,15 @@ final class StoreTests: BaseTCATestCase { }) let store = Store(initialState: ()) { reducer } - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + XCTAssertEqual(store.effectCancellables.count, 0) store.send(.start) - XCTAssertEqual(store.rootStore.effectCancellables.count, 1) + XCTAssertEqual(store.effectCancellables.count, 1) mainQueue.advance(by: 2) - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) + XCTAssertEqual(store.effectCancellables.count, 0) } @available(*, deprecated) @@ -586,12 +586,12 @@ final class StoreTests: BaseTCATestCase { } let scopedStore = store.scope(state: { $0 }, action: { $0 }) - let sendTask = scopedStore.send((), originatingFrom: nil) + let sendTask: Task? = scopedStore.send(()) await Task.yield() neverEndingTask.cancel() try await XCTUnwrap(sendTask).value - XCTAssertEqual(store.rootStore.effectCancellables.count, 0) - XCTAssertEqual(scopedStore.rootStore.effectCancellables.count, 0) + XCTAssertEqual(store.effectCancellables.count, 0) + XCTAssertEqual(scopedStore.effectCancellables.count, 0) } @Reducer @@ -707,7 +707,7 @@ final class StoreTests: BaseTCATestCase { let store = Store(initialState: Feature_testStoreVsTestStore.State()) { Feature_testStoreVsTestStore() } - await store.send(.tap, originatingFrom: nil)?.value + await store.send(.tap)?.value XCTAssertEqual(store.withState(\.count), testStore.state.count) } @@ -769,7 +769,7 @@ final class StoreTests: BaseTCATestCase { let store = Store(initialState: Feature_testStoreVsTestStore_Publisher.State()) { Feature_testStoreVsTestStore_Publisher() } - await store.send(.tap, originatingFrom: nil)?.value + await store.send(.tap)?.value XCTAssertEqual(store.withState(\.count), testStore.state.count) }