Document-based SwiftUI apps on visionOS 2.4 are unusable due to a regression

Document-based SwiftUI apps on visionOS 2.4 are unusable due to a regression
TL;DR: As a result of a gnarly regression, every document-based SwiftUI app on visionOS 2.4 is duplicating views, rendering document-based app completely unusable on the platform.

I was working on porting one of my side project apps from macOS to visionOS, and at some point wanted to test it against the newly released visionOS 2.4. What I found was my app, which I hadn’t touched in any substantial way since the previous release, was very close to unusable. I observed the following symptoms. Images and text seemed overexposed, as if multiple copies had been layered on top of one another. Once I tried scrolling in the main scroll view, that proved to be the case. There was an actual copy of the view hierarchy in its initial state behind the interactive portion of the view.

0:00
/0:04

The result was comical but concerning. Had I done something wrong? Had I accidentally regressed the functionality? (I had never ever done that before, of course!)

It turns out, the same symptoms were reproducible in any document-based app, even the one created from the Xcode template. It reproduced on both the physical device and the simulator running visionOS 2.4. The regression wasn’t mine, it’s Apple’s!

After contemplating whether anyone had bothered to test document-based apps before the public visionOS 2.4 release, and making a few initial hypotheses about what could’ve happened based on my first-hand familiarity with Apple’s engineering processes, I realized that things were not looking good for being able to test my app on my shiny Vision Pro.

Debugging

I made a sample document-based app from the Xcode template, built it, and ran it on a device running visionOS 2.4. So, this wasn’t anything about the app itself; in fact, it seems like any document-based app is suffering the same fate. Multiple copies of my content stacked on top of each other, creating that distinctive “overexposed” look, and scrolling the content produced the same duplication, as if multiple copies of my view hierarchy were stacked on top of one another. I tried removing scrollable content from the equation. The issue still reproduced. I then tried a non-document-based app, and the issue was gone.

0:00
/0:09

With that preliminary investigation out of the way, I needed to understand what was happening under the hood. My initial hypothesis was that root SwiftUI views were being added to the hierarchy multiple times, and were never removed, which produced both the overexposed look (due to plusLighter blending liberally used throughout visionOS) and the duplication effect during scrolling.

To get a better intuition and visibility into what was going on, the next step was to set a breakpoint on init of the root view.

init(document: Binding<MaterialBugDocument>) {
    self._document = document
}

The first 2 times, we were called with the following UIKit symbols in the stack trace:

#49 0x0000000185227bc8 in -[UIPresentationController _sendWillDismiss] ()
#50 0x0000000185227a2c in -[UIPresentationController _sendDismissalsAsNeeded] ()
#51 0x0000000185df8f60 in -[UIDocumentViewControllerLaunchOptions _dismissBrowserViewController] ()
#52 0x0000000185df7e60 in -[UIDocumentViewControllerLaunchOptions _documentCloseStateDidChange] ()
#53 0x00000001855ad6e8 in -[UIDocumentViewController _documentStateChanged:] ()

The third (and final) time, it was something more intuitively expected:

#44 0x00000001860145cc in -[UIView _postMovedFromSuperview:] ()
#45 0x00000001860210c8 in -[UIView(Internal) _addSubview:positioned:relativeTo:] ()
#46 0x00000001d41e0434 in __C.UIDocumentViewController.embedDocumentHostingController(Swift.Optional<__C.UIViewController>) -> () ()
#47 0x00000001d41ea340 in merged SwiftUI.DocumentViewController.documentDidOpen() -> () ()
#48 0x00000001d41df80c in @objc SwiftUI.DocumentViewController.documentDidOpen() -> () ()
#49 0x00000001855aca24 in __62-[UIDocumentViewController openDocumentWithCompletionHandler:]_block_invoke_4 ()

The SwiftUI view being created, of course, doesn’t mean much by itself – a View is a transient object, after all, and is instantiated by SwiftUI machinery many times throughout the lifecycle, driven by graph updates. However, given the symptoms and the above, I suspected that the hosting view the above calls produced was actually added to the view hierarchy each time, and was never removed.

To sanity check myself, I added some basic instrumentation to track view lifecycle events:

.onAppear {
    print("View appeared.")
}
.onDisappear {
    print("View disappeared.")
}

The output was revealing: onAppear was called three times per document, while onDisappear wasn't called at all. So, in addition to providing strong evidence to the initial intuition, this seemed to indicate the SwiftUI view hierarchy wasn't just duplicated – it was triplicated. For every one view I intended to show, the visionOS 2.4 SDK was creating three! How very generous.

Diving Deeper

I wanted to understand what exactly was going on and hopefully fix it – on my own timeline. As the next step, I wrote a quick utility method to print the entire view hierarchy:

func printViewHierarchy(level: Int = 0) {
    let indent = String(repeating: "  ", count: level)
    let className = "\(type(of: self))"
    
    print("\(indent)• \(className): frame=\(frame)")
    
    // Print specific properties for common control types
    if let label = self as? UILabel {
        print("\(indent)  text: \"\(label.text ?? "nil")\"")
    }
    // ... other control-specific info
    
    // Recursively print subviews
    for subview in self.subviews {
        subview.printViewHierarchy(level: level + 1)
    }
}

