From 7e3cd58018da5edfe032a325f64e584f64f3400c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 21 Jul 2025 08:54:03 -0700 Subject: [PATCH 1/4] Clear child store from parent when it is invalidated. --- .../SwiftUICaseStudies/CaseStudiesApp.swift | 175 +++++++++++++++++- Sources/ComposableArchitecture/Store.swift | 40 +++- 2 files changed, 209 insertions(+), 6 deletions(-) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift b/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift index 3fa429ad64cf..ae9f03dff48f 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift @@ -5,7 +5,180 @@ import SwiftUI struct CaseStudiesApp: App { var body: some Scene { WindowGroup { - RootView() + //RootView() + ContentView() } } } + + +import SwiftUI +import ComposableArchitecture + + +//@inlinable +//public func _$isIdentityEqual( +// _ lhs: IdentifiedArray, +// _ rhs: IdentifiedArray +//) -> Bool { +// false +//} + +@Reducer +struct MainFeature { + @ObservableState + struct State { + var childArray: IdentifiedArrayOf + var childSolo: ChildFeature.State? + + init() { + childSolo = ChildFeature.State(id: "1", count: 0) + // It's important to start with non empty array. + childArray = [ChildFeature.State(id: "1", count: 0)] + } + } + + enum Action { + case setAnotherIDs + case getBackToInitialIDsWithAnotherCount + case resetToInitialIDs + case childArray(IdentifiedActionOf) + case childSolo(ChildFeature.Action) + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .setAnotherIDs: + // Update ID + state.childSolo = ChildFeature.State(id: "2", count: 0) + state.childArray = [ChildFeature.State(id: "2", count: 0)] + return .none + + case .getBackToInitialIDsWithAnotherCount: + // Go back to the initial ID with a different count + state.childSolo = ChildFeature.State(id: "1", count: 10) + state.childArray = [ChildFeature.State(id: "1", count: 10)] + return .none + + case .resetToInitialIDs: + // Set the same ID as in the previous action, but with a count of 0 + // After this step, the child stores in childArray stopped working. + state.childSolo = ChildFeature.State(id: "1", count: 0) + state.childArray = [ChildFeature.State(id: "1", count: 0)] + return .none + + case .childArray: + return .none + + case .childSolo: + return .none + } + } + .ifLet(\.childSolo, action: \.childSolo) { + ChildFeature() + } + .forEach(\.childArray, action: \.childArray) { + ChildFeature() + } + } +} + +@Reducer +struct ChildFeature { + @ObservableState + struct State: Identifiable { + let id: String + var count: Int + } + + enum Action { + case plus + case minus + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .plus: + state.count += 1 + return .none + + case .minus: + state.count -= 1 + return .none + } + } + } +} + +struct ChildView: View { + let store: StoreOf + + var body: some View { + VStack { + Text("id: " + store.id) + + HStack { + Button("Minus") { + store.send(.minus) + } + + Text(store.count.description) + + Button("Plus") { + store.send(.plus) + } + } + .frame(height: 50) + .buttonStyle(.borderedProminent) + } + } +} + +struct MainView: View { + let store: StoreOf + + var body: some View { + ScrollView { + VStack { + Button("setAnotherIDs") { + store.send(.setAnotherIDs) + } + + Button("getBackToInitialIDsWithAnotherCount") { + store.send(.getBackToInitialIDsWithAnotherCount) + } + + Button("resetToInitialIDs (not working)") { + store.send(.resetToInitialIDs) + } + + Color.red.frame(height: 10) + +// Text("Child solo:") +// if let childStore = store.scope(state: \.childSolo, action: \.childSolo) { +// ChildView(store: childStore) +// } + + Color.red.frame(height: 10) + + Text("Child array:") + ForEach( + store.scope(state: \.childArray, action: \.childArray) + ) { childStore in + ChildView(store: childStore) + } + } + } + } +} + +struct ContentView: View { + @State var mainStore: StoreOf = Store(initialState: MainFeature.State()) { + MainFeature()._printChanges() + } + var body: some View { + MainView(store: mainStore) + } +} diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index c5f5917efd23..bd30182426db 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -92,15 +92,35 @@ import SwiftUI /// 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. + + +@MainActor +protocol _Store: AnyObject { + var allChildren: [AnyObject] { get } + func removeChild(_ store: AnyObject) +} + + @dynamicMemberLookup #if swift(<5.10) @MainActor(unsafe) #else @preconcurrency@MainActor #endif -public final class Store { +public final class Store: _Store { var children: [ScopeID: AnyObject] = [:] + var allChildren: [AnyObject] { + Array(children.values) + } + func removeChild(_ store: AnyObject) { + for (key, child) in children { + guard child === store + else { continue } + children.removeValue(forKey: key) + } + } + let core: any Core @_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] { core.effectCancellables } @@ -266,7 +286,7 @@ public final class Store { let id, let child = children[id] as? Store else { - let child = Store(core: childCore()) + let child = Store(core: childCore(), parent: self) if core.canStoreCacheChildren, let id { children[id] = child } @@ -313,14 +333,24 @@ public final class Store { core.send(action) } - private init(core: some Core) { + private weak var parent: (any _Store)? + + private init(core: some Core, parent: (any _Store)?) { defer { Logger.shared.log("\(storeTypeName(of: self)).init") } self.core = core + self.parent = parent if let stateType = State.self as? any ObservableState.Type { func subscribeToDidSet(_ type: T.Type) -> AnyCancellable { return core.didSet - .prefix { [weak self] _ in self?.core.isInvalid != true } + .prefix { [weak self] _ in + guard let self else { return false } + let isInvalid = self.core.isInvalid + if isInvalid { + self.parent?.removeChild(self) + } + return !isInvalid + } .compactMap { [weak self] in (self?.currentState as? T)?._$id } .removeDuplicates() .dropFirst() @@ -337,7 +367,7 @@ public final class Store { initialState: R.State, reducer: R ) { - self.init(core: RootCore(initialState: initialState, reducer: reducer)) + self.init(core: RootCore(initialState: initialState, reducer: reducer), parent: nil) } /// A publisher that emits when state changes. From 2c514fb3fc79fcd2745c770250c36d1175eb6f4c Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 21 Jul 2025 11:36:15 -0700 Subject: [PATCH 2/4] wip --- Sources/ComposableArchitecture/Store.swift | 55 ++++++++++------------ 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index bd30182426db..21b9cdbdcdfb 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -93,14 +93,6 @@ import SwiftUI /// package when targeting iOS <17) by applying the ``ObservableState()`` macro to your feature's /// state. - -@MainActor -protocol _Store: AnyObject { - var allChildren: [AnyObject] { get } - func removeChild(_ store: AnyObject) -} - - @dynamicMemberLookup #if swift(<5.10) @MainActor(unsafe) @@ -109,16 +101,11 @@ protocol _Store: AnyObject { #endif public final class Store: _Store { var children: [ScopeID: AnyObject] = [:] + private weak var parent: (any _Store)? + private let scopeID: AnyHashable? - var allChildren: [AnyObject] { - Array(children.values) - } - func removeChild(_ store: AnyObject) { - for (key, child) in children { - guard child === store - else { continue } - children.removeValue(forKey: key) - } + func removeChild(scopeID: AnyHashable) { + children[scopeID as! ScopeID] = nil } let core: any Core @@ -159,6 +146,7 @@ public final class Store: _Store { init() { self.core = InvalidCore() + self.scopeID = nil } deinit { @@ -286,7 +274,7 @@ public final class Store: _Store { let id, let child = children[id] as? Store else { - let child = Store(core: childCore(), parent: self) + let child = Store(core: childCore(), scopeID: id, parent: self) if core.canStoreCacheChildren, let id { children[id] = child } @@ -333,28 +321,24 @@ public final class Store: _Store { core.send(action) } - private weak var parent: (any _Store)? - - private init(core: some Core, parent: (any _Store)?) { - defer { Logger.shared.log("\(storeTypeName(of: self)).init") } + private init(core: some Core, 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(_ type: T.Type) -> AnyCancellable { return core.didSet - .prefix { [weak self] _ in - guard let self else { return false } - let isInvalid = self.core.isInvalid - if isInvalid { - self.parent?.removeChild(self) - } - return !isInvalid - } + .prefix { [weak self] _ in self?.core.isInvalid == false } .compactMap { [weak self] in (self?.currentState as? T)?._$id } .removeDuplicates() .dropFirst() .sink { [weak self] _ in + guard let scopeID = self?.scopeID + else { return } + parent?.removeChild(scopeID: scopeID) + } receiveValue: { [weak self] _ in guard let self else { return } self._$observationRegistrar.withMutation(of: self, keyPath: \.currentState) {} } @@ -367,7 +351,11 @@ public final class Store: _Store { initialState: R.State, reducer: R ) { - self.init(core: RootCore(initialState: initialState, reducer: reducer), parent: nil) + self.init( + core: RootCore(initialState: initialState, reducer: reducer), + scopeID: nil, + parent: nil + ) } /// A publisher that emits when state changes. @@ -595,3 +583,8 @@ let _isStorePerceptionCheckingEnabled: Bool = { @available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) extension Store: Observable {} #endif + +@MainActor +private protocol _Store: AnyObject { + func removeChild(scopeID: AnyHashable) +} From 5bb94f14dabd8e339bfdee040b46ba5b870d9cf8 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 21 Jul 2025 11:37:49 -0700 Subject: [PATCH 3/4] wip --- .../SwiftUICaseStudies/CaseStudiesApp.swift | 175 +----------------- 1 file changed, 1 insertion(+), 174 deletions(-) diff --git a/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift b/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift index ae9f03dff48f..3fa429ad64cf 100644 --- a/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift +++ b/Examples/CaseStudies/SwiftUICaseStudies/CaseStudiesApp.swift @@ -5,180 +5,7 @@ import SwiftUI struct CaseStudiesApp: App { var body: some Scene { WindowGroup { - //RootView() - ContentView() + RootView() } } } - - -import SwiftUI -import ComposableArchitecture - - -//@inlinable -//public func _$isIdentityEqual( -// _ lhs: IdentifiedArray, -// _ rhs: IdentifiedArray -//) -> Bool { -// false -//} - -@Reducer -struct MainFeature { - @ObservableState - struct State { - var childArray: IdentifiedArrayOf - var childSolo: ChildFeature.State? - - init() { - childSolo = ChildFeature.State(id: "1", count: 0) - // It's important to start with non empty array. - childArray = [ChildFeature.State(id: "1", count: 0)] - } - } - - enum Action { - case setAnotherIDs - case getBackToInitialIDsWithAnotherCount - case resetToInitialIDs - case childArray(IdentifiedActionOf) - case childSolo(ChildFeature.Action) - } - - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .setAnotherIDs: - // Update ID - state.childSolo = ChildFeature.State(id: "2", count: 0) - state.childArray = [ChildFeature.State(id: "2", count: 0)] - return .none - - case .getBackToInitialIDsWithAnotherCount: - // Go back to the initial ID with a different count - state.childSolo = ChildFeature.State(id: "1", count: 10) - state.childArray = [ChildFeature.State(id: "1", count: 10)] - return .none - - case .resetToInitialIDs: - // Set the same ID as in the previous action, but with a count of 0 - // After this step, the child stores in childArray stopped working. - state.childSolo = ChildFeature.State(id: "1", count: 0) - state.childArray = [ChildFeature.State(id: "1", count: 0)] - return .none - - case .childArray: - return .none - - case .childSolo: - return .none - } - } - .ifLet(\.childSolo, action: \.childSolo) { - ChildFeature() - } - .forEach(\.childArray, action: \.childArray) { - ChildFeature() - } - } -} - -@Reducer -struct ChildFeature { - @ObservableState - struct State: Identifiable { - let id: String - var count: Int - } - - enum Action { - case plus - case minus - } - - var body: some ReducerOf { - Reduce { state, action in - switch action { - case .plus: - state.count += 1 - return .none - - case .minus: - state.count -= 1 - return .none - } - } - } -} - -struct ChildView: View { - let store: StoreOf - - var body: some View { - VStack { - Text("id: " + store.id) - - HStack { - Button("Minus") { - store.send(.minus) - } - - Text(store.count.description) - - Button("Plus") { - store.send(.plus) - } - } - .frame(height: 50) - .buttonStyle(.borderedProminent) - } - } -} - -struct MainView: View { - let store: StoreOf - - var body: some View { - ScrollView { - VStack { - Button("setAnotherIDs") { - store.send(.setAnotherIDs) - } - - Button("getBackToInitialIDsWithAnotherCount") { - store.send(.getBackToInitialIDsWithAnotherCount) - } - - Button("resetToInitialIDs (not working)") { - store.send(.resetToInitialIDs) - } - - Color.red.frame(height: 10) - -// Text("Child solo:") -// if let childStore = store.scope(state: \.childSolo, action: \.childSolo) { -// ChildView(store: childStore) -// } - - Color.red.frame(height: 10) - - Text("Child array:") - ForEach( - store.scope(state: \.childArray, action: \.childArray) - ) { childStore in - ChildView(store: childStore) - } - } - } - } -} - -struct ContentView: View { - @State var mainStore: StoreOf = Store(initialState: MainFeature.State()) { - MainFeature()._printChanges() - } - var body: some View { - MainView(store: mainStore) - } -} From 6e8638224333adf8b31823ecef147901ad0e1c17 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 21 Jul 2025 11:38:37 -0700 Subject: [PATCH 4/4] wip --- Sources/ComposableArchitecture/Store.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Sources/ComposableArchitecture/Store.swift b/Sources/ComposableArchitecture/Store.swift index 21b9cdbdcdfb..4c14b3e862e0 100644 --- a/Sources/ComposableArchitecture/Store.swift +++ b/Sources/ComposableArchitecture/Store.swift @@ -92,7 +92,6 @@ import SwiftUI /// 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) @@ -322,7 +321,7 @@ public final class Store: _Store { } private init(core: some Core, scopeID: AnyHashable?, parent: (any _Store)?) { - defer { Logger.shared.log( "\(storeTypeName(of: self)).init") } + defer { Logger.shared.log("\(storeTypeName(of: self)).init") } self.core = core self.parent = parent self.scopeID = scopeID