pointfreeco / swift-composable-architecture

A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.
https://www.pointfree.co/collections/composable-architecture
MIT License
12.25k stars 1.42k forks source link

TCA does not track perceptible state to do with NavigationStack on iOS 16 #2952

Closed ghost closed 5 months ago

ghost commented 5 months ago

Description

I created a simple Navigation Application using TCA. https://github.com/kiroru/tca_ver_verify

import SwiftUI
import ComposableArchitecture

enum RootTab: Int, Hashable, CaseIterable {
    case home = 0
    case settings = 1
}

struct RootView: View {
    @State var store = Store(initialState: RootStore.State()) {
        RootStore()
    }
    @State var selection = RootTab.home

    var body: some View {
        WithPerceptionTracking {
            TabView(selection: $selection) {
                VStack {
                    Text("Home")
                    Button("push nav") {
                        store.send(.pushNav)
                    }
                }
                    .tabItem { Text("home") }
                    .tag(RootTab.home)

                Text("Settings")
                    .tabItem { Text("settings") }
                    .tag(RootTab.settings)
            }
            .onChange(of: selection) { newValue in
                store.send(.selectTab(newValue))
                selection = newValue
            }
            .fullScreenCover(
                isPresented: $store.presenting.sending(\.onPresent)
            ) {
                NavigationStack(path: $store.scope(state: \.path, action: \.path)
                ) {
                    EmptyView()
                } destination: {
                    switch $0.case {
                    case let .demo(store):
                        DemoView(store: store)
                    }
                }
            }
        }
        .alert(store: self.store.scope(state: \.$alert, action: \.alert))
    }
}
@Reducer
struct RootStore {

    @ObservableState
    struct State: Equatable {
        @Presents var alert: AlertState<Action.Alert>?
        var count = 0
        var path = StackState<Path.State>()

        var presenting: Bool {
            return path.count > 0
        }
    }

    enum Action {
        case alert(PresentationAction<Alert>)
        @CasePathable
        enum Alert {
            case none
        }
        case selectTab(RootTab)
        case pushNav
        case path(StackAction<Path.State, Path.Action>)
        case onPresent(Bool)
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .pushNav:
                state.path.append(.demo(.init()))
                return .none
            case .selectTab(_):
                return .none
            case let .path(action):
                switch action {
                case .element(id: _, action: .demo(.push(let num))):
                    state.path.append(.demo(.init(count: num)))
                default: break
                }
              return .none
            default:
                return .none
            }
        }
        .forEach(\.path, action: \.path)
    }

    @Reducer(state: .equatable)
    enum Path {
        case demo(DemoStore)
    }
}
import SwiftUI
import ComposableArchitecture

struct DemoView: View {
    @State var store = Store(initialState: DemoStore.State()) {
        DemoStore()
    }

    var body: some View {
        WithPerceptionTracking {
            Form {
                Section {
                    Text("\(store.count)")
                }
                Section {
                    Button("Push") {
                        store.send(.onTapButton)
                    }
                    Button("Dismiss") {
                        store.send(.dismissButtonTapped)
                    }
                }
            }
            .navigationTitle("Demo \(store.count)")
        }
    }
}

#Preview {
    DemoView()
}

@Reducer
struct DemoStore {
    @Dependency(\.dismiss) var dismiss

    @ObservableState
    struct State: Equatable {
        var count = 0
    }

    enum Action: Equatable {
        case onTapButton
        case push(Int)
        case dismissButtonTapped
    }

    var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .onTapButton:
                return .send(.push(state.count + 1))
            case .push:
                return .none
            case .dismissButtonTapped:
                return .run { _ in
                    await self.dismiss()
                }
            }
        }
    }
}

This application works well on iOS17, but does not work correctly on iOS16. Please check attached movie.

https://github.com/pointfreeco/swift-composable-architecture/assets/83206405/5a28ce39-2b66-42bb-9e4a-e77432e7b857

Runtime warning is shown below on iOS16.

