The Case of the Mystery Body Recompute: Debugging SwiftUI, Core Data, and Ghost Body Passes

Published by malhal on

If you have spent enough time with SwiftUI, you have likely run into the dreaded ghost re-render: your view appears, nothing in your data model has visibly changed, but body fires twice.

Recently, I embarked on a deep-dive investigation using LLDB, stack traces, and Self._printChanges() to uncover why a presented AlertContent view was evaluating its body twice upon mounting.

What started as a simple test case turned into a multi-layered mystery involving UIKit focus cyclesCombine @Published mechanics, and an unexpected quirk inside Core Data’s NSManagedObject lifecycle.

Here is the complete story of how we isolated every variable, exposed the culprit stack traces, and solved the mystery.

Act I: The Ghost Body Recompute Appears

It started with a straightforward SwiftUI alert view that takes an Author object for input:

struct AlertContent: View {
    @ObservedObject var author: Author
    
    var body: some View {
        let _ = Self._printChanges()
        TextField("Author Name", text: $author.name)
    }
}

When the alert was presented, Self._printChanges() logged:

AlertContent: @self, @identity, _author, _addDisabled changed.
AlertContent: _author changed.

body was being called twice immediately upon mount. Furthermore, an attached .onReceive(author.objectWillChange) was unexpectedly firing before the user had even touched the keyboard.

Act II: Suspect #1 — The UIKit Focus Trap & Binding Contract Violations

We attached LLDB to catch the exact backtrace of the second body evaluation. The backtrace revealed something unexpected:

#30 -[UITextField becomeFirstResponder]
#26 -[UITextField _notifyDidBeginEditing]
#21 SwiftUI.PlatformTextFieldCoordinator.didBeginEditing()
#19 SwiftUI.Binding.wrappedValue.setter
#3  Author.name.setter
#0  Author.objectWillChange.getter

The Revelation & Contract Violations:

When a SwiftUI TextField inside a presented alert or sheet appears, UIKit automatically makes it the firstResponder.

As part of this focus transition, PlatformTextFieldCoordinator.didBeginEditing() fires and invokes its binding’s setterimmediately after reading its getter, writing back the exact same value it just read.

Arguably, this is a bug in SwiftUI’s controls. A control should only invoke its binding setter when user interaction actually mutates state, not merely upon gaining focus or reading initial values.

You see this exact same contract violation elsewhere in SwiftUI:

  • TextField: Calling its binding setter right after calling its getter on focus.
  • List selection: Calling its binding setter when a user taps an already selected row, writing back the identical selection value.

The Problem: Eager Setters in Older Frameworks

These redundant write-backs wouldn’t be as destructive if state management frameworks guarded against identical assignments, but they handle equality very differently:

  • Swift 5.9 @Observable: Properties backed by @Observable have built-in equality checks. If a binding writes back the exact same value, the setter identifies that nothing changed and skips triggering an observation update.
  • Combine @Published & Core Data NSManagedObject: Neither @Published nor @NSManaged perform automatic value equality checks on write-back. @Published fires its willSet publisher unconditionally, even when assigned an identical value (author.name = author.name).

The Solution: A Custom Wrapper Property

Because @Published and NSManagedObject lack built-in equality guards on setter assignment, writing directly to $author.name from a TextField will cause an instant re-render on focus.

To solve this, we can wrap the property access in a dedicated computed variable on our view or model that explicitly guards against identical writes before touching the backing field:

// Explicitly guarding against identical writes for NSManagedObject / @Published
@objc(Author)
@MainActor public class Author: NSManagedObject {
    
    var name_: String {
        get {
            name ?? ""
        }
        set {
            if name ?? "" == newValue {
                return
            }
            name = newValue
        }
    }
}

// Explicitly guarding against identical writes for @Published
class Author: ObservableObject {

    private var _name: String = ""
        
    var name: String {
        get { _name }
        set {
            guard _name != newValue else { return }
            objectWillChange.send()
            _name = newValue
        }
    }
}

// Usage in view:
TextField("Author Name", text: $author.name) // or $author.name_ for NSManagedObject

By intercepting identical assignments in our wrapper variable, the auto-focus write from PlatformTextFieldCoordinator hits the return guard. author.name is never assigned, objectWillChange is never emitted, and the second body pass vanishes for standard ObservableObject classes!

Act III: Suspect #2 — The Fetched vs. Inserted Paradox

With the auto-focus issue handled for standard objects, we tested Core Data NSManagedObject instances. Here, we discovered a bizarre asymmetry:

  • Fetched NSManagedObject: Called body once (when using a guarded binding wrapper).
  • Newly Inserted NSManagedObject: Called body twice, and .onReceive(author.objectWillChange) fired on mount regardless!

Why would fetching an object from a context behave differently than inserting one?

Inspecting the Core Data Backtrace:

We traced the second body call of a newly inserted Author(context: viewContext) back into Core Data’s private frame methods:

#10 0x0000000187528578 in flushTransactions ()
#8  AG::Graph::UpdateStack::update ()
... Core Data Private Notification Engine:
_postObjectsDidChangeNotificationWithUserInfo
_willChange_Swift_Trampoline

The Root Cause:

When you initialize a new NSManagedObject directly into a NSManagedObjectContext, Core Data registers the object inside its internal NSInsertedObjectsKey dictionary.

During SwiftUI’s initial presentation graph flush (flushTransactions), Core Data processes its inserted objects queue. Its internal notification trampoline (_willChange_Swift_Trampoline) unconditionally fires objectWillChange.send() for any newly inserted managed object attached to an active view graph—which appears to be an unhandled framework bug in Core Data’s Swift integration.

Because NSManagedObject fires this change notification on initial graph attachment, SwiftUI’s AttributeGraph marks the view as dirty and schedules Pass #2.

Conversely, an existing, fetched NSManagedObject is already registered in the context and has no pending insertion state. Core Data stays silent, resulting in a single clean render pass.

Summary of Findings

Through our trace analysis, we categorized the exact behavior of every property wrapper pattern in SwiftUI:

Data ArchitectureBody Recompute CauseBehavior on Mount
@Published + TextFieldControls violate binding contracts by setting identical values; @Published lacks equality checks.⚠️ 2 Passes (Fixed with a custom wrapper variable)
Inserted NSManagedObjectCore Data’s NSInsertedObjectsKey unconditionally triggers _willChange_Swift_Trampoline.⚠️ 2 Passes (Core Data framework bug)
Fetched NSManagedObjectNo insertion event + zero duplicate writes via wrapper setter.🟢 1 Pass
Swift 5.9 @ObservableBuilt-in equality checking + fine-grained property getter tracking.🟢 1 Pass

Key Takeaways for SwiftUI Developers

  1. Beware of control binding bugs: Controls like TextField (on focus) and List (on re-selecting a row) violate the expected binding contract by writing values back to their setters without an actual data change.
  2. Guard eager setters manually: Unlike @Observable (which has built-in equality checking), @Published properties and NSManagedObject properties do not check if a new value matches the old one. Use a custom wrapper variable with an explicit guard check to absorb redundant writes.
  3. Beware of draft NSManagedObject instances: Creating temporary Core Data objects directly in the main viewContext before presentation will cause NSInsertedObjectsKey notifications to fire on view load. Consider holding temporary form state in standard @State or an uninserted draft until the user taps “Save.”
  4. Adopt @Observable where possible: Swift 5.9’s Observation framework includes built-in equality checks and replaces coarse-grained Combine publishers with fine-grained property access tracking, eliminating entire categories of initial graph-binding recomputes.