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.49k stars 1.45k forks source link

Automatic dismissal of `ConfirmationDialogState` #3337

Closed juliensagot closed 1 month ago

juliensagot commented 1 month ago

Description

Presenting a confirmation dialog with the observe closure in UIKit doesn’t work as expected. It is automatically dismissed after being presented. Further attempts to present it will result in a warning in the console:

Attempt to present <UIAlertController: 0x11000a800> on <NavigationExploration.ViewController: 0x103822000> (from <NavigationExploration.ViewController: 0x103822000>) while a presentation is in progress.

Notes

The sample project works perfectly without TCA (using just swift-navigation), which is why I’m opening an issue on this project rather than on swift-navigation.

Checklist

Expected behavior

The confirmation dialog should not automatically be dismissed after being presented.

Actual behavior

The confirmation dialog is automatically dismissed after it is presented.

Steps to reproduce

import ComposableArchitecture
import UIKit

@Reducer
struct MainFeature {
  @ObservableState
  struct State: Equatable {
    @Presents var confirmationDialog: ConfirmationDialogState<Action.ConfirmationDialog>?
  }

  enum Action {
     enum ConfirmationDialog {
      case confirmDeletion
    }
    case buttonTapped
    case confirmationDialog(PresentationAction<ConfirmationDialog>)
  }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .buttonTapped:
        state.confirmationDialog = ConfirmationDialogState(title: {
          TextState(verbatim: "Alert Title")
        })
        return .none
      case .confirmationDialog:
        return .none
      }
    }
    .ifLet(\.$confirmationDialog, action: \.confirmationDialog)
  }
}

final class ViewController: UIViewController {
  @UIBindable private var store = Store(initialState: MainFeature.State()) {
    MainFeature()
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    let button = UIButton(primaryAction: UIAction { [weak self] _ in
      self?.store.send(.buttonTapped)
    })
    button.setTitle("Tap", for: .normal)
    button.translatesAutoresizingMaskIntoConstraints = false
    view.addSubview(button)
    NSLayoutConstraint.activate([
      button.centerXAnchor.constraint(equalTo: view.centerXAnchor),
      button.centerYAnchor.constraint(equalTo: view.centerYAnchor)
    ])

    observe { [weak self] in
      guard let self else { return }

      present(item: $store.scope(state: \.confirmationDialog, action: \.confirmationDialog)) { store in
        UIAlertController(store: store)
      }
    }
  }
}

https://github.com/user-attachments/assets/25e0344c-2214-483b-8a64-58009faa3734

The Composable Architecture version information

1.14.0and main

Destination operating system

iOS 17.0

Xcode version information

16.0 beta 6 (16A5230g)

Swift Compiler version information

swift-driver version: 1.115 Apple Swift version 6.0 (swiftlang-6.0.0.9.10 clang-1600.0.26.2)
Target: arm64-apple-macosx14.0
acosmicflamingo commented 1 month ago

Why is present in an observe closure? Isn't the purpose of UIBindable to handle this heavy-lifting now? It seems like it could be the nested observe behavior that is causing issues.

juliensagot commented 1 month ago

Why is present in an observe closure? Isn't the purpose of UIBindable to handle this heavy-lifting now? It seems like it could be the nested observe behavior that is causing issues.

Oh god you're right 🤦‍♂️ Thanks a ton! That was the culprit.

acosmicflamingo commented 1 month ago

@juliensagot No problem! Glad it was an easy fix! :)