The @State Specter: Analyzing a Bug in Multi-Window SwiftUI Applications

Published on

Get weekly handpicked updates on Swift and SwiftUI!

In this article, we will delve into a bug affecting SwiftUI applications in multi-window mode and propose effective temporary solutions. We will not only detail the manifestation of this issue but also share the entire process from discovery to diagnosis, and finally, resolution. Through this exploration, our aim is to offer guidance to developers facing similar challenges, helping them better navigate the complexities of SwiftUI development.

The @Observable Macro in Multi-Window Mode: Is There a Problem?

A few days ago, a user on Reddit posed a question concerning the Observable macro:

image-20240404092012388

The user, revblaze, attempted to convert a simple piece of code built with Combine into the Observatio, but encountered a peculiar situation afterward.

When running the code based on Combine, everything worked as expected in every newly created window, with each browser functioning correctly:

Swift
struct ContentView: View {
  @StateObject private var webViewManager = WebViewManager()

  var body: some View {
    WebView(manager: webViewManager)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .onAppear {
        webViewManager.load("https://www.example.com")
      }
  }
}

class WebViewManager: ObservableObject {
  @Published var urlString: String? = nil
  let webView: WKWebView = .init()

  func load(_ urlString: String) {
    self.urlString = urlString
    guard let url = URL(string: urlString) else { return }
    let request = URLRequest(url: url)
    webView.load(request)
  }
}

struct WebView: NSViewRepresentable {
  @ObservedObject var manager: WebViewManager
  func makeNSView(context _: Context) -> WKWebView {
    return manager.webView
  }

  func updateNSView(_: WKWebView, context _: Context) {}
}

However, after transitioning the same piece of code to the Observation, an issue arose. All windows shared a single WebViewManager instance (to clarify the issue, I included a UUID in the WebViewManager and displayed it in the view):

Swift
import Observation
import SwiftUI
import WebKit

struct ContentView: View {
  @State private var webViewManager = WebViewManager()

  var body: some View {
    Text("ID:\(webViewManager.id)")
    WebView(manager: webViewManager)
      .frame(maxWidth: .infinity, maxHeight: .infinity)
      .onAppear {
        webViewManager.load("https://www.example.com")
      }
  }
}

@Observable
class WebViewManager {
  var urlString: String?
  let webView: WKWebView = .init()
  let id = UUID()

  func load(_ urlString: String) {
    self.urlString = urlString
    guard let url = URL(string: urlString) else { return }
    let request = URLRequest(url: url)
    webView.load(request)
  }
}

struct WebView: NSViewRepresentable {
  var manager: WebViewManager

  func makeNSView(context _: Context) -> WKWebView {
    return manager.webView
  }

  func updateNSView(_: WKWebView, context _: Context) {}
}

Initially, I found this question quite baffling. Based on my understanding of the Observation framework, this situation should not occur. However, observing that ContentView declared the observable object with @State reminded me of a similar issue I encountered a year ago.

The Consistency Issue of Dynamic Values in Multi-Window Mode with @State

Last year, at the SwiftUI Tech Salon held in Beijing, I showcased a project named Movie Hunter, demonstrating the cross-platform development capabilities of SwiftUI. In the project, I used a custom-developed SwiftUI Overlay Container, a customizable overlay view management library. To differentiate each overlay container, I decided to use UUID().uuidString as the unique identifier for each window’s overlay container.

Swift
@State var containerName = UUID().uuidString

VStack {
  ...
}
.overlayContainer(containerName, containerConfiguration: ContainerConfiguration.share)
.environment(\.containerName, containerName)

I initially expected that SwiftUI would generate a new UUID for each container when constructing the root view for each window, thereby allowing each window to have its own independent container with a distinct name.

However, the reality was unexpected. Once the multi-window feature was enabled (whether on macOS or iPadOS), all windows synchronized and performed the same operations, indicating that the containerName in each window was actually the same.

To more clearly demonstrate this issue, I wrote the following code:

Swift
@main
struct StateIssueInMultiWindowsApp: App {
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

struct ContentView: View {
  @State var sameIdByState = UUID()
  @GestureState var sameIdByGestureState = UUID()
  @SceneStorage("id") var sameIdBySceneStore = UUID().uuidString
  @State var number = Int.random(in: 0...10000)
  @State var date = Date.now.timeIntervalSince1970
  var body: some View {
    VStack {
      Text("State \(sameIdByState)")
      Text("GestureState \(sameIdByGestureState)")
      Text("SceneStorage \(sameIdBySceneStore)")
      Text("Dumber \(number)")
      Text("Date \(date)")
    }
    .padding()
  }
}

Through the video above, you can see that in the root view, the dynamic states declared with @State, @SceneStorage, @GestureState (such as UUID, random numbers, current date) are the same across every window.

A series of tests revealed the following patterns:

