SwiftModel tracks every model state change and every view access, so it can answer the two questions that come up most when something misbehaves:
- "What just changed in my model?" — log a diff of the model tree (and which property triggered it) as state mutates.
- "Why did this view re-render?" — log the exact property whose change invalidated a SwiftUI view's body.
Every hook below is gated on #if DEBUG, so the calls compile to nothing in release builds — safe to leave in place while you ship.
Attach withDebug() before anchoring to log changes for a model's whole lifetime:
AppModel().withDebug().withAnchor() // logs which property triggered + a diff of the changeTo enable it temporarily on a live model, call node.debug() — it returns a Cancellable you turn off when done. A trailing-closure form scopes logging to a single sub-expression:
let cancel = model.node.debug() // ... do work ...
cancel.cancel()
model.node.debug { model.filter } // watch just this expressionThe same debug: parameter exists on the observation primitives, so you can trace one mechanism in isolation instead of the whole tree:
node.memoize(debug: .init(changes: .value)) { items.sorted() }
node.forEach(Observed(debug: .triggers(.withValue)) { count }) { value in … }
node.forEach(observeModifications(debug: .triggers())) { _ in autosave() }When a view redraws and you don't know why, call $model.debug() from inside body. Each time SwiftUI invalidates the view, it names the model and key path that fired:
struct EditorBar: View {
@ObservedModel var editor: EditorModel
var body: some View {
$editor.debug()
return HStack { /* … */ }
}
}EditorModel at EditorBar.swift:42 ← EditorModel.layerMode
EditorModel at EditorBar.swift:42 ← EditorModel.selectedTrackID
Trigger lines follow reads into child models, so reading editor.canvas.scale names CanvasModel.scale. To narrow the hunt within a noisy view, wrap a region in ModelScope(debug:) — only reads inside the scope are attributed to it:
ModelScope(debug: .init(name: "toolbar")) {
EditorMidBar(editor: editor)
}What gets printed is controlled by a single DebugOptions value, shared by every hook above. The individual knobs are documented on DebugOptions itself — at a glance, you can:
- Pick the trigger format — just the property name, the
old → newvalue, or a structured diff (handy when the property is itself a model). - Pick the diff style — compact (changed lines only), collapsed, or the full before/after.
- Capture the reader's call stack (
captureAccessStack:) — appends thebodycall stack that registered a dependency, but only for paths that actually fire, so you see where a re-rendering read came from. - Observe every access (
accessObserver:) — for custom telemetry, or to drop into LLDB at the moment of read. - Redirect output (
printer:) — route debug lines to Instruments signposts,OSLog, or a file via anyTextOutputStream & Sendable, instead of stdout.
Seeing it in context: the TodoList example uses
Observed(debug:)to trace onlyitems.countandcompletedCount, and the Search example pairswithDebug()withcancelPrevious: trueto surface cancel-in-flight behaviour.