pointfreeco/swift-composable-architecture

Bug on UIKit

Closed this issue · 5 comments

Description

I found a bug when using multiple stores inside a UIPageViewController.
I made a demo app to reproduce the bug.

Basically, we have a UIPageViewController that contains 4 stores, each store is updated from a dependency and should display information about the current page's selection.

When a page is selected, we increment a counter, and the background should be green; when the page is pending, the background should be yellow, and the counter should keep going.

Simulator.Screen.Recording.-.iPhone.15.-.2024-03-28.at.02.16.12.mp4

In the first part of the video, when the header button is "No ID," you will see that the first page's counter stops after the gesture is canceled.
This is because the task is canceled on the second page but should not be canceled on the first page.

As far as I understand, the issue may come from multiple stores having the same internal IDs.

In the second part of the video, when the header button is "ID," you will see that everything is working as expected.

To workaround the issue, I created a store with IdentifiedArrayOf with a single item, and it seems to fix the bug because now each store has a different ID.

Maybe it would be great to allow "detached" store creation to avoid this kind of issue.

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() : nil
        }
        state.child?.isSelected = isSelected
        state.child?.isPending = isPending
        return .none
    }
}

@Reducer
struct ChildFeature {
    @ObservableState
    struct State: Equatable {
        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 { send in
                    for await _ in Timer.publish(every: 1, on: .main, in: .common).autoconnect().values {
                        await send(.timeReceived)
                    }
                }
            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
            }
        }
    }
}

Checklist

  • I have determined whether this bug is also reproducible in a vanilla SwiftUI project.
  • If possible, I've reproduced the issue using the main branch of this package.
  • This issue hasn't been addressed in an existing GitHub issue or discussion.

The Composable Architecture version information

1.9.2

Destination operating system

iOS 17

Xcode version information

Xcode 15.3

Swift Compiler version information

swift-driver version: 1.90.11.1 Apple Swift version 5.10 (swiftlang-5.10.0.13 clang-1500.3.9.4)
Target: arm64-apple-macosx14.0

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?

@mbrandonw

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

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.

@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

Screenshot 2024-04-01 at 16 26 34 Screenshot 2024-04-01 at 16 28 41 Screenshot 2024-04-01 at 16 28 57

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
            }
        }
    }
}

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.