chrisnyw/Rerender
A tiny, focused debug tool for spotting wasted SwiftUI invalidations. DEBUG-only, zero cost in release builds.
Why this exists
I built Rerender because I was struggling with SwiftUI performance and had no good way to see what was going wrong.
I was working on an app where certain screens would lag — scroll stutters, delayed tap responses, animations dropping frames. I knew something was causing excessive view re-evaluations, but I couldn't figure out what. SwiftUI's invalidation model is powerful but opaque: a single @Observable property read in the wrong parent can silently cascade re-evaluations across an entire view tree, and nothing in Xcode tells you it's happening until you're already in a profiling session.
I spent hours in Instruments trying to correlate signpost data with specific views. I sprinkled _printChanges() everywhere, only to drown in console output and forget to remove the calls. I'd fix one hotspot, ship, and discover another a week later. What I really wanted was something always visible while I was using the app — a live dashboard that answered: which views are re-rendering right now, how often, and why is each one re-evaluating?
Nothing on the shelf quite fit:
- Apple Instruments 26 SwiftUI template — the gold standard, but requires profiling mode, Xcode, and a willingness to stop what you're doing and profile.
- DebugSwift's render tracking — a kitchen-sink toolkit with a beta re-render feature: border + count only, no reason attribution, no
@Observableawareness. _printChanges()— private API, text-only, easy to miss in the log firehose, and you have to remember to remove every call before shipping.
So I built Rerender: the always-on, in-app, zero-setup middle ground. Drop .rerender() on the views you're suspicious of, attach .rerenderHUD() at the root, and keep building your feature. The counts and reasons update live as you interact. When something spikes, you see it immediately — not after a 10-minute Instruments session.
It's not a replacement for Instruments when you need nanosecond-accurate profiling. It's a replacement for not noticing you have a performance problem until your users complain.
Screenshots & Demo
| Wasteful vs Optimized | Isolated Regions | User Input Cascade | |---|---|---| | [Wasteful vs Optimized] | [Isolated Regions] | [User Input] | | Every child re-renders on every tick vs only the leaves that read the ticker. | Live region ticks up; static region stays at 1×. | Every keystroke re-renders static cards — the wasteful default. |
HUD Features
| Tap to Expand | Pulse Border | Drag to Corner | |---|---|---| | <img src="./Assets/hud-tap-expand.png" width="480"> | [Pulse Border] | [Drag to Corner] | | Tap any row for body timing, file location, and hang count. | Tapping a row flashes a red border on the actual view. | Drag the header; it snaps to the nearest corner. |
Console Output
[Console Output]
Features
- Render counting with per-view body re-evaluation counts, sorted by frequency.
- Reason attribution — reports which stored property changed (e.g.
"tick") or which@Observablemodel keypath mutated (e.g."ticker.tick"). Falls back to"<external signal>"only when deep reflection can't detect the change. os_signpostbody-eval timing — measures the inner view's actual body evaluation separately from wrapper overhead. Shows up in Instruments undercom.rerender.BodyEvalfor profiling. The HUD shows both body and total durations.- Hang watchdog — a high-priority background poller detects main-thread hangs and attributes them to the most recently rendered
.rerender()call site. Affected rows show a red HANG badge. Threshold is configurable viarerenderStore.hangThresholdMilliseconds(default 250ms). - Pulse-border — tap any HUD row to flash a red border around the actual view on screen for 1 second, so you can see where it is, not just its name.
- Draggable HUD — drag the header to any corner; it snaps on release. Collapse to a title strip with the chevron. Tap a row to expand inline detail (body timing, file location, hang count). On iOS the HUD renders in a separate
UIWindowabove the navigation bar so taps never leak to toolbar buttons. Forces dark color scheme for readability regardless of system appearance. - Console logging — every HUD row tap prints full stats to the Xcode console. Controllable via
rerenderStore.isConsoleLoggingEnabled. - Export to JSON — tap the clipboard icon in the HUD footer to copy all stats as pretty-printed JSON to the system pasteboard. Or call
rerenderStore.exportJSON()/exportJSONString()programmatically for CI integration. - Dependency injection — no singleton. Create a
RerenderStore, inject it via.environment(), and both.rerender()and.rerenderHUD()pick it up. Supports multiple independent stores for isolated subtrees or testing. - Zero release cost — every modifier and the entire HUD compile to no-ops under
#if !DEBUG. Leave them in production code.
Install
Swift Package Manager:
.package(url: "https://github.com/<your-handle>/Rerender.git", from: "0.3.0")Then add "Rerender" to your target's dependencies.
Requires iOS 17 / macOS 14 and Swift 6.
Usage
Set up the store
Create a RerenderStore at your app root and inject it into the SwiftUI environment:
import Rerender
@main
struct MyApp: App {
@State private var rerenderStore = RerenderStore()
var body: some Scene {
WindowGroup {
ContentView()
.environment(rerenderStore)
}
}
}@State ties the store's lifetime to the scene, so one instance lives for as long as the window does. Both .rerender() and .rerenderHUD() read this store from the environment — if you forget the injection, both calls are silent no-ops.
Count a view
MyView().rerender() // label inferred from #fileID:#line
MyView().rerender("CartTotal") // explicit label, stable across refactorsWraps the view in a counter that records every body re-evaluation to the injected RerenderStore. Each call site also reports its on-screen frame via a preference key, enabling the pulse-border feature.
Show the HUD
ContentView()
.environment(rerenderStore)
.rerenderHUD() // default: top trailing
.rerenderHUD(alignment: .bottomLeading)Renders a draggable, collapsible floating panel with a ranked list of every tracked view. On iOS the panel lives in a separate UIWindow above the navigation bar, so it never competes with toolbar buttons for touches. On macOS it falls back to a SwiftUI overlay. Drag the header to any corner — it snaps on release. Tap the chevron to collapse to a title strip. The HUD reads the same store as .rerender() — attach it below the .environment(rerenderStore) injection.
Tap a HUD row
Tapping any row in the HUD:
- Expands inline detail — body-eval duration (last + average), total duration, file location (
#fileID:#line), and hang count if any. - Prints full stats to the Xcode console — count, reason, body/total timings, hangs, timestamp, and the
ViewKey.displayName. - Pulse-borders the view — a red 3pt stroke flashes around the actual view on screen for 1 second and fades out, so you can see where it is.
Tapping again collapses the detail. Only one row expands at a time.
Configure
// Disable all tracking without removing call sites
rerenderStore.isEnabled = false
// Turn off console logging on HUD row tap
rerenderStore.isConsoleLoggingEnabled = false
// Adjust the hang detection threshold (default 250ms)
rerenderStore.hangThresholdMilliseconds = 100 // catch micro-hangsExport stats
The HUD footer has a Copy button (clipboard icon) that encodes all stats as JSON and copies to the system pasteboard. You can also export programmatically:
// Get raw Data
let jsonData = try rerenderStore.exportJSON()
// Or a UTF-8 String
let jsonString = try rerenderStore.exportJSONString()The export uses a versioned StatsExport envelope (version: 1) with ISO 8601 dates and sorted keys, suitable for diffing across builds or feeding into a CI dashboard.
Reset programmatically
struct MyScreen: View {
@Environment(RerenderStore.self) private var rerenderStore
var body: some View {
SomeView()
.onAppear { rerenderStore.reset() }
}
}Release builds
.rerender() and .rerenderHUD() expand to no-ops under #if !DEBUG. You can leave them in production code without cost.
Try the demo
cd DemoApp
open DemoApp.xcodeprojOpens an iOS 17+ demo app in Xcode with six hand-built scenarios: a "wasteful" dashboard whose parent reads a tick counter directly (everything re-renders), an "optimized" version that pushes the read into leaf views (static siblings stay at 1x), two isolation examples showing live and static regions on the same screen, and a paired wasteful/isolated TextField + Slider + Button form demonstrating how parent-owned @State cascades re-renders to every static sibling. Pick any iPhone simulator and hit Cmd+R.
See DemoApp/README.md for the full breakdown of each example.
How attribution works
Each .rerender() invocation:
- Times the wrapped view's body with
ContinuousClockand emits anos_signpostinterval undercom.rerender.BodyEval. This measures the inner view's actual body evaluation, separate from the wrapper overhead. Both durations are recorded inStats. - Deep-snapshots the wrapped view's stored properties via
Mirror, recursing one level into any property that conforms toObservable. Inside an@Observableclass, the macro-generated underscore prefixes are stripped to produce clean keypaths (e.g.ticker.tickinstead of_ticker._tick). - Dispatches the record to the environment's
RerenderStoreon@MainActor(deferred via aTaskto avoid "modifying state during view update"). - Diffs the snapshot against the previous one. The reason is:
- "initial" on first render, - the comma-joined list of changed keys when Mirror sees a diff (e.g. "ticker.tick" for an @Observable mutation, or "tick" for a struct property change), - "<external signal>" when even the deep snapshot sees no change but the body ran anyway — typically a two-levels-deep @Observable mutation or an external @State change.
- Reports its on-screen frame via an
anchorPreferenceto the nearestrerenderHUD()ancestor, enabling the pulse-border overlay when a HUD row is tapped.
Important: the observation hoist (
_ = view.body) only captures reads that happen eagerly during body construction. Reads insideForEachcontent closures,LazyVStackbuilders, or other deferred containers are invisible to it — SwiftUI evaluates those closures later during its own render pass. Extract observable values to aletabove the lazy container so they're read eagerly:// Bad — read inside ForEach closure, invisible to Rerender ForEach(items) { item in Text("\(model.count)") } // Good — read extracted, tracked by Rerender let count = model.count ForEach(items) { item in Text("\(count)") }
Hang detection
A high-priority Task.detached polls MainActor.run { } every 100ms. If the main actor takes longer than the configured threshold to respond (default 250ms, adjustable via hangThresholdMilliseconds), the hang is attributed to the ViewKey that was most recently recorded by .rerender(). The Stats struct exposes hangCount and lastHangDurationNanos, and the HUD shows a red HANG pill on affected rows.
The watchdog starts automatically when RerenderStore is created (DEBUG builds only) and stops when the store is deallocated.
Limitations
These are real and worth knowing before you trust a number:
- Deep snapshot recurses only one level into
@Observablemodels. If your model references another@Observablemodel, mutations in the nested model still report<external signal>. A deeper traversal is a straightforward extension if needed. - Body-eval timing includes the observation-hoist overhead.
Rerender.bodycalls_ = view.bodyto subscribe to the inner view's observables. This means the measured body duration is one extra body evaluation, not the one SwiftUI runs for rendering. Theos_signpostinterval lets you verify against Instruments if precision matters. - View identity uses
#fileID:#lineby default. Fragile across refactors. Pass an explicit label for anything long-lived:.rerender("CartSummary"). - Mirror reflection has a cost. Not benchmarked yet. If it shows up in your profile, set
rerenderStore.isEnabled = falseto short-circuit all work, or file an issue — sampling is a straightforward fix. - Potential overlap feedback. If
.rerenderHUD()observation invalidates a subtree containing.rerender(), counts can bias upward. On iOS the HUD already renders in a separateUIWindow, which mitigates this. On macOS (where the overlay fallback is used) it's theoretically possible but hasn't been observed in practice. - Hang attribution is probabilistic. The watchdog attributes a hang to whichever view was most recently recorded, not necessarily the view whose body caused the hang. For single-view hangs this is accurate; for complex subtrees the attribution is a best-guess starting point.
- Observable reads inside deferred closures aren't hoisted.
ForEachcontent closures,LazyVStackbuilders, and similar deferred containers are stored — not called — during_ = view.body. Extract the observable value to aletabove the container so Rerender can track it (see the note in How attribution works).
Positioning
Rerender is focused, not a toolkit. If you want network inspection, keychain dump, log viewer, and twelve other debug panels in one package, use DebugSwift. If you want to answer one question — which of my views are re-rendering, and why? — use this.
Roadmap
- v0.4 — Widget / Live Activity support; configurable sampling rate for Mirror reflection; deeper
@Observabletraversal (>1 level); separateUIWindowfor macOS HUD.
License
MIT.
Package Metadata
Repository: chrisnyw/Rerender
Stars: 0
Forks: 0
Open issues: 0
Default branch: main
Primary language: swift
License: MIT
README: README.md