| name | swiftui-debugging |
| description | Use when debugging SwiftUI issues in this macOS app — views not updating, layout problems, unnecessary re-renders, state ownership bugs, Preview crashes, or NSHostingView/NSPanel quirks. Covers both general SwiftUI debugging and macOS-specific patterns. |
SwiftUI Debugging
Overview
Systematic approaches for debugging SwiftUI issues in macOS apps. Covers view updates, state ownership, performance, layout, and macOS-specific quirks with NSHostingView and NSPanel.
Logging in macOS Apps
print() and NSLog() are invisible when launching macOS apps from CLI or Finder (menubar-only apps have no console). Always use file-based logging:
func debugLog(_ message: String) {
let entry = "\(Date()) \(message)\n"
let url = URL(fileURLWithPath: "/tmp/debug.log")
if let handle = try? FileHandle(forWritingTo: url) {
handle.seekToEndOfFile()
handle.write(entry.data(using: .utf8)!)
handle.closeFile()
} else {
try? entry.write(to: url, atomically: true, encoding: .utf8)
}
}
Remove all debug logging before committing. Use grep -r "debugLog" --include="*.swift" to verify.
View Not Updating
Symptom
View shows stale data after a confirmed state change. Common with hostingView.rootView = NewView(...) not propagating to children, or theme/color changes not reflecting.
Root Cause: SwiftUI Diffing
SwiftUI skips re-evaluating a child view's body if the child's struct inputs haven't changed — even if the child reads from a singleton or computed property that did change.
Parent: rootView = MyView(counts: same)
→ SwiftUI: "counts didn't change, skip child body"
→ Child: never re-reads StatusColors.working
→ Result: stale colors
Diagnosis
- Log at the data source (model/manager) and at the view's body — confirm data changed but body wasn't called
- Check if the view reads external state (singletons, managers) not passed through its struct inputs
- Use
Self._printChanges() at the top of body to see what SwiftUI thinks triggered (or didn't trigger) a re-evaluation
Fixes
| Root Cause | Fix |
|---|
| Child reads external state not in its inputs | Pass a changing value and add .id(value) to force recreation |
@State destroyed by .id() changes | Extract into @StateObject owned by parent above the .id() boundary |
NSColor(name:) dynamic colors cached | Use .id() to force re-resolution |
@Published changed but view doesn't update | Verify view uses @ObservedObject, not a plain property |
.id() Force-Recreation
struct NotchStatusView: View {
let counts: StatusCounts
var themeId: String = ""
var body: some View {
HStack { ... }
.id(themeId)
}
}
Tradeoff: .id() destroys the entire subtree including @State.
State Extraction (surviving .id())
class OverlayController: ObservableObject {
@Published var active: Overlay?
}
struct ParentView: View {
@StateObject private var controller = OverlayController()
var body: some View {
ChildView(controller: controller)
.id(themeId)
}
}
State Ownership Bugs
@State vs @StateObject vs @ObservedObject
| Wrapper | Owned by | Lifetime | Use when |
|---|
@State | The view | Dies with view identity (.id()) | Simple value types internal to one view |
@StateObject | The view that creates it | Survives re-renders, dies with view | You create the object and own its lifetime |
@ObservedObject | Someone else | You don't control it | Passed in from parent, not owned here |
Common Bug: @ObservedObject with inline init
struct MyView: View {
@ObservedObject var model = MyModel()
}
struct MyView: View {
@StateObject var model = MyModel()
}
Unnecessary Re-renders
Diagnosis
Add Self._printChanges() at the top of body to see what SwiftUI thinks changed:
var body: some View {
let _ = Self._printChanges()
...
}
Common Causes
- Struct not
Equatable — SwiftUI can't diff, re-renders every time. Add Equatable conformance.
- Closure properties — closures aren't equatable; every parent render creates a new closure. Move closures to methods or use
EquatableView.
- Overly broad
@Published — publishing a large object when only one field changed. Break into granular publishers or use @Observable (macOS 14+).
Layout Debugging
Visual debugging
.border(Color.red)
.background(Color.blue.opacity(0.2))
.overlay(GeometryReader { geo in
Text("\(Int(geo.size.width))×\(Int(geo.size.height))")
.font(.caption2).foregroundColor(.red)
})
Common Layout Issues
- View collapses to zero — missing
.frame() or parent doesn't propose a size. Check with background(Color.red).
- Text truncation —
fixedSize() lets text exceed proposed size, or use lineLimit(nil).
- GeometryReader takes all space — it's greedy. Wrap in an explicit
.frame() or use it only for reading, not sizing.
Preview Debugging
Preview crashes silently
- Check the diagnostic in Xcode's canvas (click the error icon)
- Previews run in a separate process — crashes in
init() or computed properties won't show a stack trace
- Use mock data that avoids file system, network, or singleton access
Preview works but app doesn't (or vice versa)
- Previews use a different bundle —
Bundle.main returns different values
- Previews may not have the same entitlements (e.g., no Automation permission)
@AppStorage reads from a different UserDefaults suite in previews
macOS-Specific: NSHostingView and NSPanel
NSHostingView rootView replacement
Setting hostingView.rootView = NewView(...) does NOT recreate the view tree — SwiftUI diffs the old and new struct. If inputs are identical, children skip body evaluation. This is the most common source of "view not updating" bugs in macOS apps using NSHostingView.
NSPanel quirks
needsDisplay = true on NSHostingView does nothing — SwiftUI manages its own display cycle
orderOut(nil) hides but doesn't destroy the hosting view; state persists
- For clickable panels (e.g., notch pill), use
NSPanel with styleMask: [.nonactivatingPanel] and override canBecomeKey to avoid stealing focus
@MainActor cascade
Making a type @MainActor (e.g., to access ThemeManager.shared) cascades to everything that uses it. Plan for this — it often requires @MainActor on models, renderers, controllers, and test classes. Add it top-down rather than chasing compiler errors bottom-up.
Quick Reference
| Problem | First step |
|---|
| View shows stale data | Self._printChanges() — is body even called? |
| View never re-renders | Check property wrappers — @ObservedObject vs @StateObject |
| State resets unexpectedly | Look for .id() changes destroying @State |
| Can't see print output | Use file-based logging (/tmp/debug.log) |
| Layout wrong | .border(Color.red) on suspect views |
| Preview crashes | Check for singleton/file access in init |
| NSHostingView children stale | Inputs unchanged → use .id() to force recreation |
@MainActor errors everywhere | Add top-down from the root type, not bottom-up |