pointfreeco/swift-navigation

Listening to `scenePhase` prevents destination update in a `task` closure

Zeta611 opened this issue · 2 comments

Describe the bug
Strangely, listening to scenePhase via @Environment(\.scenePhase) in the app struct prevents destination update in a task closure (in an asynchronous context).

To Reproduce
TestCover.zip

The view / model:

@MainActor
final class ContentViewModel: ObservableObject {
    @Published var destination: Destination?
    init() { destination = .loading }
    func task() async {
        try? await Task.sleep(for: .seconds(1))
        destination = nil
    }
    enum Destination { case loading }
}
struct ContentView: View {
    @ObservedObject private(set) var viewModel: ContentViewModel
    var body: some View {
        Text("Hello, world!")
            .fullScreenCover(
                unwrapping: $viewModel.destination,
                case: /ContentViewModel.Destination.loading
            ) { _ in ProgressView() }
            .task { await viewModel.task() }
    }
}

The app:

@main
struct TestCoverApp: App {
    // Commenting out the following line makes it work again!
    @Environment(\.scenePhase) private var scenePhase
    var body: some Scene {
        WindowGroup { ContentView(viewModel: ContentViewModel()) }
    }
}

Expected behavior
The fullScreenCover should dismiss after destination gets nil-ed in task.

Environment

  • swiftui-navigation version 0.6.1
  • Xcode 14.2
  • Swift 5.7.2
  • OS: iOS 16.2

Hey @Zeta611! When the scenePhase changes (and it happens a few times when the app launches), TestCoverApp body is re-evaluated, and a new ContentViewModel() instance is passed to the View. As a result, the early instance that received the .task signal is discarded, and no update effectively happens, as it is not observed anymore when the task function resumes.

The solution is to keep a reference to your first ContentViewModel in some way. Because you made this type @MainActor, it is not always convenient to create and store this value at the top level, but the easiest is probably to simply replace ContentView's @ObservedObject by @StateObject, as this latter will only keep the first instance it sees.

As an alternative if you can't use @StateObject, you can try this MainActorIsolated wrapper:

@MainActor
public final class MainActorIsolated<Value>: Sendable {
  public lazy var value: Value = initialValue()
  private let initialValue: @MainActor () -> Value

  nonisolated public init(initialValue: @MainActor @escaping () -> Value) {
    self.initialValue = initialValue
  }
}

and use it as:

@main
struct TestCoverApp: App {
  @Environment(\.scenePhase) private var scenePhase
  let viewModel = MainActorIsolated { ContentViewModel() }
  var body: some Scene {
    WindowGroup {
      ContentView(viewModel: viewModel.value)
    }
  }
}

(only body is re-evaluated. The TestCoverApp is not recreated1, but you can store the value out of TestCoverApp, at the top level, if you're more comfortable with that).

Footnotes

  1. Apparently at least. This is still an open question for me, and I never managed to get a proper response everywhere I asked for a counter-example where TestCoverApp would be recreated.

Thank you for providing a thorough and an insightful analysis of the lifecycle and how to workaround the issue!
Indeed, I observed that the object was recreated multiple times and was quite puzzled by it.

Thank you again, and I will close this issue as this doesn't seem to be the issue with the library.