UIKit error when attempting to present an alert while a fullscreen cover has not yet been dismissed

Hi TCA community,

when working on a project, I ran into an error with fullscreen covers and alerts. TCA attempts to present an alert before a fullscreen cover view has been dismissed, which results in a UIKit error, and the alert not being shown.

Code to reproduce

import ComposableArchitecture
import Combine
import PhotosUI
import SwiftUI

struct PhotoPickerAlertState: Equatable {
  var alert: AlertState<PhotoPickerAlertAction>?
  var isPhotoPickerPresented = false
}

enum PhotoPickerAlertAction: Equatable {
  case alertButtonTapped
  case alertDismissed
  
  /// User requested to show photo.
  case showPhotoPicker
  
  /// User picked item in photo picker.
  case photoPickerPickedPhoto(itemProvider: NSItemProvider)
  
  /// User dismissed photo picker.
  case photoPickerCanceled
  
  /// Photo picker fullscreen cover was dismissed by UI, regardless of result.
  case photoPickerDismissed
  
  case photoProcessorCompleted(Result<Bool, PhotoProcessorError>)
}

struct PhotoPickerAlertEnvironment {
  let photoProcessor = PhotoProcessor()
}

let photoPickerAlertReducer = Reducer<
  PhotoPickerAlertState, PhotoPickerAlertAction,
  PhotoPickerAlertEnvironment
> { state, action, environment in

  switch action {
  case .alertButtonTapped:
    
    state.alert = .init(
      title: .init("Hi. I’m an alert"),
      dismissButton: .cancel(.init("OK"))
    )
    
    return .none

  case .alertDismissed:
    state.alert = nil
    return .none

  case .showPhotoPicker:
    state.isPhotoPickerPresented = true
    return .none

  case .photoPickerPickedPhoto(let itemProvider):
    state.isPhotoPickerPresented = false
    
    return environment.photoProcessor.doSomethingWithPickedPhoto(item: itemProvider)
      .receive(on: DispatchQueue.main)
      // Uncomment the following line to see correct behavior
      // .delay(for: .seconds(0.4), scheduler: DispatchQueue.main)
      .catchToEffect(PhotoPickerAlertAction.photoProcessorCompleted)
    
  case .photoPickerCanceled:
    state.isPhotoPickerPresented = false
    return .none
   
  case .photoPickerDismissed:
    return .none
    
  case let .photoProcessorCompleted(.success(boolValue)):
    
    state.alert = .init(
      title: .init("You picked a cool photo! Result: \(String(describing: boolValue))"),
      dismissButton: .cancel(.init("OK"))
    )

    return .none
    
  case let .photoProcessorCompleted(.failure(error)):
    
    state.alert = .init(
      title: .init("Something failed when picking a photo: \(String(describing: error))"),
      dismissButton: .cancel(.init("OK"))
    )

    return .none
  }
  
}

struct PhotoPickerAlertView: View {
  let store: Store<PhotoPickerAlertState, PhotoPickerAlertAction>

  var body: some View {
    WithViewStore(self.store) { viewStore in
      Form {
        Section {
          Button("Pick a photo") { viewStore.send(.showPhotoPicker) }
          Button("Show alert") { viewStore.send(.alertButtonTapped) }
        }
      }
      .navigationBarTitle("Photo picker alert")
      .fullScreenCover(
        isPresented: viewStore.binding(
          get: \.isPhotoPickerPresented,
          send: PhotoPickerAlertAction.photoPickerDismissed
        )
      ) {
        PhotoPicker(store: store)
      }
      .alert(
        self.store.scope(state: \.alert),
        dismiss: .alertDismissed
      )
    }
  }
}

struct PhotoPickerAlert_Previews: PreviewProvider {
  static var previews: some View {
    NavigationView {
      PhotoPickerAlertView(
        store: .init(
          initialState: .init(),
          reducer: photoPickerAlertReducer,
          environment: .init()
        )
      )
    }
  }
}

enum PhotoProcessorError: Error {
  case someError
}

struct PhotoProcessor {
  func doSomethingWithPickedPhoto(item: NSItemProvider) -> AnyPublisher<Bool, PhotoProcessorError> {
    Just(true).setFailureType(to: PhotoProcessorError.self).eraseToAnyPublisher()
  }
}

struct PhotoPicker: UIViewControllerRepresentable {
  let store: Store<PhotoPickerAlertState, PhotoPickerAlertAction>

  func makeUIViewController(context: Context) -> PHPickerViewController {
    var configuration = PHPickerConfiguration()
    configuration.selectionLimit = 1
    let pickerViewController = PHPickerViewController(configuration: configuration)
    pickerViewController.delegate = context.coordinator
    return pickerViewController
  }

  func updateUIViewController(_: PHPickerViewController, context _: Context) {}

  func makeCoordinator() -> Coordinator {
    Coordinator(self)
  }

  class Coordinator: NSObject, PHPickerViewControllerDelegate {
    private let parent: PhotoPicker

    init(_ parent: PhotoPicker) {
      self.parent = parent
    }

    func picker(_: PHPickerViewController, didFinishPicking result: [PHPickerResult]) {
      let viewStore = ViewStore(parent.store)

      if let itemProvider = result.first?.itemProvider {
        viewStore.send(.photoPickerPickedPhoto(itemProvider: itemProvider))
      } else {
        viewStore.send(.photoPickerCanceled)
      }
    }
  }
}

Video of desired behavior: WeTransfer - Send Large Files & Share Photos Online - Up to 2GB Free

When you run the code, you do not see the alert. Instead, in the console, you see a UIKit error that is familiar to many of us:

2022-06-23 14:33:21.928104+0300 SwiftUICaseStudies[4936:1813721] [Presentation] Attempt to present <SwiftUI.PlatformAlertController: 0x7f801d916a00> on <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x7f801e006ab0> (from <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_24NavigationColumnModifier__: 0x7f802e909160>) which is already presenting <_TtGC7SwiftUI29PresentationHostingControllerVS_7AnyView_: 0x7f802d733ef0>.

What this means: UIKit attempted to present a modal viewcontroller (alert) while another modal viewcontroller (photo picker) was still being displayed, when there is no child-parent relationship between them. UIKit does not like this, and does not display the alert.

A somewhat hacky fix is on line 63 of the code. By inserting a delay before returning the result of the photo processor (which is invoked with the result of the photo picker), it works as expected. But this feels incorrect.

It feels like TCA should help me here and be able to sequence things better, so that I can just request to display the alert, without worrying what else is showing on the screen at that time. Perhaps there is a way to already do this that I have missed?