pointfreeco/swift-composable-architecture

Observable state breaks wrapped UITextView on <17.4

Closed this issue · 7 comments

Description

When a non-observable TCA feature is presented from an observable TCA feature, and that screen that contains a UITextView wrapped in a representable view, it is not possible to enter any text into the text view - it's delegate methods fire and the textView.text property always remains "".

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.

Expected behavior

I would expect the text view to work correctly.

Actual behavior

I have been able to reproduce this bug in an iOS 17.2 simulator and the original bug report from one of our internal dev builds was happening on an iOS 17.3 device. This bug does not seem to occur in 17.4. In our actual app, this component is used both on a presented sheet, but also on a different view pushed on to a navigation stack, so the actual method of presentation does not seem to matter.

This happens in one specific circumstance - when the screen with the text view is presented using @ObservableState and related TCA APIs. It does not happen when presented from a TCA feature/view that uses @PresentationState and the deprecated view store APIs. It also does not happen when presenting using pure SwiftUI and @State.

Steps to reproduce

The attached project can be used to reproduce the bug. It contains:

  • ComposeFeature is a basic feature that presents a text view and does not use observable state. The WrappedTextView is a slimmed down version of the implementation of a component from our app and takes a binding to some text state and focus state. The text view is focused when the compose view appears.
  • Three different examples of presenting a sheet that contains the compose view - uncomment out each @main to test:
    • PerceptionTextViewBugApp_NonObservable has a root store of RootFeatureNonObservable which uses @PresentationState. It displays a button which triggers the presentation of a sheet using the sheet(store:) API. When this app runs, the text view works as expected.
    • PerceptionTextViewBugApp_Observable - has a bindable root store of RootFeatureObservable which uses @Presents, @ObservableState and the sheet(item:) API to present a sheet. When this app runs, the text view does not work.
    • PerceptionTextViewBugApp_SwiftUI - this app just holds on to a store of the compose feature itself and presents the compose view using a @State and sheet(isPresented:) call. This also works as expected.

PerceptionTextViewBug.zip

The Composable Architecture version information

1.9.2 - also tried main

Destination operating system

iOS 16.4 - iOS 17.3

Xcode version information

Version 15.3 (15E204a)

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: x86_64-apple-macosx14.0

The above example project targets iOS 17, but just to rule other things out I also tried:

  • Pure SwiftUI using an @Observable model instead of @State. Works normally.
  • Changing the target to iOS 16.4 and using a @Perceptible model and WithPerceptionTracking. Works normally on both 17.2 and 16.4 simulators.

So this seems to narrow it down somehow to the TCA/perception integration with @Presents but I'm not sure how, as that is using the sheet(item:) API which is built in to SwiftUI.

One more test, I updated the SwiftUI example to use sheet(item:) instead of sheet(isPresented:) and this still works fine:

@main
struct PerceptionTextViewBugApp_SwiftUI: App {
    @State
    var store = Store(initialState: ComposeFeature.State()) {
        ComposeFeature()
    }

    @State
    var destination: Destination?

    enum Destination: Identifiable {
        case compose
        var id: Self { self }
    }

    var body: some Scene {
        WindowGroup {
            Text("SwiftUI App")
            Button("Open Compose") {
                destination = .compose
            }
            .sheet(item: $destination) { _ in
                ComposeView(store: store)
            }
        }
    }
}

It seems that the presentation API is a bit of a red-herring. The bug only seems to happen when the root view holds on to a store of some ObservableState.

With that in mind, I seem to have narrowed this down to the most minimal reproducer:

@Reducer
struct RootFeature: Reducer {
//    @ObservableState
    struct State: Equatable {
        var compose = ComposeFeature.State()
    }

    enum Action {
        case compose(ComposeFeature.Action)
    }

    var body: some ReducerOf<Self> {
        Scope(state: \.compose, action: \.compose) {
            ComposeFeature()
        }
    }
}

@main
struct PerceptionTextViewBugApp_ObservableSwiftUI: App {
    @State
    var store = Store(initialState: RootFeature.State()) {
        RootFeature()._printChanges()
    }

    @State
    var isPresented: Bool = false

    var body: some Scene {
        WindowGroup {
            Text("Observable TCA + SwiftUI App")
            Button("Open Compose") {
                isPresented = true
            }
            .sheet(isPresented: $isPresented) {
                ComposeView(store: store.scope(state: \.compose, action: \.compose))
            }
        }
    }
}

You can run the above example and see that the text view works as expected if you uncomment out @ObservableState, it stops working.

Hi @lukeredpath, can you please provide an updated project with the minimal reproducible code? I'm not sure what to update based on your new messages above.

Some good news - now I have a minimal reproducer, I have found two possible workarounds for this bug:

  • Adding @ObservationStateIgnored to the var compose: ComposeFeature.State property in the root feature state fixes the problem.
  • Presenting a version of ComposeFeature that uses observation also fixes the problem.

I'm not sure if there are other side effects of the first workaround and I'm not sure if it would be helpful in the case where you're using @Presents with a Destination enum where some cases are observable and some are not.

The second workaround at least gives me a potential fix for this, although it requires me to put more effort into migrating more leaf features of my app to use @ObservableState than I had planned.

I've added an updated demo here:
https://gist.github.com/lukeredpath/1657db6c28cf2edf9868f0f9d19dd821

PerceptionTextViewBug.zip

Here's an updated project with only the relevant code.

Firstly, I was wrong about iOS 16 - turns out this bug does not affect iOS 16. It only affects iOS 17 below 17.4 (although I have not been able to test on 17.0 or 17.1).

In the project attached:

  • If you run on iOS 17.4, both the compose and observable compose sheets work correctly and you should be able to type.
  • If you run in the 17.3 or 17.2 simulator, the observable compose works OK, but the normal compose does not work, unless you uncomment the @ObservationStateIgnored macro.

Out of curiosity, I also tried swapping out the the text state binding in ComposeView from the view store binding to a binding to some local @State var textState property. This caused every other letter to appear. I then swapped out the viewStore.send(.selectionChanged($0)) in the selection changed handler to directly mutate the local @State var and after that change it worked normally. So it appears to be more precisely, some kind of weird interaction between the representable view and view store bindings (or the view store in general) when presented from an observable feature.

I think this is a problem with over-rendering and UITextField / representables not being able to handle the over-renders. Because the parent is modern and the child is legacy, the parent feature is going to over-render a bit. You can put a print inside the WithPerceptionTracking of the .sheet(item:) to see that it prints with each key stroke.

Typically SwiftUI can deal with over-renders just fine, but there are a few key areas that it gets overwhelmed. Navigation is the biggest example, but this is another example (or maybe the root cause of the problem is the same in both situations).

However, the solution is much simpler. It is not necessary to wrap your store.scope in the sheet in WithPerceptionTracking. That is only necessary when scoping to observable features, and since \.compose is not an observable feature, there is nothing to observe.

So, just making this change fixes it:

.sheet(item: $destination) { destination in
  switch destination {
  case .compose:
    // ❗️ No `WithPerceptionTracking` here
    ComposeView(store: Self.store.scope(state: \.compose, action: \.compose))
  case .observableCompose:
    WithPerceptionTracking {
      ObservableComposeView(store: Self.store.scope(state: \.observableCompose, action: \.observableCompose))
    }
  }
}

By putting WithPerceptionTracking around ComposeView you are observing all changes in ComposeFeature, and since it's not an observable feature it means any change (no matter what) causes a view render.

Since this isn't an issue with the library I am going to convert it to a discussion.