Comments (5)
Hi @arnauddorgans, when you say:
As far as I understand, the issue may come from multiple stores having the same internal IDs.
I'm not sure what you mean by "internal ID". What ID are you referring to?
from swift-composable-architecture.
I'm not sure what you mean by "internal ID". What ID are you referring to?
Maybe some internal behavior with navigationIDPath
I don't know
from swift-composable-architecture.
Hi @arnauddorgans, what led you to believe that this is an issue with the "identity" of the store, or navigationIDPath
? A store's identity is based of its referential identity, and shouldn't be related to the navigationIDPath
.
from swift-composable-architecture.
@mbrandonw The fact that using an IdentifiedArrayOf
fixes the issue, and by setting a breakpoint on the cancel handler of the task, I can see reference of IDs in the stack trace
The complete code of the app is available under The full app code is available here
in my first message
But I put it here with the print message where you can add the breakpoint to debug it (line 101)
The full app code is available here
import SwiftUI
import ComposableArchitecture
import Combine
@Reducer
struct Feature {
@ObservableState
struct State: Equatable {
let id: Int
var child: ChildFeature.State?
var isSelected: Bool = false
var isPending: Bool = false
}
enum Action {
case task
case selectionReceived(Int)
case pendingSelectionReceived(Set<Int>)
case child(ChildFeature.Action)
}
let child: ChildFeature
@Dependency(\.selectionClient) var selectionClient
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .task:
return .run { send in
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
for await selection in selectionClient.selection.values {
await send(.selectionReceived(selection))
}
}
group.addTask {
for await selections in selectionClient.pendingSelection.values {
await send(.pendingSelectionReceived(selections))
}
}
try await group.waitForAll()
}
}
case let .selectionReceived(id):
let stateID = state.id
state.isSelected = id == stateID
return updateEffect(state: &state)
case let .pendingSelectionReceived(ids):
let stateID = state.id
state.isPending = ids.contains(stateID)
return updateEffect(state: &state)
case .child:
return .none
}
}
.ifLet(\.child, action: \.child) {
child
}
}
func updateEffect(state: inout State) -> Effect<Action> {
let isPending = state.isPending
let isSelected = state.isSelected
let shouldSelect = isPending || isSelected
if shouldSelect != (state.child != nil) {
state.child = shouldSelect ? .init(id: state.id) : nil
}
state.child?.isSelected = isSelected
state.child?.isPending = isPending
return .none
}
}
@Reducer
struct ChildFeature {
@ObservableState
struct State: Equatable {
let id: Int
var seconds: Int = 0
var isSelected: Bool = false
var isPending: Bool = false
}
enum Action {
case task
case timeReceived
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .task:
return .run { [id = state.id] send in
await withTaskCancellationHandler(operation: {
for await _ in Timer.publish(every: 1, on: .main, in: .common).autoconnect().values {
await send(.timeReceived)
}
}, onCancel: {
if id == 0 {
print("CANCEL \(id)")
}
})
}
case .timeReceived:
state.seconds += 1
return .none
}
}
}
}
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
ZStack {
if let childStore = store.scope(state: \.child, action: \.child) {
ChildView(store: childStore)
}
}
.task {
await store.send(.task).finish()
}
}
}
struct ChildView: View {
let store: StoreOf<ChildFeature>
var body: some View {
ZStack {
if store.isSelected {
Color.green
} else if store.isPending {
Color.yellow
}
Text(store.seconds, format: .number)
.font(.title)
}
.task {
await store.send(.task).finish()
}
}
}
struct SelectionClient: DependencyKey {
let selection: AnyPublisher<Int, Never>
let select: (Int) -> Void
let pendingSelection: AnyPublisher<Set<Int>, Never>
let setPendingSelection: (Set<Int>) -> Void
static let liveValue: SelectionClient = {
let subject = CurrentValueSubject<Int, Never>(0)
let pendingSelectionSubject = CurrentValueSubject<Set<Int>, Never>([])
return .init(
selection: subject.eraseToAnyPublisher(),
select: { subject.value = $0 },
pendingSelection: pendingSelectionSubject.eraseToAnyPublisher(),
setPendingSelection: { pendingSelectionSubject.value = $0 }
)
}()
}
extension DependencyValues {
var selectionClient: SelectionClient {
self[SelectionClient.self]
}
}
@main
struct TCABugApp: App {
@State var idSelected = false
var body: some Scene {
WindowGroup {
VStack {
Button(idSelected ? "ID" : "No ID") {
idSelected.toggle()
}
HostingView(idView: idSelected)
.id(idSelected)
}
}
}
}
struct HostingView: UIViewControllerRepresentable {
let idView: Bool
func makeUIViewController(context: Context) -> ViewController {
.init(idView: idView)
}
func updateUIViewController(_ uiViewController: ViewController, context: Context) { }
}
extension HostingView {
final class ViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
private let idView: Bool
@Dependency(\.selectionClient) private var selectionClient
private lazy var childControllers: [UIViewController] = [
childController(id: 0),
childController(id: 1),
childController(id: 2),
childController(id: 3)
]
init(idView: Bool) {
self.idView = idView
super.init(transitionStyle: .scroll, navigationOrientation: .vertical)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dataSource = self
delegate = self
selectionClient.select(0)
setViewControllers([childControllers[0]], direction: .forward, animated: false)
}
func childController(id: Int) -> UIViewController {
let view = childView(id: id)
return UIHostingController(rootView: view)
}
@ViewBuilder
func childView(id: Int) -> some View {
if idView {
let store = IDStoreOf<Feature>(initialState: .init(id: id)) {
Feature(child: .init())
}
IDStoreView(store: store) { store in
ContentView(store: store)
}
} else {
ContentView(store: .init(initialState: .init(id: id), reducer: {
Feature(child: .init())
}))
}
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = childControllers.firstIndex(of: viewController), childControllers.indices.contains(index - 1) else { return nil }
return childControllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = childControllers.firstIndex(of: viewController), childControllers.indices.contains(index + 1) else { return nil }
return childControllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
selectionClient.setPendingSelection(Set(pendingViewControllers.compactMap {
childControllers.firstIndex(of: $0)
}))
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed, let viewController = pageViewController.viewControllers?.last, let index = childControllers.firstIndex(of: viewController) {
selectionClient.select(index)
}
selectionClient.setPendingSelection([])
}
}
}
struct IDStoreView<ChildReducer, ChildView>: View where ChildView: View, ChildReducer: Reducer {
let store: StoreOf<IDReducer>
let content: (StoreOf<ChildReducer>) -> ChildView
init(
store idStore: IDStoreOf<ChildReducer>,
content: @escaping (StoreOf<ChildReducer>) -> ChildView
) {
let id = ChildID()
self.store = .init(
initialState: .init(idChild: .init(id: id, child: idStore.initialState)),
reducer: { IDReducer(child: idStore.reducer) }
)
self.content = content
}
var body: some View {
ForEach(store.scope(state: \.elements, action: \.elements), id: \.id) { store in
content(store.scope(state: \.child, action: \.child))
}
}
}
struct IDStoreOf<ChildReducer> where ChildReducer: Reducer {
let initialState: ChildReducer.State
let reducer: ChildReducer
init(
initialState: ChildReducer.State,
@ReducerBuilder<ChildReducer.State, ChildReducer.Action> reducer: () -> ChildReducer
) {
self.initialState = initialState
self.reducer = reducer()
}
init<BaseReducer>(
initialState: @autoclosure () -> ChildReducer.State,
@ReducerBuilder<ChildReducer.State, ChildReducer.Action> reducer: () -> ChildReducer,
withDependencies prepareDependencies: (inout DependencyValues) -> Void
) where ChildReducer == _DependencyKeyWritingReducer<BaseReducer> {
let (initialState, reducer, dependencies) = withDependencies(prepareDependencies) {
@Dependency(\.self) var dependencies
return (initialState(), reducer(), dependencies)
}
self.init(
initialState: initialState,
reducer: { reducer.dependency(\.self, dependencies) }
)
}
}
extension IDStoreView {
typealias ChildID = UUID
@Reducer
struct IDReducer {
@ObservableState
struct State {
var elements: IdentifiedArrayOf<IDChild.State>
init(idChild: IDChild.State) {
self.elements = .init(uniqueElements: [idChild])
}
}
enum Action {
case elements(IdentifiedActionOf<IDChild>)
}
let child: ChildReducer
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .elements:
return .none
}
}
.forEach(\.elements, action: \.elements) {
IDChild(child: child)
}
}
}
}
extension IDStoreView.IDReducer {
@Reducer
struct IDChild {
@ObservableState
struct State: Identifiable {
var id: IDStoreView.ChildID
var child: ChildReducer.State
}
enum Action {
case child(ChildReducer.Action)
}
let child: ChildReducer
var body: some Reducer<State, Action> {
Scope(state: \.child, action: \.child) {
child
}
}
}
}
from swift-composable-architecture.
Hi @arnauddorgans, ok I see what is happening in your project now. While you do have multiple separate stores for each page, they are technically sharing the same cancellation ID. This is just how TCA is expected to operate today. Sometime soon there will be the idea of effects being quarantined to each store, but that just isn't how it works today.
However, in my opinion it would be better to have a parent feature that encapsulates all of the pages of the controller and scope child stores from that feature rather than having 4 completely independent stores running your feature.
Here is how you can do that:
Full code:
import SwiftUI
import ComposableArchitecture
import Combine
@Reducer
struct ParentFeature {
@ObservableState
struct State {
var features: IdentifiedArrayOf<Feature.State> = []
}
enum Action {
case features(IdentifiedActionOf<Feature>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .features:
return .none
}
}
.forEach(\.features, action: \.features) {
Feature()
}
}
}
@Reducer
struct Feature {
@ObservableState
struct State: Equatable, Identifiable {
let id: Int
var child: ChildFeature.State?
var isSelected: Bool = false
var isPending: Bool = false
}
enum Action {
case task
case selectionReceived(Int)
case pendingSelectionReceived(Set<Int>)
case child(ChildFeature.Action)
}
@Dependency(\.selectionClient) var selectionClient
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .task:
return .run { send in
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
for await selection in selectionClient.selection.values {
await send(.selectionReceived(selection))
}
}
group.addTask {
for await selections in selectionClient.pendingSelection.values {
await send(.pendingSelectionReceived(selections))
}
}
try await group.waitForAll()
}
}
case let .selectionReceived(id):
let stateID = state.id
state.isSelected = id == stateID
return updateEffect(state: &state)
case let .pendingSelectionReceived(ids):
let stateID = state.id
state.isPending = ids.contains(stateID)
return updateEffect(state: &state)
case .child:
return .none
}
}
.ifLet(\.child, action: \.child) {
ChildFeature()
}
}
func updateEffect(state: inout State) -> Effect<Action> {
let isPending = state.isPending
let isSelected = state.isSelected
let shouldSelect = isPending || isSelected
if shouldSelect != (state.child != nil) {
state.child = shouldSelect ? .init(id: state.id) : nil
}
state.child?.isSelected = isSelected
state.child?.isPending = isPending
return .none
}
}
@Reducer
struct ChildFeature {
@ObservableState
struct State: Equatable {
let id: Int
var seconds: Int = 0
var isSelected: Bool = false
var isPending: Bool = false
}
enum Action {
case task
case timeReceived
}
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .task:
return .run { [id = state.id] send in
await withTaskCancellationHandler(operation: {
for await _ in Timer.publish(every: 1, on: .main, in: .common).autoconnect().values {
await send(.timeReceived)
}
}, onCancel: {
if id == 0 {
print("CANCEL \(id)")
}
})
}
case .timeReceived:
state.seconds += 1
return .none
}
}
}
}
struct ContentView: View {
let store: StoreOf<Feature>
var body: some View {
ZStack {
if let childStore = store.scope(state: \.child, action: \.child) {
ChildView(store: childStore)
}
}
.task {
await store.send(.task).finish()
}
}
}
struct ChildView: View {
let store: StoreOf<ChildFeature>
var body: some View {
ZStack {
if store.isSelected {
Color.green
} else if store.isPending {
Color.yellow
}
Text(store.seconds, format: .number)
.font(.title)
}
.task {
await store.send(.task).finish()
}
}
}
struct SelectionClient: DependencyKey {
let selection: AnyPublisher<Int, Never>
let select: (Int) -> Void
let pendingSelection: AnyPublisher<Set<Int>, Never>
let setPendingSelection: (Set<Int>) -> Void
static let liveValue: SelectionClient = {
let subject = CurrentValueSubject<Int, Never>(0)
let pendingSelectionSubject = CurrentValueSubject<Set<Int>, Never>([])
return .init(
selection: subject.eraseToAnyPublisher(),
select: { subject.value = $0 },
pendingSelection: pendingSelectionSubject.eraseToAnyPublisher(),
setPendingSelection: { pendingSelectionSubject.value = $0 }
)
}()
}
extension DependencyValues {
var selectionClient: SelectionClient {
self[SelectionClient.self]
}
}
@main
struct TCABugApp: App {
@State var idSelected = false
var body: some Scene {
WindowGroup {
HostingView()
}
}
}
struct HostingView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> ViewController {
.init()
}
func updateUIViewController(_ uiViewController: ViewController, context: Context) { }
}
extension HostingView {
final class ViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
@Dependency(\.selectionClient) private var selectionClient
let store = Store(
initialState: ParentFeature.State(
features: [
Feature.State(id: 0),
Feature.State(id: 1),
Feature.State(id: 2),
Feature.State(id: 3),
]
)
) {
ParentFeature()
}
private var featureControllers: [UIViewController] = []
init() {
super.init(transitionStyle: .scroll, navigationOrientation: .vertical)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
dataSource = self
delegate = self
observe { [weak self] in
guard let self else { return }
featureControllers = store.features.ids.map { id in
UIHostingController(
rootView: ContentView(
store: self.store.scope(
state: \.features[id: id]!,
action: \.features[id: id]
)
)
)
}
}
selectionClient.select(0)
setViewControllers([featureControllers[0]], direction: .forward, animated: false)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let index = featureControllers.firstIndex(of: viewController), featureControllers.indices.contains(index - 1) else { return nil }
return featureControllers[index - 1]
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let index = featureControllers.firstIndex(of: viewController), featureControllers.indices.contains(index + 1) else { return nil }
return featureControllers[index + 1]
}
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
selectionClient.setPendingSelection(Set(pendingViewControllers.compactMap {
featureControllers.firstIndex(of: $0)
}))
}
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
if completed, let viewController = pageViewController.viewControllers?.last, let index = featureControllers.firstIndex(of: viewController) {
selectionClient.select(index)
}
selectionClient.setPendingSelection([])
}
}
}
Putting in a little bit of upfront work to model the domains correctly comes with a lot of benefits.
Since this isn't an issue with the library I am going to convert it to a discussion and feel free to continue the conversation over there.
from swift-composable-architecture.
Related Issues (20)
- Unnecessary "observe" warning HOT 1
- .send(.presentCustomAlert, animation: .none) method does not work when present / dismiss fullScreenCover HOT 1
- Missing an expected key: 'NSPrivacyCollectedDataTypes' HOT 7
- TCA release 1.10.0 and Swift Perception 1.1.6 mismatch HOT 1
- @Shared crashes app
- Dismiss more than one feature HOT 2
- TCA 1.7.0 minimum version error iOS15 HOT 9
- Key path cannot refer to static member '....' Error HOT 1
- TCA holds on to state for longer than expected HOT 1
- Nested enum reducer error: cannot be constructed because it has no accessible initializers HOT 3
- ForEach<_StoreCollection<String, State, Action>, ObjectIdentifier, SingleTaskView>: the ID ObjectIdentifier(0x0000600003fe8b40) occurs multiple times within the collection, this will give undefined results! HOT 2
- Exhaustive testing @Shared variable mutating before action received HOT 2
- Extra Perception Warning HOT 1
- Non-main thread warning when calling using .refreshable from child @ViewBuilder property HOT 1
- Focus state doesn't work as described in the Building SyncUps Tutorial
- UUID creation not working as described in the SyncUps Tutorial HOT 2
- FileStorage persistence sometimes doesn't save data to disk. HOT 1
- Some texts not appearing correctly: body-8lumc
- SharedState with UserDefaults(suiteName:) not working properly HOT 3
- NSLock Memory leaks HOT 1
Recommend Projects
-
React
A declarative, efficient, and flexible JavaScript library for building user interfaces.
-
Vue.js
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
-
Typescript
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
-
TensorFlow
An Open Source Machine Learning Framework for Everyone
-
Django
The Web framework for perfectionists with deadlines.
-
Laravel
A PHP framework for web artisans
-
D3
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
-
Recommend Topics
-
javascript
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
-
web
Some thing interesting about web. New door for the world.
-
server
A server is a program made to process requests and deliver data to clients.
-
Machine learning
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
-
Visualization
Some thing interesting about visualization, use data art
-
Game
Some thing interesting about game, make everyone happy.
Recommend Org
-
Facebook
We are working to build community through open source technology. NB: members must have two-factor auth.
-
Microsoft
Open source projects and samples from Microsoft.
-
Google
Google ❤️ Open Source for everyone.
-
Alibaba
Alibaba Open Source for everyone
-
D3
Data-Driven Documents codes.
-
Tencent
China tencent open source team.
from swift-composable-architecture.