From ff276875c4fdfff0d7e0aa4812c2423b994db56b Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Wed, 29 Nov 2023 11:51:53 -0800 Subject: [PATCH] Return "invalid" stores from scoping (#2601) * Return "invalid" stores from scoping Scoping can sometimes be invalid, _e.g._ SwiftUI may scope through an index of a collection that no longer exists while diffing in a `ForEach`, which can cause a crash when the child state is eagerly evaluated. This commit introduces the idea of an invalid store that will only crash if the store's state is interacted with, avoiding crashes that could happen deeper in SwiftUI. * wip --- Sources/ComposableArchitecture/Store.swift | 23 ++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 897e6315ec86..18595a048c54 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -139,7 +139,7 @@ public final class Store { private var isSending = false var parentCancellable: AnyCancellable? private let reducer: any Reducer - @_spi(Internals) public var stateSubject: CurrentValueSubject + @_spi(Internals) public var stateSubject: CurrentValueSubject! #if DEBUG private let mainThreadChecksEnabled: Bool #endif @@ -175,6 +175,14 @@ public final class Store { } } + fileprivate init() { + self._isInvalidated = { true } + self.reducer = EmptyReducer() + #if DEBUG + self.mainThreadChecksEnabled = true + #endif + } + deinit { self.invalidate() Logger.shared.log("\(storeTypeName(of: self)).deinit") @@ -994,12 +1002,15 @@ private final class ScopedStoreReducer: Re @inlinable func reduce(into state: inout State, action: Action) -> Effect { - if self.isInvalid() { + let isInvalid = self.isInvalid() + if isInvalid { self.onInvalidate() } self.isSending = true defer { - state = self.toState(self.rootStore.stateSubject.value) + if !isInvalid || state is _OptionalProtocol { + state = self.toState(self.rootStore.stateSubject.value) + } self.isSending = false } if let action = self.fromAction(action), @@ -1032,6 +1043,10 @@ extension ScopedStoreReducer: AnyScopedStoreReducer { isInvalid: ((S) -> Bool)?, removeDuplicates isDuplicate: ((ChildState, ChildState) -> Bool)? ) -> Store { + guard isInvalid.map({ $0(store.stateSubject.value) == false }) ?? true + else { + return Store() + } let id = id?(store.stateSubject.value) if let id = id, let childStore = store.children[id] as? Store @@ -1053,7 +1068,7 @@ extension ScopedStoreReducer: AnyScopedStoreReducer { } let reducer = ScopedStoreReducer( rootStore: self.rootStore, - state: { [stateSubject = store.stateSubject] _ in toChildState(stateSubject.value) }, + state: { [stateSubject = store.stateSubject!] _ in toChildState(stateSubject.value) }, action: { fromChildAction($0).flatMap(fromAction) }, isInvalid: isInvalid, onInvalidate: { [weak store] in