Files
claude-plugins/pdat-swift-practice/skills/xcode-practice/SKILL.md
T
TomUviandClaude Opus 5 542dd925d0 Record being wrong about which change broke a test
A UI test went red in the same run that adopted three new APIs. The one
touching that screen was reverted; the test still failed. A modifier
order was corrected; still failed. Simulator data was wiped; still
failed. Running it against a clean checkout — which should have been the
first move, not the fourth — it failed there too, with none of the work
present.

The scrolling entry had already been rewritten to explain the fault in
confident detail. That write-up was fiction, so it now says what actually
happened: the API was convicted on circumstance and remains unproven
here, and the one claim that survives is about its two-way binding
costing invalidations.

xcode-practice gets the general lesson, since it will outlive this
particular test: stash and rerun before reasoning about which edit did
it, and a note recording a false cause is worse than no note, because
the next person believes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 01:04:24 +02:00

184 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: xcode-practice
description: Practices for SwiftUI, Core Data, XcodeGen and Swift Testing that come from shipped apps rather than documentation — work that must not happen in a view body, blobs that must stay off the main thread, derived collections computed once, Keychain access outside view init, multi-target project hygiene, and reading test output correctly. Use when writing or reviewing SwiftUI views, adding files to an XcodeGen project, or interpreting a test run.
---
# Practices for Apple platform apps
These are not restatements of Apple's guidance. Each one is here because it
caused a freeze, a regression, or a wrong conclusion in a real app, and the
same mistake was made more than once.
Apple's own skills — `swiftui-specialist`, `swiftui-whats-new-27`,
`app-intents-specialist` and the rest that ship with Xcode — are the
authority on framework APIs. This skill covers what they do not: the
failure modes of putting those APIs together. Where they overlap, Apple
wins.
## Nothing that touches the disk, the network or a renderer belongs in `body`
A view's `body` runs far more often than it looks. Every unrelated state
change re-runs it, so anything expensive in there runs on the main thread
at the frequency of redraws.
Three separate instances of this shipped before being caught:
- `ShareLink(item: writeVCardFile())` — wrote a file to disk on every
redraw of a detail screen, because the argument is evaluated when the
view is built, not when the user taps share.
- Serialising a whole record to JSON inside a toolbar `Menu`, twice, to
decide whether two menu items should appear.
- Rendering barcodes with Core Image inside `body`, at up to 12× scale.
The fix is always the same shape: prepare the value in `.task(id:)`, hold
it in `@State`, and let `body` read the finished thing.
```swift
@State private var exportURL: URL?
var body: some View {
List { … }
.task(id: exportSignature) {
let payload = Payload(record) // main actor: reads the model
exportURL = await Task.detached { // off the main thread: writes
payload.writeTemporaryFile()
}.value
}
}
```
Give `.task` an `id` that covers every field the output depends on, or the
file goes stale after an edit.
## Keep external-storage blobs off the main thread
Core Data attributes with external storage (photos, PDFs) hit the file
system when read, deleted, or attached — the property access looks free
and is not. Three separate freezes in one app traced back to this.
Delete through `performBackgroundTask`, build attachments in the
background, write temporary files in a detached task. Never touch a blob
attribute in a `body`, in a `ForEach`, or in a synchronous button action.
## Compute derived collections once per pass
A computed property that sorts or filters runs again at every use, and a
computed property used *inside a closure* runs once per element:
```swift
// O(n² log n): `hero` re-sorts the whole collection for every card
let rest = sortedCards.filter { $0 != hero }
```
Hoist it into a local (`let hero = hero`), or better, build one value that
carries everything the body needs — the sorted list, the highlighted item,
the groups, the filter chips — and compute it once at the top of `body`.
Watch for repeated database fetches hidden inside such properties; a
registry lookup called from three computed properties is three fetches per
redraw.
## Keychain access does not belong in a view's `init`
`State(initialValue: Keychain.load(…))` in an initialiser looks like it
runs once. View initialisers run on every parent redraw, and the value is
discarded every time but the first. Read it in the parent's `.task` and
pass the loaded value down as a plain `let`.
Also: check what the Keychain returned. `SecItemUpdate` and `SecItemAdd`
fail on a locked device (`errSecInteractionNotAllowed`). Ignoring the
status means the person walks away believing a PIN was saved that was not.
Return the status and surface it.
## Prefer `sheet(item:)` over hand-built bindings
`sheet(isPresented: Binding(get:set:))` allocates a closure on every body
evaluation and compares poorly. When two optionals must agree — a share
and its container, say — wrap them in one `Identifiable` value so the
sheet cannot open with half the data.
## Cache expensive renderers, not just their output
`CIContext()` is costly to create; one shared instance serves the whole
app. Rendered images are worth an `NSCache` keyed by their inputs, which
fixes every call site at once instead of restructuring each view.
## XcodeGen: regenerate, then build every target
After adding a file: `xcodegen generate`, or the build fails with "cannot
find in scope".
More important, and easier to miss: targets list their sources
individually. A model type added for the phone app is *not* automatically
available to a watch or widget target that compiles the same shared model
file. A watch app sat broken for several commits this way, because only
the iOS and macOS builds were being checked.
Build every target after a change to shared model code, not just the one
being worked on.
## Read the right test summary
With Swift Testing, `xcodebuild`'s familiar line
```
Executed 0 tests, with 0 failures
```
is the XCTest counter and says nothing about Swift Testing suites. Theirs
looks like:
```
✔ Test run with 84 tests in 14 suites passed after 0.809 seconds.
```
Reaching a conclusion from the wrong line means declaring a whole suite
dead when it is passing. Also: do not pipe a test run through `tail` or a
narrow `grep` and then diagnose from what survived — filter the saved log
afterwards instead.
## Check a failure against an unchanged tree before blaming the change
A UI test started failing in the same run that introduced three API
changes. The obvious suspect — the one touching that exact screen — was
reverted, and the test still failed; a modifier order was corrected, still
failed; the simulator's app data was wiped, still failed. Only then was
the test run against a clean checkout with none of the work present, and
it failed there too. The change had been innocent all along, and two
rounds of edits went into explaining a fault that did not exist.
`git stash` and one run is cheaper than any amount of reasoning about
which edit did it. Do it first, not fourth. The same goes for what gets
written down afterwards: a note recording a false cause is worse than no
note, because the next person believes it.
Corollary for UI tests: when one goes red without a code change, suspect
what the test depends on but does not control — accumulated seed data,
`@AppStorage` surviving between launches, a simulator location and the
"nearby" sort order it drives, or an element scrolled out of a lazy
container and therefore missing from the accessibility tree entirely.
## Core Data in tests
Build objects through the context's own model rather than
`NSManagedObject.entity()`; with more than one coordinator alive, the
latter returns an entity from the wrong store and the object silently
loses its defaults. Keep the context alive for the whole test — a context
that deallocates mid-test takes its objects with it.
## Default main actor isolation
In a project that defaults to main actor isolation, plain data types need
`nonisolated` explicitly: `Codable` structs crossing to background work,
`ActivityAttributes` conformances handed to ActivityKit, enums used from
detached tasks. Swift 6 rejects using a main-actor-isolated conformance
from a nonisolated async context, so this surfaces as a build failure
against a new SDK rather than as a bug.
## Small things that cost an afternoon each
- `navigationDestination` belongs on the root of a stack, not inside rows.
- One `CLMonitor` instance per name; a second one crashes with "already in
use".
- Anchor `confirmationDialog` on the control that triggered it, or iPad
shows a popover floating in the middle of the screen.