let scenes = UIApplication.shared.connectedScenes.first as? UIWindowScene
let window = scenes?.windows.first
window?.rootViewController?.view.printViewHierarchy()

Running this on the main window’s root view controller’s view after it had appeared yielded the following output (I took the liberty to truncate it to make it easier to parse):

• UILayoutContainerView: frame=(0.0, 0.0, 1280.0, 720.0), tag=0, accessibilityID=nil
  • UINavigationTransitionView: frame=(0.0, 0.0, 1280.0, 720.0), tag=0, accessibilityID=nil
    • UIViewControllerWrapperView: frame=(0.0, 0.0, 1280.0, 720.0), tag=0, accessibilityID=nil
      • UIView: frame=(0.0, 0.0, 1280.0, 720.0), tag=0, accessibilityID=nil
        • _UIHostingView<ModifiedContent<ModifiedContent<AnyView, DocumentSceneRootBoxModifier>, DocumentBaseModifier>>: frame=(0.0, 0.0, 0.0, 0.0), tag=0, accessibilityID=nil
          <...>
        • _UIHostingView<ModifiedContent<ModifiedContent<AnyView, DocumentSceneRootBoxModifier>, DocumentBaseModifier>>: frame=(0.0, 0.0, 0.0, 0.0), tag=0, accessibilityID=nil
          <...>
        • _UIHostingView<ModifiedContent<ModifiedContent<AnyView, DocumentSceneRootBoxModifier>, DocumentBaseModifier>>: frame=(0.0, 0.0, 0.0, 0.0), tag=0, accessibilityID=nil
          <...>
  • UIKitNavigationBar: frame=(0.0, 0.0, 1280.0, 92.0), tag=0, accessibilityID=nil
    <...>

And here is the smoking gun: three identical sibling instances of _UIHostingView were being created for each document window.

Looking at the hierarchy output, I noticed that these extra views were identical siblings – same class, same parent view, same everything. This gave me an idea for a surgical workaround: scan the view hierarchy for groups of sibling views with the same class name that contains "_UIHostingView", and remove all but the last one of them. If this works, I’ll at least be able to test the application on the new visionOS without it being completely broken.

I crafted a utility extension that does exactly this:

extension UIView {
    /// Removes redundant sibling view instances of the same class
    /// containing the provided substring in their class name,
    /// keeping only one instance per group of sibling views.
    func cleanupRedundantViews(withClassNameContaining classNameSubstring: String) {
        func processViewGroup(_ viewGroup: [UIView]) {
            // Group sibling views by their class name
            var viewsByClassName: [String: [UIView]] = [:]
            
            for view in viewGroup {
                let className = NSStringFromClass(type(of: view))
                if className.contains(classNameSubstring) {
                    viewsByClassName[className, default: []].append(view)
                }
                
                // Recursively process this view's children
                processViewGroup(view.subviews)
            }
            
            // For each group of same-class views, keep only one
            for (_, views) in viewsByClassName where views.count > 1 {
                // Keep the last one, remove the rest
                for view in views.dropLast() {
                    view.removeFromSuperview()
                    print("Removing \(view)...")
                }
            }
        }
        
        // Start with the root view's immediate subviews
        processViewGroup(self.subviews)
    }
}

With this utility in hand, I could now apply it when the view appears:

.onAppear {
    let scenes = UIApplication.shared.connectedScenes.first as? UIWindowScene
    let window = scenes?.windows.first
    
    window?.rootViewController?.view.cleanupRedundantViews(withClassNameContaining: "_UIHostingView")
}

I ran the app again, and... success! The redundant views were removed, leaving only one instance of each view. The SwiftUI views were also cleaned up correctly, and the disappear handler ran as expected. But, most importantly, the app was usable again, with proper rendering and scrolling behavior.

The Aftermath

I've posted the sample project, which includes the bug as well as the aforementioned hacky workaround, here: https://github.com/philptr/SwiftUIDocumentBasedBug. This workaround isn’t perfect. It's a hack that shouldn’t be necessary, and it comes with some limitations. The real fix, of course, needs to be originated by Apple (I have reported it as FB17146267).

Whatever the cause, this is a clear reminder that it’s much easier to regress something you aren’t using yourself. The SwiftUI document-based app architecture is notoriously pretty limited and oftentimes feels underpowered next to their Kit counterparts, NSDocument and UIDocument. Given how forcibly lightweight and thus underpowered this initial architecture is, it is completely unsurprising that none of Apple’s first-party apps on visionOS adopt the SwiftUI document-based architecture. It is therefore even less surprising (though, of course, pretty upsetting) that a bug like this one was not caught in testing passes, during the dogfooding process, or throughout the Seed cycle.

Whatever the present issues may be, I remain excited and hopeful for SwiftUI’s future, which includes the document-based architecture, among many other things. After all, it is perhaps the only light at the end of the tunnel that can save native development on Apple platforms at this point, and the best way for third-party developers like myself to do our part is by providing feedback.