swiftui-interaction-footguns — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited swiftui-interaction-footguns (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
A class of bugs that look fine in code but break at runtime. These have shipped to production from real projects (see Sightings section). Sweep this checklist on every SwiftUI View review.
Button / NavigationLink / TabView / Form / MenuNavigationSplitView / sidebar)Button { } label: { LayoutWithSpacer } .buttonStyle(.plain) → hit-test shrinks to drawn content; the Spacer-expanded area is not tappable. Fix: .contentShape(Rectangle()) on the label's outermost container.NavigationLink, Menu, and any custom interactive view with .onTapGesture + Spacer / frame(maxWidth: .infinity) / padding.frame(maxWidth: .infinity) enlarge the visual frame but do not automatically enlarge the hit region under .plain. When in doubt, add .contentShape..accessibilityIdentifier on a container can cascade down and clobber its accessibility-element descendants' own identifiers — in XCUITest the children then report the container's id, and a query for the child's id finds nothing ("No matches found"). Fix: put the container-level id on a leaf (a title Text), or attach it to a zero-size Color.clear marker in a .background(...) — a sibling layer rather than an ancestor.ScrollView above tappable cards can work fine. So don't reason from the view hierarchy — drive the actual XCUITest query and see which element answers..accessibilityIdentifier(flag ? "x" : "")) are fine, but if several siblings can carry the same id simultaneously, queries must use .matching(identifier:).firstMatch or the tap is ambiguous.NavigationLink(value:) or Button — a bare Label is non-interactive even if it visually looks like a row.NavigationStack, not split. Snapshot tests for iPhone fixtures must force .compact (see next item).horizontalSizeClass on Mac@Environment(\.horizontalSizeClass) returns .regular for every macOS-hosted SwiftUI view — even iPhone-shaped fixtures inside NSHostingView. iPhone snapshot tests must inject .compact explicitly via .environment(\.horizontalSizeClass, .compact)..task { await viewModel.bootstrap() } re-fires on every view mount / identity change. If a test pre-seeds VM state, the task overwrites it back to .loading. Fix: hasBootstrapped latch in the VM + a separate retry() method for user-driven retry..task(id:) cancels and restarts when id changes — confirm that's the intent.dynamicTypeSize.isAccessibilitySize ? VStack : HStack reflow never fires and labels still clip off-screen (negative-x frame). (Proved in a real project: a game board presented in a modal; cells/labels enlarged but the gate read .large.) Use a geometry-driven layout instead — ViewThatFits(in: .horizontal) { HStack; VStack } picks the row/column from the actual offered width, no env read.minimumScaleFactor never engages and .frame(maxHeight:) alone just clips it..dynamicTypeSize(...DynamicTypeSize.xLarge). The cap clamps only sizes ABOVE .xLarge, so default .large rendering — and committed snapshot baselines — stay byte-identical; surrounding content keeps scaling. Standard compact-numeric-control approach; geometry/UIFont-driven, so a modal's stale env can't defeat it.DynamicTypeSize.accessibility3 into an NSHostingView bypasses the modal env-propagation path and gives a false pass (real example: three rounds passed snapshots, all failed on device). Verify AX4 and AX5 on a booted simulator instead (drive loop → interactive-simulator-ux-audit), eyeball the screenshot — don't trust the injected-env snapshot.Picker, Button(.borderedProminent), ProgressView, Toggle, Stepper etc. follow .tint / .accentColor. A custom theme color does not auto-propagate — apply .tint(<your accent Color>) on each system control or at a high-enough ancestor.NSHostingView snapshot environmentcolorScheme override needs host.appearance = NSAppearance(named: ...) on macOS — SwiftUI's .preferredColorScheme does not propagate through NSHostingView.locale and horizontalSizeClass overrides must be set on the View before wrapping in NSHostingView; mutating after host creation is unreliable..labelsHidden() on Picker when the label is provided externally (avoids duplicated label rendering on Mac)..buttonStyle(.borderedProminent) honours .tint from iOS 15+ / macOS 12+; both APIs were introduced together in SwiftUI 3..foregroundStyle(...) chained after .buttonStyle(.borderedProminent) on the Button itself is silently ignored — the prominent style keeps its own default label color, even though the code compiles and unit tests pass. Fix: apply .foregroundStyle to the label content inside the Button { } label: { … } closure, not chained on the Button call. Only a rendered screenshot exposes the ignored-placement variant..onAppear does not re-fire on fullScreenCover dismissfullScreenCover dismisses back to its presenting view, that view's .onAppear does not re-fire — an .onAppear { refresh() } wired for "refresh when the user returns from the modal" silently never runs. (Verified on-device across repeated open/dismiss cycles; the only fresh .onAppear fire in that round-trip is a transient re-mount at open, not at dismiss.).onChange, never .onAppear. Unit tests and code review both pass the wrong wiring; only an instrumented on-device or simulator run (a log probe in the refresh call, driving open→dismiss) exposes it.if/elseif A { ViewA } else { ViewB } gives the two branches distinct identities; state (@State, .task latches, focus) resets on switch. Use a single view with conditional modifiers when identity preservation matters.fullScreenCover(isPresented: $bool) / sheet(isPresented:) driven by a separate optional @State for the content, set back-to-back (data = x; isPresented = true), races: the cover presents from the Bool before the optional propagates into the content closure, so if let data { … } renders the empty branch → a blank cover. Looks correct in code; only a runtime drive (not snapshots, not unit tests) catches it. Fix: make the payload Identifiable and use fullScreenCover(item: $data) { data in … } — presentation and data are then atomic. (@MainActor payload → mark id nonisolated.)@Observable + @Bindable@Observable model via let vm = … does not establish a binding scope; passing vm into a child that needs @Bindable var vm requires the child to redeclare with @Bindable. Forgetting this silently breaks two-way bindings (TextField, Toggle).async method on a non-isolated view-model from .task (.task { await vm.load() }) fails with "sending 'self.vm' risks causing data races"; reading or writing the model's properties in body does not. Fix: annotate the view-model class with @MainActor (implicit under the Xcode 26 template defaults).navigationDestination / factory closure.loaded. Confirm by logging ObjectIdentifier(self) in bootstrap() vs ObjectIdentifier(viewModel) in body — a mismatch means the VM instance body reads is not the one that finished loading (an orphaned VM).RouteFactory.view(for:) that the destination calls) and stored as @Bindable is re-minted on every destination re-render. Any parent re-render gives the view a fresh `.idle` instance, and because the view keeps the same SwiftUI identity its .task { bootstrap() } does NOT re-fire — so it's stuck loading forever while the original (already-.loaded) instance is orphaned. This is a runtime-only bug: only a live drive of the app catches it; offline (no re-render trigger) it stays hidden.@State (first-value-wins: _viewModel = State(wrappedValue: viewModel)), so SwiftUI retains the first instance across destination re-invocations. Never @Bindable for a factory-built destination VM.Button { } label: { card-with-Spacer } with .buttonStyle(.plain), shrinking the tap target to drawn content only. Caught by macOS smoke test, not by Code Reviewer. Fix: .contentShape(Rectangle()).NavigationSplitView sidebar items were bare Labels with no NavigationLink / Button, so clicking did nothing. Same review-blind-spot path.VStack; the two buttons inside it, each with their own ids, both reported the container's id and could not be tapped. Passed code review and unit tests; only a failing XCUITest surfaced it. Fix: id moved to the leaf Text; a later container-level anchor used a zero-size .background marker (sibling, not ancestor) instead.fullScreenCover: fullScreenCover(isPresented: $bool) + a separate optional @State for content, set back-to-back, raced → content closure's if let rendered the empty branch (a11y tree = 1 element vs 99 for a real board). Dual-model CR + unit tests passed; only the idb interactive audit caught it. Fix: fullScreenCover(item:) with an Identifiable payload.@Bindable view-model re-minted on every navigationDestination re-render; an ad banner WebView finishing its load was enough of a parent re-render to trigger it, so the screen sat on its spinner forever. Same class of bug has appeared as "transient VM loses state" in other forms. Caught only by an interactive drive with network/ads active, not by unit tests. Fix: destination view owns the VM via @State (see the "View-model built inside a navigationDestination / factory closure" checklist entry).swiftui-navigation-architecture — the shape these components are wired into; go there for push/sheet/cover choice, not here.collaboration-skills:subagent-review-cycles — Code Reviewer dispatch brief should explicitly name this skill when reviewing SwiftUI Views.swiftui-expert:swiftui-expert-skill (aggregated external) — broader domain skill (Instruments traces, hang/hitch profiling); different scope.swiftui-pro:swiftui-pro (aggregated external) — a broad "SwiftUI mistakes LLMs make" catalog (navigation/layout/animation/state/deprecated-API). This skill is the narrower complement: runtime-only bugs that shipped past code review in one project, each with a reproduction note (vmid logging, blank-fullScreenCover race, stale-modal Dynamic Type, idb-verify-not-snapshot). Use both.references/official-docs.md.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.