スクリーンショット 2024-03-29 14 00 05(3)

I created the same app overwritten by TCA v1.6.0 to know what is going on. I'm surprised that v1.6.0 is correctly works. I doubt that TCA does not track perceptible state to do with NavigationStack on iOS 16.

Regards.

Checklist

Expected behavior

NavigationStack is not pushed.

Actual behavior

NavigationStack should be pushed.

Steps to reproduce

  1. Launch Application
  2. Tap "push nav" on Home Tab
  3. Tap "Push" on Demo View
  4. Navigation Stack is pushed by increamented store

The Composable Architecture version information

1.9.2

Destination operating system

iOS 16

Xcode version information

15.3

Swift Compiler version information

No response

lukeredpath commented 5 months ago

It looks like you’re missing WithPerceptionTracking inside your navigation stack’s destination closure, so iOS 16 cannot detect changes to the state inside the presented view.

ghost commented 5 months ago

It looks like you’re missing WithPerceptionTracking inside your navigation stack’s destination closure, so iOS 16 cannot detect changes to the state inside the presented view.

@lukeredpath

Thank you for your response. Based on your suggestion, I wrapped the navigation stack's destination closure with WithPerceptionTracking. However, there have been no changes.

スクリーンショット 2024-03-30 8 16 54

I tried a different approach. I replaced NavigationStack with NavigationStackStore, and then it worked correctly on iOS16. I think there might be a slight difference between NavigationStack and NavigationStackStore.

NavigationStackStore(self.store.scope(state: \.path, action: \.path)) {
                    EmptyView()
                } destination: { store in
                    SwitchStore(store) {
                        switch $0 {
                        case .demo:
                            CaseLet(/RootStore.Path.State.demo,
                                     action: RootStore.Path.Action.demo) { _store in
                                DemoView(store: _store)
                            }
                        }
                    }
                }
lukeredpath commented 5 months ago

@tsudo-kiroru the purple warnings are your clue that you're definitely missing a WithPerceptionTracking somewhere, that's why your view is not responding to state changes on iOS 16. Try expanding the backtrace of that error.

ghost commented 5 months ago

@tsudo-kiroru the purple warnings are your clue that you're definitely missing a WithPerceptionTracking somewhere, that's why your view is not responding to state changes on iOS 16. Try expanding the backtrace of that error.

@lukeredpath Thank you for your reply. Based on your suggestion, I fixed my code as following below. I wrapped NavigationStack destination scope with WithPerceptionTracking. Then, the purple warnings disappeared. So I guess that state must be tracked.

NavigationStack(path: $store.scope(state: \.path, action: \.path)
                ) {
                    EmptyView()
                } destination: { destination in
                    WithPerceptionTracking {
                        let scope = destination.case
                        switch scope {
                        case let .demo(store):
                            DemoView(store: store)
                        }
                    }
                }

However, the navigation feature does not work correctly on iOS 16. There seems to be a difference between iOS 16 and iOS 17(As same as capturing movie). It becomes harder to find the problem because no errors or warnings occur.

mbrandonw commented 5 months ago

Hi @tsudo-kiroru, I believe this is just a problem with vanilla SwiftUI in general. The following code snippet works fine in iOS 17, but does not work in iOS 16:

class Model: ObservableObject {
  @Published var values: [Int] = []
}

struct ParentView: View {
  @StateObject var model = Model()

  var body: some View {
    Form {
      Button("Open") {
        model.values.append(1)
      }
      .sheet(
        isPresented: Binding(
          get: { !model.values.isEmpty },
          set: { if !$0 { model.values = [] } }
        )
      ) {
        NavigationStack(path: $model.values) {
          EmptyView()
          .navigationDestination(for: Int.self) { int in
            Form {
              Button("Push") {
                model.values.append(.random(in: 0...1_000))
              }
            }
          }
        }
      }
    }
  }
}

Since this is not a TCA issue I am going to convert it to a discussion, but please feel free to continue the conversation over there.