  • In the root view, dynamic data created through @State or similar mechanisms (such as @SceneStorage, @GestureState) are entirely the same in every new window.
  • The initial values of dynamic data declared with @State in new windows are the same as those in the first window. Even if the data in one window is modified, it does not affect the other windows, indicating that the states of each window are actually independent; only their initial values are copied.
  • This issue appears only in the root view.
  • Observable objects declared with StateObject do not encounter this problem.

Given the specific conditions under which this issue occurs, I forgot to submit feedback to Apple after solving the problem. However, as developers turn to the new Observation framework and declare observable objects with @State, the likelihood of encountering this type of issue significantly increases.

By modifying the test code, we added an observable object based on the Observation framework and declared with @State:

Swift
@Observable
class ObservableID {
  var id = UUID()
  init(){
    print("Observable init")
  }
}

// Add in ContentView
@State var object = ObservableID()

Text("ObservableID \(object.id)")

The results show that all windows are using the same instance of the observable object, which is unacceptable for applications that need to provide an independent state container for each window.

Temporary Solutions

Although we cannot fully discern on which level the issue originates (whether it’s the optimization mechanism of @State or the construction mechanism of the window root view), we have identified the pattern in which the issue manifests. Thus, we can employ some straightforward methods to circumvent it.

Wrapping Views to Isolate the Root View

By creating a new wrapping view to encompass the original root view (ContentView), we can effectively bypass this issue.

Swift
@main
struct StateIssueInMultiWindowsApp: App {
  var body: some Scene {
    WindowGroup {
      RootView()
    }
  }
}

struct RootView: View {
  var body: some View {
    ContentView()
  }
}

Utilizing onAppear or Task to Reset States

Employing onAppear or task to reassign state values is also an effective strategy. This approach ensures that state values are refreshed when the root view is loaded, thus avoiding the issue of shared states.

Swift
.onAppear{
  sameIdByState = UUID()
  ...
}

Adopting either of the above methods can successfully avoid the issue of consistency in state values in a multi-window environment and, of course, can also address the situation encountered by revblaze at the beginning of this article.

Additionally, if the scene’s root view can receive different parameter values from external sources each time it is created, or if scene construction methods such as WindowGroup(for: Item.self) are used, this approach can sometimes circumvent the issue, though it cannot guarantee absolute effectiveness. I have submitted feedback to Apple (FB13707448) and hope that they will be able to provide a fix in the near future.

A Future Perspective on @State

The @State property wrapper holds a pivotal role in SwiftUI, having supported the binding of views to local states since the early days of SwiftUI. Its key features include:

  • High optimization for the SwiftUI view system, utilizing the isRead property to determine actual usage by views, ensuring precise association between state and view.
  • When developers use the wrappedValue of @State to create custom Bindings, issues may arise in rare cases (such as application crashes due to state changes not synchronizing with view updates). However, the ProjectedValue provided by @State, which directly manipulates the underlying data (bypassing wrappedValue), offers a safer and more efficient approach.
  • Before strict concurrency checking was implemented, @State was considered thread-safe, allowing value-type states to be modified across different threads without issues.

However, with the introduction of the Observation framework, the usage scenarios and roles of @State have undergone significant changes. It is no longer solely used for managing value-type states but has taken on broader responsibilities in lifecycle management. Moreover, as the concurrency programming model becomes more widespread and strict concurrency checks are enforced, the lack of explicit @MainActor annotation in @State, unlike in StateObject, has gradually become more apparent.

Given the new issues discussed in this article, as well as the importance of @State in modern SwiftUI applications and the challenges encountered in its use, we have reason to anticipate further developments and optimizations to @State at the upcoming WWDC 2024.

Conclusion

This article has not only revealed the bug encountered with the use of @State in multi-window mode of SwiftUI applications but also aims to encourage developers to think and analyze problems from more angles, rather than merely scratching the surface. Understanding the essence of the problem is key to solving it, guiding us to find the correct approach and methods.

Epilogue

Before the publication of this article, I received another request for help from a fellow netizen. He encountered a perplexing issue while using tvOS 17.

image-20240405203729630

Swift
struct ContentView: View {
  var body: some View {
    HStack {
      Text("Left")

      Form {
        Section(header: Text("rate")) {
          Button("0.5") {}
          Button("1.0") {}
          Button("1.5") {}
        }
      }
    }
  }
}

This code worked fine on tvOS 16, but after upgrading to tvOS 17, when a Button gains focus, its left-side highlight is incomplete (it gets truncated).

image-20240405201157359

After several tests, I observed the following patterns:

  • Everything is normal on tvOS 16, but not on tvOS 17.
  • Replacing Form with List did not resolve the issue.
  • Replacing Form with VStack solved the problem.
  • Replacing Form with ScrollView + VStack brought back the issue.

This indicates that the problem might be related to some enhancement made to ScrollView in tvOS 17.

In my previous article, Deep Dive into the New Features of ScrollView in SwiftUI 5, I introduced all the new features that Apple brought to ScrollView at WWDC 2023. One of them, scrollClipDisabled, is intended to control whether scrolling content is clipped to fit the boundaries of the scroll container.

Based on the current phenomenon, it’s likely that this issue is related to that modifier. Thus, I made the following adjustment to the code:

Swift
struct ContentView: View {
  var body: some View {
    HStack {
      Text("Left")

      Form {
        Section(header: Text("rate")) {
          Button("0.5") {}
          Button("1.0") {}
          Button("1.5") {}
        }
      }
      .scrollClipDisabled()  // disable clip
    }
  }
}

image-20240405202541019

Subsequently, the problem was resolved.

From starting to test the problem code to solving the issue, the entire process took less than ten minutes. This also validates the core message of this article: when encountering a problem, delving into its essence can often make solving it much simpler.

Weekly Swift & SwiftUI insights, delivered every Monday night. Join developers worldwide.
Easy unsubscribe, zero spam guaranteed