From 6ed00fc8c65e155a9137b61309c47ba20b22e946 Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sat, 9 May 2026 12:18:21 -0400 Subject: [PATCH 1/7] [build-ios-apps] Expand SwiftUI performance guidance --- .../skills/swiftui-performance-audit/SKILL.md | 11 +- .../references/code-smells.md | 96 +++++++- .../references/review-guide.md | 222 ++++++++++++++++++ .../references/performance.md | 8 +- .../skills/swiftui-view-refactor/SKILL.md | 2 + 5 files changed, 332 insertions(+), 7 deletions(-) create mode 100644 plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md index 73f2e4cc..25e4135f 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/SKILL.md @@ -12,7 +12,7 @@ Use this skill to diagnose SwiftUI performance issues from code first, then requ ## Workflow 1. Classify the symptom: slow rendering, janky scrolling, high CPU, memory growth, hangs, or excessive view updates. -2. If code is available, start with a code-first review using `references/code-smells.md`. +2. If code is available, first read any repo-local SwiftUI performance guide that exists, then start the code-first review using `references/review-guide.md` and `references/code-smells.md`. 3. If code is not available, ask for the smallest useful slice: target view, data flow, reproduction steps, and deployment target. 4. If code review is inconclusive or runtime evidence is required, guide the user through profiling with `references/profiling-intake.md`. 5. Summarize likely causes, evidence, remediation, and validation steps using `references/report-template.md`. @@ -43,8 +43,9 @@ Focus on: - Layout thrash from complex hierarchies, `GeometryReader`, or preference chains. - Large image decode or resize work on the main thread. - Animation or transition work applied too broadly. +- Stored builder closures, broad action captures, and manual bindings that force avoidable recomputation. -Use `references/code-smells.md` for the detailed smell catalog and fix guidance. +Use `references/review-guide.md` for the full decision model and `references/code-smells.md` for a fast scan of common smells and fix guidance. Provide: - Likely root causes with code references. @@ -74,11 +75,14 @@ Apply targeted fixes: - Narrow state scope and reduce broad observation fan-out. - Stabilize identities for `ForEach` and lists. - Move heavy work out of `body` into derived state updated from inputs, model-layer precomputation, memoized helpers, or background preprocessing. Use `@State` only for view-owned state, not as an ad hoc cache for arbitrary computation. +- Prefer value-based modifiers over view-tree-breaking branches when only style or behavior changes. +- Keep one stable root view per `ForEach` element, and avoid `.id(...)` resets unless identity is the real feature. +- Avoid stored builder closures and manual `Binding(get:set:)` in hot paths when a stored child view or key-path binding would do. - Use `equatable()` only when equality is cheaper than recomputing the subtree and the inputs are truly value-semantic. - Downsample images before rendering. - Reduce layout complexity or use fixed sizing where possible. -Use `references/code-smells.md` for examples, Observation-specific fan-out guidance, and remediation patterns. +Use `references/review-guide.md` for the full rule set and `references/code-smells.md` for examples, Observation-specific fan-out guidance, and remediation patterns. ## 6. Verify @@ -96,6 +100,7 @@ Use `references/report-template.md` when formatting the final audit. ## References +- Full SwiftUI performance review guide: `references/review-guide.md` - Profiling intake and collection checklist: `references/profiling-intake.md` - Common code smells and remediation patterns: `references/code-smells.md` - Audit output template: `references/report-template.md` diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md index 8d5a7bb6..e46b9113 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md @@ -86,6 +86,42 @@ var content: some View { Prefer one stable base view and localize conditions to sections or modifiers. This reduces root identity churn and makes diffing cheaper. +### View-tree-breaking styling helpers + +```swift +Text(title) + .if(isHighlighted) { $0.bold() } +``` + +If the condition only changes style or behavior, prefer a value-based modifier: + +```swift +Text(title) + .fontWeight(isHighlighted ? .bold : .regular) +``` + +Custom `.if`-style helpers often swap structural identity even when the visual change looks small. + +### Variable root counts inside `ForEach` + +```swift +ForEach(items) { item in + if item.isVisible { + Row(item) + } +} +``` + +Prefer filtering before iteration, or keep one stable root view per element if the condition must stay inside the row. + +### Force-refresh identity + +```swift +content.id(UUID()) +``` + +Treat `.id(...)` as an identity tool, not a refresh hammer. Changing it resets state and defeats diffing. + ### Image decoding on the main thread ```swift @@ -110,6 +146,18 @@ var body: some View { If many views read the same broad collection or root model, small changes can fan out into wide invalidation. Prefer narrower derived inputs, smaller observable surfaces, or per-item state closer to the leaf views. +### High-volume environment writes + +```swift +.environment(\.scrollOffset, scrollOffset) +``` + +Rapidly changing environment values wake every environment-reading descendant. Prefer a stable observable reference in the environment and mutate the one field that interested leaves read. + +### Wide geometry readers + +Large subtrees under `GeometryReader` or `ScrollViewReader` can react to layout changes they do not care about. Keep reader scope tight and move unrelated stateful content outside. + ### Broad `ObservableObject` reads on iOS 16 and earlier ```swift @@ -120,6 +168,43 @@ final class Model: ObservableObject { The same warning applies to legacy observation. Avoid having many descendants observe a large shared object when they only need one derived field. +## Closure and binding smells + +### Stored builder closures + +```swift +struct Container: View { + let content: () -> Content + + var body: some View { + content() + } +} +``` + +Prefer evaluating a non-escaping builder in `init` and storing the built view. Stored closures are harder for SwiftUI to compare and can capture more parent state than intended. + +### Broad action captures + +```swift +Button("Retry") { + viewModel.retry() +} +``` + +In hot paths, prefer a method reference or a narrower capture when that avoids dragging large, frequently changing state into the closure value. + +### Manual bindings in hot paths + +```swift +Toggle("Enabled", isOn: Binding( + get: { model.isEnabled }, + set: { model.isEnabled = $0 } +)) +``` + +Prefer `$model.isEnabled` when a key-path binding exists. Manual bindings store closures and are harder to diff between updates. + ## Remediation notes ### `@State` is not a generic cache @@ -132,6 +217,10 @@ Better alternatives: - memoize in a dedicated helper - preprocess on a background task before rendering +### `@ObservationIgnored` should be surgical + +Use it for mutable non-render state such as caches, tasks, cancellables, services, and lazy dependencies that views do not read. Do not apply it to immutable `let` dependencies or blanket-ignore model state just to silence updates. + ### `equatable()` is conditional guidance Use `equatable()` only when: @@ -145,6 +234,7 @@ Do not apply `equatable()` as a blanket fix for all redraws. When multiple smells appear together, prioritize in this order: 1. Broad invalidation and observation fan-out 2. Unstable identity and list churn -3. Main-thread work during render -4. Image decode or resize cost -5. Layout and animation complexity +3. Main-thread work during render or lifecycle modifiers +4. Stored closures or manual bindings in hot paths +5. Image decode or resize cost +6. Layout and animation complexity diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md new file mode 100644 index 00000000..d82993fb --- /dev/null +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -0,0 +1,222 @@ +# SwiftUI performance review guide + +Use this as the deep reference when auditing SwiftUI code. If the target repo already has a local SwiftUI performance guide, read that first and treat it as authoritative; use this document to fill gaps, not to override local conventions. + +## Mental model + +SwiftUI performance is mostly about three things: + +- **Identity**: how SwiftUI recognizes the same logical view across updates. +- **Lifetime**: where state storage lives for that identity. +- **Dependencies**: which values a view reads while evaluating `body`. + +Prefer designs that preserve stable identity, keep dependency surfaces narrow, and make every code path reachable from `body` cheap and side-effect free. + +## State and Observation + +Prefer Observation for new feature code when the deployment target allows it. + +- Use `@Observable` models for feature state. +- Own them with `@State` in the root view, pass them explicitly to children, and use `@Bindable` only where bindings are required. +- Prefer `@Environment(Type.self)` or explicit parameters over broad `@EnvironmentObject` usage. +- Split "god" observables when different parts of the screen read unrelated fields. +- Mark mutable non-render bookkeeping such as caches, task handles, cancellables, services, and lazy dependencies with `@ObservationIgnored` when they should not participate in invalidation. +- Do not mark immutable `let` dependencies with `@ObservationIgnored`; they are already outside mutable observed state. + +Observation is read-tracked. The important design move is not merely "use `@Observable`", but "make each leaf read only what it truly needs." + +## Structural identity + +### Avoid `AnyView` in hot paths + +Flag `AnyView` in lists, large `ForEach` content, chat rows, and frequently updating surfaces. Prefer: + +- `@ViewBuilder` functions returning `some View` +- enums plus `switch` +- concrete wrapper views with stable structure + +Use `AnyView` only for narrow boundaries where the type erasure is the point. + +### Prefer value changes over tree swaps + +When state only changes style or behavior, keep one view type and vary values: + +```swift +Text(title) + .fontWeight(isHighlighted ? .bold : .regular) + .opacity(isDisabled ? 0.4 : 1) +``` + +Avoid helper modifiers such as `.if`, `.when`, or `.apply(if:)` when they branch between structurally different trees for a styling-only change. Those helpers still lower into `_ConditionalContent`, which can reset state, break animations, and increase work in repeated rows. + +Structural branches are fine when the UI truly changes shape, such as loading, loaded, and error states. If state must survive the swap, lift it above the branch. + +### Keep one root view per `ForEach` element + +For large collections, each element should yield a constant number of root views. + +- Prefer filtering before `ForEach` over returning "sometimes nothing". +- Avoid returning a variable number of sibling roots per element. +- If a condition must stay inside the row, wrap it in one stable container. + +### Treat `.id(...)` as a sharp tool + +Changing `.id(...)` resets state and prevents diffing across the old and new identity. + +- Flag `.id(UUID())` and other force-refresh patterns. +- Use explicit identity only when it encodes real stable data or drives a deliberate scroll/animation behavior. + +### Prefer native controls plus styles + +When a wrapper exists only to style `Button`, `Toggle`, `Label`, or another native control, prefer a `ButtonStyle`, `ToggleStyle`, or related style protocol. Wrapper views are still appropriate when they encode domain semantics, combine multiple controls, or add real behavior. + +## Cheap view evaluation + +Treat all code reachable from `body` as hot-path code: + +- `body` +- computed properties used by `body` +- helper methods called by `body` +- alternate "measured body" implementations + +Flag: + +- sorting or filtering large collections during render +- formatter allocation +- parsing, image processing, JSON work, or synchronous I/O +- heavyweight layout calculations +- long-running work in initializers, `onAppear`, or `onChange` + +Prefer: + +- model-layer precomputation +- derived state updated from meaningful input changes +- cached helpers and formatter reuse +- background preprocessing before render + +`@State` is not a generic cache. Use it only when the derived value belongs to the view lifecycle and has a clear update contract. + +## Lists and large collections + +- Prefer `List` for table-like content with system affordances. +- Prefer `ScrollView` plus lazy containers for custom layouts when list affordances are not needed. +- Avoid nested scroll containers such as `ScrollView { List { ... } }`. +- Use stable domain IDs rather than indices, mutable values, or per-render UUIDs. +- Be cautious with `ForEach(0..: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + } +} +``` + +Stored escaping builder closures make diffing harder and can drag parent dependencies into children. + +### Action closures + +Action closures are fine, but captures still matter. + +- Prefer method references when they reduce the capture set. +- Use capture lists when only a small subset is needed. +- In hot paths, flag actions that capture a large view model or broad state unnecessarily. + +### Manual bindings + +Prefer key-path bindings such as `$state` and `$model.property` over `Binding(get:set:)` where possible. + +Manual bindings store closures and are harder for SwiftUI to compare across updates. Strongly question them in list rows, chat input, and other frequently updating controls. If one is necessary, isolate it in a small subview and document why a key-path binding was insufficient. + +## Images and animations + +- Decode and downsample large images before rendering. +- Avoid broad animation modifiers that cause a large subtree to animate for tiny state changes. +- Prefer focused transitions over animating entire container hierarchies. + +## Profiling + +Use Instruments' SwiftUI template to inspect: + +- Update Groups +- Long View Body Updates +- Other Long Updates +- cause-and-effect graphs + +When a PR materially changes a chat thread, large scrolling surface, or complex animated area, add a `// PERF:` note when useful so future maintainers know how to reproduce and measure the sensitive path. + +## Review checklist + +Always ask: + +1. Is there `AnyView` in a hot path? +2. Is any code reachable from `body` doing heavy work? +3. Are list identities stable and unique? +4. Does each `ForEach` element produce one stable root? +5. Are parent views, geometry readers, or environment writes causing broad invalidation? +6. Is Observation being used with narrow reads and correct ignored state? +7. Is heavy work tied to lifecycle modifiers or the main actor? +8. Are stored builder closures or broad action captures causing avoidable churn? +9. Are manual bindings used where key-path bindings would work? +10. Could similar branches be value-based modifiers instead of tree swaps? +11. Is `.equatable()` backed by real evidence rather than hope? diff --git a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md index b574e73a..95d3edd4 100644 --- a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md +++ b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md @@ -2,15 +2,19 @@ ## Intent -Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation. +Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated, or at risk of unnecessary recomputation. This page is a build-time guardrail, not a substitute for a full audit; when the task is specifically about performance, use the `swiftui-performance-audit` skill and its deeper review guide. ## Core rules - Give `ForEach` and list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate. +- Keep one stable root view per `ForEach` element; filter before iterating instead of making rows appear and disappear at the root. - Keep expensive filtering, sorting, and formatting out of `body`; precompute or move it into a model/helper when it is not trivial. - Narrow observation scope so only the views that read changing state need to update. - Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently. - Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers. +- Prefer value-based modifiers over `.if`-style helpers when a condition changes only styling or behavior. +- Avoid `AnyView`, stored builder closures, and manual `Binding(get:set:)` in hot paths when concrete views, stored child views, or key-path bindings would do. +- Keep `GeometryReader` and high-volume environment writes tightly scoped so one hot signal does not wake an unrelated subtree. ## Example: stable identity @@ -60,3 +64,5 @@ If the work is more expensive than a small derived property, move it into a mode - Recomputing heavy transforms every render - Observing a large object from many descendants when only one field matters - Building custom scroll containers when `List`, `LazyVStack`, or `LazyHGrid` would already solve the problem +- Using `.id(...)` as a force-refresh mechanism instead of a real identity boundary +- Reaching for `.equatable()` before composition and dependency scope have been fixed diff --git a/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md b/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md index 181f94f9..d516eedc 100644 --- a/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md +++ b/plugins/build-ios-apps/skills/swiftui-view-refactor/SKILL.md @@ -130,6 +130,7 @@ private func reload(for searchText: String) async { - Avoid `body` or computed views that return completely different root branches via `if/else`. - Prefer a single stable base view with conditions inside sections/modifiers (`overlay`, `opacity`, `disabled`, `toolbar`, etc.). - Root-level branch swapping causes identity churn, broader invalidation, and extra recomputation. +- When the refactor is motivated by redraws, scroll hitches, or typing lag, also use the `swiftui-performance-audit` skill so the change is checked against the fuller performance rules around observation scope, `ForEach` identity, stored closures, and manual bindings. Prefer: @@ -189,6 +190,7 @@ init(dependency: Dependency) { 5. If a view model exists or is explicitly required, replace optional view models with a non-optional `@State` view model initialized in `init`. 6. Confirm Observation usage: `@State` for root `@Observable` models on iOS 17+, legacy wrappers only when the deployment target requires them. 7. Keep behavior intact: do not change layout or business logic unless requested. +8. If the refactor is performance-motivated, verify the result against the `swiftui-performance-audit` guidance rather than assuming a smaller file automatically became a faster view. ## Notes From 010221a4d10eae6f59ff67a284fb5953534aa327 Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sat, 9 May 2026 17:19:41 -0400 Subject: [PATCH 2/7] Generalize SwiftUI performance guidance --- .../swiftui-performance-audit/references/review-guide.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md index d82993fb..499cdec1 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -29,7 +29,7 @@ Observation is read-tracked. The important design move is not merely "use `@Obse ### Avoid `AnyView` in hot paths -Flag `AnyView` in lists, large `ForEach` content, chat rows, and frequently updating surfaces. Prefer: +Flag `AnyView` in lists, large `ForEach` content, dynamic rows, and frequently updating surfaces. Prefer: - `@ViewBuilder` functions returning `some View` - enums plus `switch` @@ -186,7 +186,7 @@ Action closures are fine, but captures still matter. Prefer key-path bindings such as `$state` and `$model.property` over `Binding(get:set:)` where possible. -Manual bindings store closures and are harder for SwiftUI to compare across updates. Strongly question them in list rows, chat input, and other frequently updating controls. If one is necessary, isolate it in a small subview and document why a key-path binding was insufficient. +Manual bindings store closures and are harder for SwiftUI to compare across updates. Strongly question them in list rows, text input, and other frequently updating controls. If one is necessary, isolate it in a small subview and document why a key-path binding was insufficient. ## Images and animations @@ -203,7 +203,7 @@ Use Instruments' SwiftUI template to inspect: - Other Long Updates - cause-and-effect graphs -When a PR materially changes a chat thread, large scrolling surface, or complex animated area, add a `// PERF:` note when useful so future maintainers know how to reproduce and measure the sensitive path. +When a PR materially changes a large scrolling surface, frequently updated screen, or complex animated area, add a `// PERF:` note when useful so future maintainers know how to reproduce and measure the sensitive path. ## Review checklist From ba02cdd59a59a93c7bd93601c5e8d31ffc16a93c Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sun, 10 May 2026 17:12:47 -0400 Subject: [PATCH 3/7] Clarify SwiftUI performance guardrails --- .../references/code-smells.md | 45 +++++++++++++++++++ .../references/review-guide.md | 29 +++++++----- .../references/performance.md | 6 ++- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md index e46b9113..51ca3f72 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md @@ -158,6 +158,20 @@ Rapidly changing environment values wake every environment-reading descendant. P Large subtrees under `GeometryReader` or `ScrollViewReader` can react to layout changes they do not care about. Keep reader scope tight and move unrelated stateful content outside. +### Broad preference chains + +`PreferenceKey`, `anchorPreference`, `overlayPreferenceValue`, and `onPreferenceChange` can move layout data across a wide subtree. Keep payloads small and stable, and publish them from the measured view rather than from a broad container. + +### Geometry-driven state loops + +```swift +.onPreferenceChange(FramePreferenceKey.self) { frame in + selectedFrame = frame +} +``` + +If the state write changes layout, SwiftUI can emit another measurement and repeat the cycle. Compare against the previous value, threshold noisy values, or move the state boundary closer to the measured view. + ### Broad `ObservableObject` reads on iOS 16 and earlier ```swift @@ -205,6 +219,35 @@ Toggle("Enabled", isOn: Binding( Prefer `$model.isEnabled` when a key-path binding exists. Manual bindings store closures and are harder to diff between updates. +## Animation smells + +### Broad container animations + +```swift +VStack { + Header() + List(items) { item in + Row(item) + } +} +.animation(.default, value: selection) +``` + +Broad animation modifiers can animate unrelated child updates and add layout work across a large subtree. Apply animation to the smallest view that owns the visual change, such as the row, control, or transition. + +When the animation belongs to a subset of a larger view and the deployment target supports it, prefer SwiftUI's scoped animation modifier: + +```swift +List(items) { item in + Row(item) + .animation(.default) { content in + content.opacity(item.id == selection ? 1 : 0.5) + } +} +``` + +Only the modifiers applied inside the closure inherit the animation. On older deployment targets, move the animation modifier to the smallest affected view. + ## Remediation notes ### `@State` is not a generic cache @@ -217,6 +260,8 @@ Better alternatives: - memoize in a dedicated helper - preprocess on a background task before rendering +If the value is derived from inputs, document or encode the exact input-change path that refreshes it. + ### `@ObservationIgnored` should be surgical Use it for mutable non-render state such as caches, tasks, cancellables, services, and lazy dependencies that views do not read. Do not apply it to immutable `let` dependencies or blanket-ignore model state just to silence updates. diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md index 499cdec1..b86718ff 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -96,6 +96,10 @@ Prefer: `@State` is not a generic cache. Use it only when the derived value belongs to the view lifecycle and has a clear update contract. +- Define which input changes refresh the state. +- Prefer model or store precomputation when the value is not view-owned. +- Prefer memoized helpers or background preprocessing when the work is expensive but not stateful UI. + ## Lists and large collections - Prefer `List` for table-like content with system affordances. @@ -113,11 +117,13 @@ Flag views that hold many unrelated `@State` values or read a large observable j ### Narrow geometry scope -Treat `GeometryReader`, `ScrollViewReader`, and preference chains as hot spots. +Treat these APIs as hot spots when they feed state or sit above large subtrees: `GeometryReader`, `ScrollViewReader`, `PreferenceKey`, `anchorPreference`, `overlayPreferenceValue`, and `onPreferenceChange`. - Keep them around only the subtree that needs geometry. - Move unrelated stateful descendants outside the reader subtree. +- Keep preference payloads small, stable, and tied to the coordinate space where they will be consumed. - Avoid geometry-driven state loops unless the geometry materially changes the UI. +- Compare or threshold measured values before writing state so tiny layout changes do not create a measure-update-layout cycle. ### Avoid high-volume environment writes @@ -192,6 +198,7 @@ Manual bindings store closures and are harder for SwiftUI to compare across upda - Decode and downsample large images before rendering. - Avoid broad animation modifiers that cause a large subtree to animate for tiny state changes. +- Flag `.animation(..., value:)` or `withAnimation` usage that wraps a large container when only a small control, row, or transition should animate. Prefer SwiftUI's scoped `.animation(...) { content in ... }` modifier when the deployment target supports it; otherwise move the animation modifier to the smallest affected view. - Prefer focused transitions over animating entire container hierarchies. ## Profiling @@ -211,12 +218,14 @@ Always ask: 1. Is there `AnyView` in a hot path? 2. Is any code reachable from `body` doing heavy work? -3. Are list identities stable and unique? -4. Does each `ForEach` element produce one stable root? -5. Are parent views, geometry readers, or environment writes causing broad invalidation? -6. Is Observation being used with narrow reads and correct ignored state? -7. Is heavy work tied to lifecycle modifiers or the main actor? -8. Are stored builder closures or broad action captures causing avoidable churn? -9. Are manual bindings used where key-path bindings would work? -10. Could similar branches be value-based modifiers instead of tree swaps? -11. Is `.equatable()` backed by real evidence rather than hope? +3. Is `@State` being used as an ad hoc cache without a clear input-change contract? +4. Are list identities stable and unique? +5. Does each `ForEach` element produce one stable root? +6. Are parent views, geometry readers, preference chains, or environment writes causing broad invalidation? +7. Is Observation being used with narrow reads and correct ignored state? +8. Is heavy work tied to lifecycle modifiers or the main actor? +9. Are stored builder closures or broad action captures causing avoidable churn? +10. Are manual bindings used where key-path bindings would work? +11. Could similar branches be value-based modifiers instead of tree swaps? +12. Is `.equatable()` backed by real evidence rather than hope? +13. Is animation scoped to the smallest view that owns the visual change? diff --git a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md index 95d3edd4..475219b9 100644 --- a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md +++ b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md @@ -14,7 +14,9 @@ Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated - Avoid swapping entire top-level view trees for small state changes; keep a stable root view and vary localized sections or modifiers. - Prefer value-based modifiers over `.if`-style helpers when a condition changes only styling or behavior. - Avoid `AnyView`, stored builder closures, and manual `Binding(get:set:)` in hot paths when concrete views, stored child views, or key-path bindings would do. -- Keep `GeometryReader` and high-volume environment writes tightly scoped so one hot signal does not wake an unrelated subtree. +- Keep `GeometryReader`, preference chains, and high-volume environment writes tightly scoped so one hot signal does not wake an unrelated subtree. +- Use `@State` only for view-owned state, not as an ad hoc cache for arbitrary expensive computation. +- Scope animation to the smallest view that owns the visual change; prefer scoped `.animation(...) { content in ... }` modifiers when available over broad animation modifiers on large containers. ## Example: stable identity @@ -66,3 +68,5 @@ If the work is more expensive than a small derived property, move it into a mode - Building custom scroll containers when `List`, `LazyVStack`, or `LazyHGrid` would already solve the problem - Using `.id(...)` as a force-refresh mechanism instead of a real identity boundary - Reaching for `.equatable()` before composition and dependency scope have been fixed +- Publishing broad preference payloads or geometry changes on every tiny layout update +- Animating an entire container when only a small row, control, or transition changes From 4ee6cbb9110b3e8226ed5b05f059ad66397070a6 Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sun, 10 May 2026 17:25:18 -0400 Subject: [PATCH 4/7] Tighten SwiftUI performance docs --- .../references/code-smells.md | 10 +++++++--- .../references/review-guide.md | 8 +++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md index 51ca3f72..b92379ae 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md @@ -26,6 +26,8 @@ final class DistanceFormatter { } ``` +Formatter allocation and string formatting in `body` both run during view update work. Precompute display strings before rendering when the calculation is expensive or repeated across rows. + ### Heavy computed properties ```swift @@ -136,15 +138,15 @@ Prefer decode and downsample work off the main thread, then store the processed ```swift @Observable final class Model { - var items: [Item] = [] + var favoriteIDs: Set = [] } var body: some View { - Row(isFavorite: model.items.contains(item)) + Row(isFavorite: model.favoriteIDs.contains(item.id)) } ``` -If many views read the same broad collection or root model, small changes can fan out into wide invalidation. Prefer narrower derived inputs, smaller observable surfaces, or per-item state closer to the leaf views. +If many views read the same broad collection or root model, small changes can fan out into wide invalidation. This still applies when the collection read is hidden behind a helper called from `body`. Prefer passing a derived value such as `isFavorite`, using smaller observable surfaces, or moving per-item state closer to the leaf views. ### High-volume environment writes @@ -154,6 +156,8 @@ If many views read the same broad collection or root model, small changes can fa Rapidly changing environment values wake every environment-reading descendant. Prefer a stable observable reference in the environment and mutate the one field that interested leaves read. +Even skipped body updates can have check cost after an environment value changes, so do not put high-frequency geometry, timer, scroll, or collection values directly in the environment. + ### Wide geometry readers Large subtrees under `GeometryReader` or `ScrollViewReader` can react to layout changes they do not care about. Keep reader scope tight and move unrelated stateful content outside. diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md index b86718ff..dd853ca9 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -23,7 +23,7 @@ Prefer Observation for new feature code when the deployment target allows it. - Mark mutable non-render bookkeeping such as caches, task handles, cancellables, services, and lazy dependencies with `@ObservationIgnored` when they should not participate in invalidation. - Do not mark immutable `let` dependencies with `@ObservationIgnored`; they are already outside mutable observed state. -Observation is read-tracked. The important design move is not merely "use `@Observable`", but "make each leaf read only what it truly needs." +Observation is read-tracked. The important design move is not merely "use `@Observable`", but "make each leaf read only what it truly needs." If every row reads the same broad collection or root model, each row can depend on that whole value even when it only needs one derived fact. ## Structural identity @@ -79,6 +79,8 @@ Treat all code reachable from `body` as hot-path code: - helper methods called by `body` - alternate "measured body" implementations +View bodies run on the main thread, and synchronous work reached from `body` has to complete before SwiftUI can finish the update for the next frame. + Flag: - sorting or filtering large collections during render @@ -129,6 +131,8 @@ Treat these APIs as hot spots when they feed state or sit above large subtrees: Rapidly writing scroll offsets, geometry, timers, or large arrays into the environment wakes every environment-reading view. Prefer putting a stable observable reference in the environment and mutating fields on that object so only the views that read the changing field update. +Even when SwiftUI decides a dependent view's `body` does not need to run, there is still cost in checking that dependency after the environment value changes. + When reacting to high-frequency signals, prefer thresholds, debouncing, or model-layer coalescing over responding to every tick. ## `ForEach` and state behavior @@ -210,6 +214,8 @@ Use Instruments' SwiftUI template to inspect: - Other Long Updates - cause-and-effect graphs +Use Update Groups to find when SwiftUI is doing work, Long View Body Updates to find slow `body` evaluation, and the cause-and-effect graph to compare the interaction's expected update fan-out with the actual views that were invalidated. If a tap should update two rows but the graph shows the whole list, narrow the dependencies before reaching for `.equatable()` or other local triage. + When a PR materially changes a large scrolling surface, frequently updated screen, or complex animated area, add a `// PERF:` note when useful so future maintainers know how to reproduce and measure the sensitive path. ## Review checklist From 5dab57f784cb229597a3d76155f4935310554a03 Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sun, 10 May 2026 17:35:24 -0400 Subject: [PATCH 5/7] Clarify ForEach row state guidance --- .../skills/swiftui-performance-audit/references/review-guide.md | 1 + .../skills/swiftui-ui-patterns/references/performance.md | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md index dd853ca9..07443f5a 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -141,6 +141,7 @@ When reacting to high-frequency signals, prefer thresholds, debouncing, or model - Stable IDs matter most when rows own state. - Keep row-scoped state on a stable row root when child content appears and disappears conditionally. +- In lazy containers, put row-lifetime `@State` on the stable root view returned from `ForEach`. Nested child state can be recreated after offscreen content is rebuilt; lift it to the row root, pass a binding, or move it into a model when it must survive scrolling. - Treat dynamic index IDs as suspicious whenever the collection mutates. ## Concurrency and lifecycle modifiers diff --git a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md index 475219b9..33a0d5c7 100644 --- a/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md +++ b/plugins/build-ios-apps/skills/swiftui-ui-patterns/references/performance.md @@ -8,6 +8,7 @@ Use these rules when a SwiftUI screen is large, scroll-heavy, frequently updated - Give `ForEach` and list content stable identity. Do not use unstable indices as identity when the collection can reorder or mutate. - Keep one stable root view per `ForEach` element; filter before iterating instead of making rows appear and disappear at the root. +- In lazy containers, keep row-lifetime `@State` on the stable root view returned from `ForEach`; nested child state can be recreated when offscreen content is rebuilt. - Keep expensive filtering, sorting, and formatting out of `body`; precompute or move it into a model/helper when it is not trivial. - Narrow observation scope so only the views that read changing state need to update. - Prefer lazy containers for larger scrolling content and extract subviews when only part of a screen changes frequently. From 15ef71a5fade277ea7191e78848d9b8c4c41cacc Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sun, 10 May 2026 17:41:54 -0400 Subject: [PATCH 6/7] Refine SwiftUI lifecycle guidance --- .../swiftui-performance-audit/references/code-smells.md | 4 ---- .../swiftui-performance-audit/references/review-guide.md | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md index b92379ae..8aee9081 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md @@ -266,10 +266,6 @@ Better alternatives: If the value is derived from inputs, document or encode the exact input-change path that refreshes it. -### `@ObservationIgnored` should be surgical - -Use it for mutable non-render state such as caches, tasks, cancellables, services, and lazy dependencies that views do not read. Do not apply it to immutable `let` dependencies or blanket-ignore model state just to silence updates. - ### `equatable()` is conditional guidance Use `equatable()` only when: diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md index 07443f5a..9493e07f 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -150,6 +150,7 @@ SwiftUI runs on the main actor. Keep synchronous work off the main thread and ou - Prefer `.task { ... }` for view-scoped async work because SwiftUI cancels it with the view lifecycle. - Avoid `Task.detached` for view-initiated work unless the lifetime and cancellation story are explicit. +- Do not assume `.task` or `async` makes CPU-heavy synchronous work leave the main actor. Move expensive parsing, image decoding, formatting, or database work into a non-main-actor helper or service; in Swift 6.2+ use `@concurrent` when an async helper must explicitly hop off the caller's actor. - Flag heavy `.onAppear` and `.onChange` handlers, especially for text input, geometry, timers, and scrolling. - Avoid per-row network `.task` work in large lists unless it is cached, bounded, or intentionally coordinated. From 3ee6d1c1777fedababd3c59f25800543aed1ad19 Mon Sep 17 00:00:00 2001 From: Jonah Grant Date: Sun, 10 May 2026 17:48:55 -0400 Subject: [PATCH 7/7] Polish SwiftUI performance guidance --- .../references/code-smells.md | 14 ++++++++++++++ .../references/profiling-intake.md | 3 ++- .../references/review-guide.md | 5 +++-- .../swiftui-ui-patterns/references/async-state.md | 1 + .../swiftui-ui-patterns/references/performance.md | 2 +- 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md index 8aee9081..b5fe372a 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/code-smells.md @@ -64,6 +64,20 @@ ForEach(items.filter { $0.isEnabled }) { item in Prefer a prefiltered collection with stable identity. +### Eager stacks with large repeated content + +```swift +ScrollView { + VStack { + ForEach(items) { item in + Row(item) + } + } +} +``` + +Standard stacks load their child view hierarchy immediately. For large repeated content, profile whether `List`, `LazyVStack`, `LazyHStack`, or a lazy grid avoids unnecessary initial view creation. Keep standard stacks for small content or when lazy layout does not improve the measured path. + ### Unstable identity ```swift diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md index 39b6530d..ff7c3900 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/profiling-intake.md @@ -17,6 +17,7 @@ Use this checklist when code review alone cannot explain the SwiftUI performance Ask the user to: - Run the app in a Release build when possible. +- Prefer a real-device capture for final performance decisions; treat Simulator traces as exploratory. - Use the SwiftUI Instruments template. - Reproduce the exact problematic interaction only long enough to capture the issue. - Capture the SwiftUI timeline and Time Profiler together. @@ -39,6 +40,6 @@ Ask the user to: ## Common traps - Debug builds can distort SwiftUI timing and allocation behavior. -- Simulator traces can miss device-only rendering or memory issues. +- Simulator traces can miss device-only rendering, memory, thermal, and scrolling behavior. - Mixed interactions in one capture make attribution harder. - Screenshots without the reproduction note are much harder to interpret. diff --git a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md index 9493e07f..bd95b513 100644 --- a/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md +++ b/plugins/build-ios-apps/skills/swiftui-performance-audit/references/review-guide.md @@ -105,7 +105,8 @@ Prefer: ## Lists and large collections - Prefer `List` for table-like content with system affordances. -- Prefer `ScrollView` plus lazy containers for custom layouts when list affordances are not needed. +- For custom layouts with many repeated offscreen children, consider `ScrollView` plus lazy containers when list affordances are not needed. +- Prefer standard stacks for small content or when profiling does not show a lazy-container benefit. - Avoid nested scroll containers such as `ScrollView { List { ... } }`. - Use stable domain IDs rather than indices, mutable values, or per-render UUIDs. - Be cautious with `ForEach(0..