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

7.9 KiB
Raw Blame History

name, description
name description
xcode-practice 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.

@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:

// 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.