Every piece was correct — @StateObject, @Published, PreferenceKey — yet the view body never re-evaluated. The objectWillChange publisher fired into the void. Here’s how a single utility function buried three layers deep silently killed our SwiftUI lifecycle.
The Setup
We have a straightforward SwiftUI screen — a view that shows a success message via a UIHostingController. The architecture was clean and battle-tested:
- A
ViewModelconforming toObservableObjectwith@Publishedproperties - A SwiftUI view using
@StateObjectto observe the ViewModel - A
PreferenceKeyto communicate navigation events up to the hosting controller - Button taps that mutate state, triggering navigation
This exact pattern worked flawlessly across dozens of other screens in the app. But on this screen, nothing worked.
Users tapped buttons. State changed. But the view never updated. Navigation never fired. The screen was frozen in time.
Proving the Observation Chain Was Broken
I stopped guessing and started instrumenting. I added logs to see: does objectWillChange actually fire?
.onAppear {
viewModel.objectWillChange
.sink { _ in
print("objectWillChange fired")
}
.store(in: &cancellables)
}
It fired. Every single time. The ViewModel was doing its job — publishing changes exactly as expected. The @Published properties were updating, objectWillChange was broadcasting. The observation chain from ViewModel to SwiftUI was intact.
So why wasn’t the body re-evaluating?
The SwiftUI Update Cycle — A Quick Primer
SwiftUI doesn’t re-evaluate a view’s body just because an ObservableObject changed. It re-evaluates when its internal dependency tracking system — the AttributeGraph — determines that a dependency has been invalidated.
The AttributeGraph is SwiftUI’s private, under-the-hood dependency graph. It tracks which pieces of state each view reads during its last body evaluation. When state changes, SwiftUI walks the graph, finds the affected views, and schedules them for re-evaluation.
But here’s the thing: the AttributeGraph can enter a cycle. And when it does, SwiftUI silently stops updating.
Finding the Cycle
After hours of debugging, I turned to an environment variable that exposes SwiftUI’s internal logging:
SWIFT_DEBUG_ENABLE_LOGGING=1
This floods the console with AttributeGraph activity. And buried in the noise, there it was:
=== AttributeGraph: cycle detected through attribute <X> ===
A cycle. SwiftUI had detected a circular dependency in the attribute graph and, rather than crash or loop infinitely, it simply stopped updating the affected subgraph. No warning in Xcode. No runtime error. Just silence.
The Root Cause
The cycle was caused by a utility function three layers deep in our view hierarchy. Here’s the simplified version of what happened:
struct SuccessView: View {
@StateObject var viewModel = SuccessViewModel()
var body: some View {
VStack {
Text(viewModel.formattedMessage) // <-- The culprit hides here
Button("Continue") {
viewModel.onContinueTapped()
}
}
.onPreferenceChange(NavigationKey.self) { value in
// handle navigation
}
}
}
The formattedMessage property looked completely innocent:
class SuccessViewModel: ObservableObject {
@Published var message: String = ""
var formattedMessage: AttributedString {
let html = "<b>\(message)</b>"
let data = Data(html.utf8)
let nsAttr = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil
)
return AttributedString(nsAttr ?? NSAttributedString())
}
}
This is where it gets wild.
NSAttributedString(data:options:documentAttributes:) with .html as the document type doesn’t use a simple parser. Under the hood, it spins up a WebKit instance to parse the HTML. WebKit needs a run loop to operate, so it pumps the CFRunLoop on the current thread.
When the CFRunLoop is pumped during a SwiftUI body evaluation, it flushes pending Core Animation transactions. Those CA transactions include layout and display updates that SwiftUI itself had queued. Flushing them mid-evaluation tells the AttributeGraph that outputs are being read before the current evaluation pass has finished producing them.
The result? The AttributeGraph sees a node being read that is currently being written — a cycle.
SwiftUI body starts evaluating
→ reads formattedMessage
→ NSAttributedString(.html) fires up WebKit
→ WebKit pumps CFRunLoop
→ CFRunLoop flushes CA transactions
→ CA transactions trigger pending SwiftUI layout
→ SwiftUI tries to read attributes still being written
→ 💥 Cycle detected
The AttributeGraph detected the cycle and shut down updates for the entire subgraph. Our @Published changes were real, objectWillChange was firing, but SwiftUI refused to call body because the dependency graph was poisoned by a re-entrant run loop.
The cruel part? This only happened on this screen because it was the only screen where HTML-formatted strings were computed synchronously inside a body evaluation path. Every other screen either used plain strings or pre-computed the attributed string outside the view lifecycle.
The Fix
Once identified, the fix was straightforward. Never let NSAttributedString(.html) execute during a SwiftUI body evaluation. We moved the HTML parsing out of the computed property and into the state mutation:
class SuccessViewModel: ObservableObject {
@Published var message: String = ""
@Published var formattedMessage: AttributedString = AttributedString()
func updateMessage(_ newMessage: String) {
self.message = newMessage
// Parse HTML on the main thread, but OUTSIDE of body evaluation
let html = "<b>\(newMessage)</b>"
let data = Data(html.utf8)
if let nsAttr = try? NSAttributedString(
data: data,
options: [.documentType: NSAttributedString.DocumentType.html],
documentAttributes: nil
) {
self.formattedMessage = AttributedString(nsAttr)
}
}
}
By pre-computing the AttributedString at the point of state mutation rather than lazily during body, the WebKit/CFRunLoop dance happens before SwiftUI ever starts its evaluation pass. No re-entrant run loop. No flushed CA transactions. No cycle.
For cases where you absolutely need HTML-attributed strings, an even safer approach is to parse them off the main thread entirely:
func updateMessage(_ newMessage: String) {
self.message = newMessage
DispatchQueue.global().async {
let html = "<b>\(newMessage)</b>"
let data = Data(html.utf8)
let nsAttr = try? NSAttributedString(
data: data,
options: [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
],
documentAttributes: nil
)
DispatchQueue.main.async { [weak self] in
self?.formattedMessage = AttributedString(nsAttr ?? NSAttributedString())
}
}
}
Lessons Learned
1. objectWillChange firing does not mean SwiftUI will update your view.
The observation mechanism and the update mechanism are separate systems. The AttributeGraph is the gatekeeper, and it can refuse to update even when state has legitimately changed.
2. AttributeGraph cycles fail silently. There’s no runtime error, no Xcode warning, no crash. Your view just stops updating. This makes them incredibly difficult to diagnose without knowing what to look for.
3. Never spin a run loop during SwiftUI body evaluation.
NSAttributedString(.html), URLSession synchronous calls, or anything that pumps CFRunLoop can flush Core Animation transactions mid-evaluation and create re-entrant cycles in the AttributeGraph.
4. Use SWIFT_DEBUG_ENABLE_LOGGING=1 when stuck.
It’s noisy, but it’s the only reliable way to see what SwiftUI’s dependency graph is actually doing. Look for “cycle detected” messages.
5. NSAttributedString(.html) is a hidden WebKit invocation.
It’s not a lightweight string parser. It instantiates a WebKit renderer, pumps the run loop, and can have side effects far beyond what the API surface suggests. Treat it as an async operation, even though the API is synchronous.
6. Utility functions can hide dangerous runtime behavior. The cycle wasn’t visible in the view code — it was buried inside a computed property that looked like a simple string formatter. When debugging SwiftUI update issues, don’t trust abstractions. Ask: “Does this code touch the run loop?”
Final Thought
SwiftUI’s declarative model is powerful, but it rests on a complex, opaque runtime. When things work, it feels like magic. When they break, the magic turns against you — silently, invisibly.
The AttributeGraph assumes it has exclusive control over the evaluation order of your view hierarchy. The moment something re-enters the run loop during that evaluation — whether it’s WebKit parsing HTML, a synchronous network call, or any other CFRunLoop interaction — that assumption is violated. The graph detects a cycle that isn’t really a logical cycle in your code, but a temporal cycle caused by re-entrant execution.
The screen that was “frozen in time” is now working perfectly. The fix was moving one computation out of a computed property. The debugging took two days.