The Case of the Mystery Body Recompute: Debugging SwiftUI, Core Data, and Ghost Body Passes
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 cycles, Combine @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.Listselection: 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@Observablehave 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 DataNSManagedObject: Neither@Publishednor@NSManagedperform automatic value equality checks on write-back.@Publishedfires itswillSetpublisher 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: Calledbodyonce (when using a guarded binding wrapper). - Newly Inserted
NSManagedObject: Calledbodytwice, 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 Architecture | Body Recompute Cause | Behavior on Mount |
@Published + TextField | Controls violate binding contracts by setting identical values; @Published lacks equality checks. | ⚠️ 2 Passes (Fixed with a custom wrapper variable) |
Inserted NSManagedObject | Core Data’s NSInsertedObjectsKey unconditionally triggers _willChange_Swift_Trampoline. | ⚠️ 2 Passes (Core Data framework bug) |
Fetched NSManagedObject | No insertion event + zero duplicate writes via wrapper setter. | 🟢 1 Pass |
Swift 5.9 @Observable | Built-in equality checking + fine-grained property getter tracking. | 🟢 1 Pass |
Key Takeaways for SwiftUI Developers
- Beware of control binding bugs: Controls like
TextField(on focus) andList(on re-selecting a row) violate the expected binding contract by writing values back to their setters without an actual data change. - Guard eager setters manually: Unlike
@Observable(which has built-in equality checking),@Publishedproperties andNSManagedObjectproperties do not check if a new value matches the old one. Use a custom wrapper variable with an explicitguardcheck to absorb redundant writes. - Beware of draft
NSManagedObjectinstances: Creating temporary Core Data objects directly in the mainviewContextbefore presentation will causeNSInsertedObjectsKeynotifications to fire on view load. Consider holding temporary form state in standard@Stateor an uninserted draft until the user taps “Save.” - Adopt
@Observablewhere possible: Swift 5.9’sObservationframework 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.