From ae037f7bec99249d2f4f6099b34c352eef71592c Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Mon, 19 Jan 2026 01:41:00 -0500 Subject: [PATCH] skills --- CLAUDE.md | 185 ++ README.md | 104 ++ commands/build.md | 120 ++ commands/plan.md | 82 + commands/status.md | 113 ++ lib/tmux.sh | 216 +++ skill-index/index.yaml | 282 +++ .../skills/axiom-build-debugging/SKILL.md | 513 ++++++ .../skills/axiom-database-migration/SKILL.md | 430 +++++ skill-index/skills/axiom-grdb/SKILL.md | 670 +++++++ .../skills/axiom-memory-debugging/SKILL.md | 1219 ++++++++++++ .../axiom-networking/LEGACY-IOS12-25.md | 375 ++++ .../skills/axiom-networking/MIGRATION.md | 235 +++ .../skills/axiom-networking/REFERENCES.md | 20 + skill-index/skills/axiom-networking/SKILL.md | 978 ++++++++++ .../skills/axiom-swift-concurrency/SKILL.md | 950 ++++++++++ .../skills/axiom-swift-performance/SKILL.md | 1628 +++++++++++++++++ .../skills/axiom-swift-testing/SKILL.md | 725 ++++++++ skill-index/skills/axiom-swiftdata/SKILL.md | 1489 +++++++++++++++ .../axiom-swiftui-architecture/SKILL.md | 1515 +++++++++++++++ .../skills/axiom-swiftui-debugging/SKILL.md | 1289 +++++++++++++ .../skills/axiom-swiftui-performance/SKILL.md | 1137 ++++++++++++ skill-index/skills/axiom-ui-testing/SKILL.md | 1162 ++++++++++++ .../skills/axiom-xcode-debugging/SKILL.md | 255 +++ 24 files changed, 15692 insertions(+) create mode 100644 CLAUDE.md create mode 100644 README.md create mode 100644 commands/build.md create mode 100644 commands/plan.md create mode 100644 commands/status.md create mode 100644 lib/tmux.sh create mode 100644 skill-index/index.yaml create mode 100644 skill-index/skills/axiom-build-debugging/SKILL.md create mode 100644 skill-index/skills/axiom-database-migration/SKILL.md create mode 100644 skill-index/skills/axiom-grdb/SKILL.md create mode 100644 skill-index/skills/axiom-memory-debugging/SKILL.md create mode 100644 skill-index/skills/axiom-networking/LEGACY-IOS12-25.md create mode 100644 skill-index/skills/axiom-networking/MIGRATION.md create mode 100644 skill-index/skills/axiom-networking/REFERENCES.md create mode 100644 skill-index/skills/axiom-networking/SKILL.md create mode 100644 skill-index/skills/axiom-swift-concurrency/SKILL.md create mode 100644 skill-index/skills/axiom-swift-performance/SKILL.md create mode 100644 skill-index/skills/axiom-swift-testing/SKILL.md create mode 100644 skill-index/skills/axiom-swiftdata/SKILL.md create mode 100644 skill-index/skills/axiom-swiftui-architecture/SKILL.md create mode 100644 skill-index/skills/axiom-swiftui-debugging/SKILL.md create mode 100644 skill-index/skills/axiom-swiftui-performance/SKILL.md create mode 100644 skill-index/skills/axiom-ui-testing/SKILL.md create mode 100644 skill-index/skills/axiom-xcode-debugging/SKILL.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aa1994b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,185 @@ +# Claude Code Vertical + +Multi-agent orchestration system for Claude Code. Scale horizontally (multiple planning sessions) and vertically (weavers executing in parallel). + +## Architecture + +``` +You (Terminal) + | + v +Planner (interactive) <- You talk here + | + v (specs) +Orchestrator (tmux background) + | + +-> Weaver 01 (tmux) -> Verifier (subagent) -> PR + +-> Weaver 02 (tmux) -> Verifier (subagent) -> PR + +-> Weaver 03 (tmux) -> Verifier (subagent) -> PR +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `/plan` | Start interactive planning session | +| `/build ` | Execute a plan (spawns orchestrator + weavers) | +| `/status [plan-id]` | Check status of plans and weavers | + +## Quick Start + +```bash +# Start planning +claude +> /plan + +# Design your specs interactively... +# When ready, planner tells you the plan-id + +# Execute +> /build plan-20260119-1430 + +# Check status +> /status plan-20260119-1430 +``` + +## Directory Structure + +``` +.claude/vertical/ + plans/ + / + meta.json # Plan metadata + specs/ # Spec YAML files + 01-schema.yaml + 02-backend.yaml + run/ + state.json # Orchestrator state + summary.md # Human-readable results + weavers/ + w-01.json # Weaver status + session ID + w-02.json +``` + +## All Agents Use Opus + +Every agent in the system uses `claude-opus-4-5-20250514`: +- Planner +- Orchestrator +- Weavers +- Verifiers (subagents) + +## Tmux Session Naming + +``` +vertical--orch # Orchestrator +vertical--w-01 # Weaver 1 +vertical--w-02 # Weaver 2 +``` + +## Skill Index + +Skills live in `skill-index/skills/`. The orchestrator uses `skill-index/index.yaml` to match `skill_hints` from specs to actual skills. + +To add a skill: +1. Create `skill-index/skills//SKILL.md` +2. Add entry to `skill-index/index.yaml` + +## Resuming Sessions + +Every Claude session can be resumed: + +```bash +# Get session ID from weaver status +cat .claude/vertical/plans//run/weavers/w-01.json | jq -r .session_id + +# Resume +claude --resume +``` + +## Debugging + +```bash +# Source helpers +source lib/tmux.sh + +# List all sessions +vertical_list_sessions + +# Attach to a weaver +vertical_attach vertical-plan-20260119-1430-w-01 + +# Capture output +vertical_capture_output vertical-plan-20260119-1430-w-01 + +# Kill a plan's sessions +vertical_kill_plan plan-20260119-1430 +``` + +## Spec Format + +```yaml +name: feature-name +description: What this PR accomplishes + +skill_hints: + - relevant-skill-1 + - relevant-skill-2 + +building_spec: + requirements: + - Requirement 1 + - Requirement 2 + constraints: + - Constraint 1 + files: + - src/path/to/file.ts + +verification_spec: + - type: command + run: "npm run typecheck" + expect: exit_code 0 + - type: file-contains + path: src/path/to/file.ts + pattern: "expected pattern" + +pr: + branch: feature/branch-name + base: main + title: "feat: description" +``` + +## Context Isolation + +Each agent gets minimal, focused context: + +| Agent | Receives | Does NOT Receive | +|-------|----------|------------------| +| Planner | Full codebase, your questions | Weaver implementation | +| Orchestrator | Specs, skill index, status | Actual code | +| Weaver | Spec + skills | Other weavers' work | +| Verifier | Verification spec only | Building requirements | + +This prevents context bloat and keeps agents focused. + +## Parallel Execution + +Multiple planning sessions can run simultaneously: + +``` +Terminal 1: /plan auth system +Terminal 2: /plan payment system +Terminal 3: /plan notification system +``` + +Each spawns its own orchestrator and weavers. All run in parallel. + +## Weavers Always Create PRs + +Weavers follow the eval-skill pattern: +1. Build implementation +2. Spawn verifier subagent +3. Fix on failure (max 5 iterations) +4. Create PR on success + +No PR = failure. This is enforced. diff --git a/README.md b/README.md new file mode 100644 index 0000000..41d3f77 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Claude Code Vertical + +Scale your Claude Code usage horizontally and vertically. + +- **Horizontal**: Run multiple planning sessions in parallel +- **Vertical**: Each plan spawns multiple weavers executing specs concurrently + +## Quick Start + +```bash +# Start a planning session +claude +> /plan + +# Design specs interactively with the planner... +# When ready: +> /build plan-20260119-1430 + +# Check status +> /status plan-20260119-1430 +``` + +## Architecture + +``` +You (Terminal) + | + v +Planner (interactive) <- You talk here + | + v (specs) +Orchestrator (tmux background) + | + +-> Weaver 01 (tmux) -> Verifier (subagent) -> PR + +-> Weaver 02 (tmux) -> Verifier (subagent) -> PR + +-> Weaver 03 (tmux) -> Verifier (subagent) -> PR +``` + +## Commands + +| Command | Description | +|---------|-------------| +| `/plan` | Start interactive planning session | +| `/build ` | Execute plan via tmux weavers | +| `/status [plan-id]` | Check plan/weaver status | + +## Directory Structure + +``` +claude-code-vertical/ +├── CLAUDE.md # Project instructions +├── skills/ +│ ├── planner/ # Interactive planning +│ ├── orchestrator/ # Tmux + weaver management +│ ├── weaver-base/ # Base skill for all weavers +│ └── verifier/ # Verification subagent +├── commands/ +│ ├── plan.md +│ ├── build.md +│ └── status.md +├── skill-index/ +│ ├── index.yaml # Skill registry +│ └── skills/ # Available skills +├── lib/ +│ └── tmux.sh # Tmux helper functions +└── .claude/ + └── vertical/ + └── plans/ # Your plans live here +``` + +## All Agents Use Opus + +Every agent uses `claude-opus-4-5-20250514` for maximum capability. + +## Skill Index + +The orchestrator matches `skill_hints` from specs to skills in `skill-index/index.yaml`. + +Current skills include: +- Swift/iOS development (concurrency, SwiftUI, testing, debugging) +- Build and memory debugging +- Database and networking patterns +- Agent orchestration tools + +## Resume Any Session + +```bash +# Find session ID +cat .claude/vertical/plans//run/weavers/w-01.json | jq -r .session_id + +# Resume +claude --resume +``` + +## Tmux Helpers + +```bash +source lib/tmux.sh + +vertical_status # Show all plans +vertical_list_sessions # List tmux sessions +vertical_attach # Attach to session +vertical_kill_plan # Kill all sessions for a plan +``` diff --git a/commands/build.md b/commands/build.md new file mode 100644 index 0000000..0b2db91 --- /dev/null +++ b/commands/build.md @@ -0,0 +1,120 @@ +--- +description: Execute a plan by launching orchestrator and weavers in tmux. Creates PRs for each spec. +argument-hint: [spec-names...] +--- + +# /build Command + +Execute a plan. Launches orchestrator in tmux, which spawns weavers for each spec. + +## Usage + +``` +/build plan-20260119-1430 +/build plan-20260119-1430 01-schema 02-backend +``` + +## What Happens + +1. Read plan from `.claude/vertical/plans//` +2. Launch orchestrator in tmux: `vertical--orch` +3. Orchestrator reads specs, selects skills, spawns weavers +4. Each weaver runs in tmux: `vertical--w-01`, etc. +5. Weavers build, verify, create PRs +6. Results written to `.claude/vertical/plans//run/` + +## Execution Flow + +``` +/build plan-20260119-1430 + | + +-> Orchestrator (tmux: vertical-plan-20260119-1430-orch) + | + +-> Weaver 01 (tmux: vertical-plan-20260119-1430-w-01) + | | + | +-> Verifier (subagent) + | +-> PR #42 + | + +-> Weaver 02 (tmux: vertical-plan-20260119-1430-w-02) + | | + | +-> Verifier (subagent) + | +-> PR #43 + | + +-> Summary written to run/summary.md +``` + +## Parallelization + +- Independent specs (all with `pr.base: main`) run in parallel +- Dependent specs (with `pr.base: `) wait for dependencies + +## Monitoring + +Check status while running: + +``` +/status plan-20260119-1430 +``` + +Or directly: + +```bash +# List tmux sessions +tmux list-sessions | grep vertical + +# Attach to orchestrator +tmux attach -t vertical-plan-20260119-1430-orch + +# Attach to a weaver +tmux attach -t vertical-plan-20260119-1430-w-01 + +# Capture weaver output +tmux capture-pane -t vertical-plan-20260119-1430-w-01 -p +``` + +## Results + +When complete, find results at: + +- `.claude/vertical/plans//run/state.json` - Overall status +- `.claude/vertical/plans//run/summary.md` - Human-readable summary +- `.claude/vertical/plans//run/weavers/w-*.json` - Per-weaver status + +## Debugging Failures + +If a weaver fails, you can resume its session: + +```bash +# Get session ID from weaver status +cat .claude/vertical/plans//run/weavers/w-01.json | jq -r .session_id + +# Resume +claude --resume +``` + +Or attach to the tmux session if still running: + +```bash +tmux attach -t vertical--w-01 +``` + +## Killing a Build + +```bash +# Kill all sessions for a plan +source lib/tmux.sh +vertical_kill_plan plan-20260119-1430 + +# Or kill everything +vertical_kill_all +``` + +## Implementation Notes + +This command: +1. Loads orchestrator skill +2. Generates orchestrator prompt with plan context +3. Spawns tmux session with `claude -p "" --dangerously-skip-permissions --model opus` +4. Returns immediately (orchestrator runs in background) + +The orchestrator handles everything from there. diff --git a/commands/plan.md b/commands/plan.md new file mode 100644 index 0000000..94102d1 --- /dev/null +++ b/commands/plan.md @@ -0,0 +1,82 @@ +--- +description: Start an interactive planning session. Design specs through Q&A, then hand off to build. +argument-hint: [description] +--- + +# /plan Command + +Start a planning session. You become the planner agent. + +## Usage + +``` +/plan +/plan Add user authentication with OAuth +``` + +## What Happens + +1. Load the planner skill from `skills/planner/SKILL.md` +2. Generate a plan ID: `plan-YYYYMMDD-HHMMSS` +3. Create plan directory: `.claude/vertical/plans//` +4. Enter interactive planning mode + +## Planning Flow + +1. **Understand** - Ask questions until the task is crystal clear +2. **Research** - Explore the codebase, find patterns +3. **Design** - Break into specs (each = one PR) +4. **Write** - Create spec files in `specs/` directory +5. **Hand off** - Tell user to run `/build ` + +## Spec Output + +Specs go to: `.claude/vertical/plans//specs/` + +``` +01-schema.yaml +02-backend.yaml +03-frontend.yaml +``` + +## Transitioning to Build + +When specs are ready: + +``` +Specs ready. To execute: + + /build + +To execute specific specs: + + /build 01-schema 02-backend + +To check status: + + /status +``` + +## Multiple Planning Sessions + +You can run multiple planning sessions in parallel: + +``` +# Terminal 1 +/plan Add authentication + +# Terminal 2 +/plan Add payment processing +``` + +Each gets its own plan-id and can be built independently. + +## Resuming + +Planning sessions are Claude Code sessions. Resume with: + +``` +claude --resume +``` + +The session ID is saved in `.claude/vertical/plans//meta.json`. diff --git a/commands/status.md b/commands/status.md new file mode 100644 index 0000000..07ffa26 --- /dev/null +++ b/commands/status.md @@ -0,0 +1,113 @@ +--- +description: Check status of plans and weavers. Shows tmux sessions, weaver progress, and PRs. +argument-hint: [plan-id] +--- + +# /status Command + +Check the status of plans and weavers. + +## Usage + +``` +/status # All plans +/status plan-20260119-1430 # Specific plan +``` + +## Output + +### All Plans + +``` +=== Active Tmux Sessions === +vertical-plan-20260119-1430-orch +vertical-plan-20260119-1430-w-01 +vertical-plan-20260119-1430-w-02 +vertical-plan-20260119-1445-orch + +=== Plan Status === + plan-20260119-1430: running + plan-20260119-1445: running + plan-20260119-1400: complete +``` + +### Specific Plan + +``` +=== Plan: plan-20260119-1430 === +Status: running +Started: 2026-01-19T14:35:00Z + +=== Specs === + 01-schema.yaml + 02-backend.yaml + 03-frontend.yaml + +=== Weavers === + w-01 complete 01-schema.yaml https://github.com/owner/repo/pull/42 + w-02 verifying 02-backend.yaml - + w-03 waiting 03-frontend.yaml - + +=== Tmux Sessions === + vertical-plan-20260119-1430-orch running + vertical-plan-20260119-1430-w-01 done + vertical-plan-20260119-1430-w-02 running +``` + +## Weaver Statuses + +| Status | Meaning | +|--------|---------| +| waiting | Waiting for dependency | +| building | Implementing the spec | +| verifying | Running verification checks | +| fixing | Fixing verification failures | +| complete | PR created successfully | +| failed | Failed after max iterations | +| blocked | Dependency failed | + +## Quick Commands + +```bash +# Source helpers +source lib/tmux.sh + +# List all sessions +vertical_list_sessions + +# Status for all plans +vertical_status + +# Weaver status for a plan +vertical_weaver_status plan-20260119-1430 + +# Capture recent output from a weaver +vertical_capture_output vertical-plan-20260119-1430-w-01 + +# Attach to a session +vertical_attach vertical-plan-20260119-1430-w-01 +``` + +## Reading Results + +After completion: + +```bash +# Summary +cat .claude/vertical/plans/plan-20260119-1430/run/summary.md + +# State +cat .claude/vertical/plans/plan-20260119-1430/run/state.json | jq + +# Specific weaver +cat .claude/vertical/plans/plan-20260119-1430/run/weavers/w-01.json | jq +``` + +## PRs Created + +When weavers complete, PRs are listed in: +- The summary.md file +- Each weaver's status JSON (`pr` field) +- The overall state.json (`weavers..pr`) + +Merge order is indicated in summary.md for stacked PRs. diff --git a/lib/tmux.sh b/lib/tmux.sh new file mode 100644 index 0000000..dc312f2 --- /dev/null +++ b/lib/tmux.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# Tmux helper functions for claude-code-vertical +# Source this file: source lib/tmux.sh + +VERTICAL_PREFIX="vertical" + +# Generate a plan ID +vertical_plan_id() { + echo "plan-$(date +%Y%m%d-%H%M%S)" +} + +# Create plan directory structure +vertical_init_plan() { + local plan_id=$1 + local base_dir="${2:-.claude/vertical}" + + mkdir -p "${base_dir}/plans/${plan_id}/specs" + mkdir -p "${base_dir}/plans/${plan_id}/run/weavers" + + echo "${base_dir}/plans/${plan_id}" +} + +# Spawn orchestrator for a plan +vertical_spawn_orchestrator() { + local plan_id=$1 + local workdir=$2 + local prompt_file=$3 + + local session_name="${VERTICAL_PREFIX}-${plan_id}-orch" + + tmux new-session -d -s "$session_name" -c "$workdir" \ + "claude -p \"\$(cat ${prompt_file})\" --dangerously-skip-permissions --model claude-opus-4-5-20250514; echo 'Session ended. Press any key to close.'; read" + + echo "$session_name" +} + +# Spawn a weaver for a spec +vertical_spawn_weaver() { + local plan_id=$1 + local weaver_num=$2 + local workdir=$3 + local prompt_file=$4 + + local session_name="${VERTICAL_PREFIX}-${plan_id}-w-${weaver_num}" + + tmux new-session -d -s "$session_name" -c "$workdir" \ + "claude -p \"\$(cat ${prompt_file})\" --dangerously-skip-permissions --model claude-opus-4-5-20250514; echo 'Session ended. Press any key to close.'; read" + + echo "$session_name" +} + +# List all vertical sessions +vertical_list_sessions() { + tmux list-sessions 2>/dev/null | grep "^${VERTICAL_PREFIX}-" || echo "No active sessions" +} + +# List sessions for a specific plan +vertical_list_plan_sessions() { + local plan_id=$1 + tmux list-sessions 2>/dev/null | grep "^${VERTICAL_PREFIX}-${plan_id}" || echo "No sessions for ${plan_id}" +} + +# Check if a session is still running +vertical_session_alive() { + local session_name=$1 + tmux has-session -t "$session_name" 2>/dev/null && echo "running" || echo "done" +} + +# Capture recent output from a session +vertical_capture_output() { + local session_name=$1 + local lines=${2:-50} + + tmux capture-pane -t "$session_name" -p -S "-${lines}" 2>/dev/null +} + +# Attach to a session interactively +vertical_attach() { + local session_name=$1 + tmux attach -t "$session_name" +} + +# Kill a single session +vertical_kill_session() { + local session_name=$1 + tmux kill-session -t "$session_name" 2>/dev/null +} + +# Kill all sessions for a plan +vertical_kill_plan() { + local plan_id=$1 + + # Kill orchestrator + tmux kill-session -t "${VERTICAL_PREFIX}-${plan_id}-orch" 2>/dev/null + + # Kill all weavers + for sess in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${VERTICAL_PREFIX}-${plan_id}-w-"); do + tmux kill-session -t "$sess" 2>/dev/null + done + + echo "Killed all sessions for ${plan_id}" +} + +# Kill all vertical sessions +vertical_kill_all() { + for sess in $(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep "^${VERTICAL_PREFIX}-"); do + tmux kill-session -t "$sess" 2>/dev/null + done + + echo "Killed all vertical sessions" +} + +# Get status of all plans +vertical_status() { + local base_dir="${1:-.claude/vertical}" + + echo "=== Active Tmux Sessions ===" + vertical_list_sessions + echo "" + + echo "=== Plan Status ===" + if [ -d "${base_dir}/plans" ]; then + for plan_dir in "${base_dir}/plans"/*/; do + if [ -d "$plan_dir" ]; then + local plan_id=$(basename "$plan_dir") + local state_file="${plan_dir}run/state.json" + + if [ -f "$state_file" ]; then + local status=$(jq -r '.status // "unknown"' "$state_file" 2>/dev/null) + echo " ${plan_id}: ${status}" + else + local meta_file="${plan_dir}meta.json" + if [ -f "$meta_file" ]; then + echo " ${plan_id}: ready (not started)" + else + echo " ${plan_id}: incomplete" + fi + fi + fi + done + else + echo " No plans found" + fi +} + +# Get weaver status for a plan +vertical_weaver_status() { + local plan_id=$1 + local base_dir="${2:-.claude/vertical}" + local weavers_dir="${base_dir}/plans/${plan_id}/run/weavers" + + if [ ! -d "$weavers_dir" ]; then + echo "No weaver data for ${plan_id}" + return + fi + + echo "=== Weavers for ${plan_id} ===" + for weaver_file in "${weavers_dir}"/*.json; do + if [ -f "$weaver_file" ]; then + local weaver_name=$(basename "$weaver_file" .json) + local status=$(jq -r '.status // "unknown"' "$weaver_file" 2>/dev/null) + local spec=$(jq -r '.spec // "?"' "$weaver_file" 2>/dev/null) + local pr=$(jq -r '.pr // "-"' "$weaver_file" 2>/dev/null) + + printf " %-10s %-15s %-30s %s\n" "$weaver_name" "$status" "$spec" "$pr" + fi + done +} + +# Generate weaver prompt file +vertical_generate_weaver_prompt() { + local output_file=$1 + local weaver_base_skill=$2 + local spec_file=$3 + local additional_skills=$4 # space-separated list of skill files + + cat > "$output_file" << 'PROMPT_HEADER' +You are a weaver agent. Execute the spec below. + + +PROMPT_HEADER + + cat "$weaver_base_skill" >> "$output_file" + + cat >> "$output_file" << 'PROMPT_MID1' + + + +PROMPT_MID1 + + cat "$spec_file" >> "$output_file" + + cat >> "$output_file" << 'PROMPT_MID2' + + +PROMPT_MID2 + + if [ -n "$additional_skills" ]; then + echo "" >> "$output_file" + for skill_file in $additional_skills; do + if [ -f "$skill_file" ]; then + echo "--- $(basename "$skill_file") ---" >> "$output_file" + cat "$skill_file" >> "$output_file" + echo "" >> "$output_file" + fi + done + echo "" >> "$output_file" + fi + + cat >> "$output_file" << 'PROMPT_FOOTER' + +Execute the spec now. Spawn verifier when implementation is complete. Create PR when verification passes. +PROMPT_FOOTER + + echo "$output_file" +} diff --git a/skill-index/index.yaml b/skill-index/index.yaml new file mode 100644 index 0000000..a308dd4 --- /dev/null +++ b/skill-index/index.yaml @@ -0,0 +1,282 @@ +# Skill Index +# Orchestrator uses this to match skill_hints from specs to actual skills +# +# Format: +# id: unique identifier (matches directory name) +# path: relative path to SKILL.md +# description: what the skill does +# triggers: keywords that activate this skill +# domains: broad categories + +skills: + # === iOS/Swift Development === + + - id: axiom-swift-concurrency + path: skill-index/skills/axiom-swift-concurrency/SKILL.md + description: "Swift 6 strict concurrency patterns with actor isolation and async/await" + triggers: + - actor-isolated + - Sendable + - data race + - MainActor + - async/await + - swift concurrency + - thread safe + domains: + - ios + - swift + - concurrency + + - id: axiom-swiftui-performance + path: skill-index/skills/axiom-swiftui-performance/SKILL.md + description: "SwiftUI performance optimization, view identity, and rendering efficiency" + triggers: + - swiftui slow + - view redraws + - swiftui performance + - body called + - swiftui optimization + domains: + - ios + - swiftui + - performance + + - id: axiom-swiftui-debugging + path: skill-index/skills/axiom-swiftui-debugging/SKILL.md + description: "SwiftUI debugging techniques and common issue resolution" + triggers: + - swiftui not updating + - view not refreshing + - swiftui bug + - swiftui debug + domains: + - ios + - swiftui + - debugging + + - id: axiom-swiftui-architecture + path: skill-index/skills/axiom-swiftui-architecture/SKILL.md + description: "SwiftUI app architecture patterns and best practices" + triggers: + - swiftui architecture + - swiftui patterns + - view model + - observable + domains: + - ios + - swiftui + - architecture + + - id: axiom-swift-performance + path: skill-index/skills/axiom-swift-performance/SKILL.md + description: "Swift performance optimization and profiling" + triggers: + - swift slow + - swift performance + - optimize swift + - profiling + domains: + - ios + - swift + - performance + + - id: axiom-swift-testing + path: skill-index/skills/axiom-swift-testing/SKILL.md + description: "Swift Testing framework patterns and best practices" + triggers: + - swift testing + - unit test + - xctest + - test swift + domains: + - ios + - swift + - testing + + - id: axiom-xcode-debugging + path: skill-index/skills/axiom-xcode-debugging/SKILL.md + description: "Xcode debugging, build issues, and environment troubleshooting" + triggers: + - xcode error + - build failed + - xcode debug + - simulator + - provisioning + domains: + - ios + - xcode + - debugging + + - id: axiom-build-debugging + path: skill-index/skills/axiom-build-debugging/SKILL.md + description: "Build system debugging, SPM conflicts, dependency issues" + triggers: + - build error + - spm conflict + - dependency + - linker error + - module not found + domains: + - ios + - build + - debugging + + - id: axiom-memory-debugging + path: skill-index/skills/axiom-memory-debugging/SKILL.md + description: "Memory leak detection, retain cycles, and memory optimization" + triggers: + - memory leak + - retain cycle + - memory warning + - heap + - instruments memory + domains: + - ios + - memory + - debugging + + - id: axiom-networking + path: skill-index/skills/axiom-networking/SKILL.md + description: "Network.framework patterns and URLSession best practices" + triggers: + - networking + - urlsession + - api call + - http request + - network error + domains: + - ios + - networking + + - id: axiom-swiftdata + path: skill-index/skills/axiom-swiftdata/SKILL.md + description: "SwiftData persistence patterns and best practices" + triggers: + - swiftdata + - modelcontainer + - modelcontext + - persistence + domains: + - ios + - data + - persistence + + - id: axiom-database-migration + path: skill-index/skills/axiom-database-migration/SKILL.md + description: "Safe database schema migrations for SwiftData, Core Data, GRDB" + triggers: + - migration + - schema change + - database upgrade + - model version + domains: + - ios + - data + - migration + + - id: axiom-grdb + path: skill-index/skills/axiom-grdb/SKILL.md + description: "GRDB SQLite patterns and best practices" + triggers: + - grdb + - sqlite + - database query + - sql + domains: + - ios + - data + - sqlite + + - id: axiom-ui-testing + path: skill-index/skills/axiom-ui-testing/SKILL.md + description: "UI testing with XCUITest and accessibility identifiers" + triggers: + - ui test + - xcuitest + - automation + - accessibility identifier + - ui recording + domains: + - ios + - testing + - ui + + # === General Development === + + - id: coding-agent + path: skill-index/skills/coding-agent/SKILL.md + description: "Run Claude Code, Codex, or other coding agents in background" + triggers: + - spawn agent + - background agent + - coding agent + - codex + domains: + - orchestration + - agents + + - id: tmux + path: skill-index/skills/tmux/SKILL.md + description: "Remote-control tmux sessions for interactive CLIs" + triggers: + - tmux + - terminal session + - pane + - session management + domains: + - terminal + - orchestration + + # === Communication === + + - id: slack + path: skill-index/skills/slack/SKILL.md + description: "Slack messaging and workspace integration" + triggers: + - slack + - message slack + - slack channel + domains: + - communication + + # === Utilities === + + - id: summarize + path: skill-index/skills/summarize/SKILL.md + description: "Summarize URLs, podcasts, and documents" + triggers: + - summarize + - transcript + - tldr + domains: + - utility + + - id: oracle + path: skill-index/skills/oracle/SKILL.md + description: "Oracle CLI for prompt bundling and file attachments" + triggers: + - oracle + - prompt bundle + domains: + - utility + + - id: clawdhub + path: skill-index/skills/clawdhub/SKILL.md + description: "Search and install agent skills from clawdhub.com" + triggers: + - clawdhub + - install skill + - skill registry + domains: + - skills + - registry + + - id: bird + path: skill-index/skills/bird/SKILL.md + description: "X/Twitter CLI for reading, searching, and posting" + triggers: + - twitter + - tweet + - x post + domains: + - social diff --git a/skill-index/skills/axiom-build-debugging/SKILL.md b/skill-index/skills/axiom-build-debugging/SKILL.md new file mode 100644 index 0000000..8a0460c --- /dev/null +++ b/skill-index/skills/axiom-build-debugging/SKILL.md @@ -0,0 +1,513 @@ +--- +name: axiom-build-debugging +description: Use when encountering dependency conflicts, CocoaPods/SPM resolution failures, "Multiple commands produce" errors, or framework version mismatches - systematic dependency and build configuration debugging for iOS projects. Includes pressure scenario guidance for resisting quick fixes under time constraints +skill_type: discipline +version: 1.1.0 +last_updated: TDD-tested with production crisis scenarios +--- + +# Build Debugging + +## Overview + +Check dependencies BEFORE blaming code. **Core principle** 80% of persistent build failures are dependency resolution issues (CocoaPods, SPM, framework conflicts), not code bugs. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I added a Swift Package but I'm getting 'No such module' errors. The package is in my Xcode project but won't compile." +→ The skill covers SPM resolution workflows, package cache clearing, and framework search path diagnostics + +#### 2. "The build is failing with 'Multiple commands produce' the same output file. How do I figure out which files are duplicated?" +→ The skill shows how to identify duplicate target membership and resolve file conflicts in build settings + +#### 3. "CocoaPods installed dependencies successfully but the build still fails. How do I debug CocoaPods issues?" +→ The skill covers Podfile.lock conflict resolution, linking errors, and version constraint debugging + +#### 4. "My build works on my Mac but fails on the CI server. Both machines have the latest Xcode. What's different?" +→ The skill explains dependency caching differences, environment-specific paths, and reproducible build strategies + +#### 5. "I'm getting framework version conflicts and I don't know which dependency is causing it. How do I resolve this?" +→ The skill demonstrates dependency graph analysis and version constraint resolution strategies for complex dependency trees + +--- + +## Red Flags — Dependency/Build Issues + +If you see ANY of these, suspect dependency problem: +- "No such module" after adding package +- "Multiple commands produce" same output file +- Build succeeds on one machine, fails on another +- CocoaPods install succeeds but build fails +- SPM resolution takes forever or times out +- Framework version conflicts in error logs + +## Quick Decision Tree + +``` +Build failing? +├─ "No such module XYZ"? +│ ├─ After adding SPM package? +│ │ └─ Clean build folder + reset package caches +│ ├─ After pod install? +│ │ └─ Check Podfile.lock conflicts +│ └─ Framework not found? +│ └─ Check FRAMEWORK_SEARCH_PATHS +├─ "Multiple commands produce"? +│ └─ Duplicate files in target membership +├─ SPM resolution hangs? +│ └─ Clear package caches + derived data +└─ Version conflicts? + └─ Use dependency resolution strategies below +``` + +## Common Build Issues + +### Issue 1: SPM Package Not Found + +**Symptom**: "No such module PackageName" after adding Swift Package + +**❌ WRONG**: +```bash +# Rebuilding without cleaning +xcodebuild build +``` + +**✅ CORRECT**: +```bash +# Reset package caches first +rm -rf ~/Library/Developer/Xcode/DerivedData +rm -rf ~/Library/Caches/org.swift.swiftpm + +# Reset packages in project +xcodebuild -resolvePackageDependencies + +# Clean build +xcodebuild clean build -scheme YourScheme +``` + +### Issue 2: CocoaPods Conflicts + +**Symptom**: Pod install succeeds but build fails with framework errors + +**Check Podfile.lock**: +```bash +# See what versions were actually installed +cat Podfile.lock | grep -A 2 "PODS:" + +# Compare with Podfile requirements +cat Podfile | grep "pod " +``` + +**Fix version conflicts**: +```ruby +# Podfile - be explicit about versions +pod 'Alamofire', '~> 5.8.0' # Not just 'Alamofire' +pod 'SwiftyJSON', '5.0.1' # Exact version if needed +``` + +**Clean reinstall**: +```bash +# Remove all pods +rm -rf Pods/ +rm Podfile.lock + +# Reinstall +pod install + +# Open workspace (not project!) +open YourApp.xcworkspace +``` + +### Issue 3: Multiple Commands Produce Error + +**Symptom**: "Multiple commands produce '/path/to/file'" + +**Cause**: Same file added to multiple targets or build phases + +**Fix**: +1. Open Xcode +2. Select file in navigator +3. File Inspector → Target Membership +4. Uncheck duplicate targets +5. Or: Build Phases → Copy Bundle Resources → remove duplicates + +### Issue 4: Framework Search Paths + +**Symptom**: "Framework not found" or "Linker command failed" + +**Check build settings**: +```bash +# Show all build settings +xcodebuild -showBuildSettings -scheme YourScheme | grep FRAMEWORK_SEARCH_PATHS +``` + +**Fix in Xcode**: +1. Target → Build Settings +2. Search "Framework Search Paths" +3. Add path: `$(PROJECT_DIR)/Frameworks` (recursive) +4. Or: `$(inherited)` to inherit from project + +### Issue 5: SPM Version Conflicts + +**Symptom**: Package resolution fails with version conflicts + +**See dependency graph**: +```bash +# In project directory +swift package show-dependencies + +# Or see resolved versions +cat Package.resolved +``` + +**Fix conflicts**: +```swift +// Package.swift - be explicit +.package(url: "https://github.com/owner/repo", exact: "1.2.3") // Exact version +.package(url: "https://github.com/owner/repo", from: "1.2.0") // Minimum version +.package(url: "https://github.com/owner/repo", .upToNextMajor(from: "1.0.0")) // SemVer +``` + +**Reset resolution**: +```bash +# Clear package caches +rm -rf .build +rm Package.resolved + +# Re-resolve +swift package resolve +``` + +## Dependency Resolution Strategies + +### Strategy 1: Lock to Specific Versions + +When stability matters more than latest features: + +**CocoaPods**: +```ruby +pod 'Alamofire', '5.8.0' # Exact version +pod 'SwiftyJSON', '~> 5.0.0' # Any 5.0.x +``` + +**SPM**: +```swift +.package(url: "...", exact: "1.2.3") +``` + +### Strategy 2: Use Version Ranges + +When you want bug fixes but not breaking changes: + +**CocoaPods**: +```ruby +pod 'Alamofire', '~> 5.8' # 5.8.x but not 5.9 +pod 'SwiftyJSON', '>= 5.0', '< 6.0' # Range +``` + +**SPM**: +```swift +.package(url: "...", from: "1.2.0") // 1.2.0 and higher +.package(url: "...", .upToNextMajor(from: "1.0.0")) // 1.x.x but not 2.0.0 +``` + +### Strategy 3: Fork and Pin + +When you need custom modifications: + +```bash +# Fork repo on GitHub +# Clone your fork +git clone https://github.com/yourname/package.git + +# In Package.swift, use your fork +.package(url: "https://github.com/yourname/package", branch: "custom-fixes") +``` + +### Strategy 4: Exclude Transitive Dependencies + +When a dependency's dependency conflicts: + +**SPM (not directly supported, use workarounds)**: +```swift +// Instead of this: +.package(url: "https://github.com/problematic/package") + +// Fork it and remove the conflicting dependency from its Package.swift +``` + +**CocoaPods**: +```ruby +# Exclude specific subspecs +pod 'Firebase/Core' # Not all of Firebase +pod 'Firebase/Analytics' +``` + +## Build Configuration Issues + +### Debug vs Release Differences + +**Symptom**: Builds in Debug, fails in Release (or vice versa) + +**Check optimization settings**: +```bash +# Compare Debug and Release settings +xcodebuild -showBuildSettings -configuration Debug > debug.txt +xcodebuild -showBuildSettings -configuration Release > release.txt +diff debug.txt release.txt +``` + +**Common culprits**: +- SWIFT_OPTIMIZATION_LEVEL (-Onone vs -O) +- ENABLE_TESTABILITY (YES in Debug, NO in Release) +- DEBUG preprocessor flag +- Code signing settings + +### Workspace vs Project + +**Always open workspace with CocoaPods**: +```bash +# ❌ WRONG +open YourApp.xcodeproj + +# ✅ CORRECT +open YourApp.xcworkspace +``` + +**Check which you're building**: +```bash +# For workspace +xcodebuild -workspace YourApp.xcworkspace -scheme YourScheme build + +# For project only (no CocoaPods) +xcodebuild -project YourApp.xcodeproj -scheme YourScheme build +``` + +## Pressure Scenarios: When to Resist "Quick Fix" Advice + +### The Problem + +Under deadline pressure, senior engineers and teammates provide "quick fixes" based on pattern-matching: +- "Just regenerate the lock file" +- "Increment the build number" +- "Delete DerivedData and rebuild" + +These feel safe because they come from experience. **But if the diagnosis is wrong, the fix wastes time you don't have.** + +**Critical insight** Time pressure makes authority bias STRONGER. You're more likely to trust advice when stressed. + +### Red Flags — STOP Before Acting + +If you hear ANY of these, pause 5 minutes before executing: + +- ❌ **"This smells like..."** (pattern-matching, not diagnosis) +- ❌ **"Just..."** (underestimating complexity) +- ❌ **"This usually fixes it"** (worked once ≠ works always) +- ❌ **"You have plenty of time"** (overconfidence about 24-hour turnaround) +- ❌ **"This is safe"** (regenerating lock files CAN break things) + +**Your brain under pressure** Trusts these phrases because they sound confident. Doesn't ask "but do they have evidence THIS is the root cause?" + +### Mandatory Diagnosis Before "Quick Fix" + +When someone senior suggests a fix under time pressure: + +#### Step 1: Ask (Don't argue) +``` +"I understand the pressure. Before we regenerate lock files, +can we spend 5 minutes comparing the broken build to our +working build? I want to know what we're fixing." +``` + +#### Step 2: Demand Evidence +- "What makes you think it's a lock file issue?" +- "What changed between our last successful build and this failure?" +- "Can we see the actual error from App Store build vs our build?" + +#### Step 3: Document the Gamble +``` +If we try "pod install": +- Time to execute: 10 minutes +- Time to learn it failed: 24 hours (next submission cycle) +- Remaining time if it fails: 6 days +- Alternative: Spend 1-2 hours diagnosing first + +Cost of being wrong with quick fix: High +Cost of spending 1 hour on diagnosis: Low +``` + +#### Step 4: Push Back Professionally +``` +"I want to move fast too. A 1-hour diagnosis now means we +won't waste another 24-hour cycle. Let's document what we're +testing before we submit." +``` + +#### Why this works +- You're not questioning their expertise +- You're asking for evidence (legitimate request) +- You're showing you understand the pressure +- You're making the time math visible + +### Real-World Example: App Store Review Blocker + +**Scenario** App rejected in App Store build, passes locally. + +**Senior says** "Regenerate lock file and resubmit (7 days buffer)" + +#### What you do +1. ❌ WRONG: Execute immediately, fail after 24 hours, now 6 days left +2. ✅ RIGHT: Spend 1 hour comparing builds first + +#### Comparison checklist +``` +Local build that works: +- Pod versions in Podfile.lock: [list them] +- Xcode version: [version] +- Derived Data: [timestamp] +- CocoaPods version: [version] + +App Store build that fails: +- Pod versions used: [from error message] +- Build system: [App Store's environment] +- Differences: [explicitly document] +``` + +#### After comparison +- If versions match: Lock file isn't the issue. Skip the quick fix. +- If versions differ: Now you understand what to fix. + +**Time saved** 24 hours of wasted iteration. + +### When to Trust Quick Fixes (Rare) + +Quick fixes are safe ONLY when: + +- [ ] You've seen this EXACT error before (not "similar") +- [ ] You know the root cause (not "this usually works") +- [ ] You can reproduce it locally (so you know if fix worked) +- [ ] You have >48 hours buffer (so failure costs less) +- [ ] You documented the fix in case you need to explain it later + +#### In production crises, NONE of these are usually true. + +--- + +## Testing Checklist + +### When Adding Dependencies +- [ ] Specify exact versions or ranges (not just latest) +- [ ] Check for known conflicts with existing deps +- [ ] Test clean build after adding +- [ ] Commit lockfile (Podfile.lock or Package.resolved) + +### When Builds Fail +- [ ] Run mandatory environment checks (xcode-debugging skill) +- [ ] Check dependency lockfiles for changes +- [ ] Verify using correct workspace/project file +- [ ] Compare working vs broken build settings + +### Before Shipping +- [ ] Test both Debug and Release builds +- [ ] Verify all dependencies have compatible licenses +- [ ] Check binary size impact of dependencies +- [ ] Test on clean machine or CI + +## Common Mistakes + +### ❌ Not Committing Lockfiles +```bash +# ❌ BAD: .gitignore includes lockfiles +Podfile.lock +Package.resolved +``` + +**Why**: Team members get different versions, builds differ + +### ❌ Using "Latest" Version +```ruby +# ❌ BAD: No version specified +pod 'Alamofire' +``` + +**Why**: Breaking changes when dependency updates + +### ❌ Mixing Package Managers +``` +Project uses both: +- CocoaPods (Podfile) +- Carthage (Cartfile) +- SPM (Package.swift) +``` + +**Why**: Conflicts are inevitable, pick one primary manager + +### ❌ Not Cleaning After Dependency Changes +```bash +# ❌ BAD: Just rebuild +xcodebuild build + +# ✅ GOOD: Clean first +xcodebuild clean build +``` + +### ❌ Opening Project Instead of Workspace +When using CocoaPods, always open .xcworkspace not .xcodeproj + +## Command Reference + +```bash +# CocoaPods +pod install # Install dependencies +pod update # Update to latest versions +pod update PodName # Update specific pod +pod outdated # Check for updates +pod deintegrate # Remove CocoaPods from project + +# Swift Package Manager +swift package resolve # Resolve dependencies +swift package update # Update dependencies +swift package show-dependencies # Show dependency tree +swift package reset # Reset package cache +xcodebuild -resolvePackageDependencies # Xcode's SPM resolve + +# Carthage +carthage update # Update dependencies +carthage bootstrap # Download pre-built frameworks +carthage build --platform iOS # Build for specific platform + +# Xcode Build +xcodebuild clean # Clean build folder +xcodebuild -list # List schemes and targets +xcodebuild -showBuildSettings # Show all build settings +``` + +## Real-World Impact + +**Before** (trial-and-error with dependencies): +- Dependency issue: 2-4 hours debugging +- Clean builds not run consistently +- Version conflicts surprise team +- CI failures from dependency mismatches + +**After** (systematic dependency management): +- Dependency issue: 15-30 minutes (check lockfile → resolve) +- Clean builds mandatory after dep changes +- Explicit version constraints prevent surprises +- CI matches local builds (committed lockfiles) + +**Key insight** Lock down dependency versions early. Flexibility causes more problems than it solves. + +## Resources + +**Docs**: swift.org/package-manager, /xcode/build-system + +**GitHub**: Carthage/Carthage + +**Skills**: axiom-xcode-debugging + +--- + +**History:** See git log for changes diff --git a/skill-index/skills/axiom-database-migration/SKILL.md b/skill-index/skills/axiom-database-migration/SKILL.md new file mode 100644 index 0000000..f3085ce --- /dev/null +++ b/skill-index/skills/axiom-database-migration/SKILL.md @@ -0,0 +1,430 @@ +--- +name: axiom-database-migration +description: Use when adding/modifying database columns, encountering "FOREIGN KEY constraint failed", "no such column", "cannot add NOT NULL column" errors, or creating schema migrations for SQLite/GRDB/SQLiteData - prevents data loss with safe migration patterns and testing workflows for iOS/macOS apps +skill_type: discipline +version: 1.0.0 +--- + +# Database Migration + +## Overview + +Safe database schema evolution for production apps with user data. **Core principle** Migrations are immutable after shipping. Make them additive, idempotent, and thoroughly tested. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I need to add a new column to store user preferences, but the app is already live with user data. How do I do this safely?" +→ The skill covers safe additive patterns for adding columns without losing existing data, including idempotency checks + +#### 2. "I'm getting 'cannot add NOT NULL column' errors when I try to migrate. What does this mean and how do I fix it?" +→ The skill explains why NOT NULL columns fail with existing rows, and shows the safe pattern (nullable first, backfill later) + +#### 3. "I need to change a column from text to integer. Can I just ALTER the column type?" +→ The skill demonstrates the safe pattern: add new column → migrate data → deprecate old (NEVER delete) + +#### 4. "I'm adding a foreign key relationship between tables. How do I add the relationship without breaking existing data?" +→ The skill covers safe foreign key patterns: add column → populate data → add index (SQLite limitations explained) + +#### 5. "Users are reporting crashes after the last update. I changed a migration but the app is already in production. What do I do?" +→ The skill explains migrations are immutable after shipping; shows how to create a new migration to fix the issue rather than modifying the old one + +--- + +## ⛔ NEVER Do These (Data Loss Risk) + +#### These actions DESTROY user data in production + +❌ **NEVER use DROP TABLE** with user data +❌ **NEVER modify shipped migrations** (create new one instead) +❌ **NEVER recreate tables** to change schema (loses data) +❌ **NEVER add NOT NULL column** without DEFAULT value +❌ **NEVER delete columns** (SQLite doesn't support DROP COLUMN safely) + +#### If you're tempted to do any of these, STOP and use the safe patterns below. + +## Mandatory Rules + +#### ALWAYS follow these + +1. **Additive only** Add new columns/tables, never delete +2. **Idempotent** Check existence before creating (safe to run twice) +3. **Transactional** Wrap entire migration in single transaction +4. **Test both paths** Fresh install AND migration from previous version +5. **Nullable first** Add columns as NULL, backfill later if needed +6. **Immutable** Once shipped to users, migrations cannot be changed + +## Safe Patterns + +### Adding Column (Most Common) + +```swift +// ✅ Safe pattern +func migration00X_AddNewColumn() throws { + try database.write { db in + // 1. Check if column exists (idempotency) + let hasColumn = try db.columns(in: "tableName") + .contains { $0.name == "newColumn" } + + if !hasColumn { + // 2. Add as nullable (works with existing rows) + try db.execute(sql: """ + ALTER TABLE tableName + ADD COLUMN newColumn TEXT + """) + } + } +} +``` + +#### Why this works +- Nullable columns don't require DEFAULT +- Existing rows get NULL automatically +- No data transformation needed +- Safe for users upgrading from old versions + +### Adding Column with Default Value + +```swift +// ✅ Safe pattern with default +func migration00X_AddColumnWithDefault() throws { + try database.write { db in + let hasColumn = try db.columns(in: "tracks") + .contains { $0.name == "playCount" } + + if !hasColumn { + try db.execute(sql: """ + ALTER TABLE tracks + ADD COLUMN playCount INTEGER DEFAULT 0 + """) + } + } +} +``` + +### Changing Column Type (Advanced) + +**Pattern**: Add new column → migrate data → deprecate old (NEVER delete) + +```swift +// ✅ Safe pattern for type change +func migration00X_ChangeColumnType() throws { + try database.write { db in + // Step 1: Add new column with new type + try db.execute(sql: """ + ALTER TABLE users + ADD COLUMN age_new INTEGER + """) + + // Step 2: Migrate existing data + try db.execute(sql: """ + UPDATE users + SET age_new = CAST(age_old AS INTEGER) + WHERE age_old IS NOT NULL + """) + + // Step 3: Application code uses age_new going forward + // (Never delete age_old column - just stop using it) + } +} +``` + +### Adding Foreign Key Constraint + +```swift +// ✅ Safe pattern for foreign keys +func migration00X_AddForeignKey() throws { + try database.write { db in + // Step 1: Add new column (nullable initially) + try db.execute(sql: """ + ALTER TABLE tracks + ADD COLUMN album_id TEXT + """) + + // Step 2: Populate the data + try db.execute(sql: """ + UPDATE tracks + SET album_id = ( + SELECT id FROM albums + WHERE albums.title = tracks.album_name + ) + """) + + // Step 3: Add index (helps query performance) + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS idx_tracks_album_id + ON tracks(album_id) + """) + + // Note: SQLite doesn't allow adding FK constraints to existing tables + // The foreign key relationship is enforced at the application level + } +} +``` + +### Complex Schema Refactoring + +**Pattern**: Break into multiple migrations + +```swift +// Migration 1: Add new structure +func migration010_AddNewTable() throws { + try database.write { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS new_structure ( + id TEXT PRIMARY KEY, + data TEXT + ) + """) + } +} + +// Migration 2: Copy data +func migration011_MigrateData() throws { + try database.write { db in + try db.execute(sql: """ + INSERT INTO new_structure (id, data) + SELECT id, data FROM old_structure + """) + } +} + +// Migration 3: Add indexes +func migration012_AddIndexes() throws { + try database.write { db in + try db.execute(sql: """ + CREATE INDEX IF NOT EXISTS idx_new_structure_data + ON new_structure(data) + """) + } +} + +// Old structure stays around (deprecated in code) +``` + +## Testing Checklist + +#### BEFORE deploying any migration + +```swift +// Test 1: Migration path (CRITICAL - tests data preservation) +@Test func migrationFromV1ToV2Succeeds() async throws { + let db = try Database(inMemory: true) + + // Simulate v1 schema + try db.write { db in + try db.execute(sql: "CREATE TABLE tableName (id TEXT PRIMARY KEY)") + try db.execute(sql: "INSERT INTO tableName (id) VALUES ('test1')") + } + + // Run v2 migration + try db.runMigrations() + + // Verify data survived + new column exists + try db.read { db in + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName") + #expect(count == 1) // Data preserved + + let columns = try db.columns(in: "tableName").map { $0.name } + #expect(columns.contains("newColumn")) // New column exists + } +} +``` + +**Test 2** Fresh install (run all migrations, verify final schema) +```swift +@Test func freshInstallCreatesCorrectSchema() async throws { + let db = try Database(inMemory: true) + + // Run all migrations + try db.runMigrations() + + // Verify final schema + try db.read { db in + let tables = try db.tables() + #expect(tables.contains("tableName")) + + let columns = try db.columns(in: "tableName").map { $0.name } + #expect(columns.contains("id")) + #expect(columns.contains("newColumn")) + } +} +``` + +**Test 3** Idempotency (run migrations twice, should not throw) +```swift +@Test func migrationsAreIdempotent() async throws { + let db = try Database(inMemory: true) + + // Run migrations twice + try db.runMigrations() + try db.runMigrations() // Should not throw + + // Verify still correct + try db.read { db in + let count = try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tableName") + #expect(count == 0) // No duplicate data + } +} +``` + +#### Manual testing (before TestFlight) +1. Install v(n-1) build on device → add real user data +2. Install v(n) build (with new migration) +3. Verify: App launches, data visible, no crashes + +## Decision Tree + +``` +What are you trying to do? +├─ Add new column? +│ └─ ALTER TABLE ADD COLUMN (nullable) → Done +├─ Add column with default? +│ └─ ALTER TABLE ADD COLUMN ... DEFAULT value → Done +├─ Change column type? +│ └─ Add new column → Migrate data → Deprecate old → Done +├─ Delete column? +│ └─ Mark as deprecated in code → Never delete from schema → Done +├─ Rename column? +│ └─ Add new column → Migrate data → Deprecate old → Done +├─ Add foreign key? +│ └─ Add column → Populate data → Add index → Done +└─ Complex refactor? + └─ Break into multiple migrations → Test each step → Done +``` + +## Common Errors + +| Error | Fix | +|-------|-----| +| `FOREIGN KEY constraint failed` | Check parent row exists, or disable FK temporarily | +| `no such column: columnName` | Add migration to create column | +| `cannot add NOT NULL column` | Use nullable column first, backfill in separate migration | +| `table tableName already exists` | Add `IF NOT EXISTS` clause | +| `duplicate column name` | Check if column exists before adding (idempotency) | + +## Common Mistakes + +❌ **Adding NOT NULL without DEFAULT** +```swift +// ❌ Fails on existing data +ALTER TABLE albums ADD COLUMN rating INTEGER NOT NULL +``` + +✅ **Correct: Add as nullable first** +```swift +ALTER TABLE albums ADD COLUMN rating INTEGER // NULL allowed +// Backfill in separate migration if needed +UPDATE albums SET rating = 0 WHERE rating IS NULL +``` + +❌ **Forgetting to check for existence** — Always add `IF NOT EXISTS` or manual check + +❌ **Modifying shipped migrations** — Create new migration instead + +❌ **Not testing migration path** — Always test upgrade from previous version + +## GRDB-Specific Patterns + +### DatabaseMigrator Setup + +```swift +var migrator = DatabaseMigrator() + +// Migration 1 +migrator.registerMigration("v1") { db in + try db.execute(sql: """ + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL + ) + """) +} + +// Migration 2 +migrator.registerMigration("v2") { db in + let hasColumn = try db.columns(in: "users") + .contains { $0.name == "email" } + + if !hasColumn { + try db.execute(sql: """ + ALTER TABLE users + ADD COLUMN email TEXT + """) + } +} + +// Apply migrations +try migrator.migrate(dbQueue) +``` + +### Checking Migration Status + +```swift +// Check which migrations have been applied +let appliedMigrations = try dbQueue.read { db in + try migrator.appliedMigrations(db) +} +print("Applied migrations: \(appliedMigrations)") + +// Check if migrations are needed +let hasBeenMigrated = try dbQueue.read { db in + try migrator.hasBeenMigrated(db) +} +``` + +## SwiftData Migrations + +For SwiftData (iOS 17+), use `VersionedSchema` and `SchemaMigrationPlan`: + +```swift +// Define schema versions +enum MyAppSchemaV1: VersionedSchema { + static var versionIdentifier = Schema.Version(1, 0, 0) + static var models: [any PersistentModel.Type] { + [Track.self, Album.self] + } +} + +enum MyAppSchemaV2: VersionedSchema { + static var versionIdentifier = Schema.Version(2, 0, 0) + static var models: [any PersistentModel.Type] { + [Track.self, Album.self, Playlist.self] // Added Playlist + } +} + +// Define migration plan +enum MyAppMigrationPlan: SchemaMigrationPlan { + static var schemas: [any VersionedSchema.Type] { + [MyAppSchemaV1.self, MyAppSchemaV2.self] + } + + static var stages: [MigrationStage] { + [migrateV1toV2] + } + + static let migrateV1toV2 = MigrationStage.custom( + fromVersion: MyAppSchemaV1.self, + toVersion: MyAppSchemaV2.self, + willMigrate: nil, + didMigrate: { context in + // Custom migration logic here + } + ) +} +``` + +## Real-World Impact + +**Before** Developer adds NOT NULL column → migration fails for 50% of users → emergency rollback → data inconsistency + +**After** Developer adds nullable column → tests both paths → smooth deployment → backfills data in v2 + +**Key insight** Migrations can't be rolled back in production. Get them right the first time through thorough testing. + +--- + +**Last Updated**: 2025-11-28 +**Frameworks**: SQLite, GRDB, SwiftData +**Status**: Production-ready patterns for safe schema evolution diff --git a/skill-index/skills/axiom-grdb/SKILL.md b/skill-index/skills/axiom-grdb/SKILL.md new file mode 100644 index 0000000..ccb8297 --- /dev/null +++ b/skill-index/skills/axiom-grdb/SKILL.md @@ -0,0 +1,670 @@ +--- +name: axiom-grdb +description: Use when writing raw SQL queries with GRDB, complex joins, ValueObservation for reactive queries, DatabaseMigrator patterns, query profiling under performance pressure, or dropping down from SQLiteData for performance - direct SQLite access for iOS/macOS +skill_type: discipline +version: 1.1.0 +last_updated: TDD-tested with complex query performance scenarios +--- + +# GRDB + +## Overview + +Direct SQLite access using [GRDB.swift](https://github.com/groue/GRDB.swift) — a toolkit for SQLite databases with type-safe queries, migrations, and reactive observation. + +**Core principle** Type-safe Swift wrapper around raw SQL with full SQLite power when you need it. + +**Requires** iOS 13+, Swift 5.7+ +**License** MIT (free and open source) + +## When to Use GRDB + +#### Use raw GRDB when you need +- ✅ Complex SQL joins across 4+ tables +- ✅ Window functions (ROW_NUMBER, RANK, LAG/LEAD) +- ✅ Reactive queries with ValueObservation +- ✅ Full control over SQL for performance +- ✅ Advanced migration logic beyond schema changes + +**Note:** SQLiteData now supports GROUP BY (`.group(by:)`) and HAVING (`.having()`) via the query builder — see the `axiom-sqlitedata-ref` skill. + +#### Use SQLiteData instead when +- Type-safe `@Table` models are sufficient +- CloudKit sync needed +- Prefer declarative queries over SQL + +#### Use SwiftData when +- Simple CRUD with native Apple integration +- Don't need raw SQL control + +**For migrations** See the `axiom-database-migration` skill for safe schema evolution patterns. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "I need to query messages with their authors and count of reactions in one query. How do I write the JOIN?" +→ The skill shows complex JOIN queries with multiple tables and aggregations + +#### 2. "I want to observe a filtered list and update the UI whenever notes with a specific tag change." +→ The skill covers ValueObservation patterns for reactive query updates + +#### 3. "I'm importing thousands of chat records and need custom migration logic. How do I use DatabaseMigrator?" +→ The skill explains migration registration, data transforms, and safe rollback patterns + +#### 4. "My query is slow (takes 10+ seconds). How do I profile and optimize it?" +→ The skill covers EXPLAIN QUERY PLAN, database.trace for profiling, and index creation + +#### 5. "I need to fetch tasks grouped by due date with completion counts, ordered by priority. Raw SQL seems easier than type-safe queries." +→ The skill demonstrates when GRDB's raw SQL is clearer than type-safe wrappers + +--- + +## Database Setup + +### DatabaseQueue (Single Connection) + +```swift +import GRDB + +// File-based database +let dbPath = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] +let dbQueue = try DatabaseQueue(path: "\(dbPath)/db.sqlite") + +// In-memory database (tests) +let dbQueue = try DatabaseQueue() +``` + +### DatabasePool (Connection Pool) + +```swift +// For apps with heavy concurrent access +let dbPool = try DatabasePool(path: dbPath) +``` + +**Use Queue for** Most apps (simpler, sufficient) +**Use Pool for** Heavy concurrent writes from multiple threads + +## Record Types + +### Using Codable + +```swift +struct Track: Codable { + var id: String + var title: String + var artist: String + var duration: TimeInterval +} + +// Fetch +let tracks = try dbQueue.read { db in + try Track.fetchAll(db, sql: "SELECT * FROM tracks") +} + +// Insert +try dbQueue.write { db in + try track.insert(db) // Codable conformance provides insert +} +``` + +### FetchableRecord (Read-Only) + +```swift +struct TrackInfo: FetchableRecord { + var title: String + var artist: String + var albumTitle: String + + init(row: Row) { + title = row["title"] + artist = row["artist"] + albumTitle = row["album_title"] + } +} + +let results = try dbQueue.read { db in + try TrackInfo.fetchAll(db, sql: """ + SELECT tracks.title, tracks.artist, albums.title as album_title + FROM tracks + JOIN albums ON tracks.albumId = albums.id + """) +} +``` + +### PersistableRecord (Write) + +```swift +struct Track: Codable, PersistableRecord { + var id: String + var title: String + + // Customize table name + static let databaseTableName = "tracks" +} + +try dbQueue.write { db in + var track = Track(id: "1", title: "Song") + try track.insert(db) + + track.title = "Updated" + try track.update(db) + + try track.delete(db) +} +``` + +## Raw SQL Queries + +### Reading Data + +```swift +// Fetch all rows +let rows = try dbQueue.read { db in + try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"]) +} + +// Fetch single value +let count = try dbQueue.read { db in + try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM tracks") +} + +// Fetch into Codable +let tracks = try dbQueue.read { db in + try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY title") +} +``` + +### Writing Data + +```swift +try dbQueue.write { db in + try db.execute(sql: """ + INSERT INTO tracks (id, title, artist, duration) + VALUES (?, ?, ?, ?) + """, arguments: ["1", "Song", "Artist", 240]) +} +``` + +### Transactions + +```swift +try dbQueue.write { db in + // Automatic transaction - all or nothing + for track in tracks { + try track.insert(db) + } + // Commits automatically on success, rolls back on error +} +``` + +## Type-Safe Query Interface + +### Filtering + +```swift +let request = Track + .filter(Column("genre") == "Rock") + .filter(Column("duration") > 180) + +let tracks = try dbQueue.read { db in + try request.fetchAll(db) +} +``` + +### Sorting + +```swift +let request = Track + .order(Column("title").asc) + .limit(10) +``` + +### Joins + +```swift +struct TrackWithAlbum: FetchableRecord { + var trackTitle: String + var albumTitle: String +} + +let request = Track + .joining(required: Track.belongsTo(Album.self)) + .select(Column("title").forKey("trackTitle"), Column("album_title").forKey("albumTitle")) + +let results = try dbQueue.read { db in + try TrackWithAlbum.fetchAll(db, request) +} +``` + +## Complex Joins + +```swift +let sql = """ + SELECT + tracks.title as track_title, + albums.title as album_title, + artists.name as artist_name, + COUNT(plays.id) as play_count + FROM tracks + JOIN albums ON tracks.albumId = albums.id + JOIN artists ON albums.artistId = artists.id + LEFT JOIN plays ON plays.trackId = tracks.id + WHERE artists.genre = ? + GROUP BY tracks.id + HAVING play_count > 10 + ORDER BY play_count DESC + LIMIT 50 + """ + +struct TrackStats: FetchableRecord { + var trackTitle: String + var albumTitle: String + var artistName: String + var playCount: Int + + init(row: Row) { + trackTitle = row["track_title"] + albumTitle = row["album_title"] + artistName = row["artist_name"] + playCount = row["play_count"] + } +} + +let stats = try dbQueue.read { db in + try TrackStats.fetchAll(db, sql: sql, arguments: ["Rock"]) +} +``` + +## ValueObservation (Reactive Queries) + +### Basic Observation + +```swift +import GRDB +import Combine + +let observation = ValueObservation.tracking { db in + try Track.fetchAll(db) +} + +// Start observing with Combine +let cancellable = observation.publisher(in: dbQueue) + .sink( + receiveCompletion: { _ in }, + receiveValue: { tracks in + print("Tracks updated: \(tracks.count)") + } + ) +``` + +### SwiftUI Integration + +```swift +import GRDB +import GRDBQuery // https://github.com/groue/GRDBQuery + +@Query(Tracks()) +var tracks: [Track] + +struct Tracks: Queryable { + static var defaultValue: [Track] { [] } + + func publisher(in dbQueue: DatabaseQueue) -> AnyPublisher<[Track], Error> { + ValueObservation + .tracking { db in try Track.fetchAll(db) } + .publisher(in: dbQueue) + .eraseToAnyPublisher() + } +} +``` + +**See** [GRDBQuery documentation](https://github.com/groue/GRDBQuery) for SwiftUI reactive bindings. + +### Filtered Observation + +```swift +func observeGenre(_ genre: String) -> ValueObservation<[Track]> { + ValueObservation.tracking { db in + try Track + .filter(Column("genre") == genre) + .fetchAll(db) + } +} + +let cancellable = observeGenre("Rock") + .publisher(in: dbQueue) + .sink { tracks in + print("Rock tracks: \(tracks.count)") + } +``` + +## Migrations + +### DatabaseMigrator + +```swift +var migrator = DatabaseMigrator() + +// Migration 1: Create tables +migrator.registerMigration("v1") { db in + try db.create(table: "tracks") { t in + t.column("id", .text).primaryKey() + t.column("title", .text).notNull() + t.column("artist", .text).notNull() + t.column("duration", .real).notNull() + } +} + +// Migration 2: Add column +migrator.registerMigration("v2_add_genre") { db in + try db.alter(table: "tracks") { t in + t.add(column: "genre", .text) + } +} + +// Migration 3: Add index +migrator.registerMigration("v3_add_indexes") { db in + try db.create(index: "idx_genre", on: "tracks", columns: ["genre"]) +} + +// Run migrations +try migrator.migrate(dbQueue) +``` + +**For migration safety patterns** See the `axiom-database-migration` skill. + +### Migration with Data Transform + +```swift +migrator.registerMigration("v4_normalize_artists") { db in + // 1. Create new table + try db.create(table: "artists") { t in + t.column("id", .text).primaryKey() + t.column("name", .text).notNull() + } + + // 2. Extract unique artists + try db.execute(sql: """ + INSERT INTO artists (id, name) + SELECT DISTINCT + lower(replace(artist, ' ', '_')) as id, + artist as name + FROM tracks + """) + + // 3. Add foreign key to tracks + try db.alter(table: "tracks") { t in + t.add(column: "artistId", .text) + .references("artists", onDelete: .cascade) + } + + // 4. Populate foreign keys + try db.execute(sql: """ + UPDATE tracks + SET artistId = ( + SELECT id FROM artists + WHERE artists.name = tracks.artist + ) + """) +} +``` + +## Performance Patterns + +### Batch Writes + +```swift +try dbQueue.write { db in + for batch in tracks.chunked(into: 500) { + for track in batch { + try track.insert(db) + } + } +} +``` + +### Prepared Statements + +```swift +try dbQueue.write { db in + let statement = try db.makeStatement(sql: """ + INSERT INTO tracks (id, title, artist, duration) + VALUES (?, ?, ?, ?) + """) + + for track in tracks { + try statement.execute(arguments: [track.id, track.title, track.artist, track.duration]) + } +} +``` + +### Indexes + +```swift +try db.create(index: "idx_tracks_artist", on: "tracks", columns: ["artist"]) +try db.create(index: "idx_tracks_genre_duration", on: "tracks", columns: ["genre", "duration"]) + +// Unique index +try db.create(index: "idx_tracks_unique_title", on: "tracks", columns: ["title"], unique: true) +``` + +### Query Planning + +```swift +// Analyze query performance +let explanation = try dbQueue.read { db in + try String.fetchOne(db, sql: "EXPLAIN QUERY PLAN SELECT * FROM tracks WHERE artist = ?", arguments: ["Artist"]) +} +print(explanation) +``` + +## Dropping Down from SQLiteData + +When using SQLiteData but need GRDB for specific operations: + +```swift +import SQLiteData +import GRDB + +@Dependency(\.database) var database // SQLiteData Database + +// Access underlying GRDB DatabaseQueue +try await database.database.write { db in + // Full GRDB power here + try db.execute(sql: "CREATE INDEX idx_genre ON tracks(genre)") +} +``` + +#### Common scenarios +- Complex JOIN queries +- Custom migrations +- Bulk SQL operations +- ValueObservation setup + +## Quick Reference + +### Common Operations + +```swift +// Read single value +let count = try db.fetchOne(Int.self, sql: "SELECT COUNT(*) FROM tracks") + +// Read all rows +let rows = try Row.fetchAll(db, sql: "SELECT * FROM tracks WHERE genre = ?", arguments: ["Rock"]) + +// Write +try db.execute(sql: "INSERT INTO tracks VALUES (?, ?, ?)", arguments: [id, title, artist]) + +// Transaction +try dbQueue.write { db in + // All or nothing +} + +// Observe changes +ValueObservation.tracking { db in + try Track.fetchAll(db) +}.publisher(in: dbQueue) +``` + +## Resources + +**GitHub**: groue/GRDB.swift, groue/GRDBQuery + +**Docs**: sqlite.org/docs.html + +**Skills**: axiom-database-migration, axiom-sqlitedata, axiom-swiftdata + +## Production Performance: Query Optimization Under Pressure + +### Red Flags — When GRDB Queries Slow Down + +If you see ANY of these symptoms: +- ❌ Complex JOIN query takes 10+ seconds +- ❌ ValueObservation runs on every single change (battery drain) +- ❌ Can't explain why migration ran twice on old version + +#### DO NOT +1. Blindly add indexes (don't know which columns help) +2. Move logic to Swift (premature escape from database) +3. Over-engineer migrations (distrust the system) + +#### DO +1. Profile with `database.trace` +2. Use `EXPLAIN QUERY PLAN` to understand execution +3. Trust GRDB's migration versioning system + +### Profiling Complex Queries + +#### When query is slow (10+ seconds) + +```swift +var database = try DatabaseQueue(path: dbPath) + +// Enable tracing to see SQL execution +database.trace { print($0) } + +// Run the slow query +try database.read { db in + let results = try Track.fetchAll(db) // Watch output for execution time +} + +// Use EXPLAIN QUERY PLAN to understand execution: +try database.read { db in + let plan = try String(fetching: db, sql: "EXPLAIN QUERY PLAN SELECT ...") + print(plan) + // Look for SCAN (slow, full table) vs SEARCH (fast, indexed) +} +``` + +#### Add indexes strategically + +```swift +// Add index on frequently queried column +try database.write { db in + try db.execute(sql: "CREATE INDEX idx_plays_track_id ON plays(track_id)") +} +``` + +#### Time cost +- Profile: 10 min (enable trace, run query, read output) +- Understand: 5 min (interpret EXPLAIN QUERY PLAN) +- Fix: 5 min (add index) +- **Total: 20 minutes** (vs 30+ min blindly trying solutions) + +### ValueObservation Performance + +#### When using reactive queries, know the costs + +```swift +// Re-evaluates query on ANY write to database +ValueObservation.tracking { db in + try Track.fetchAll(db) +}.start(in: database, onError: { }, onChange: { tracks in + // Called for every change — CPU spike! +}) +``` + +#### Optimization patterns + +```swift +// Coalesce rapid updates (recommended) +ValueObservation.tracking { db in + try Track.fetchAll(db) +}.removeDuplicates() // Skip duplicate results + .debounce(for: 0.5, scheduler: DispatchQueue.main) // Batch updates + .start(in: database, ...) +``` + +#### Decision framework +- Small datasets (<1000 records): Use plain `.tracking` +- Medium datasets (1-10k records): Add `.removeDuplicates()` + `.debounce()` +- Large datasets (10k+ records): Use explicit table dependencies or predicates + +### Migration Versioning Guarantees + +#### Trust GRDB's DatabaseMigrator - it prevents re-running migrations + +```swift +var migrator = DatabaseMigrator() + +migrator.registerMigration("v1_initial") { db in + try db.execute(sql: "CREATE TABLE tracks (...)") +} + +migrator.registerMigration("v2_add_plays") { db in + try db.execute(sql: "CREATE TABLE plays (...)") +} + +// GRDB guarantees: +// - Each migration runs exactly ONCE +// - In order (v1, then v2) +// - Safe to call migrate() multiple times +try migrator.migrate(dbQueue) +``` + +#### You don't need defensive SQL (IF NOT EXISTS) +- GRDB tracks which migrations have run +- Running `migrate()` twice only executes new ones +- Over-engineering adds complexity without benefit + +#### Trust it. + +--- + +## Common Mistakes + +### ❌ Not using transactions for batch writes +```swift +for track in 50000Tracks { + try dbQueue.write { db in try track.insert(db) } // 50k transactions! +} +``` +**Fix** Single transaction with batches + +### ❌ Synchronous database access on main thread +```swift +let tracks = try dbQueue.read { db in try Track.fetchAll(db) } // Blocks UI +``` +**Fix** Use async/await or dispatch to background queue + +### ❌ Forgetting to add indexes +```swift +// Slow query without index +try Track.filter(Column("genre") == "Rock").fetchAll(db) +``` +**Fix** Create indexes on frequently queried columns + +### ❌ N+1 queries +```swift +for track in tracks { + let album = try Album.fetchOne(db, key: track.albumId) // N queries! +} +``` +**Fix** Use JOIN or batch fetch + +--- + +**Targets:** iOS 13+, Swift 5.7+ +**Framework:** GRDB.swift 6.0+ +**History:** See git log for changes diff --git a/skill-index/skills/axiom-memory-debugging/SKILL.md b/skill-index/skills/axiom-memory-debugging/SKILL.md new file mode 100644 index 0000000..960e1b7 --- /dev/null +++ b/skill-index/skills/axiom-memory-debugging/SKILL.md @@ -0,0 +1,1219 @@ +--- +name: axiom-memory-debugging +description: Use when you see memory warnings, 'retain cycle', app crashes from memory pressure, or when asking 'why is my app using so much memory', 'how do I find memory leaks', 'my deinit is never called', 'Instruments shows memory growth', 'app crashes after 10 minutes' - systematic memory leak detection and fixes for iOS/macOS +skill_type: discipline +version: 1.0.0 +# MCP annotations (ignored by Claude Code) +mcp: + category: debugging + tags: [memory, leaks, instruments, retain-cycles, performance, allocations] + related: [performance-profiling, axiom-objc-block-retain-cycles] +--- + +# Memory Debugging + +## Overview + +Memory issues manifest as crashes after prolonged use. **Core principle** 90% of memory leaks follow 3 patterns (retain cycles, timer/observer leaks, collection growth). Diagnose systematically with Instruments, never guess. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My app crashes after 10-15 minutes of use, but there are no error messages. How do I figure out what's leaking?" +→ The skill covers systematic Instruments workflows to identify memory leaks vs normal memory pressure, with real diagnostic patterns + +#### 2. "I'm seeing memory jump from 50MB to 200MB+ when I perform a specific action. Is this a leak or normal caching behavior?" +→ The skill distinguishes between progressive leaks (continuous growth) and temporary spikes (caches that stabilize), with diagnostic criteria + +#### 3. "View controllers don't seem to be deallocating after I dismiss them. How do I find the retain cycle causing this?" +→ The skill demonstrates Memory Graph Debugger techniques to identify objects holding strong references and common retain cycle patterns + +#### 4. "I have timers/observers in my code and I think they're causing memory leaks. How do I verify and fix this?" +→ The skill covers the 5 diagnostic patterns, including specific timer and observer leak signatures with prevention strategies + +#### 5. "My app uses 200MB of memory and I don't know if that's normal or if I have multiple leaks. How do I diagnose?" +→ The skill provides the Instruments decision tree to distinguish normal memory use, expected caches, and actual leaks requiring fixes + +--- + +## Red Flags — Memory Leak Likely + +If you see ANY of these, suspect memory leak not just heavy memory use: + +- Progressive memory growth: 50MB → 100MB → 200MB (not plateauing) +- App crashes after 10-15 minutes with no error in Xcode console +- Memory warnings appear repeatedly in device logs +- Specific screen/operation makes memory jump (10-50MB spike) +- View controllers don't deallocate after dismiss (visible in Memory Graph Debugger) +- Same operation run multiple times causes linear memory growth + +#### Difference from normal memory use +- Normal: App uses 100MB, stays at 100MB (memory pressure handled by iOS) +- Leak: App uses 50MB, becomes 100MB, 150MB, 200MB → CRASH + +## Mandatory First Steps + +**ALWAYS run these commands/checks FIRST** (before reading code): + +```bash +# 1. Check device logs for memory warnings +# Connect device, open Xcode Console (Cmd+Shift+2) +# Trigger the crash scenario +# Look for: "Memory pressure critical", "Jetsam killed", "Low Memory" + +# 2. Check which objects are leaking +# Use Memory Graph Debugger (below) — shows object count growth + +# 3. Check instruments baseline +# Xcode → Product → Profile → Memory +# Run for 1 minute, note baseline +# Perform operation 5 times, note if memory keeps growing +``` + +#### What this tells you +- **Memory stays flat** → Likely not a leak, check memory pressure handling +- **Memory grows linearly** → Classic leak (timer, observer, closure capture) +- **Sudden spikes then flattens** → Probably normal (caches, lazy loading) +- **Spikes AND keeps growing** → Compound leak (multiple leaks stacking) + +#### Why diagnostics first +- Finding leak with Instruments: 5-15 minutes +- Guessing and testing fixes: 45+ minutes + +## Quick Decision Tree + +``` +Memory growing? +├─ Progressive growth every minute? +│ └─ Likely retain cycle or timer leak +├─ Spike when action performed? +│ └─ Check if operation runs multiple times +├─ Spike then flat for 30 seconds? +│ └─ Probably normal (collections, caches) +├─ Multiple large spikes stacking? +│ └─ Compound leak (multiple sources) +└─ Can't tell from visual inspection? + └─ Use Instruments Memory Graph (see below) +``` + +## Detecting Leaks — Step by Step + +### Step 1: Memory Graph Debugger (Fastest Leak Detection) + +``` +1. Open your app in Xcode simulator +2. Click: Debug → Memory Graph Debugger (or icon in top toolbar) +3. Wait for graph to generate (5-10 seconds) +4. Look for PURPLE/RED circles with "⚠" badge +5. Click them → Xcode shows retain cycle chain +``` + +#### What you're looking for +``` +✅ Object appears once +❌ Object appears 2+ times (means it's retained multiple times) +``` + +#### Example output (indicates leak) +``` +PlayerViewModel + ↑ strongRef from: progressTimer + ↑ strongRef from: TimerClosure [weak self] captured self + ↑ CYCLE DETECTED: This creates a retain cycle! +``` + +### Step 2: Instruments (Detailed Memory Analysis) + +``` +1. Product → Profile (Cmd+I) +2. Select "Memory" template +3. Run scenario that causes memory growth +4. Perform action 5-10 times +5. Check: Does memory line go UP for each action? + - YES → Leak confirmed + - NO → Probably not a leak +``` + +#### Key instruments to check +- **Heap Allocations**: Shows object count +- **Leaked Objects**: Direct leak detection +- **VM Tracker**: Shows memory by type +- **System Memory**: Shows OS pressure + +#### How to read the graph +``` +Time ──→ +Memory + │ ▗━━━━━━━━━━━━━━━━ ← Memory keeps growing (LEAK) + │ ▄▀ + │ ▄▀ + │ ▄ + └───────────────────── + Action 1 2 3 4 5 + +vs normal pattern: + +Time ──→ +Memory + │ ▗━━━━━━━━━━━━━━━━━━ ← Memory plateaus (OK) + │ ▄▀ + │▄ + └───────────────────── + Action 1 2 3 4 5 +``` + +### Step 3: View Controller Memory Check + +For SwiftUI or UIKit view controllers: + +```swift +// SwiftUI: Check if view disappears cleanly +@main +struct DebugApp: App { + init() { + NotificationCenter.default.addObserver( + forName: NSNotification.Name("UIViewControllerWillDeallocate"), + object: nil, + queue: .main + ) { _ in + print("✅ ViewController deallocated") + } + } + var body: some Scene { ... } +} + +// UIKit: Add deinit logging +class MyViewController: UIViewController { + deinit { + print("✅ MyViewController deallocated") + } +} + +// SwiftUI: Use deinit in view models +@MainActor +class ViewModel: ObservableObject { + deinit { + print("✅ ViewModel deallocated") + } +} +``` + +#### Test procedure +``` +1. Add deinit logging above +2. Launch app in Xcode +3. Navigate to view/create ViewModel +4. Navigate away/dismiss +5. Check Console: Do you see "✅ deallocated"? + - YES → No leak there + - NO → Object is retained somewhere +``` + +## Common Memory Leak Patterns (With Fixes) + +### Pattern 1: Timer Leaks (Most Common) + +#### ❌ Leak — Timer retains closure, closure retains self +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentTrack: Track? + private var progressTimer: Timer? + + func startPlayback(_ track: Track) { + currentTrack = track + // LEAK: Timer.scheduledTimer captures 'self' in closure + // Even with [weak self], the Timer itself is strong + progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateProgress() + } + // Timer is never stopped → keeps firing forever + } + + // Missing: Timer never invalidated + deinit { + // LEAK: If timer still running, deinit never called + } +} +``` + +#### Leak mechanism +``` +ViewController → strongly retains ViewModel + ↓ +ViewModel → strongly retains Timer + ↓ +Timer → strongly retains closure + ↓ +Closure → captures [weak self] but still holds reference to Timer +``` + +#### Closure captures `self` weakly BUT +- Timer is still strong reference in ViewModel +- Timer is still running (repeats: true) +- Even with [weak self], timer closure doesn't go away + +#### ✅ Fix 1: Invalidate on deinit +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentTrack: Track? + private var progressTimer: Timer? + + func startPlayback(_ track: Track) { + currentTrack = track + progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.updateProgress() + } + } + + func stopPlayback() { + progressTimer?.invalidate() + progressTimer = nil // Important: nil after invalidate + currentTrack = nil + } + + deinit { + progressTimer?.invalidate() // ← CRITICAL FIX + progressTimer = nil + } +} +``` + +#### ✅ Fix 2: Use AnyCancellable (Modern approach) +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentTrack: Track? + private var cancellable: AnyCancellable? + + func startPlayback(_ track: Track) { + currentTrack = track + + // Timer with Combine - auto-cancels when cancellable is released + cancellable = Timer.publish( + every: 1.0, + tolerance: 0.1, + on: .main, + in: .default + ) + .autoconnect() + .sink { [weak self] _ in + self?.updateProgress() + } + } + + func stopPlayback() { + cancellable?.cancel() // Auto-cleans up + cancellable = nil + currentTrack = nil + } + + // No need for deinit — Combine handles cleanup +} +``` + +#### ✅ Fix 3: Weak self + nil check (Emergency fix) +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentTrack: Track? + private var progressTimer: Timer? + + func startPlayback(_ track: Track) { + currentTrack = track + + // If progressTimer already exists, stop it first + progressTimer?.invalidate() + progressTimer = nil + + progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { + // If self deallocated, timer still fires but does nothing + // Still not ideal - timer keeps consuming CPU + return + } + self.updateProgress() + } + } + + func stopPlayback() { + progressTimer?.invalidate() + progressTimer = nil + } + + deinit { + progressTimer?.invalidate() + progressTimer = nil + } +} +``` + +#### Why the fixes work +- `invalidate()`: Stops timer immediately, breaks retain cycle +- `cancellable`: Automatically invalidates when released +- `[weak self]`: If ViewModel released before timer, timer becomes no-op +- `deinit cleanup`: Ensures timer always cleaned up + +#### Test the fix +```swift +func testPlayerViewModelNotLeaked() { + var viewModel: PlayerViewModel? = PlayerViewModel() + let track = Track(id: "1", title: "Song") + viewModel?.startPlayback(track) + + // Verify timer running + XCTAssertNotNil(viewModel?.progressTimer) + + // Stop and deallocate + viewModel?.stopPlayback() + viewModel = nil + + // ✅ Should deallocate without leak warning +} +``` + +### Pattern 2: Observer/Notification Leaks + +#### ❌ Leak — Observer holds strong reference to self +```swift +@MainActor +class PlayerViewModel: ObservableObject { + init() { + // LEAK: addObserver keeps strong reference to self + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionChange), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + // No matching removeObserver → accumulates listeners + } + + @objc private func handleAudioSessionChange() { } + + deinit { + // Missing: Never unregistered + } +} +``` + +#### ✅ Fix 1: Manual cleanup in deinit +```swift +@MainActor +class PlayerViewModel: ObservableObject { + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAudioSessionChange), + name: AVAudioSession.routeChangeNotification, + object: nil + ) + } + + @objc private func handleAudioSessionChange() { } + + deinit { + NotificationCenter.default.removeObserver(self) // ← FIX + } +} +``` + +#### ✅ Fix 2: Use modern Combine approach (Best practice) +```swift +@MainActor +class PlayerViewModel: ObservableObject { + private var cancellables = Set() + + init() { + NotificationCenter.default.publisher( + for: AVAudioSession.routeChangeNotification + ) + .sink { [weak self] _ in + self?.handleAudioSessionChange() + } + .store(in: &cancellables) // Auto-cleanup with viewModel + } + + private func handleAudioSessionChange() { } + + // No deinit needed - cancellables auto-cleanup +} +``` + +#### ✅ Fix 3: Use @Published with map (Reactive) +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentRoute: AVAudioSession.AudioSessionRouteDescription? + private var cancellables = Set() + + init() { + NotificationCenter.default.publisher( + for: AVAudioSession.routeChangeNotification + ) + .map { _ in AVAudioSession.sharedInstance().currentRoute } + .assign(to: &$currentRoute) // Auto-cleanup with publisher chain + } +} +``` + +### Pattern 3: Closure Capture Leaks (Collection/Array) + +#### ❌ Leak — Closure captured in array, captures self +```swift +@MainActor +class PlaylistViewController: UIViewController { + private var tracks: [Track] = [] + private var updateCallbacks: [(Track) -> Void] = [] // LEAK SOURCE + + func addUpdateCallback() { + // LEAK: Closure captures 'self' + updateCallbacks.append { [self] track in + self.refreshUI(with: track) // Strong capture of self + } + // updateCallbacks grows and never cleared + } + + // No mechanism to clear callbacks + deinit { + // updateCallbacks still references self + } +} +``` + +#### Leak mechanism +``` +ViewController + ↓ strongly owns +updateCallbacks array + ↓ contains +Closure captures self + ↓ CYCLE +Back to ViewController (can't deallocate) +``` + +#### ✅ Fix 1: Use weak self in closure +```swift +@MainActor +class PlaylistViewController: UIViewController { + private var tracks: [Track] = [] + private var updateCallbacks: [(Track) -> Void] = [] + + func addUpdateCallback() { + updateCallbacks.append { [weak self] track in + self?.refreshUI(with: track) // Weak capture + } + } + + deinit { + updateCallbacks.removeAll() // Clean up array + } +} +``` + +#### ✅ Fix 2: Use unowned (when you're certain self lives longer) +```swift +@MainActor +class PlaylistViewController: UIViewController { + private var updateCallbacks: [(Track) -> Void] = [] + + func addUpdateCallback() { + updateCallbacks.append { [unowned self] track in + self.refreshUI(with: track) // Unowned is faster + } + // Use unowned ONLY if callback always destroyed before ViewController + } + + deinit { + updateCallbacks.removeAll() + } +} +``` + +#### ✅ Fix 3: Cancel callbacks when done (Reactive) +```swift +@MainActor +class PlaylistViewController: UIViewController { + private var cancellables = Set() + + func addUpdateCallback(_ handler: @escaping (Track) -> Void) { + // Use PassthroughSubject instead of array + Just(()) + .sink { [weak self] in + handler(/* track */) + } + .store(in: &cancellables) + } + + // When done: + func clearCallbacks() { + cancellables.removeAll() // Cancels all subscriptions + } +} +``` + +#### Test the fix +```swift +func testCallbacksNotLeak() { + var viewController: PlaylistViewController? = PlaylistViewController() + viewController?.addUpdateCallback { _ in } + + // Verify callback registered + XCTAssert(viewController?.updateCallbacks.count ?? 0 > 0) + + // Clear and deallocate + viewController?.updateCallbacks.removeAll() + viewController = nil + + // ✅ Should deallocate +} +``` + +### Pattern 4: Strong Reference Cycles (Closures + Properties) + +#### ❌ Leak — Two objects strongly reference each other +```swift +@MainActor +class Player: NSObject { + var delegate: PlayerDelegate? // Strong reference + var onPlaybackEnd: (() -> Void)? // ← Closure captures self + + init(delegate: PlayerDelegate) { + self.delegate = delegate + // LEAK CYCLE: + // Player → (owns) → delegate + // delegate → (through closure) → owns → Player + } +} + +class PlaylistController: PlayerDelegate { + var player: Player? + + override init() { + super.init() + self.player = Player(delegate: self) // Self-reference cycle + + player?.onPlaybackEnd = { [self] in + // LEAK: Closure captures self + // self owns player + // player owns delegate (self) + // Cycle! + self.playNextTrack() + } + } +} +``` + +#### ✅ Fix: Break cycle with weak self +```swift +@MainActor +class PlaylistController: PlayerDelegate { + var player: Player? + + override init() { + super.init() + self.player = Player(delegate: self) + + player?.onPlaybackEnd = { [weak self] in + // Weak self breaks the cycle + self?.playNextTrack() + } + } + + deinit { + player?.onPlaybackEnd = nil // Optional cleanup + player = nil + } +} +``` + +### Pattern 5: View/Layout Callback Leaks + +#### ❌ Leak — View layout callback retains view controller +```swift +@MainActor +class DetailViewController: UIViewController { + let customView = UIView() + + override func viewDidLoad() { + super.viewDidLoad() + + // LEAK: layoutIfNeeded closure captures self + customView.layoutIfNeeded = { [self] in + // Every layout triggers this, keeping self alive + self.updateLayout() + } + } +} +``` + +#### ✅ Fix: Use @IBAction or proper delegation pattern +```swift +@MainActor +class DetailViewController: UIViewController { + @IBOutlet weak var customView: CustomView! + + override func viewDidLoad() { + super.viewDidLoad() + customView.delegate = self // Weak reference through protocol + } + + deinit { + customView?.delegate = nil // Clean up + } +} + +protocol CustomViewDelegate: AnyObject { // AnyObject = weak by default + func customViewDidLayout(_ view: CustomView) +} +``` + +### Pattern 6: PhotoKit Image Request Leaks + +#### ❌ Leak — PHImageManager requests accumulate without cancellation + +This pattern is specific to photo/media apps using PhotoKit or similar async image loading APIs. + +```swift +// LEAK: Image requests not cancelled when cells scroll away +class PhotoViewController: UIViewController { + let imageManager = PHImageManager.default() + + func collectionView(_ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "cell", for: indexPath) + let asset = photos[indexPath.item] + + // LEAK: Requests accumulate - never cancelled + imageManager.requestImage( + for: asset, + targetSize: thumbnailSize, + contentMode: .aspectFill, + options: nil + ) { [weak self] image, _ in + cell.imageView.image = image // Still called even if cell scrolled away + } + + return cell + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Each scroll triggers 50+ new image requests + // Previous requests still pending, accumulating in queue + } +} +``` + +#### Symptoms +- Memory jumps 50MB+ when scrolling long photo lists +- Crashes happen after scrolling through 100+ photos +- Specific operation causes leak (photo scrolling, not other screens) +- Works fine locally with 10 photos, crashes on user devices with 1000+ photos + +**Root cause** `PHImageManager.requestImage()` returns a `PHImageRequestID` that must be explicitly cancelled. Without cancellation, pending requests queue up and hold memory. + +#### ✅ Fix: Store request ID and cancel in prepareForReuse() + +```swift +class PhotoCell: UICollectionViewCell { + @IBOutlet weak var imageView: UIImageView! + private var imageRequestID: PHImageRequestID = PHInvalidImageRequestID + + func configure(with asset: PHAsset, imageManager: PHImageManager) { + // Cancel previous request before starting new one + if imageRequestID != PHInvalidImageRequestID { + imageManager.cancelImageRequest(imageRequestID) + } + + imageRequestID = imageManager.requestImage( + for: asset, + targetSize: PHImageManagerMaximumSize, + contentMode: .aspectFill, + options: nil + ) { [weak self] image, _ in + self?.imageView.image = image + } + } + + override func prepareForReuse() { + super.prepareForReuse() + + // CRITICAL: Cancel pending request when cell is reused + if imageRequestID != PHInvalidImageRequestID { + PHImageManager.default().cancelImageRequest(imageRequestID) + imageRequestID = PHInvalidImageRequestID + } + + imageView.image = nil // Clear stale image + } + + deinit { + // Safety check - shouldn't be needed if prepareForReuse called + if imageRequestID != PHInvalidImageRequestID { + PHImageManager.default().cancelImageRequest(imageRequestID) + } + } +} + +// Controller +class PhotoViewController: UIViewController, UICollectionViewDataSource { + let imageManager = PHImageManager.default() + + func collectionView(_ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "PhotoCell", + for: indexPath) as! PhotoCell + let asset = photos[indexPath.item] + cell.configure(with: asset, imageManager: imageManager) + return cell + } +} +``` + +#### Key points +- Store `PHImageRequestID` in cell (not in view controller) +- Cancel BEFORE starting new request (prevents request storms) +- Cancel in `prepareForReuse()` (critical for collection views) +- Check `imageRequestID != PHInvalidImageRequestID` before cancelling + +#### Other async APIs with similar patterns +- `AVAssetImageGenerator.generateCGImagesAsynchronously()` → call `cancelAllCGImageGeneration()` +- `URLSession.dataTask()` → call `cancel()` on task +- Custom image caches → implement `invalidate()` or `cancel()` method + +## Debugging Non-Reproducible Memory Issues + +**Challenge** Memory leak only happens with specific user data (large photo collections, complex data models) that you can't reproduce locally. + +### Step 1: Enable Remote Memory Diagnostics + +Add MetricKit diagnostics to your app: + +```swift +import MetricKit + +class MemoryDiagnosticsManager { + static let shared = MemoryDiagnosticsManager() + + private let metricManager = MXMetricManager.shared + + func startMonitoring() { + metricManager.add(self) + } +} + +extension MemoryDiagnosticsManager: MXMetricManagerSubscriber { + func didReceive(_ payloads: [MXMetricPayload]) { + for payload in payloads { + if let memoryMetrics = payload.memoryMetrics { + let peakMemory = memoryMetrics.peakMemoryUsage + + // Log if exceeding threshold + if peakMemory > 400_000_000 { // 400MB + print("⚠️ High memory: \(peakMemory / 1_000_000)MB") + // Send to analytics + } + } + } + } +} +``` + +### Step 2: Ask Users for Device Logs + +When user reports crash: + +1. iPhone → Settings → Privacy & Security → Analytics → Analytics Data +2. Look for latest crash log (named like `YourApp_2024-01-15-12-45-23`) +3. Email or upload to your support system +4. Xcode → Window → Devices & Simulators → select device → View Device Logs +5. Search for "Memory" or "Jetsam" in logs + +### Step 3: TestFlight Beta Testing + +Before App Store release: + +```swift +#if DEBUG +// Add to AppDelegate +import os.log +let logger = os.log(subsystem: "com.yourapp.memory", category: "lifecycle") + +// Log memory milestones +func logMemory(_ event: String) { + let memoryUsage = ProcessInfo.processInfo.physicalMemory / 1_000_000 + os.log("🔍 [%s] Memory: %dMB", log: logger, type: .info, event, memoryUsage) +} +#endif +``` + +Send TestFlight build to affected users: +1. Build → Archive → Distribute App +2. Select TestFlight +3. Add affected user email +4. In TestFlight, ask user to: + - Reproduce the crash scenario + - Check if memory stabilizes (logs to system.log) + - Report if crash still happens + +### Step 4: Verify Fix Production Deployment + +After deploying fix: + +1. Monitor MetricKit metrics for 24-48 hours +2. Check crash rate drop in App Analytics +3. If still seeing high memory users: + - Add more diagnostic logging for next version + - Consider lower memory device testing (iPad with constrained memory) + +## Systematic Debugging Workflow + +### Phase 1: Confirm Leak (5 minutes) + +``` +1. Open app in simulator +2. Xcode → Product → Profile → Memory +3. Record baseline memory +4. Repeat action 10 times +5. Check memory graph: + - Flat line = NOT a leak (stop here) + - Steady climb = LEAK (go to Phase 2) +``` + +### Phase 2: Locate Leak (10-15 minutes) + +``` +1. Close Instruments +2. Xcode → Debug → Memory Graph Debugger +3. Wait for graph (5-10 sec) +4. Look for purple/red circles with ⚠ +5. Click on leaked object +6. Read the retain cycle chain: + PlayerViewModel (leak) + ↑ retained by progressTimer + ↑ retained by TimerClosure + ↑ retained by [self] capture +``` + +#### Common leak locations (in order of likelihood) +- Timers (50% of leaks) +- Notifications/KVO (25%) +- Closures in arrays/collections (15%) +- Delegate cycles (10%) + +### Phase 3: Test Hypothesis (5 minutes) + +Apply fix from "Common Patterns" section above, then: + +```swift +// Add deinit logging +class PlayerViewModel: ObservableObject { + deinit { + print("✅ PlayerViewModel deallocated - leak fixed!") + } +} +``` + +Run in Xcode, perform operation, check console for dealloc message. + +### Phase 4: Verify Fix with Instruments (5 minutes) + +``` +1. Product → Profile → Memory +2. Repeat action 10 times +3. Confirm: Memory stays flat (not climbing) +4. If climbing continues, go back to Phase 2 (second leak) +``` + +## Compound Leaks (Multiple Sources) + +Real apps often have 2-3 leaks stacking: + +``` +Leak 1: Timer in PlayerViewModel (+10MB/minute) +Leak 2: Observer in delegate (+5MB/minute) +Result: +15MB/minute → Crashes in 13 minutes +``` + +#### How to find compound leaks + +``` +1. Fix obvious leak (Timer) +2. Run Instruments again +3. If memory STILL growing, there's a second leak +4. Repeat Phase 1-3 for each leak +5. Test each fix in isolation (revert one, test another) +``` + +## Memory Leak Detection — Testing Checklist + +```swift +// Pattern 1: Verify object deallocates +@Test func viewModelDeallocates() { + var vm: PlayerViewModel? = PlayerViewModel() + vm?.startPlayback(Track(id: "1", title: "Test")) + + // Cleanup + vm?.stopPlayback() + vm = nil + + // If no crash, object deallocated +} + +// Pattern 2: Verify timer stops +@Test func timerStopsOnDeinit() { + var vm: PlayerViewModel? = PlayerViewModel() + let startCount = Timer.activeCount() + + vm?.startPlayback(Track(id: "1", title: "Test")) + XCTAssertGreater(Timer.activeCount(), startCount) + + vm?.stopPlayback() + vm = nil + + XCTAssertEqual(Timer.activeCount(), startCount) +} + +// Pattern 3: Verify observer unregistered +@Test func observerRemovedOnDeinit() { + var vc: DetailViewController? = DetailViewController() + let startCount = NotificationCenter.default.observers().count + + // Perform action that adds observer + _ = vc + + vc = nil + XCTAssertEqual(NotificationCenter.default.observers().count, startCount) +} + +// Pattern 4: Memory stability over time +@Test func memoryStableAfterRepeatedActions() { + let vm = PlayerViewModel() + + var measurements: [UInt] = [] + for _ in 0..<10 { + vm.startPlayback(Track(id: "1", title: "Test")) + vm.stopPlayback() + + let memory = ProcessInfo.processInfo.physicalMemory + measurements.append(memory) + } + + // Check last 5 measurements are within 10% of each other + let last5 = Array(measurements.dropFirst(5)) + let average = last5.reduce(0, +) / UInt(last5.count) + + for measurement in last5 { + XCTAssertLessThan( + abs(Int(measurement) — Int(average)), + Int(average / 10) // 10% tolerance + ) + } +} +``` + +## Command Line Tools for Memory Debugging + +```bash +# Monitor memory in real-time +# Connect device, then +xcrun xctrace record --template "Memory" --output memory.trace + +# Analyze with command line +xcrun xctrace dump memory.trace + +# Check for leaked objects +instruments -t "Leaks" -a YourApp -p 1234 + +# Memory pressure simulator +xcrun simctl spawn booted launchctl list | grep memory + +# Check malloc statistics +leaks -atExit -excludeNoise YourApp +``` + +## Common Mistakes + +❌ **Using [weak self] but never calling invalidate()** +- Weak self prevents immediate crash but doesn't stop timer +- Timer keeps running and consuming CPU/battery +- ALWAYS call `invalidate()` or `cancel()` on timers/subscribers + +❌ **Invalidating timer but keeping strong reference** +```swift +// ❌ Wrong +timer?.invalidate() // Stops firing but timer still referenced +// ❌ Should be: +timer?.invalidate() +timer = nil // Release the reference +``` + +❌ **Assuming AnyCancellable auto-cleanup is automatic** +```swift +// ❌ Wrong - if cancellable goes out of scope, subscription ends immediately +func setupListener() { + let cancellable = NotificationCenter.default + .publisher(for: .myNotification) + .sink { _ in } + // cancellable is local, goes out of scope immediately + // Subscription dies before any notifications arrive +} + +// ✅ Right - store in property +@MainActor +class MyClass: ObservableObject { + private var cancellables = Set() + + func setupListener() { + NotificationCenter.default + .publisher(for: .myNotification) + .sink { _ in } + .store(in: &cancellables) // Stored as property + } +} +``` + +❌ **Not testing the fix** +- Apply fix → Assume it's correct → Deploy +- ALWAYS run Instruments after fix to confirm memory flat + +❌ **Fixing the wrong leak first** +- Multiple leaks = fix largest first (biggest memory impact) +- Use Memory Graph to identify what's actually leaking + +❌ **Adding deinit with only logging, no cleanup** +```swift +// ❌ Wrong - just logs, doesn't clean up +deinit { + print("ViewModel deallocating") // Doesn't stop timer! +} + +// ✅ Right - actually stops the leak +deinit { + timer?.invalidate() + timer = nil + NotificationCenter.default.removeObserver(self) +} +``` + +❌ **Using Instruments Memory template instead of Leaks** +- Memory template: Shows memory usage (not leaks) +- Leaks template: Detects actual leaks +- Use both: Memory for trend, Leaks for detection + +## Instruments Quick Reference + +| Scenario | Tool | What to Look For | +|----------|------|------------------| +| Progressive memory growth | Memory | Line steadily climbing = leak | +| Specific object leaking | Memory Graph | Purple/red circles = leak objects | +| Direct leak detection | Leaks | Red "! Leak" badge = confirmed leak | +| Memory by type | VM Tracker | Find objects consuming most memory | +| Cache behavior | Allocations | Find objects allocated but not freed | + +## Real-World Impact + +**Before** 50+ PlayerViewModel instances created/destroyed +- Each uncleared timer fires every second +- Memory: 50MB → 100MB (1min) → 200MB (2min) → Crash (13min) +- Developer spends 2+ hours debugging + +**After** Timer properly invalidated in all view models +- One instance created/destroyed = memory flat +- No timer accumulation +- Memory: 50MB → 50MB → 50MB (stable for hours) + +**Key insight** 90% of leaks come from forgetting to stop timers, observers, or subscriptions. Always clean up in `deinit` or use reactive patterns that auto-cleanup. + +--- + +## Simulator Verification + +After fixing memory leaks, verify your app's UI still renders correctly and doesn't introduce visual regressions. + +### Why Verify After Memory Fixes + +Memory fixes can sometimes break functionality: +- **Premature cleanup** — Object deallocated while still needed +- **Broken bindings** — Weak references become nil unexpectedly +- **State loss** — Data cleared too early in lifecycle + +**Always verify**: +- UI still renders correctly +- No blank screens or missing content +- Animations still work +- App doesn't crash on navigation + +### Quick Visual Verification + +```bash +# 1. Build with memory fix +xcodebuild build -scheme YourScheme + +# 2. Launch in simulator +xcrun simctl launch booted com.your.bundleid + +# 3. Navigate to affected screen +xcrun simctl openurl booted "debug://problem-screen" +sleep 1 + +# 4. Capture screenshot +/axiom:screenshot + +# 5. Verify UI looks correct (no blank views, missing images, etc.) +``` + +### Stress Testing with Screenshots + +Test the screen that was leaking, repeatedly: + +```bash +# Navigate to screen multiple times, capture at each iteration +for i in {1..10}; do + xcrun simctl openurl booted "debug://player-screen?id=$i" + sleep 2 + xcrun simctl io booted screenshot /tmp/stress-test-$i.png +done + +# All screenshots should look correct (not degraded) +``` + +### Full Verification Workflow + +```bash +/axiom:test-simulator +``` + +Then describe: +- "Navigate to PlayerView 10 times and verify UI doesn't degrade" +- "Open and close SettingsView repeatedly, screenshot each time" +- "Check console logs for deallocation messages" + +### Before/After Example + +**Before fix** (timer leak): +```bash +# After navigating to PlayerView 20 times: +# - Memory at 200MB +# - UI sluggish +# - Screenshot shows normal UI (but app will crash soon) +``` + +**After fix** (timer cleanup added): +```bash +# After navigating to PlayerView 20 times: +# - Memory stable at 50MB +# - UI responsive +# - Screenshot shows normal UI +# - Console logs show: "PlayerViewModel deinitialized" after each navigation +``` + +**Key verification**: Screenshots AND memory both stable = fix is correct + +--- + +**Last Updated**: 2025-11-28 +**Frameworks**: UIKit, SwiftUI, Combine, Foundation +**Status**: Production-ready patterns for leak detection and prevention diff --git a/skill-index/skills/axiom-networking/LEGACY-IOS12-25.md b/skill-index/skills/axiom-networking/LEGACY-IOS12-25.md new file mode 100644 index 0000000..b965738 --- /dev/null +++ b/skill-index/skills/axiom-networking/LEGACY-IOS12-25.md @@ -0,0 +1,375 @@ +# Legacy iOS 12-25 NWConnection Patterns + +These patterns use NWConnection with completion handlers for apps supporting iOS 12-25. If your app targets iOS 26+, use NetworkConnection with async/await instead (see main SKILL.md). + +--- + +## Pattern 2a: NWConnection with TLS (iOS 12-25) + +**Use when** Supporting iOS 12-25, need TLS encryption, can't use async/await yet + +**Time cost** 10-15 minutes + +#### ✅ GOOD: NWConnection with Completion Handlers + +```swift +import Network + +// Create connection with TLS +let connection = NWConnection( + host: NWEndpoint.Host("mail.example.com"), + port: NWEndpoint.Port(integerLiteral: 993), + using: .tls // TCP inferred +) + +// Handle connection state changes +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("Connection established") + self?.sendInitialData() + case .waiting(let error): + print("Waiting for network: \(error)") + // Show "Waiting..." UI, don't fail immediately + case .failed(let error): + print("Connection failed: \(error)") + case .cancelled: + print("Connection cancelled") + default: + break + } +} + +// Start connection +connection.start(queue: .main) + +// Send data with pacing +func sendData() { + let data = Data("Hello, world!".utf8) + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if let error = error { + print("Send error: \(error)") + return + } + // contentProcessed callback = network stack consumed data + // This is when you should send next chunk (pacing) + self?.sendNextChunk() + }) +} + +// Receive exact byte count +func receiveData() { + connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + print("Received \(data.count) bytes") + // Process data... + self?.receiveData() // Continue receiving + } + } +} +``` + +#### Key differences from NetworkConnection +- Must use `[weak self]` in all completion handlers to prevent retain cycles +- stateUpdateHandler receives state, not async sequence +- send/receive use completion callbacks, not async/await + +#### When to use +- Supporting iOS 12-15 (70% of devices as of 2024) +- Codebases not yet using async/await +- Libraries needing backward compatibility + +#### Migration to NetworkConnection (iOS 26+) +- stateUpdateHandler → connection.states async sequence +- Completion handlers → try await calls +- [weak self] → No longer needed (async/await handles cancellation) + +--- + +## Pattern 2b: NWConnection UDP Batch (iOS 12-25) + +**Use when** Supporting iOS 12-25, sending multiple UDP datagrams efficiently, need ~30% CPU reduction + +**Time cost** 10-15 minutes + +**Background** Traditional UDP sockets send one datagram per syscall. If you're sending 100 small packets, that's 100 context switches. Batching reduces this to ~1 syscall. + +#### ❌ BAD: Individual UDP Sends (High CPU) +```swift +// WRONG — 100 context switches for 100 packets +for frame in videoFrames { + sendto(socket, frame.bytes, frame.count, 0, &addr, addrlen) + // Each send = context switch to kernel +} +``` + +#### ✅ GOOD: Batched UDP Sends (30% Lower CPU) + +```swift +import Network + +// UDP connection +let connection = NWConnection( + host: NWEndpoint.Host("stream-server.example.com"), + port: NWEndpoint.Port(integerLiteral: 9000), + using: .udp +) + +connection.stateUpdateHandler = { state in + if case .ready = state { + print("Ready to send UDP") + } +} + +connection.start(queue: .main) + +// Batch sending for efficiency +func sendVideoFrames(_ frames: [Data]) { + connection.batch { + for frame in frames { + connection.send(content: frame, completion: .contentProcessed { error in + if let error = error { + print("Send error: \(error)") + } + }) + } + } + // All sends batched into ~1 syscall + // 30% lower CPU usage vs individual sends +} + +// Receive UDP datagrams +func receiveFrames() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + // Process video frame + self?.displayFrame(data) + self?.receiveFrames() // Continue receiving + } + } +} +``` + +#### Performance characteristics +- **Without batch** 100 datagrams = 100 syscalls = 100 context switches +- **With batch** 100 datagrams = ~1 syscall = 1 context switch +- **Result** ~30% lower CPU usage (measured with Instruments) + +#### When to use +- Real-time video/audio streaming +- Gaming with frequent updates (player position) +- High-frequency sensor data (IoT) + +**WWDC 2018 demo** Live video streaming showed 30% lower CPU on receiver with user-space networking + batching + +--- + +## Pattern 2c: NWListener (iOS 12-25) + +**Use when** Need to accept incoming connections, building servers or peer-to-peer apps, supporting iOS 12-25 + +**Time cost** 20-25 minutes + +#### ❌ BAD: Manual Socket Listening +```swift +// WRONG — Manual socket management +let sock = socket(AF_INET, SOCK_STREAM, 0) +bind(sock, &addr, addrlen) +listen(sock, 5) +while true { + let client = accept(sock, nil, nil) // Blocks thread + // Handle client... +} +``` + +#### ✅ GOOD: NWListener with Automatic Connection Handling + +```swift +import Network + +// Create listener with default parameters +let listener = try NWListener(using: .tcp, on: 1029) + +// Advertise Bonjour service +listener.service = NWListener.Service(name: "MyApp", type: "_myservice._tcp") + +// Handle service registration updates +listener.serviceRegistrationUpdateHandler = { update in + switch update { + case .add(let endpoint): + if case .service(let name, let type, let domain, _) = endpoint { + print("Advertising as: \(name).\(type)\(domain)") + } + default: + break + } +} + +// Handle incoming connections +listener.newConnectionHandler = { [weak self] newConnection in + print("New connection from: \(newConnection.endpoint)") + + // Configure connection + newConnection.stateUpdateHandler = { state in + switch state { + case .ready: + print("Client connected") + self?.handleClient(newConnection) + case .failed(let error): + print("Client connection failed: \(error)") + default: + break + } + } + + // Start handling this connection + newConnection.start(queue: .main) +} + +// Handle listener state +listener.stateUpdateHandler = { state in + switch state { + case .ready: + print("Listener ready on port \(listener.port ?? 0)") + case .failed(let error): + print("Listener failed: \(error)") + default: + break + } +} + +// Start listening +listener.start(queue: .main) + +// Handle client data +func handleClient(_ connection: NWConnection) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + + if let data = data { + print("Received \(data.count) bytes") + + // Echo back + connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Send error: \(error)") + } + }) + + self?.handleClient(connection) // Continue receiving + } + } +} +``` + +#### When to use +- Peer-to-peer apps (file sharing, messaging) +- Local network services +- Development/testing servers + +#### Bonjour advertising +- Automatic service discovery on local network +- No hardcoded IPs needed +- Works with NWBrowser for discovery + +#### Security considerations +- Use TLS parameters for encryption: `NWListener(using: .tls, on: port)` +- Validate client connections before processing data +- Set connection limits to prevent DoS + +--- + +## Pattern 2d: Network Discovery (iOS 12-25) + +**Use when** Discovering services on local network (Bonjour), building peer-to-peer apps, supporting iOS 12-25 + +**Time cost** 25-30 minutes + +#### ❌ BAD: Hardcoded IP Addresses +```swift +// WRONG — Brittle, requires manual configuration +let connection = NWConnection(host: "192.168.1.100", port: 9000, using: .tcp) +// What if IP changes? What if multiple devices? +``` + +#### ✅ GOOD: NWBrowser for Service Discovery + +```swift +import Network + +// Browse for services on local network +let browser = NWBrowser(for: .bonjour(type: "_myservice._tcp", domain: nil), using: .tcp) + +// Handle discovered services +browser.browseResultsChangedHandler = { results, changes in + for result in results { + switch result.endpoint { + case .service(let name, let type, let domain, _): + print("Found service: \(name).\(type)\(domain)") + // Connect to this service + self.connectToService(result.endpoint) + default: + break + } + } +} + +// Handle browser state +browser.stateUpdateHandler = { state in + switch state { + case .ready: + print("Browser ready") + case .failed(let error): + print("Browser failed: \(error)") + default: + break + } +} + +// Start browsing +browser.start(queue: .main) + +// Connect to discovered service +func connectToService(_ endpoint: NWEndpoint) { + let connection = NWConnection(to: endpoint, using: .tcp) + + connection.stateUpdateHandler = { state in + if case .ready = state { + print("Connected to service") + } + } + + connection.start(queue: .main) +} +``` + +#### When to use +- Peer-to-peer discovery (AirDrop-like features) +- Local network printers, media servers +- Development/testing (find test servers automatically) + +#### Performance characteristics +- mDNS-based (multicast DNS, no central server) +- Near-instant discovery on same subnet +- Automatic updates when services appear/disappear + +#### iOS 26+ alternative +- Use NetworkBrowser with Wi-Fi Aware for peer-to-peer without infrastructure +- See Pattern 1d in network-framework-ref skill + +--- + +Return to [main networking skill](SKILL.md). diff --git a/skill-index/skills/axiom-networking/MIGRATION.md b/skill-index/skills/axiom-networking/MIGRATION.md new file mode 100644 index 0000000..501003f --- /dev/null +++ b/skill-index/skills/axiom-networking/MIGRATION.md @@ -0,0 +1,235 @@ +# Network Framework Migration Guides + +## Migration 1: From BSD Sockets to NWConnection + +### Migration mapping + +| BSD Sockets | NWConnection | Notes | +|-------------|--------------|-------| +| `socket() + connect()` | `NWConnection(host:port:using:) + start()` | Non-blocking by default | +| `send() / sendto()` | `connection.send(content:completion:)` | Async, returns immediately | +| `recv() / recvfrom()` | `connection.receive(minimumIncompleteLength:maximumLength:completion:)` | Async, returns immediately | +| `bind() + listen()` | `NWListener(using:on:)` | Automatic port binding | +| `accept()` | `listener.newConnectionHandler` | Callback for each connection | +| `getaddrinfo()` | Let NWConnection handle DNS | Smart resolution with racing | +| `SCNetworkReachability` | `connection.stateUpdateHandler` waiting state | No race conditions | +| `setsockopt()` | `NWParameters` configuration | Type-safe options | + +### Example migration + +#### Before (BSD Sockets) +```c +// BEFORE — Blocking, manual DNS, error-prone +var hints = addrinfo() +hints.ai_family = AF_INET +hints.ai_socktype = SOCK_STREAM + +var results: UnsafeMutablePointer? +getaddrinfo("example.com", "443", &hints, &results) + +let sock = socket(results.pointee.ai_family, results.pointee.ai_socktype, 0) +connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // BLOCKS + +let data = "Hello".data(using: .utf8)! +data.withUnsafeBytes { ptr in + send(sock, ptr.baseAddress, data.count, 0) +} +``` + +#### After (NWConnection) +```swift +// AFTER — Non-blocking, automatic DNS, type-safe +let connection = NWConnection( + host: NWEndpoint.Host("example.com"), + port: NWEndpoint.Port(integerLiteral: 443), + using: .tls +) + +connection.stateUpdateHandler = { state in + if case .ready = state { + let data = Data("Hello".utf8) + connection.send(content: data, completion: .contentProcessed { error in + if let error = error { + print("Send failed: \(error)") + } + }) + } +} + +connection.start(queue: .main) +``` + +### Benefits +- 20 lines → 10 lines +- No manual DNS, no blocking, no unsafe pointers +- Automatic Happy Eyeballs, proxy support, WiFi Assist + +--- + +## Migration 2: From NWConnection to NetworkConnection (iOS 26+) + +### Why migrate +- Async/await eliminates callback hell +- TLV framing and Coder protocol built-in +- No [weak self] needed (async/await handles cancellation) +- State monitoring via async sequences + +### Migration mapping + +| NWConnection (iOS 12-25) | NetworkConnection (iOS 26+) | Notes | +|-------------------------|----------------------------|-------| +| `connection.stateUpdateHandler = { state in }` | `for await state in connection.states { }` | Async sequence | +| `connection.send(content:completion:)` | `try await connection.send(content)` | Suspending function | +| `connection.receive(minimumIncompleteLength:maximumLength:completion:)` | `try await connection.receive(exactly:)` | Suspending function | +| Manual JSON encode/decode | `Coder(MyType.self, using: .json)` | Built-in Codable support | +| Custom framer | `TLV { TLS() }` | Built-in Type-Length-Value | +| `[weak self]` everywhere | No `[weak self]` needed | Task cancellation automatic | + +### Example migration + +#### Before (NWConnection) +```swift +// BEFORE — Completion handlers, manual memory management +let connection = NWConnection(host: "example.com", port: 443, using: .tls) + +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + self?.sendData() + case .waiting(let error): + print("Waiting: \(error)") + case .failed(let error): + print("Failed: \(error)") + default: + break + } +} + +connection.start(queue: .main) + +func sendData() { + let data = Data("Hello".utf8) + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if let error = error { + print("Send error: \(error)") + return + } + self?.receiveData() + }) +} + +func receiveData() { + connection.receive(minimumIncompleteLength: 10, maximumLength: 10) { [weak self] (data, context, isComplete, error) in + if let error = error { + print("Receive error: \(error)") + return + } + if let data = data { + print("Received: \(data)") + } + } +} +``` + +#### After (NetworkConnection) +```swift +// AFTER — Async/await, automatic memory management +let connection = NetworkConnection( + to: .hostPort(host: "example.com", port: 443) +) { + TLS() +} + +// Monitor states in background task +Task { + for await state in connection.states { + switch state { + case .preparing: + print("Connecting...") + case .ready: + print("Ready") + case .waiting(let error): + print("Waiting: \(error)") + case .failed(let error): + print("Failed: \(error)") + default: + break + } + } +} + +// Send and receive with async/await +func sendAndReceive() async throws { + let data = Data("Hello".utf8) + try await connection.send(data) + + let received = try await connection.receive(exactly: 10).content + print("Received: \(received)") +} +``` + +### Benefits +- 30 lines → 15 lines +- No callback nesting, no [weak self] +- Errors propagate naturally with throws +- Automatic cancellation on Task exit + +--- + +## Migration 3: From URLSession StreamTask to NetworkConnection + +### When to migrate +- Need UDP (StreamTask only supports TCP) +- Need custom protocols beyond TCP/TLS +- Need low-level control (packet pacing, ECN, service class) + +### When to STAY with URLSession +- Doing HTTP/HTTPS (URLSession optimized for this) +- Need WebSocket support +- Need built-in caching, cookie handling + +### Example migration + +#### Before (URLSession StreamTask) +```swift +// BEFORE — URLSession for TCP/TLS stream +let task = URLSession.shared.streamTask(withHostName: "example.com", port: 443) + +task.resume() + +task.write(Data("Hello".utf8), timeout: 10) { error in + if let error = error { + print("Write error: \(error)") + } +} + +task.readData(ofMinLength: 10, maxLength: 10, timeout: 10) { data, atEOF, error in + if let error = error { + print("Read error: \(error)") + return + } + if let data = data { + print("Received: \(data)") + } +} +``` + +#### After (NetworkConnection) +```swift +// AFTER — NetworkConnection for TCP/TLS +let connection = NetworkConnection( + to: .hostPort(host: "example.com", port: 443) +) { + TLS() +} + +func sendAndReceive() async throws { + try await connection.send(Data("Hello".utf8)) + let data = try await connection.receive(exactly: 10).content + print("Received: \(data)") +} +``` + +--- + +Return to [main networking skill](SKILL.md). diff --git a/skill-index/skills/axiom-networking/REFERENCES.md b/skill-index/skills/axiom-networking/REFERENCES.md new file mode 100644 index 0000000..43f4b03 --- /dev/null +++ b/skill-index/skills/axiom-networking/REFERENCES.md @@ -0,0 +1,20 @@ +# Networking References + +## WWDC Sessions + +- **WWDC 2018-715** — Introducing Network.framework: User-space networking demo (30% CPU reduction), deprecation of CFSocket/NSStream/SCNetworkReachability, smart connection establishment, mobility support + +- **WWDC 2025-250** — Use structured concurrency with Network framework: NetworkConnection with async/await (iOS 26+), TLV framing and Coder protocol, NetworkListener and NetworkBrowser, Wi-Fi Aware peer-to-peer discovery + +## Apple Documentation + +- [Network Framework Documentation](https://developer.apple.com/documentation/network) +- [NWConnection](https://developer.apple.com/documentation/network/nwconnection) +- [NetworkConnection (iOS 26+)](https://developer.apple.com/documentation/Network/NetworkConnection) +- [Building a Custom Peer-to-Peer Protocol](https://developer.apple.com/documentation/Network/building-a-custom-peer-to-peer-protocol) + +## Related Axiom Skills + +- **networking-diag** — Systematic troubleshooting for connection timeouts, TLS failures, data not arriving, performance issues +- **network-framework-ref** — Comprehensive API reference with all 12 WWDC 2025 code examples, migration strategies, testing checklists +- **swift-concurrency** — Async/await patterns, @MainActor usage, Task cancellation (needed for NetworkConnection) diff --git a/skill-index/skills/axiom-networking/SKILL.md b/skill-index/skills/axiom-networking/SKILL.md new file mode 100644 index 0000000..1963cfd --- /dev/null +++ b/skill-index/skills/axiom-networking/SKILL.md @@ -0,0 +1,978 @@ +--- +name: axiom-networking +description: Use when implementing Network.framework connections, debugging connection failures, migrating from sockets/URLSession streams, or adopting structured concurrency networking patterns - prevents deprecated API usage, reachability anti-patterns, and thread-safety violations with iOS 12-26+ APIs +skill_type: discipline +version: 1.0.0 +last_updated: 2025-12-02 +apple_platforms: iOS 12+ (NWConnection), iOS 26+ (NetworkConnection) +--- + +# Network.framework Networking + +## When to Use This Skill + +Use when: +- Implementing UDP/TCP connections for gaming, streaming, or messaging apps +- Migrating from BSD sockets, CFSocket, NSStream, or SCNetworkReachability +- Debugging connection timeouts or TLS handshake failures +- Supporting network transitions (WiFi ↔ cellular) gracefully +- Adopting structured concurrency networking patterns (iOS 26+) +- Implementing custom protocols over TLS/QUIC +- Requesting code review of networking implementation before shipping + +#### Related Skills +- Use `axiom-networking-diag` for systematic troubleshooting of connection failures, timeouts, and performance issues +- Use `axiom-network-framework-ref` for comprehensive API reference with all WWDC examples + +## Example Prompts + +#### 1. "How do I migrate from SCNetworkReachability? My app checks connectivity before connecting." +#### 2. "My connection times out after 60 seconds. How do I debug this?" +#### 3. "Should I use NWConnection or NetworkConnection? What's the difference?" + +--- + +## Red Flags — Anti-Patterns to Prevent + +If you're doing ANY of these, STOP and use the patterns in this skill: + +### ❌ CRITICAL — Never Do These + +#### 1. Using SCNetworkReachability to check connectivity before connecting +```swift +// ❌ WRONG — Race condition +if SCNetworkReachabilityGetFlags(reachability, &flags) { + connection.start() // Network may change between check and start +} +``` +**Why this fails** Network state changes between reachability check and connect(). You miss Network.framework's smart connection establishment (Happy Eyeballs, proxy handling, WiFi Assist). Apple deprecated this API in 2018. + +#### 2. Blocking socket operations on main thread +```swift +// ❌ WRONG — Guaranteed ANR (Application Not Responding) +let socket = socket(AF_INET, SOCK_STREAM, 0) +connect(socket, &addr, addrlen) // Blocks main thread +``` +**Why this fails** Main thread hang → frozen UI → App Store rejection for responsiveness. Even "quick" connects take 200-500ms. + +#### 3. Manual DNS resolution with getaddrinfo +```swift +// ❌ WRONG — Misses Happy Eyeballs, proxies, VPN +var hints = addrinfo(...) +getaddrinfo("example.com", "443", &hints, &results) +// Now manually try each address... +``` +**Why this fails** You reimplement 10+ years of Apple's connection logic poorly. Misses IPv4/IPv6 racing, proxy evaluation, VPN detection. + +#### 4. Hardcoded IP addresses instead of hostnames +```swift +// ❌ WRONG — Breaks proxy/VPN compatibility +let host = "192.168.1.1" // or any IP literal +``` +**Why this fails** Proxy auto-configuration (PAC) needs hostname to evaluate rules. VPNs can't route properly. DNS-based load balancing broken. + +#### 5. Ignoring waiting state — not handling lack of connectivity +```swift +// ❌ WRONG — Poor UX +connection.stateUpdateHandler = { state in + if case .ready = state { + // Handle ready + } + // Missing: .waiting case +} +``` +**Why this fails** User sees "Connection failed" in Airplane Mode instead of "Waiting for network." No automatic retry when WiFi returns. + +#### 6. Not using [weak self] in NWConnection completion handlers +```swift +// ❌ WRONG — Memory leak +connection.send(content: data, completion: .contentProcessed { error in + self.handleSend(error) // Retain cycle: connection → handler → self → connection +}) +``` +**Why this fails** Connection retains completion handler, handler captures self strongly, self retains connection → memory leak. + +#### 7. Mixing async/await and completion handlers in NetworkConnection (iOS 26+) +```swift +// ❌ WRONG — Structured concurrency violation +Task { + let connection = NetworkConnection(...) + connection.send(data) // async/await + connection.stateUpdateHandler = { ... } // completion handler — don't mix +} +``` +**Why this fails** NetworkConnection designed for pure async/await. Mixing paradigms creates difficult error propagation and cancellation issues. + +#### 8. Not supporting network transitions +```swift +// ❌ WRONG — Connection fails on WiFi → cellular transition +// No viabilityUpdateHandler, no betterPathUpdateHandler +// User walks out of building → connection dies +``` +**Why this fails** Modern apps must handle network changes gracefully. 40% of connection failures happen during network transitions. + +--- + +## Mandatory First Steps + +**ALWAYS complete these steps** before writing any networking code: + +```swift +// Step 1: Identify your use case +// Record: "UDP gaming" vs "TLS messaging" vs "Custom protocol over QUIC" +// Ask: What data am I sending? Real-time? Reliable delivery needed? + +// Step 2: Check if URLSession is sufficient +// URLSession handles: HTTP, HTTPS, WebSocket, TCP/TLS streams (via StreamTask) +// Network.framework handles: UDP, custom protocols, low-level control, peer-to-peer + +// If HTTP/HTTPS/WebSocket → STOP, use URLSession instead +// Example: +URLSession.shared.dataTask(with: url) { ... } // ✅ Correct for HTTP + +// Step 3: Choose API version based on deployment target +if #available(iOS 26, *) { + // Use NetworkConnection (structured concurrency, async/await) + // TLV framing built-in, Coder protocol for Codable types +} else { + // Use NWConnection (completion handlers) + // Manual framing or custom framers +} + +// Step 4: Verify you're NOT using deprecated APIs +// Search your codebase for these: +// - SCNetworkReachability → Use connection waiting state +// - CFSocket → Use NWConnection +// - NSStream, CFStream → Use NWConnection +// - NSNetService → Use NWBrowser or NetworkBrowser +// - getaddrinfo → Let Network.framework handle DNS + +// To search: +// grep -rn "SCNetworkReachability\|CFSocket\|NSStream\|getaddrinfo" . +``` + +#### What this tells you +- If HTTP/HTTPS: Use URLSession, not Network.framework +- If iOS 26+ deployment: Use NetworkConnection with async/await +- If iOS 12-25 support needed: Use NWConnection with completion handlers +- If any deprecated API found: Must migrate before shipping (App Store review concern) + +--- + +## Decision Tree + +Use this to select the correct pattern in 2 minutes: + +``` +Need networking? +├─ HTTP, HTTPS, or WebSocket? +│ └─ YES → Use URLSession (NOT Network.framework) +│ ✅ URLSession.shared.dataTask(with: url) +│ ✅ URLSession.webSocketTask(with: url) +│ ✅ URLSession.streamTask(withHostName:port:) for TCP/TLS +│ +├─ iOS 26+ and can use structured concurrency? +│ └─ YES → NetworkConnection path (async/await) +│ ├─ TCP with TLS security? +│ │ └─ Pattern 1a: NetworkConnection + TLS +│ │ Time: 10-15 minutes +│ │ +│ ├─ UDP for gaming/streaming? +│ │ └─ Pattern 1b: NetworkConnection + UDP +│ │ Time: 10-15 minutes +│ │ +│ ├─ Need message boundaries (framing)? +│ │ └─ Pattern 1c: TLV Framing +│ │ Type-Length-Value for mixed message types +│ │ Time: 15-20 minutes +│ │ +│ └─ Send/receive Codable objects directly? +│ └─ Pattern 1d: Coder Protocol +│ No manual JSON encoding needed +│ Time: 10-15 minutes +│ +└─ iOS 12-25 or need completion handlers? + └─ YES → NWConnection path (callbacks) + ├─ TCP with TLS security? + │ └─ Pattern 2a: NWConnection + TLS + │ stateUpdateHandler, completion-based send/receive + │ Time: 15-20 minutes + │ + ├─ UDP streaming with batching? + │ └─ Pattern 2b: NWConnection + UDP Batch + │ connection.batch for 30% CPU reduction + │ Time: 10-15 minutes + │ + ├─ Listening for incoming connections? + │ └─ Pattern 2c: NWListener + │ Accept inbound connections, newConnectionHandler + │ Time: 20-25 minutes + │ + └─ Network discovery (Bonjour)? + └─ Pattern 2d: NWBrowser + Discover services on local network + Time: 25-30 minutes +``` + +#### Quick selection guide +- Gaming (low latency, some loss OK) → UDP patterns (1b or 2b) +- Messaging (reliable, ordered) → TLS patterns (1a or 2a) +- Mixed message types → TLV or Coder (1c or 1d) +- Peer-to-peer → Discovery patterns (2d) + incoming (2c) + +--- + +## Common Patterns + +### Pattern 1a: NetworkConnection with TLS (iOS 26+) + +**Use when** iOS 26+ deployment, need reliable TCP with TLS security, want async/await + +**Time cost** 10-15 minutes + +#### ❌ BAD: Manual DNS, Blocking Socket +```swift +// WRONG — Don't do this +var hints = addrinfo(...) +getaddrinfo("www.example.com", "1029", &hints, &results) +let sock = socket(AF_INET, SOCK_STREAM, 0) +connect(sock, results.pointee.ai_addr, results.pointee.ai_addrlen) // Blocks! +``` + +#### ✅ GOOD: NetworkConnection with Declarative Stack + +```swift +import Network + +// Basic connection with TLS +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLS() // TCP and IP inferred automatically +} + +// Send and receive with async/await +public func sendAndReceiveWithTLS() async throws { + let outgoingData = Data("Hello, world!".utf8) + try await connection.send(outgoingData) + + let incomingData = try await connection.receive(exactly: 98).content + print("Received data: \(incomingData)") +} + +// Optional: Monitor connection state for UI updates +Task { + for await state in connection.states { + switch state { + case .preparing: + print("Establishing connection...") + case .ready: + print("Connected!") + case .waiting(let error): + print("Waiting for network: \(error)") + case .failed(let error): + print("Connection failed: \(error)") + case .cancelled: + print("Connection cancelled") + @unknown default: + break + } + } +} +``` + +#### Custom parameters for low data mode + +```swift +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029), + using: .parameters { + TLS { + TCP { + IP() + .fragmentationEnabled(false) + } + } + } + .constrainedPathsProhibited(true) // Don't use cellular in low data mode +) +``` + +#### When to use +- Secure messaging, email protocols (IMAP, SMTP) +- Custom protocols requiring encryption +- APIs using non-HTTP protocols + +#### Performance characteristics +- Smart connection establishment: Happy Eyeballs (IPv4/IPv6 racing), proxy evaluation, VPN detection +- TLS 1.3 by default (faster handshake) +- User-space networking: ~30% lower CPU usage vs sockets + +#### Debugging +- Enable logging: `-NWLoggingEnabled 1 -NWConnectionLoggingEnabled 1` +- Check connection.states async sequence for state transitions +- Test on real device with Airplane Mode toggle + +--- + +### Pattern 1b: NetworkConnection UDP (iOS 26+) + +**Use when** iOS 26+ deployment, need UDP datagrams for gaming or real-time streaming, want async/await + +**Time cost** 10-15 minutes + +#### ❌ BAD: Blocking UDP Socket +```swift +// WRONG — Don't do this +let sock = socket(AF_INET, SOCK_DGRAM, 0) +let sent = sendto(sock, buffer, length, 0, &addr, addrlen) +// Blocks, no batching, axiom-high CPU overhead +``` + +#### ✅ GOOD: NetworkConnection with UDP + +```swift +import Network + +// UDP connection for real-time data +let connection = NetworkConnection( + to: .hostPort(host: "game-server.example.com", port: 9000) +) { + UDP() +} + +// Send game state update +public func sendGameUpdate() async throws { + let gameState = Data("player_position:100,50".utf8) + try await connection.send(gameState) +} + +// Receive game updates +public func receiveGameUpdates() async throws { + while true { + let (data, _) = try await connection.receive() + processGameState(data) + } +} + +// Batch multiple datagrams for efficiency (30% CPU reduction) +public func sendMultipleUpdates(_ updates: [Data]) async throws { + for update in updates { + try await connection.send(update) + } +} +``` + +#### When to use +- Real-time gaming (player position, game state) +- Live streaming (video/audio frames where some loss is acceptable) +- IoT telemetry (sensor data) + +#### Performance characteristics +- User-space networking: ~30% lower CPU vs sockets +- Batching multiple sends reduces context switches +- ECN (Explicit Congestion Notification) enabled automatically + +#### Debugging +- Use Instruments Network template to profile datagram throughput +- Check for packet loss with receive timeouts +- Test on cellular network (higher latency/loss) + +--- + +### Pattern 1c: TLV Framing (iOS 26+) + +**Use when** Need message boundaries on stream protocols (TCP/TLS), have mixed message types, want type-safe message handling + +**Time cost** 15-20 minutes + +**Background** Stream protocols (TCP/TLS) don't preserve message boundaries. If you send 3 chunks, receiver might get them 1 byte at a time, or all at once. TLV (Type-Length-Value) solves this by encoding each message with its type and length. + +#### ❌ BAD: Manual Length Prefix Parsing +```swift +// WRONG — Error-prone, boilerplate-heavy +let lengthData = try await connection.receive(exactly: 4).content +let length = lengthData.withUnsafeBytes { $0.load(as: UInt32.self) } +let messageData = try await connection.receive(exactly: Int(length)).content +// Now decode manually... +``` + +#### ✅ GOOD: TLV Framing with Type Safety + +```swift +import Network + +// Define your message types +enum GameMessage: Int { + case selectedCharacter = 0 + case move = 1 +} + +struct GameCharacter: Codable { + let character: String +} + +struct GameMove: Codable { + let row: Int + let column: Int +} + +// Connection with TLV framing +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + TLV { + TLS() + } +} + +// Send typed messages +public func sendWithTLV() async throws { + let characterData = try JSONEncoder().encode(GameCharacter(character: "🐨")) + try await connection.send(characterData, type: GameMessage.selectedCharacter.rawValue) +} + +// Receive typed messages +public func receiveWithTLV() async throws { + let (incomingData, metadata) = try await connection.receive() + + switch GameMessage(rawValue: metadata.type) { + case .selectedCharacter: + let character = try JSONDecoder().decode(GameCharacter.self, from: incomingData) + print("Character selected: \(character)") + case .move: + let move = try JSONDecoder().decode(GameMove.self, from: incomingData) + print("Move: row=\(move.row), column=\(move.column)") + case .none: + print("Unknown message type: \(metadata.type)") + } +} +``` + +#### When to use +- Mixed message types in same connection (chat + presence + typing indicators) +- Existing protocols using TLV (many custom protocols) +- Need message boundaries without heavy framing overhead + +#### How it works +- Type: UInt32 message identifier (your enum raw value) +- Length: UInt32 message size (automatic) +- Value: Actual message bytes + +#### Performance characteristics +- Minimal overhead: 8 bytes per message (type + length) +- No manual parsing: Framework handles framing +- Type-safe: Compiler catches message type errors + +--- + +### Pattern 1d: Coder Protocol (iOS 26+) + +**Use when** Sending/receiving Codable types, want to eliminate JSON boilerplate, need type-safe message handling + +**Time cost** 10-15 minutes + +**Background** Most apps manually encode Codable types to JSON, send bytes, receive bytes, decode JSON. Coder protocol eliminates this boilerplate by handling serialization automatically. + +#### ❌ BAD: Manual JSON Encoding/Decoding +```swift +// WRONG — Boilerplate-heavy, error-prone +let encoder = JSONEncoder() +let data = try encoder.encode(message) +try await connection.send(data) + +let receivedData = try await connection.receive().content +let decoder = JSONDecoder() +let message = try decoder.decode(GameMessage.self, from: receivedData) +``` + +#### ✅ GOOD: Coder Protocol for Direct Codable Send/Receive + +```swift +import Network + +// Define message types as Codable enum +enum GameMessage: Codable { + case selectedCharacter(String) + case move(row: Int, column: Int) +} + +// Connection with Coder protocol +let connection = NetworkConnection( + to: .hostPort(host: "www.example.com", port: 1029) +) { + Coder(GameMessage.self, using: .json) { + TLS() + } +} + +// Send Codable types directly +public func sendWithCoder() async throws { + let selectedCharacter: GameMessage = .selectedCharacter("🐨") + try await connection.send(selectedCharacter) // No encoding needed! +} + +// Receive Codable types directly +public func receiveWithCoder() async throws { + let gameMessage = try await connection.receive().content // Returns GameMessage! + + switch gameMessage { + case .selectedCharacter(let character): + print("Character selected: \(character)") + case .move(let row, let column): + print("Move: (\(row), \(column))") + } +} +``` + +#### Supported formats +- `.json` — JSON encoding (most common, human-readable) +- `.propertyList` — Property list encoding (smaller, faster) + +#### When to use +- App-to-app communication (you control both ends) +- Prototyping (fastest time to working code) +- Type-safe protocols (compiler catches message structure changes) + +#### When NOT to use +- Interoperating with non-Swift servers +- Need custom wire format +- Performance-critical (prefer TLV with manual encoding for control) + +#### Benefits +- No JSON boilerplate: ~50 lines → ~10 lines +- Type-safe: Compiler catches message structure changes +- Automatic framing: Handles message boundaries + +--- + +## Legacy iOS 12-25 Patterns + +For apps supporting iOS 12-25 that can't use async/await yet, see [LEGACY-IOS12-25.md](LEGACY-IOS12-25.md): +- Pattern 2a: NWConnection with TLS (completion handlers) +- Pattern 2b: NWConnection UDP Batch (30% CPU reduction) +- Pattern 2c: NWListener (accepting connections, Bonjour) +- Pattern 2d: Network Discovery (NWBrowser for service discovery) + + +## Pressure Scenarios + +### Scenario 1: Reachability Race Condition Under App Store Deadline + +#### Context + +You're 3 days from App Store submission. QA reports connection failures on cellular networks (15% failure rate). Your PM reviews the code and suggests: "Just add a reachability check before connecting. If there's no network, show an error immediately instead of timing out." + +#### Pressure signals +- ⏰ **Deadline pressure** "App Store deadline is Friday. We need this fixed by EOD Wednesday." +- 👔 **Authority pressure** PM (non-technical) suggesting specific implementation +- 💸 **Sunk cost** Already spent 2 hours debugging connection logs, found nothing obvious +- 📊 **Customer impact** "15% of users affected, mostly on cellular" + +#### Rationalization trap + +*"SCNetworkReachability is Apple's API, it must be correct. I've seen it in Stack Overflow answers with 500+ upvotes. Adding a quick reachability check will fix the issue today, and I can refactor it properly after launch. The deadline is more important than perfect code right now."* + +#### Why this fails + +1. **Race condition** Network state changes between reachability check and connection start. You check "WiFi available" at 10:00:00.000, but WiFi disconnects at 10:00:00.050, then you call connection.start() at 10:00:00.100. Connection fails, but reachability said it was available. + +2. **Misses smart connection establishment** Network.framework tries multiple strategies (IPv4, IPv6, proxies, WiFi Assist fallback to cellular). SCNetworkReachability gives you "yes/no" but doesn't tell you which strategy will work. + +3. **Deprecated API** Apple explicitly deprecated SCNetworkReachability in WWDC 2018. App Store Review may flag this as using legacy APIs. + +4. **Doesn't solve actual problem** 15% cellular failures likely caused by not handling waiting state, not by absence of reachability check. + +#### MANDATORY response + +```swift +// ❌ NEVER check reachability before connecting +/* +if SCNetworkReachabilityGetFlags(reachability, &flags) { + if flags.contains(.reachable) { + connection.start() + } else { + showError("No network") // RACE CONDITION + } +} +*/ + +// ✅ ALWAYS let Network.framework handle waiting state +let connection = NWConnection( + host: NWEndpoint.Host("api.example.com"), + port: NWEndpoint.Port(integerLiteral: 443), + using: .tls +) + +connection.stateUpdateHandler = { [weak self] state in + switch state { + case .preparing: + // Show: "Connecting..." + self?.showStatus("Connecting...") + + case .ready: + // Connection established + self?.hideStatus() + self?.sendRequest() + + case .waiting(let error): + // CRITICAL: Don't fail here, show "Waiting for network" + // Network.framework will automatically retry when network returns + print("Waiting for network: \(error)") + self?.showStatus("Waiting for network...") + // User walks out of elevator → WiFi returns → automatic retry + + case .failed(let error): + // Only fail after framework exhausts all options + // (tried IPv4, IPv6, proxies, WiFi Assist, waited for network) + print("Connection failed: \(error)") + self?.showError("Connection failed. Please check your network.") + + case .cancelled: + self?.hideStatus() + + @unknown default: + break + } +} + +connection.start(queue: .main) +``` + +#### Professional push-back template + +*"I understand the deadline pressure. However, adding SCNetworkReachability will create a race condition that will make the 15% failure rate worse, not better. Apple deprecated this API in 2018 specifically because it causes these issues.* + +*The correct fix is to handle the waiting state properly, which Network.framework provides. This will actually solve the cellular failures because the framework will automatically retry when network becomes available (e.g., user walks out of elevator, WiFi returns).* + +*Implementation time: 15 minutes to add waiting state handler vs 2-4 hours debugging reachability race conditions. The waiting state approach is both faster AND more reliable."* + +#### Time saved +- **Reachability approach** 30 min to implement + 2-4 hours debugging race conditions + potential App Store rejection = 3-5 hours total +- **Waiting state approach** 15 minutes to implement + 0 hours debugging = 15 minutes total +- **Savings** 2.5-4.5 hours + avoiding App Store review issues + +#### Actual root cause of 15% cellular failures + +Likely missing waiting state handler. When user is in area with weak cellular, connection moves to waiting state. Without handler, app shows "Connection failed" instead of "Waiting for network," so user force-quits and reports "doesn't work on cellular." + +--- + +### Scenario 2: Blocking Socket Call Causing Main Thread Hang + +#### Context + +Your app has 1-star reviews: "App freezes for 5-10 seconds randomly." After investigation, you find a "quick" socket connect() call on the main thread. Your tech lead says: "This is a legacy code path from 2015. It only connects to localhost (127.0.0.1), so it should be instant. The real fix is a 3-week refactor to move all networking to a background queue, but we don't have time. Just leave it for now." + +#### Pressure signals +- ⏰ **Time pressure** "3-week refactor, we're in feature freeze for 2.0 launch" +- 💸 **Sunk cost** "This code has worked for 8 years, why change it now?" +- 🎯 **Scope pressure** "It's just localhost, not a real network call" +- 📊 **Low frequency** "Only 2% of users see this freeze" + +#### Rationalization trap + +*"Connecting to localhost is basically instant. The freeze must be caused by something else. Besides, refactoring this legacy code is risky—what if I break something? Better to leave working code alone and focus on the new features for 2.0."* + +#### Why this fails + +1. **Even localhost can block** If the app has many threads, the kernel may schedule other work before returning from connect(). Even 50-100ms is visible to users as a stutter. + +2. **ANR (Application Not Responding)** iOS watchdog will terminate your app if main thread blocks for >5 seconds. This explains "random" crashes. + +3. **Localhost isn't always available** If VPN is active, localhost routing can be delayed. If device is under memory pressure, kernel scheduling is slower. + +4. **Guaranteed App Store rejection** Apple's App Store Review Guidelines explicitly check for main thread blocking. This will fail App Review's performance tests. + +#### MANDATORY response + +```swift +// ❌ NEVER call blocking socket APIs on main thread +/* +let sock = socket(AF_INET, SOCK_STREAM, 0) +connect(sock, &addr, addrlen) // BLOCKS MAIN THREAD → ANR +*/ + +// ✅ ALWAYS use async connection, even for localhost +func connectToLocalhost() { + let connection = NWConnection( + host: "127.0.0.1", + port: 8080, + using: .tcp + ) + + connection.stateUpdateHandler = { [weak self] state in + switch state { + case .ready: + print("Connected to localhost") + self?.sendRequest(on: connection) + case .failed(let error): + print("Localhost connection failed: \(error)") + default: + break + } + } + + // Non-blocking, returns immediately + connection.start(queue: .main) +} +``` + +#### Alternative: If you must keep legacy socket code (not recommended) + +```swift +// Move blocking call to background queue (minimum viable fix) +DispatchQueue.global(qos: .userInitiated).async { + let sock = socket(AF_INET, SOCK_STREAM, 0) + connect(sock, &addr, addrlen) // Still blocks, but not main thread + + DispatchQueue.main.async { + // Update UI after connection + } +} +``` + +#### Professional push-back template + +*"I understand this code has been stable for 8 years. However, Apple's App Store Review now runs automated performance tests that will fail apps with main thread blocking. This will block our 2.0 release.* + +*The fix doesn't require a 3-week refactor. I can wrap the existing socket code in a background queue dispatch in 30 minutes. Or, I can replace it with NWConnection (non-blocking) in 45 minutes, which also eliminates the socket management code entirely.* + +*Neither approach requires touching other parts of the codebase. We can ship 2.0 on schedule AND fix the ANR crashes."* + +#### Time saved +- **Leave it alone** 0 hours upfront + 4-8 hours when App Review rejects + user churn from 1-star reviews +- **Background queue fix** 30 minutes = main thread safe +- **NWConnection fix** 45 minutes = main thread safe + eliminates socket management +- **Savings** 3-7 hours + avoiding App Store rejection + +--- + +### Scenario 3: Design Review Pressure — "Use WebSockets for Everything" + +#### Context + +Your team is building a multiplayer game with real-time player positions (20 updates/second). In architecture review, the senior architect says: "All our other apps use WebSockets for networking. We should use WebSockets here too for consistency. It's production-proven, and the backend team already knows how to deploy WebSocket servers." + +#### Pressure signals +- 👔 **Authority pressure** Senior architect with 15 years experience +- 🏢 **Org consistency** "All other apps use WebSockets" +- 💼 **Backend expertise** "Backend team doesn't know UDP" +- 📊 **Proven technology** "WebSockets are battle-tested" + +#### Rationalization trap + +*"The architect has way more experience than me. If WebSockets work for the other apps, they'll work here too. UDP sounds complicated and risky. Better to stick with proven technology than introduce something new that might break in production."* + +#### Why this fails for real-time gaming + +1. **Head-of-line blocking** WebSockets use TCP. If one packet is lost, TCP blocks ALL subsequent packets until retransmission succeeds. In a game, this means old player position (frame 100) blocks new position (frame 120), causing stutter. + +2. **Latency overhead** TCP requires 3-way handshake (SYN, SYN-ACK, ACK) before sending data. For 20 updates/second, this overhead adds 50-150ms latency. + +3. **Unnecessary reliability** Game position updates don't need guaranteed delivery. If frame 100 is lost, frame 101 (5ms later) makes it obsolete. TCP retransmits frame 100, wasting bandwidth. + +4. **Connection establishment** WebSockets require HTTP upgrade handshake (4 round trips) before data transfer. UDP starts sending immediately. + +#### MANDATORY response + +```swift +// ❌ WRONG for real-time gaming +/* +let webSocket = URLSession.shared.webSocketTask(with: url) +webSocket.resume() +webSocket.send(.data(positionUpdate)) { error in + // TCP guarantees delivery but blocks on loss + // Old position blocks new position → stutter +} +*/ + +// ✅ CORRECT for real-time gaming +let connection = NWConnection( + host: NWEndpoint.Host("game-server.example.com"), + port: NWEndpoint.Port(integerLiteral: 9000), + using: .udp +) + +connection.stateUpdateHandler = { state in + if case .ready = state { + print("Ready to send game updates") + } +} + +connection.start(queue: .main) + +// Send player position updates (20/second) +func sendPosition(_ position: PlayerPosition) { + let data = encodePosition(position) + connection.send(content: data, completion: .contentProcessed { error in + // Fire and forget, no blocking + // If this frame is lost, next frame (50ms later) makes it obsolete + }) +} +``` + +#### Technical comparison table + +| Aspect | WebSocket (TCP) | UDP | +|--------|----------------|-----| +| Latency (typical) | 50-150ms | 10-30ms | +| Head-of-line blocking | Yes (old data blocks new) | No | +| Connection setup | 4 round trips (HTTP upgrade) | 0 round trips | +| Packet loss handling | Blocks until retransmit | Continues with next packet | +| Bandwidth (20 updates/sec) | ~40 KB/s | ~20 KB/s | +| Best for | Chat, API calls | Gaming, streaming | + +#### Professional push-back template + +*"I appreciate the concern about consistency and proven technology. WebSockets are excellent for our other apps because they're doing chat, notifications, and API calls—use cases where guaranteed delivery matters.* + +*However, real-time gaming has different requirements. Let me explain with a concrete example:* + +*Player moves from position A to B to C (3 updates in 150ms). With WebSockets:* +*- Frame A sent* +*- Frame A packet lost* +*- Frame B sent, but TCP blocks it (waiting for Frame A retransmit)* +*- Frame C sent, also blocked* +*- Frame A retransmits, arrives 200ms later* +*- Frames B and C finally delivered* +*- Result: 200ms of frozen player position, then sudden jump to C* + +*With UDP:* +*- Frame A sent and lost* +*- Frame B sent and delivered (50ms later)* +*- Frame C sent and delivered (50ms later)* +*- Result: Smooth position updates, no freeze* + +*The backend team doesn't need to learn UDP from scratch—they can use the same Network.framework on server-side Swift (Vapor, Hummingbird). Implementation time is the same.* + +*I'm happy to do a proof-of-concept this week showing latency comparison. We can measure both approaches with real data."* + +#### When WebSockets ARE correct +- Chat applications (message delivery must be reliable) +- Turn-based games (moves must arrive in order) +- API calls over persistent connection +- Live notifications/updates + +#### Time saved +- **WebSocket approach** 2 days implementation + 1-2 weeks debugging stutter/lag issues + potential rewrite = 3-4 weeks +- **UDP approach** 2 days implementation + smooth gameplay = 2 days +- **Savings** 2-3 weeks + better user experience + +--- + +## Migration Guides + +For detailed migration guides from legacy networking APIs, see [MIGRATION.md](MIGRATION.md): +- Migration 1: BSD Sockets → NWConnection +- Migration 2: NWConnection → NetworkConnection (iOS 26+) +- Migration 3: URLSession StreamTask → NetworkConnection + + +## Checklist + +Before shipping networking code, verify: + +### Deprecated API Check +- [ ] Not using SCNetworkReachability anywhere in codebase +- [ ] Not using CFSocket, NSSocket, or BSD sockets directly +- [ ] Not using NSStream or CFStream +- [ ] Not using NSNetService (use NWBrowser instead) +- [ ] Not calling getaddrinfo for manual DNS resolution + +### Connection Configuration +- [ ] Using hostname, NOT hardcoded IP address +- [ ] TLS enabled for sensitive data (passwords, tokens, user content) +- [ ] Handling waiting state with user feedback ("Waiting for network...") +- [ ] Not checking reachability before calling connection.start() + +### Memory Management +- [ ] Using [weak self] in all NWConnection completion handlers +- [ ] Or using NetworkConnection (iOS 26+) with async/await (no [weak self] needed) +- [ ] Calling connection.cancel() when done to free resources + +### Network Transitions +- [ ] Supporting network changes (WiFi → cellular, or vice versa) +- [ ] Using viabilityUpdateHandler or betterPathUpdateHandler (NWConnection) +- [ ] Or monitoring connection.states async sequence (NetworkConnection) +- [ ] NOT tearing down connection immediately on viability change + +### Testing on Real Devices +- [ ] Tested on real device (not just simulator) +- [ ] Tested WiFi → cellular transition (walk out of building) +- [ ] Tested Airplane Mode toggle (enable → disable) +- [ ] Tested on IPv6-only network (some cellular carriers) +- [ ] Tested with corporate VPN active +- [ ] Tested with low signal (basement, elevator) + +### Performance +- [ ] Using connection.batch for multiple UDP datagrams (30% CPU reduction) +- [ ] Using contentProcessed completion for send pacing (not sleep()) +- [ ] Profiled with Instruments Network template +- [ ] Connection establishment < 500ms (check with logging) + +### Error Handling +- [ ] Handling .failed state with specific error +- [ ] Timeout handling (don't wait forever in .preparing) +- [ ] TLS handshake errors logged for debugging +- [ ] User-facing errors are actionable ("Check network" not "POSIX error 61") + +### iOS 26+ Features (if using NetworkConnection) +- [ ] Using TLV framing if need message boundaries +- [ ] Using Coder protocol if sending Codable types +- [ ] Using NetworkListener instead of NWListener +- [ ] Using NetworkBrowser with Wi-Fi Aware for peer-to-peer + +--- + +## Real-World Impact + +### User-Space Networking: 30% CPU Reduction + +**WWDC 2018 Demo** Live UDP video streaming comparison: +- **BSD sockets** ~30% higher CPU usage on receiver +- **Network.framework** ~30% lower CPU usage + +**Why** Traditional sockets copy data kernel → userspace. Network.framework uses memory-mapped regions (no copy) and reduces context switches from 100 syscalls → ~1 syscall (with batching). + +#### Impact for your app +- Lower battery drain (30% less CPU = longer battery life) +- Smoother gameplay (more CPU for rendering) +- Cooler device (less thermal throttling) + +### Smart Connection Establishment: 50% Faster + +#### Traditional approach +1. Call getaddrinfo (100-300ms DNS lookup) +2. Try first IPv6 address, wait 5 seconds for timeout +3. Try IPv4 address, finally connects + +#### Network.framework (Happy Eyeballs) +1. Start DNS lookup in background +2. As soon as first address arrives, try connecting +3. Start second connection attempt 50ms later +4. Use whichever connects first + +**Result** 50% faster connection establishment in dual-stack environments (measured by Apple) + +### Proper State Handling: 10x Crash Reduction + +**Customer report** App crash rate dropped from 5% → 0.5% after implementing waiting state handler. + +**Before** App showed "Connection failed" when no network, users force-quit app → crash report. + +**After** App showed "Waiting for network" and automatically retried when WiFi returned → users saw seamless reconnection. + +--- + +## Resources + +**WWDC**: 2018-715, 2025-250 + +**Skills**: axiom-networking-diag, axiom-network-framework-ref + +--- + +**Last Updated** 2025-12-02 +**Status** Production-ready patterns from WWDC 2018 and WWDC 2025 +**Tested** Patterns validated against Apple documentation and WWDC transcripts diff --git a/skill-index/skills/axiom-swift-concurrency/SKILL.md b/skill-index/skills/axiom-swift-concurrency/SKILL.md new file mode 100644 index 0000000..0258261 --- /dev/null +++ b/skill-index/skills/axiom-swift-concurrency/SKILL.md @@ -0,0 +1,950 @@ +--- +name: axiom-swift-concurrency +description: Use when you see 'actor-isolated', 'Sendable', 'data race', '@MainActor' errors, or when asking 'why is this not thread safe', 'how do I use async/await', 'what is @MainActor for', 'my app is crashing with concurrency errors', 'how do I fix data races' - Swift 6 strict concurrency patterns with actor isolation and async/await +skill_type: discipline +version: 1.0.0 +--- + +# Swift 6 Concurrency Guide + +**Purpose**: Progressive journey from single-threaded to concurrent Swift code +**Swift Version**: Swift 6.0+, Swift 6.2+ for `@concurrent` +**iOS Version**: iOS 17+ (iOS 18.2+ for `@concurrent`) +**Xcode**: Xcode 16+ (Xcode 16.2+ for `@concurrent`) +**Context**: WWDC 2025-268 "Embracing Swift concurrency" - approachable path to data-race safety + +## When to Use This Skill + +✅ **Use this skill when**: +- Starting a new project and deciding concurrency strategy +- Debugging Swift 6 concurrency errors (actor isolation, data races, Sendable warnings) +- Deciding when to introduce async/await vs concurrency +- Implementing `@MainActor` classes or async functions +- Converting delegate callbacks to async-safe patterns +- Deciding between `@MainActor`, `nonisolated`, `@concurrent`, or actor isolation +- Resolving "Sending 'self' risks causing data races" errors +- Making types conform to `Sendable` +- Offloading CPU-intensive work to background threads +- UI feels unresponsive and profiling shows main thread bottleneck + +❌ **Do NOT use this skill for**: +- General Swift syntax (use Swift documentation) +- SwiftUI-specific patterns (use `axiom-swiftui-debugging` or `axiom-swiftui-performance`) +- API-specific patterns (use API documentation) + +## Core Philosophy: Start Single-Threaded + +> **Apple's Guidance (WWDC 2025-268)**: "Your apps should start by running all of their code on the main thread, and you can get really far with single-threaded code." + +### The Progressive Journey + +``` +Single-Threaded → Asynchronous → Concurrent → Actors + ↓ ↓ ↓ ↓ + Start here Hide latency Background Move data + (network) CPU work off main +``` + +**When to advance**: +1. **Stay single-threaded** if UI is responsive and operations are fast +2. **Add async/await** when high-latency operations (network, file I/O) block UI +3. **Add concurrency** when CPU-intensive work (image processing, parsing) freezes UI +4. **Add actors** when too much main actor code causes contention + +**Key insight**: Concurrent code is more complex. Only introduce concurrency when profiling shows it's needed. + +--- + +## Step 1: Single-Threaded Code (Start Here) + +All code runs on the **main thread** by default in Swift 6. + +```swift +// ✅ Simple, single-threaded +class ImageModel { + var imageCache: [URL: Image] = [:] + + func fetchAndDisplayImage(url: URL) throws { + let data = try Data(contentsOf: url) // Reads local file + let image = decodeImage(data) + view.displayImage(image) + } + + func decodeImage(_ data: Data) -> Image { + // Decode image data + return Image() + } +} +``` + +**Main Actor Mode** (Xcode 26+): +- Enabled by default for new projects +- All code protected by `@MainActor` unless explicitly marked otherwise +- Access shared state safely without worrying about concurrent access + +**Build Setting** (Xcode 26+): +``` +Build Settings → Swift Compiler — Language +→ "Default Actor Isolation" = Main Actor + +Build Settings → Swift Compiler — Upcoming Features +→ "Approachable Concurrency" = Yes +``` + +**When this is enough**: If all operations are fast (<16ms for 60fps), stay single-threaded! + +--- + +## Step 2: Asynchronous Tasks (Hide Latency) + +Add async/await when **waiting on data** (network, file I/O) would freeze UI. + +### Problem: Network Access Blocks UI + +```swift +// ❌ Blocks main thread until network completes +func fetchAndDisplayImage(url: URL) throws { + let (data, _) = try URLSession.shared.data(from: url) // ❌ Freezes UI! + let image = decodeImage(data) + view.displayImage(image) +} +``` + +### Solution: Async/Await + +```swift +// ✅ Suspends without blocking main thread +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) // ✅ Suspends here + let image = decodeImage(data) // ✅ Resumes here when data arrives + view.displayImage(image) +} +``` + +**What happens**: +1. Function starts on main thread +2. `await` suspends function without blocking main thread +3. URLSession fetches data on background thread (library handles this) +4. Function resumes on main thread when data arrives +5. UI stays responsive the entire time + +### Task Creation + +Create tasks in response to user events: + +```swift +class ImageModel { + var url: URL = URL(string: "https://swift.org")! + + func onTapEvent() { + Task { // ✅ Create task for user action + do { + try await fetchAndDisplayImage(url: url) + } catch { + displayError(error) + } + } + } +} +``` + +### Task Interleaving (Important Concept) + +Multiple async tasks can run on the **same thread** by taking turns: + +``` +Task 1: [Fetch Image] → (suspend) → [Decode] → [Display] +Task 2: [Fetch News] → (suspend) → [Display News] + +Main Thread Timeline: +[Fetch Image] → [Fetch News] → [Decode Image] → [Display Image] → [Display News] +``` + +**Benefits**: +- Main thread never sits idle +- Tasks make progress as soon as possible +- No concurrency yet—still single-threaded! + +**When to use tasks**: +- High-latency operations (network, file I/O) +- Library APIs handle background work for you (URLSession, FileManager) +- Your own code stays on main thread + +--- + +## Step 3: Concurrent Code (Background Threads) + +Add concurrency when **CPU-intensive work** blocks UI. + +### Problem: Decoding Blocks UI + +Profiling shows `decodeImage()` takes 200ms, causing UI glitches: + +```swift +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let image = decodeImage(data) // ❌ 200ms on main thread! + view.displayImage(image) +} +``` + +### Solution 1: `@concurrent` Attribute (Swift 6.2+) + +Forces function to **always run on background thread**: + +```swift +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + let image = await decodeImage(data) // ✅ Runs on background thread + view.displayImage(image) +} + +@concurrent +func decodeImage(_ data: Data) async -> Image { + // ✅ Always runs on background thread pool + // Good for: image processing, file I/O, parsing + return Image() +} +``` + +**What `@concurrent` does**: +- Function always switches to background thread pool +- Compiler highlights main actor data access (shows what you need to fix) +- Cannot access `@MainActor` properties without `await` + +**Requirements**: Swift 6.2, Xcode 16.2+, iOS 18.2+ + +### Solution 2: `nonisolated` (Library APIs) + +If providing a general-purpose API, use `nonisolated` instead: + +```swift +// ✅ Stays on caller's actor +nonisolated +func decodeImage(_ data: Data) -> Image { + // Runs on whatever actor called it + // Main actor → stays on main actor + // Background → stays on background + return Image() +} +``` + +**When to use `nonisolated`**: +- Library APIs where **caller decides** where work happens +- Small operations that might be OK on main thread +- General-purpose code used in many contexts + +**When to use `@concurrent`**: +- Operations that **should always** run on background (image processing, parsing) +- Performance-critical work that shouldn't block UI + +### Breaking Ties to Main Actor + +When you mark a function `@concurrent`, compiler shows main actor access: + +```swift +@MainActor +class ImageModel { + var cachedImage: [URL: Image] = [:] // Main actor data + + @concurrent + func decodeImage(_ data: Data, at url: URL) async -> Image { + if let image = cachedImage[url] { // ❌ Error: main actor access! + return image + } + // decode... + } +} +``` + +**Strategy 1: Move to caller** (keep work synchronous): + +```swift +func fetchAndDisplayImage(url: URL) async throws { + // ✅ Check cache on main actor BEFORE async work + if let image = cachedImage[url] { + view.displayImage(image) + return + } + + let (data, _) = try await URLSession.shared.data(from: url) + let image = await decodeImage(data) // No URL needed now + view.displayImage(image) +} + +@concurrent +func decodeImage(_ data: Data) async -> Image { + // ✅ No main actor access needed + return Image() +} +``` + +**Strategy 2: Use await** (access main actor asynchronously): + +```swift +@concurrent +func decodeImage(_ data: Data, at url: URL) async -> Image { + // ✅ Await to access main actor data + if let image = await cachedImage[url] { + return image + } + // decode... +} +``` + +**Strategy 3: Make nonisolated** (if doesn't need actor): + +```swift +nonisolated +func decodeImage(_ data: Data) -> Image { + // ✅ No actor isolation, can call from anywhere + return Image() +} +``` + +### Concurrent Thread Pool + +When work runs on background: + +``` +Main Thread: [UI] → (suspend) → [UI Update] + ↓ +Background Pool: [Task A] → [Task B] → [Task A resumes] + Thread 1 Thread 2 Thread 3 +``` + +**Key points**: +- System manages thread pool size (1-2 threads on Watch, many on Mac) +- Task can resume on different thread than it started +- You never specify which thread—system optimizes automatically + +--- + +## Step 4: Actors (Move Data Off Main Thread) + +Add actors when **too much code runs on main actor** causing contention. + +### Problem: Main Actor Contention + +```swift +@MainActor +class ImageModel { + var cachedImage: [URL: Image] = [:] + let networkManager: NetworkManager = NetworkManager() // ❌ Also @MainActor + + func fetchAndDisplayImage(url: URL) async throws { + // ✅ Background work... + let connection = await networkManager.openConnection(for: url) // ❌ Hops to main! + let data = try await connection.data(from: url) + await networkManager.closeConnection(connection, for: url) // ❌ Hops to main! + + let image = await decodeImage(data) + view.displayImage(image) + } +} +``` + +**Issue**: Background task keeps hopping to main actor for network manager access. + +### Solution: Network Manager Actor + +```swift +// ✅ Move network state off main actor +actor NetworkManager { + var openConnections: [URL: Connection] = [:] + + func openConnection(for url: URL) -> Connection { + if let connection = openConnections[url] { + return connection + } + let connection = Connection() + openConnections[url] = connection + return connection + } + + func closeConnection(_ connection: Connection, for url: URL) { + openConnections.removeValue(forKey: url) + } +} + +@MainActor +class ImageModel { + let networkManager: NetworkManager = NetworkManager() + + func fetchAndDisplayImage(url: URL) async throws { + // ✅ Now runs mostly on background + let connection = await networkManager.openConnection(for: url) + let data = try await connection.data(from: url) + await networkManager.closeConnection(connection, for: url) + + let image = await decodeImage(data) + view.displayImage(image) + } +} +``` + +**What changed**: +- `NetworkManager` is now an `actor` instead of `@MainActor class` +- Network state isolated in its own actor +- Background code can access network manager without hopping to main actor +- Main thread freed up for UI work + +### When to Use Actors + +✅ **Use actors for**: +- Non-UI subsystems with independent state (network manager, cache, database) +- Data that's causing main actor contention +- Separating concerns from UI code + +❌ **Do NOT use actors for**: +- UI-facing classes (ViewModels, View Controllers) → Use `@MainActor` +- Model classes used by UI → Keep `@MainActor` or non-Sendable +- Every class in your app (actors add complexity) + +**Guideline**: Profile first. If main actor has too much state causing bottlenecks, extract one subsystem at a time into actors. + +--- + +## Sendable Types (Data Crossing Actor Boundaries) + +When data passes between actors or tasks, Swift checks it's **Sendable** (safe to share). + +### Value Types Are Sendable + +```swift +// ✅ Value types copy when passed +let url = URL(string: "https://swift.org")! + +Task { + // ✅ This is a COPY of url, not the original + // URLSession.shared.data runs on background automatically + let data = try await URLSession.shared.data(from: url) +} + +// ✅ Original url unchanged by background task +``` + +**Why safe**: Each actor gets its own independent copy. Changes don't affect other copies. + +### What's Sendable? + +```swift +// ✅ Basic types +extension URL: Sendable {} +extension String: Sendable {} +extension Int: Sendable {} +extension Date: Sendable {} + +// ✅ Collections of Sendable elements +extension Array: Sendable where Element: Sendable {} +extension Dictionary: Sendable where Key: Sendable, Value: Sendable {} + +// ✅ Structs/enums with Sendable storage +struct Track: Sendable { + let id: String + let title: String + let duration: TimeInterval +} + +enum PlaybackState: Sendable { + case stopped + case playing + case paused +} + +// ✅ Main actor types +@MainActor class ImageModel {} // Implicitly Sendable (actor protects state) + +// ✅ Actor types +actor NetworkManager {} // Implicitly Sendable (actor protects state) +``` + +### Reference Types (Classes) and Sendable + +```swift +// ❌ Classes are NOT Sendable by default +class MyImage { + var width: Int + var height: Int + var pixels: [Color] + + func scale(by factor: Double) { + // Mutates shared state + } +} + +let image = MyImage() +let otherImage = image // ✅ Both reference SAME object + +image.scale(by: 0.5) // ✅ Changes visible through otherImage! +``` + +**Problem with concurrency**: + +```swift +func scaleAndDisplay(imageName: String) { + let image = loadImage(imageName) + + Task { + image.scale(by: 0.5) // Background task modifying + } + + view.displayImage(image) // Main thread reading + // ❌ DATA RACE! Both threads could touch same object! +} +``` + +**Solution 1: Finish modifications before sending**: + +```swift +@concurrent +func scaleAndDisplay(imageName: String) async { + let image = loadImage(imageName) + image.scale(by: 0.5) // ✅ All modifications on background + image.applyAnotherEffect() // ✅ Still on background + + await view.displayImage(image) // ✅ Send to main actor AFTER modifications done + // ✅ Main actor now owns image exclusively +} +``` + +**Solution 2: Don't share classes concurrently**: + +Keep model classes `@MainActor` or non-Sendable to prevent concurrent access. + +### Sendable Checking + +Happens automatically when: +- Passing data into/out of actors +- Passing data into/out of tasks +- Crossing actor boundaries with `await` + +```swift +func fetchAndDisplayImage(url: URL) async throws { + let (data, _) = try await URLSession.shared.data(from: url) + // ↑ Sendable ↑ Sendable (crosses to background) + + let image = await decodeImage(data) + // ↑ data crosses to background (must be Sendable) + // ↑ image returns to main (must be Sendable) +} +``` + +--- + +## Common Patterns (Copy-Paste Templates) + +### Pattern 1: Sendable Enum/Struct + +**When**: Type crosses actor boundaries + +```swift +// ✅ Enum (no associated values) +private enum PlaybackState: Sendable { + case stopped + case playing + case paused +} + +// ✅ Struct (all properties Sendable) +struct Track: Sendable { + let id: String + let title: String + let artist: String? +} + +// ✅ Enum with Sendable associated values +enum Result: Sendable { + case success(data: Data) + case failure(error: Error) // Error is Sendable +} +``` + +--- + +### Pattern 2: Delegate Value Capture (CRITICAL) + +**When**: `nonisolated` delegate method needs to update `@MainActor` state + +```swift +nonisolated func delegate(_ param: SomeType) { + // ✅ Step 1: Capture delegate parameter values BEFORE Task + let value = param.value + let status = param.status + + // ✅ Step 2: Task hop to MainActor + Task { @MainActor in + // ✅ Step 3: Safe to access self (we're on MainActor) + self.property = value + print("Status: \(status)") + } +} +``` + +**Why**: Delegate methods are `nonisolated` (called from library's threads). Capture parameters before Task. Accessing `self` inside `Task { @MainActor in }` is safe. + +--- + +### Pattern 3: Weak Self in Tasks + +**When**: Task is stored as property OR runs for long time + +```swift +class MusicPlayer { + private var progressTask: Task? + + func startMonitoring() { + progressTask = Task { [weak self] in // ✅ Weak capture + guard let self = self else { return } + + while !Task.isCancelled { + await self.updateProgress() + } + } + } + + deinit { + progressTask?.cancel() + } +} +``` + +**Note**: Short-lived Tasks (not stored) can use strong captures. + +--- + +### Pattern 4: Background Work with @concurrent + +**When**: CPU-intensive work should always run on background (Swift 6.2+) + +```swift +@concurrent +func decodeImage(_ data: Data) async -> Image { + // ✅ Always runs on background thread pool + // Good for: image processing, file I/O, JSON parsing + return Image() +} + +// Usage +let image = await decodeImage(data) // Automatically offloads +``` + +**Requirements**: Swift 6.2, Xcode 16.2+, iOS 18.2+ + +--- + +### Pattern 5: Isolated Protocol Conformances (Swift 6.2+) + +**When**: Type needs to conform to protocol with specific actor isolation + +```swift +protocol Exportable { + func export() +} + +class PhotoProcessor { + @MainActor + func exportAsPNG() { + // Export logic requiring UI access + } +} + +// ✅ Conform with explicit isolation +extension StickerModel: @MainActor Exportable { + func export() { + photoProcessor.exportAsPNG() // ✅ Safe: both on MainActor + } +} +``` + +**When to use**: Protocol methods need specific actor context (main actor for UI, background for processing) + +--- + +### Pattern 6: Atomic Snapshots + +**When**: Reading multiple properties that could change mid-access + +```swift +var currentTime: TimeInterval { + get async { + // ✅ Cache reference for atomic snapshot + guard let player = player else { return 0 } + return player.currentTime + } +} +``` + +--- + +### Pattern 7: MainActor for UI Code + +**When**: Code touches UI + +```swift +@MainActor +class PlayerViewModel: ObservableObject { + @Published var currentTrack: Track? + @Published var isPlaying: Bool = false + + func play(_ track: Track) async { + // Already on MainActor + self.currentTrack = track + self.isPlaying = true + } +} +``` + +--- + +## Data Persistence Concurrency Patterns + +### Pattern 8: Background SwiftData Access + +```swift +actor DataFetcher { + let modelContainer: ModelContainer + + func fetchAllTracks() async throws -> [Track] { + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.title)] + ) + return try context.fetch(descriptor) + } +} + +@MainActor +class TrackViewModel: ObservableObject { + @Published var tracks: [Track] = [] + + func loadTracks() async { + let fetchedTracks = try await fetcher.fetchAllTracks() + self.tracks = fetchedTracks // Back on MainActor + } +} +``` + +### Pattern 9: Core Data Thread-Safe Fetch + +```swift +actor CoreDataFetcher { + func fetchTracksID(genre: String) async throws -> [String] { + let context = persistentContainer.newBackgroundContext() + var trackIDs: [String] = [] + + try await context.perform { + let request = NSFetchRequest(entityName: "Track") + request.predicate = NSPredicate(format: "genre = %@", genre) + let results = try context.fetch(request) + trackIDs = results.map { $0.id } // Extract IDs before leaving context + } + + return trackIDs // Lightweight, Sendable + } +} +``` + +### Pattern 10: Batch Import with Progress + +```swift +actor DataImporter { + func importRecords(_ records: [RawRecord], onProgress: @MainActor (Int, Int) -> Void) async throws { + let chunkSize = 1000 + let context = ModelContext(modelContainer) + + for (index, chunk) in records.chunked(into: chunkSize).enumerated() { + for record in chunk { + context.insert(Track(from: record)) + } + try context.save() + + let processed = (index + 1) * chunkSize + await onProgress(min(processed, records.count), records.count) + + if Task.isCancelled { throw CancellationError() } + } + } +} +``` + +### Pattern 11: GRDB Background Execution + +```swift +actor DatabaseQueryExecutor { + let dbQueue: DatabaseQueue + + func fetchUserWithPosts(userId: String) async throws -> (user: User, posts: [Post]) { + return try await dbQueue.read { db in + let user = try User.filter(Column("id") == userId).fetchOne(db)! + let posts = try Post + .filter(Column("userId") == userId) + .order(Column("createdAt").desc) + .limit(100) + .fetchAll(db) + return (user, posts) + } + } +} +``` + +--- + +## Quick Decision Tree + +``` +Starting new feature? +└─ Is UI responsive with all operations on main thread? + ├─ YES → Stay single-threaded (Step 1) + └─ NO → Continue... + └─ Do you have high-latency operations? (network, file I/O) + ├─ YES → Add async/await (Step 2) + └─ NO → Continue... + └─ Do you have CPU-intensive work? (Instruments shows main thread busy) + ├─ YES → Add @concurrent or nonisolated (Step 3) + └─ NO → Continue... + └─ Is main actor contention causing slowdowns? + └─ YES → Extract subsystem to actor (Step 4) + +Error: "Main actor-isolated property accessed from nonisolated context" +├─ In delegate method? +│ └─ Pattern 2: Value Capture Before Task +├─ In async function? +│ └─ Add @MainActor or call from Task { @MainActor in } +└─ In @concurrent function? + └─ Move access to caller, use await, or make nonisolated + +Error: "Type does not conform to Sendable" +├─ Enum/struct with Sendable properties? +│ └─ Add `: Sendable` +└─ Class? + └─ Make @MainActor or keep non-Sendable (don't share concurrently) + +Want to offload work to background? +├─ Always background (image processing)? +│ └─ Use @concurrent (Swift 6.2+) +├─ Caller decides? +│ └─ Use nonisolated +└─ Too much main actor state? + └─ Extract to actor +``` + +--- + +## Build Settings (Xcode 16+) + +``` +Build Settings → Swift Compiler — Language +→ "Default Actor Isolation" = Main Actor +→ "Approachable Concurrency" = Yes + +Build Settings → Swift Compiler — Concurrency +→ "Strict Concurrency Checking" = Complete +``` + +**What this enables**: +- Main actor mode (all code @MainActor by default) +- Compile-time data race prevention +- Progressive concurrency adoption + +--- + +## Anti-Patterns (DO NOT DO THIS) + +### ❌ Using Concurrency When Not Needed + +```swift +// ❌ Premature optimization +@concurrent +func addNumbers(_ a: Int, _ b: Int) async -> Int { + return a + b // ❌ Trivial work, concurrency adds overhead +} + +// ✅ Keep simple +func addNumbers(_ a: Int, _ b: Int) -> Int { + return a + b +} +``` + +### ❌ Strong Self in Stored Tasks + +```swift +// ❌ Memory leak +progressTask = Task { + while true { + await self.update() // ❌ Strong capture + } +} + +// ✅ Weak capture +progressTask = Task { [weak self] in + guard let self = self else { return } + // ... +} +``` + +### ❌ Making Every Class an Actor + +```swift +// ❌ Don't do this +actor MyViewModel: ObservableObject { // ❌ UI code should be @MainActor! + @Published var state: State // ❌ Won't work correctly +} + +// ✅ Do this +@MainActor +class MyViewModel: ObservableObject { + @Published var state: State +} +``` + +--- + +## Code Review Checklist + +### Before Adding Concurrency +- [ ] Profiled and confirmed UI unresponsiveness +- [ ] Identified specific slow operations (network, CPU, contention) +- [ ] Started with simplest solution (async → concurrent → actors) + +### Async/Await +- [ ] Used for high-latency operations only +- [ ] Task creation in response to events +- [ ] Error handling with do-catch + +### Background Work +- [ ] `@concurrent` for always-background work (Swift 6.2+) +- [ ] `nonisolated` for library APIs +- [ ] No blocking operations on main actor + +### Sendable +- [ ] Value types for data crossing actors +- [ ] Classes stay @MainActor or non-Sendable +- [ ] No concurrent modification of shared classes + +### Actors +- [ ] Only for non-UI subsystems +- [ ] UI code stays @MainActor +- [ ] Model classes stay @MainActor or non-Sendable + +--- + +## Real-World Impact + +**Before**: Random crashes, data races, "works on my machine" bugs, premature complexity +**After**: Compile-time guarantees, progressive adoption, only use concurrency when needed + +**Key insight**: Swift 6's approach makes you prove code is safe before compilation succeeds. Start simple, add complexity only when profiling proves it's needed. + +--- + +## Resources + +**WWDC**: 2025-268, 2025-245, 2022-110351, 2021-10133 + +**Docs**: /swift/adoptingswift6, /swift/sendable + +--- + +**Last Updated**: 2025-12-01 +**Status**: Enhanced with WWDC 2025-268 progressive journey, @concurrent attribute, isolated conformances, and approachable concurrency patterns diff --git a/skill-index/skills/axiom-swift-performance/SKILL.md b/skill-index/skills/axiom-swift-performance/SKILL.md new file mode 100644 index 0000000..519c78d --- /dev/null +++ b/skill-index/skills/axiom-swift-performance/SKILL.md @@ -0,0 +1,1628 @@ +--- +name: axiom-swift-performance +description: Use when optimizing Swift code performance, reducing memory usage, improving runtime efficiency, dealing with COW, ARC overhead, generics specialization, or collection optimization +skill_type: discipline +version: 1.2.0 +--- + +# Swift Performance Optimization + +## Purpose + +**Core Principle**: Optimize Swift code by understanding language-level performance characteristics—value semantics, ARC behavior, generic specialization, and memory layout—to write fast, efficient code without premature micro-optimization. + +**Swift Version**: Swift 6.2+ (for InlineArray, Span, `@concurrent`) +**Xcode**: 16+ +**Platforms**: iOS 18+, macOS 15+ + +**Related Skills**: +- `axiom-performance-profiling` — Use Instruments to measure (do this first!) +- `axiom-swiftui-performance` — SwiftUI-specific optimizations +- `axiom-build-performance` — Compilation speed +- `axiom-swift-concurrency` — Correctness-focused concurrency patterns + +## When to Use This Skill + +### ✅ Use this skill when + +- App profiling shows Swift code as the bottleneck (Time Profiler hotspots) +- Excessive memory allocations or retain/release traffic +- Implementing performance-critical algorithms or data structures +- Writing framework or library code with performance requirements +- Optimizing tight loops or frequently called methods +- Dealing with large data structures or collections +- Code review identifying performance anti-patterns + +### ❌ Do NOT use this skill for + +- **First step optimization** — Use `axiom-performance-profiling` first to measure +- **SwiftUI performance** — Use `axiom-swiftui-performance` skill instead +- **Build time optimization** — Use `axiom-build-performance` skill instead +- **Premature optimization** — Profile first, optimize later +- **Readability trade-offs** — Don't sacrifice clarity for micro-optimizations + +## Quick Decision Tree + +``` +Performance issue identified? +│ +├─ Profiler shows excessive copying? +│ └─ → Part 1: Noncopyable Types +│ └─ → Part 2: Copy-on-Write +│ +├─ Retain/release overhead in Time Profiler? +│ └─ → Part 4: ARC Optimization +│ +├─ Generic code in hot path? +│ └─ → Part 5: Generics & Specialization +│ +├─ Collection operations slow? +│ └─ → Part 7: Collection Performance +│ +├─ Async/await overhead visible? +│ └─ → Part 8: Concurrency Performance +│ +├─ Struct vs class decision? +│ └─ → Part 3: Value vs Reference +│ +└─ Memory layout concerns? + └─ → Part 9: Memory Layout +``` + +--- + +## Part 1: Noncopyable Types (~Copyable) + +**Swift 6.0+** introduces noncopyable types for performance-critical scenarios where you want to avoid implicit copies. + +### When to Use + +- Large types that should never be copied (file handles, GPU buffers) +- Types with ownership semantics (must be explicitly consumed) +- Performance-critical code where copies are expensive + +### Basic Pattern + +```swift +// Noncopyable type +struct FileHandle: ~Copyable { + private let fd: Int32 + + init(path: String) throws { + self.fd = open(path, O_RDONLY) + guard fd != -1 else { throw FileError.openFailed } + } + + deinit { + close(fd) + } + + // Must explicitly consume + consuming func close() { + _ = consume self + } +} + +// Usage +func processFile() throws { + let handle = try FileHandle(path: "/data.txt") + // handle is automatically consumed at end of scope + // Cannot accidentally copy handle +} +``` + +### Ownership Annotations + +```swift +// consuming - takes ownership, caller cannot use after +func process(consuming data: [UInt8]) { + // data is consumed +} + +// borrowing - temporary access without ownership +func validate(borrowing data: [UInt8]) -> Bool { + // data can still be used by caller + return data.count > 0 +} + +// inout - mutable access +func modify(inout data: [UInt8]) { + data.append(0) +} +``` + +### Performance Impact + +- **Eliminates implicit copies**: Compiler error instead of runtime copy +- **Zero-cost abstraction**: Same performance as manual memory management +- **Use when**: Type is expensive to copy (>64 bytes) and copies are rare + +--- + +## Part 2: Copy-on-Write (COW) + +Swift collections use COW for efficient memory sharing. Understanding when copies happen is critical for performance. + +### How COW Works + +```swift +var array1 = [1, 2, 3] // Single allocation +var array2 = array1 // Share storage (no copy) +array2.append(4) // Now copies (array1 modified array2) +``` + +### Custom COW Implementation + +```swift +final class Storage { + var data: [T] + init(_ data: [T]) { self.data = data } +} + +struct COWArray { + private var storage: Storage + + init(_ data: [T]) { + self.storage = Storage(data) + } + + // COW check before mutation + private mutating func ensureUnique() { + if !isKnownUniquelyReferenced(&storage) { + storage = Storage(storage.data) + } + } + + mutating func append(_ element: T) { + ensureUnique() // Copy if shared + storage.data.append(element) + } + + subscript(index: Int) -> T { + get { storage.data[index] } + set { + ensureUnique() // Copy before mutation + storage.data[index] = newValue + } + } +} +``` + +### Performance Tips + +```swift +// ❌ Accidental copy in loop +for i in 0.. 64 bytes or contains large data | +| **Identity** | No identity needed | Needs identity (===) | +| **Inheritance** | Not needed | Inheritance required | +| **Mutation** | Infrequent | Frequent in-place updates | +| **Sharing** | No sharing needed | Must be shared across scope | + +### Small Structs (Fast) + +```swift +// ✅ Fast - fits in registers, no heap allocation +struct Point { + var x: Double // 8 bytes + var y: Double // 8 bytes +} // Total: 16 bytes - excellent for struct + +struct Color { + var r, g, b, a: UInt8 // 4 bytes total - perfect for struct +} +``` + +### Large Structs (Slow) + +```swift +// ❌ Slow - excessive copying +struct HugeData { + var buffer: [UInt8] // 1MB + var metadata: String +} + +func process(_ data: HugeData) { // Copies 1MB! + // ... +} + +// ✅ Use reference semantics for large data +final class HugeData { + var buffer: [UInt8] + var metadata: String +} + +func process(_ data: HugeData) { // Only copies pointer (8 bytes) + // ... +} +``` + +### Indirect Storage for Flexibility + +```swift +// Best of both worlds +struct LargeDataWrapper { + private final class Storage { + var largeBuffer: [UInt8] + init(_ buffer: [UInt8]) { self.largeBuffer = buffer } + } + + private var storage: Storage + + init(buffer: [UInt8]) { + self.storage = Storage(buffer) + } + + // Value semantics externally, reference internally + var buffer: [UInt8] { + get { storage.largeBuffer } + set { + if !isKnownUniquelyReferenced(&storage) { + storage = Storage(newValue) + } else { + storage.largeBuffer = newValue + } + } + } +} +``` + +--- + +## Part 4: ARC Optimization + +Automatic Reference Counting adds overhead. Minimize it where possible. + +### Weak vs Unowned Performance + +```swift +class Parent { + var child: Child? +} + +class Child { + // ❌ Weak adds overhead (optional, thread-safe zeroing) + weak var parent: Parent? +} + +// ✅ Unowned when you know lifetime guarantees +class Child { + unowned let parent: Parent // No overhead, crashes if parent deallocated +} +``` + +**Performance**: `unowned` is ~2x faster than `weak` (no atomic operations). + +**Use when**: Child lifetime < Parent lifetime (guaranteed). + +### Closure Capture Optimization + +```swift +class DataProcessor { + var data: [Int] + + // ❌ Captures self strongly, then uses weak - unnecessary weak overhead + func process(completion: @escaping () -> Void) { + DispatchQueue.global().async { [weak self] in + guard let self else { return } + self.data.forEach { print($0) } + completion() + } + } + + // ✅ Capture only what you need + func process(completion: @escaping () -> Void) { + let data = self.data // Copy value type + DispatchQueue.global().async { + data.forEach { print($0) } // No self captured + completion() + } + } +} +``` + +### Reducing Retain/Release Traffic + +```swift +// ❌ Multiple retain/release pairs +for object in objects { + process(object) // retain, release +} + +// ✅ Single retain for entire loop +func processAll(_ objects: [MyClass]) { + // Compiler optimizes to single retain/release + for object in objects { + process(object) + } +} +``` + +### Observable Object Lifetimes + +**From WWDC 2021-10216**: Object lifetimes end at **last use**, not at closing brace. + +```swift +// ❌ Relying on observed lifetime is fragile +class Traveler { + weak var account: Account? + + deinit { + print("Deinitialized") // May run BEFORE expected with ARC optimizations! + } +} + +func test() { + let traveler = Traveler() + let account = Account(traveler: traveler) + // traveler's last use is above - may deallocate here! + account.printSummary() // weak reference may be nil! +} + +// ✅ Explicitly extend lifetime when needed +func test() { + let traveler = Traveler() + let account = Account(traveler: traveler) + + withExtendedLifetime(traveler) { + account.printSummary() // traveler guaranteed to live + } +} + +// Alternative: defer at end of scope +func test() { + let traveler = Traveler() + defer { withExtendedLifetime(traveler) {} } + + let account = Account(traveler: traveler) + account.printSummary() +} +``` + +**Why This Matters**: Observed object lifetimes are an emergent property of compiler optimizations and can change between: +- Xcode versions +- Build configurations (Debug vs Release) +- Unrelated code changes that enable new optimizations + +**Build Setting**: Enable "Optimize Object Lifetimes" (Xcode 13+) during development to expose hidden lifetime bugs early. + +--- + +## Part 5: Generics & Specialization + +Generic code can be fast or slow depending on specialization. + +### Specialization Basics + +```swift +// Generic function +func process(_ value: T) { + print(value) +} + +// Calling with concrete type +process(42) // Compiler specializes: process_Int(42) +process("hello") // Compiler specializes: process_String("hello") +``` + +### Existential Overhead + +```swift +protocol Drawable { + func draw() +} + +// ❌ Existential container - expensive (heap allocation, indirection) +func drawAll(shapes: [any Drawable]) { + for shape in shapes { + shape.draw() // Dynamic dispatch through witness table + } +} + +// ✅ Generic with constraint - can specialize +func drawAll(shapes: [T]) { + for shape in shapes { + shape.draw() // Static dispatch after specialization + } +} +``` + +**Performance**: Generic version ~10x faster (eliminates witness table overhead). + +### Existential Container Internals + +**From WWDC 2016-416**: `any Protocol` uses an existential container with specific performance characteristics. + +```swift +// Existential Container Memory Layout (64-bit systems) +// +// Small Type (≤24 bytes): +// ┌──────────────────┬──────────────┬────────────────┐ +// │ Value (inline) │ Type │ Protocol │ +// │ 3 words max │ Metadata │ Witness Table │ +// │ (24 bytes) │ (8 bytes) │ (8 bytes) │ +// └──────────────────┴──────────────┴────────────────┘ +// ↑ No heap allocation - value stored directly +// +// Large Type (>24 bytes): +// ┌──────────────────┬──────────────┬────────────────┐ +// │ Heap Pointer → │ Type │ Protocol │ +// │ (8 bytes) │ Metadata │ Witness Table │ +// │ │ (8 bytes) │ (8 bytes) │ +// └──────────────────┴──────────────┴────────────────┘ +// ↑ Heap allocation required - pointer to actual value +// +// Total container size: 40 bytes (5 words on 64-bit) +// Threshold: 3 words (24 bytes) determines inline vs heap + +// Small type example - stored inline (FAST) +struct Point: Drawable { + var x, y, z: Double // 24 bytes - fits inline! +} + +let drawable: any Drawable = Point(x: 1, y: 2, z: 3) +// ✅ Point stored directly in container (no heap allocation) + +// Large type example - heap allocated (SLOWER) +struct Rectangle: Drawable { + var x, y, width, height: Double // 32 bytes - exceeds inline buffer +} + +let drawable: any Drawable = Rectangle(x: 0, y: 0, width: 10, height: 20) +// ❌ Rectangle allocated on heap, container stores pointer + +// Performance comparison: +// - Small existential (≤24 bytes): ~5ns access time +// - Large existential (>24 bytes): ~15ns access time (heap indirection) +// - Generic `some Drawable`: ~2ns access time (no container) +``` + +**Design Tip**: Keep protocol-conforming types ≤24 bytes when used as `any Protocol` for best performance. Use `some Protocol` instead of `any Protocol` when possible to eliminate all container overhead. + +### `@_specialize` Attribute + +```swift +// Force specialization for common types +@_specialize(where T == Int) +@_specialize(where T == String) +func process(_ value: T) -> T { + // Expensive generic operation + return value +} + +// Compiler generates: +// - func process_Int(_ value: Int) -> Int +// - func process_String(_ value: String) -> String +// - Generic fallback for other types +``` + +### `any` vs `some` + +```swift +// ❌ any - existential, runtime overhead +func makeDrawable() -> any Drawable { + return Circle() // Heap allocation +} + +// ✅ some - opaque type, compile-time type +func makeDrawable() -> some Drawable { + return Circle() // No overhead, type known at compile time +} +``` + +--- + +## Part 6: Inlining + +Inlining eliminates function call overhead but increases code size. + +### When to Inline + +```swift +// ✅ Small, frequently called functions +@inlinable +public func fastAdd(_ a: Int, _ b: Int) -> Int { + return a + b +} + +// ❌ Large functions - code bloat +@inlinable // Don't do this! +public func complexAlgorithm() { + // 100 lines of code... +} +``` + +### Cross-Module Optimization + +```swift +// Framework code +public struct Point { + public var x: Double + public var y: Double + + // ✅ Inlinable for cross-module optimization + @inlinable + public func distance(to other: Point) -> Double { + let dx = x - other.x + let dy = y - other.y + return sqrt(dx*dx + dy*dy) + } +} + +// Client code +let p1 = Point(x: 0, y: 0) +let p2 = Point(x: 3, y: 4) +let d = p1.distance(to: p2) // Inlined across module boundary +``` + +### `@usableFromInline` + +```swift +// Internal helper that can be inlined +@usableFromInline +internal func helperFunction() { } + +// Public API that uses it +@inlinable +public func publicAPI() { + helperFunction() // Can inline internal function +} +``` + +**Trade-off**: `@inlinable` exposes implementation, prevents future optimization. + +--- + +## Part 7: Collection Performance + +Choosing the right collection and using it correctly matters. + +### Array vs ContiguousArray + +```swift +// ❌ Array - may use NSArray bridging (Swift/ObjC interop) +let array: Array = [1, 2, 3] + +// ✅ ContiguousArray - guaranteed contiguous memory (no bridging) +let array: ContiguousArray = [1, 2, 3] +``` + +**Use `ContiguousArray` when**: No ObjC bridging needed (pure Swift), ~15% faster. + +### Reserve Capacity + +```swift +// ❌ Multiple reallocations +var array: [Int] = [] +for i in 0..<10000 { + array.append(i) // Reallocates ~14 times +} + +// ✅ Single allocation +var array: [Int] = [] +array.reserveCapacity(10000) +for i in 0..<10000 { + array.append(i) // No reallocations +} +``` + +### Dictionary Hashing + +```swift +struct BadKey: Hashable { + var data: [Int] + + // ❌ Expensive hash (iterates entire array) + func hash(into hasher: inout Hasher) { + for element in data { + hasher.combine(element) + } + } +} + +struct GoodKey: Hashable { + var id: UUID // Fast hash + var data: [Int] // Not hashed + + // ✅ Hash only the unique identifier + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} +``` + +### InlineArray (Swift 6.2) + +Fixed-size arrays stored directly on the stack—no heap allocation, no COW overhead. + +```swift +// Traditional Array - heap allocated, COW overhead +var sprites: [Sprite] = Array(repeating: .default, count: 40) + +// InlineArray - stack allocated, no COW +var sprites = InlineArray<40, Sprite>(repeating: .default) +// Alternative syntax (if available) +var sprites: [40 of Sprite] = ... +``` + +**When to Use InlineArray**: +- Fixed size known at compile time +- Performance-critical paths (tight loops, hot paths) +- Want to avoid heap allocation entirely +- Small to medium sizes (practical limit ~1KB stack usage) + +**Key Characteristics**: +```swift +let inline = InlineArray<10, Int>(repeating: 0) + +// ✅ Stack allocated - no heap +print(MemoryLayout.size(ofValue: inline)) // 80 bytes (10 × 8) + +// ✅ Value semantics - but eagerly copied (not COW!) +var copy = inline // Copies all 10 elements immediately +copy[0] = 100 // No COW check needed + +// ✅ Provides Span access for zero-copy operations +let span = inline.span // Read-only view +let mutableSpan = inline.mutableSpan // Mutable view +``` + +**Performance Comparison**: +```swift +// Array: Heap allocation + COW overhead +var array = Array(repeating: 0, count: 100) +// - Allocation: ~1μs (heap) +// - Copy: ~50ns (COW reference bump) +// - Mutation: ~50ns (uniqueness check) + +// InlineArray: Stack allocation, no COW +var inline = InlineArray<100, Int>(repeating: 0) +// - Allocation: 0ns (stack frame) +// - Copy: ~400ns (eager copy all 100 elements) +// - Mutation: 0ns (no uniqueness check) +``` + +**24-Byte Threshold Connection**: + +InlineArray relates to the existential container threshold from Part 5: + +```swift +// Existential containers store ≤24 bytes inline +struct Small: Protocol { + var a, b, c: Int64 // 24 bytes - fits inline +} + +// InlineArray of 3 Int64s also ≤24 bytes +let inline = InlineArray<3, Int64>(repeating: 0) +// Size: 24 bytes - same threshold, different purpose + +// Both avoid heap allocation at this size +let existential: any Protocol = Small(...) // Inline storage +let array = inline // Stack storage +``` + +**Copy Semantics Warning**: +```swift +// ❌ Unexpected: InlineArray copies eagerly +func processLarge(_ data: InlineArray<1000, UInt8>) { + // Copies all 1000 bytes on call! +} + +// ✅ Use Span to avoid copy +func processLarge(_ data: Span) { + // Zero-copy view, no matter the size +} + +// Best practice: Store InlineArray, pass Span +struct Buffer { + var storage = InlineArray<1000, UInt8>(repeating: 0) + + func process() { + helper(storage.span) // Pass view, not copy + } +} +``` + +**When NOT to Use InlineArray**: +- Dynamic sizes (use Array) +- Large data (>1KB stack usage risky) +- Frequently passed by value (use Span instead) +- Need COW semantics (use Array) + +### Lazy Sequences + +```swift +// ❌ Eager evaluation - processes entire array +let result = array + .map { expensive($0) } + .filter { $0 > 0 } + .first // Only need first element! + +// ✅ Lazy evaluation - stops at first match +let result = array + .lazy + .map { expensive($0) } + .filter { $0 > 0 } + .first // Only evaluates until first match +``` + +--- + +## Part 8: Concurrency Performance + +Async/await and actors add overhead. Use appropriately. + +### Actor Isolation Overhead + +```swift +actor Counter { + private var value = 0 + + // ❌ Actor call overhead for simple operation + func increment() { + value += 1 + } +} + +// Calling from different isolation domain +for _ in 0..<10000 { + await counter.increment() // 10,000 actor hops! +} + +// ✅ Batch operations to reduce actor overhead +actor Counter { + private var value = 0 + + func incrementBatch(_ count: Int) { + value += count + } +} + +await counter.incrementBatch(10000) // Single actor hop +``` + +### async/await vs Completion Handlers + +```swift +// async/await overhead: ~20-30μs per suspension point + +// ❌ Unnecessary async for fast synchronous operation +func compute() async -> Int { + return 42 // Instant, but pays async overhead +} + +// ✅ Keep synchronous operations synchronous +func compute() -> Int { + return 42 // No overhead +} +``` + +### Task Creation Cost + +```swift +// ❌ Creating task per item (~100μs overhead each) +for item in items { + Task { + await process(item) + } +} + +// ✅ Single task for batch +Task { + for item in items { + await process(item) + } +} + +// ✅ Or use TaskGroup for parallelism +await withTaskGroup(of: Void.self) { group in + for item in items { + group.addTask { + await process(item) + } + } +} +``` + +### `@concurrent` Attribute (Swift 6.2) + +```swift +// Force background execution +@concurrent +func expensiveComputation() -> Int { + // Always runs on background thread, even if called from MainActor + return complexCalculation() +} + +// Safe to call from main actor without blocking +@MainActor +func updateUI() async { + let result = await expensiveComputation() // Guaranteed off main thread + label.text = "\(result)" +} +``` + +### nonisolated Performance + +```swift +actor DataStore { + private var data: [Int] = [] + + // ❌ Isolated - actor overhead even for read-only + func getCount() -> Int { + data.count + } + + // ✅ nonisolated for immutable state + nonisolated var storedCount: Int { + // Must be immutable + return data.count // Error: cannot access isolated property + } +} +``` + +--- + +## Part 9: Memory Layout + +Understanding memory layout helps optimize cache performance and reduce allocations. + +### Struct Padding + +```swift +// ❌ Poor layout (24 bytes due to padding) +struct BadLayout { + var a: Bool // 1 byte + 7 padding + var b: Int64 // 8 bytes + var c: Bool // 1 byte + 7 padding +} +print(MemoryLayout.size) // 24 bytes + +// ✅ Optimized layout (16 bytes) +struct GoodLayout { + var b: Int64 // 8 bytes + var a: Bool // 1 byte + var c: Bool // 1 byte + 6 padding +} +print(MemoryLayout.size) // 16 bytes +``` + +### Alignment + +```swift +// Query alignment +print(MemoryLayout.alignment) // 8 +print(MemoryLayout.alignment) // 4 + +// Structs align to largest member +struct Mixed { + var int32: Int32 // 4 bytes, 4-byte aligned + var double: Double // 8 bytes, 8-byte aligned +} +print(MemoryLayout.alignment) // 8 (largest member) +``` + +### Cache-Friendly Data Structures + +```swift +// ❌ Poor cache locality +struct PointerBased { + var next: UnsafeMutablePointer? // Pointer chasing +} + +// ✅ Array-based for cache locality +struct ArrayBased { + var data: ContiguousArray // Contiguous memory +} + +// Array iteration ~10x faster due to cache prefetching +``` + +--- + +## Part 10: Typed Throws (Swift 6) + +Typed throws can be faster than untyped by avoiding existential overhead. + +### Untyped vs Typed + +```swift +// Untyped - existential container for error +func fetchData() throws -> Data { + // Can throw any Error + throw NetworkError.timeout +} + +// Typed - concrete error type +func fetchData() throws(NetworkError) -> Data { + // Can only throw NetworkError + throw NetworkError.timeout +} +``` + +### Performance Impact + +```swift +// Measure with tight loop +func untypedThrows() throws -> Int { + throw GenericError.failed +} + +func typedThrows() throws(GenericError) -> Int { + throw GenericError.failed +} + +// Benchmark: typed ~5-10% faster (no existential overhead) +``` + +### When to Use + +- **Typed**: Library code with well-defined error types, hot paths +- **Untyped**: Application code, error types unknown at compile time + +--- + +## Part 11: Span Types + +**Swift 6.2+** introduces Span—a non-escapable, non-owning view into memory that provides safe, efficient access to contiguous data. + +### What is Span? + +Span is a modern replacement for `UnsafeBufferPointer` that provides: +- **Spatial safety**: Bounds-checked operations prevent out-of-bounds access +- **Temporal safety**: Lifetime inherited from source, preventing use-after-free +- **Zero overhead**: No heap allocation, no reference counting +- **Non-escapable**: Cannot outlive the data it references + +```swift +// Traditional unsafe approach +func processUnsafe(_ data: UnsafeMutableBufferPointer) { + data[100] = 0 // Crashes if out of bounds! +} + +// Safe Span approach +func processSafe(_ data: MutableSpan) { + data[100] = 0 // Traps with clear error if out of bounds +} +``` + +### When to Use Span vs Array vs UnsafeBufferPointer + +| Use Case | Recommendation | +|----------|---------------| +| **Own the data** | Array (full ownership, COW) | +| **Temporary view for reading** | Span (safe, fast) | +| **Temporary view for writing** | MutableSpan (safe, fast) | +| **C interop, performance-critical** | RawSpan (untyped bytes) | +| **Unsafe performance** | UnsafeBufferPointer (legacy, avoid) | + +### Basic Span Usage + +```swift +let array = [1, 2, 3, 4, 5] + +// Get read-only span +let span = array.span +print(span[0]) // 1 +print(span.count) // 5 + +// Iterate safely +for element in span { + print(element) +} + +// Slicing (creates new span, no copy) +let slice = span[1..<3] // Span viewing [2, 3] +``` + +### MutableSpan for Modifications + +```swift +var array = [10, 20, 30, 40, 50] + +// Get mutable span +let mutableSpan = array.mutableSpan + +// Modify through span +mutableSpan[0] = 100 +mutableSpan[1] = 200 + +print(array) // [100, 200, 30, 40, 50] + +// Safe bounds checking +// mutableSpan[10] = 0 // Fatal error: Index out of range +``` + +### RawSpan for Untyped Bytes + +```swift +struct PacketHeader { + var version: UInt8 + var flags: UInt8 + var length: UInt16 +} + +func parsePacket(_ data: RawSpan) -> PacketHeader? { + guard data.count >= MemoryLayout.size else { + return nil + } + + // Safe byte-level access + let version = data[0] + let flags = data[1] + let lengthLow = data[2] + let lengthHigh = data[3] + + return PacketHeader( + version: version, + flags: flags, + length: UInt16(lengthHigh) << 8 | UInt16(lengthLow) + ) +} + +// Usage +let bytes: [UInt8] = [1, 0x80, 0x00, 0x10] // Version 1, flags 0x80, length 16 +let rawSpan = bytes.rawSpan +if let header = parsePacket(rawSpan) { + print("Packet version: \(header.version)") +} +``` + +### Span-Providing Properties + +Swift 6.2 collections automatically provide span properties: + +```swift +// Array provides .span and .mutableSpan +let array = [1, 2, 3] +let span: Span = array.span + +// ContiguousArray provides spans +let contiguous = ContiguousArray([1, 2, 3]) +let span2 = contiguous.span + +// UnsafeBufferPointer provides .span (migration path) +let buffer: UnsafeBufferPointer = ... +let span3 = buffer.span // Modern safe wrapper +``` + +### Performance Characteristics + +```swift +// ❌ Array copy - heap allocation +func process(_ array: [Int]) { + // Array copied if passed across module boundary +} + +// ❌ UnsafeBufferPointer - no bounds checking +func process(_ buffer: UnsafeBufferPointer) { + buffer[100] // Crash or memory corruption! +} + +// ✅ Span - no copy, bounds-checked, temporal safety +func process(_ span: Span) { + span[100] // Safe trap if out of bounds +} + +// Performance: Span is as fast as UnsafeBufferPointer (~2ns access) +// but with safety guarantees (bounds checks are optimized away when safe) +``` + +### Non-Escapable Lifetime Safety + +```swift +// ✅ Safe - span lifetime bound to array +func useSpan() { + let array = [1, 2, 3, 4, 5] + let span = array.span + process(span) // Safe - array still alive +} + +// ❌ Compiler prevents this +func dangerousSpan() -> Span { + let array = [1, 2, 3] + return array.span // Error: Cannot return non-escapable value +} + +// This is what temporal safety prevents +// (Compare to UnsafeBufferPointer which ALLOWS this bug!) +``` + +### Integration with InlineArray + +```swift +// InlineArray provides span access +let inline = InlineArray<10, UInt8>() +let span: Span = inline.span +let mutableSpan: MutableSpan = inline.mutableSpan + +// Efficient zero-copy parsing +func parseHeader(_ span: Span) -> Header { + // Direct access to inline storage via span + Header( + magic: span[0], + version: span[1], + flags: span[2] + ) +} + +let header = parseHeader(inline.span) // No heap allocation! +``` + +### Migration from UnsafeBufferPointer + +```swift +// Old pattern (unsafe) +func processLegacy(_ buffer: UnsafeBufferPointer) { + for i in 0..) { + for element in span { // Safe iteration + print(element) + } +} + +// Migration bridge +let buffer: UnsafeBufferPointer = ... +let span = buffer.span // Wrap unsafe pointer in safe span +processModern(span) +``` + +### Common Patterns + +```swift +// Pattern 1: Binary parsing with RawSpan +func parse(_ span: RawSpan) -> T? { + guard span.count >= MemoryLayout.size else { + return nil + } + return span.load(as: T.self) // Safe type reinterpretation +} + +// Pattern 2: Chunked processing +func processChunks(_ data: Span, chunkSize: Int) { + var offset = 0 + while offset < data.count { + let end = min(offset + chunkSize, data.count) + let chunk = data[offset..) { + span.withUnsafeBufferPointer { buffer in + // Only escape to unsafe inside controlled scope + c_function(buffer.baseAddress, buffer.count) + } +} +``` + +### When NOT to Use Span + +```swift +// ❌ Don't use Span for ownership +struct Document { + var data: Span // Error: Span can't be stored +} + +// ✅ Use Array for owned data +struct Document { + var data: [UInt8] + + // Provide span access when needed + var dataSpan: Span { + data.span + } +} + +// ❌ Don't try to escape Span from scope +func getSpan() -> Span { // Error: Non-escapable + let array = [1, 2, 3] + return array.span +} + +// ✅ Process in scope, return owned data +func processAndReturn() -> [Int] { + let array = [1, 2, 3] + process(array.span) // Process with span + return array // Return owned data +} +``` + +--- + +## Copy-Paste Patterns + +### Pattern 1: COW Wrapper + +```swift +final class Storage { + var value: T + init(_ value: T) { self.value = value } +} + +struct COWWrapper { + private var storage: Storage + + init(_ value: T) { + storage = Storage(value) + } + + var value: T { + get { storage.value } + set { + if !isKnownUniquelyReferenced(&storage) { + storage = Storage(newValue) + } else { + storage.value = newValue + } + } + } +} +``` + +### Pattern 2: Performance-Critical Loop + +```swift +func processLargeArray(_ input: [Int]) -> [Int] { + var result = ContiguousArray() + result.reserveCapacity(input.count) + + for element in input { + result.append(transform(element)) + } + + return Array(result) +} +``` + +### Pattern 3: Inline Cache Lookup + +```swift +private var cache: [Key: Value] = [:] + +@inlinable +func getCached(_ key: Key) -> Value? { + return cache[key] // Inlined across modules +} +``` + +--- + +## Anti-Patterns + +### ❌ Anti-Pattern 1: Premature Optimization + +```swift +// Don't optimize without measuring first! + +// ❌ Complex optimization with no measurement +struct OverEngineered { + @usableFromInline var data: ContiguousArray + // 100 lines of COW logic... +} + +// ✅ Start simple, measure, then optimize +struct Simple { + var data: [UInt8] +} +// Profile → Optimize if needed +``` + +### ❌ Anti-Pattern 2: Weak Everywhere + +```swift +class Manager { + // ❌ Unnecessary weak reference overhead + weak var delegate: Delegate? + weak var dataSource: DataSource? + weak var observer: Observer? +} + +// ✅ Use unowned when lifetime is guaranteed +class Manager { + unowned let delegate: Delegate // Delegate outlives Manager + weak var dataSource: DataSource? // Optional, may be nil +} +``` + +### ❌ Anti-Pattern 3: Actor for Everything + +```swift +// ❌ Actor overhead for simple synchronous data +actor SimpleCounter { + private var count = 0 + + func increment() { + count += 1 + } +} + +// ✅ Use lock-free atomics or @unchecked Sendable +import Atomics +struct AtomicCounter: @unchecked Sendable { + private let count = ManagedAtomic(0) + + func increment() { + count.wrappingIncrement(ordering: .relaxed) + } +} +``` + +--- + +## Code Review Checklist + +### Memory Management +- [ ] Large structs (>64 bytes) use indirect storage or are classes +- [ ] COW types use `isKnownUniquelyReferenced` before mutation +- [ ] Collections use `reserveCapacity` when size is known +- [ ] Weak references only where needed (prefer unowned when safe) + +### Generics +- [ ] Protocol types use `some` instead of `any` where possible +- [ ] Hot paths use concrete types or `@_specialize` +- [ ] Generic constraints are as specific as possible + +### Collections +- [ ] Pure Swift code uses `ContiguousArray` over `Array` +- [ ] Dictionary keys have efficient `hash(into:)` implementations +- [ ] Lazy evaluation used for short-circuit operations + +### Concurrency +- [ ] Synchronous operations don't use `async` +- [ ] Actor calls are batched when possible +- [ ] Task creation is minimized (use TaskGroup) +- [ ] CPU-intensive work uses `@concurrent` (Swift 6.2) + +### Optimization +- [ ] Profiling data exists before optimization +- [ ] Inlining only for small, frequently called functions +- [ ] Memory layout optimized for cache locality (large structs) + +--- + +## Pressure Scenarios + +### Scenario 1: "Just make it faster, we ship tomorrow" + +**The Pressure**: Manager sees "slow" in profiler, demands immediate action. + +**Red Flags**: +- No baseline measurements +- No Time Profiler data showing hotspots +- "Make everything faster" without targets + +**Time Cost Comparison**: +- Premature optimization: 2 days of work, no measurable improvement +- Profile-guided optimization: 2 hours profiling + 4 hours fixing actual bottleneck = 40% faster + +**How to Push Back Professionally**: +``` +"I want to optimize effectively. Let me spend 30 minutes with Instruments +to find the actual bottleneck. This prevents wasting time on code that's +not the problem. I've seen this save days of work." +``` + +### Scenario 2: "Use actors everywhere for thread safety" + +**The Pressure**: Team adopts Swift 6, decides "everything should be an actor." + +**Red Flags**: +- Actor for simple value types +- Actor for synchronous-only operations +- Async overhead in tight loops + +**Time Cost Comparison**: +- Actor everywhere: 100μs overhead per operation, janky UI +- Appropriate isolation: 10μs overhead, smooth 60fps + +**How to Push Back Professionally**: +``` +"Actors are great for isolation, but they add overhead. For this simple +counter, lock-free atomics are 10x faster. Let's use actors where we need +them—shared mutable state—and avoid them for pure value types." +``` + +### Scenario 3: "Inline everything for speed" + +**The Pressure**: Someone reads that inlining is faster, marks everything `@inlinable`. + +**Red Flags**: +- Large functions marked `@inlinable` +- Internal implementation details exposed +- Binary size increases 50% + +**Time Cost Comparison**: +- Inline everything: Code bloat, slower app launch (3s → 5s) +- Selective inlining: Fast launch, actual hotspots optimized + +**How to Push Back Professionally**: +``` +"Inlining trades code size for speed. The compiler already inlines when +beneficial. Manual @inlinable should be for small, frequently called +functions. Let's profile and inline the 3 actual hotspots, not everything." +``` + +--- + +## Real-World Examples + +### Example 1: Image Processing Pipeline + +**Problem**: Processing 1000 images takes 30 seconds. + +**Investigation**: +```swift +// Original code +func processImages(_ images: [UIImage]) -> [ProcessedImage] { + var results: [ProcessedImage] = [] + for image in images { + results.append(expensiveProcess(image)) // Reallocations! + } + return results +} +``` + +**Solution**: +```swift +func processImages(_ images: [UIImage]) -> [ProcessedImage] { + var results = ContiguousArray() + results.reserveCapacity(images.count) // Single allocation + + for image in images { + results.append(expensiveProcess(image)) + } + + return Array(results) +} +``` + +**Result**: 30s → 8s (73% faster) by eliminating reallocations. + +### Example 2: Actor Batching for Counter + +**Problem**: Actor counter in tight loop causes UI jank. + +**Investigation**: +```swift +// Original - 10,000 actor hops +for _ in 0..<10000 { + await counter.increment() // ~100μs each = 1 second total! +} +``` + +**Solution**: +```swift +// Batch operations +actor Counter { + private var value = 0 + + func incrementBatch(_ count: Int) { + value += count + } +} + +await counter.incrementBatch(10000) // Single actor hop +``` + +**Result**: 1000ms → 0.1ms (10,000x faster) by batching. + +### Example 3: Generic Specialization + +**Problem**: Protocol-based rendering is slow. + +**Investigation**: +```swift +// Original - existential overhead +func render(shapes: [any Shape]) { + for shape in shapes { + shape.draw() // Dynamic dispatch + } +} +``` + +**Solution**: +```swift +// Specialized generic +func render(shapes: [S]) { + for shape in shapes { + shape.draw() // Static dispatch after specialization + } +} + +// Or use @_specialize +@_specialize(where S == Circle) +@_specialize(where S == Rectangle) +func render(shapes: [S]) { } +``` + +**Result**: 100ms → 10ms (10x faster) by eliminating witness table overhead. + +### Example 4: Apple Password Monitoring Migration + +**Problem**: Apple's Password Monitoring service needed to scale while reducing costs. + +**Original Implementation**: Java-based service +- High memory usage (gigabytes) +- 50% Kubernetes cluster utilization +- Moderate throughput + +**Swift Rewrite Benefits**: +```swift +// Key performance wins from Swift's features: + +// 1. Deterministic memory management (no GC pauses) +// - No stop-the-world garbage collection +// - Predictable latency for real-time processing + +// 2. Value semantics + COW +// - Efficient data sharing without defensive copying +// - Reduced memory churn + +// 3. Zero-cost abstractions +// - Generic specialization eliminates runtime overhead +// - Protocol conformances optimized away +``` + +**Results** (Apple's published metrics): +- **40% throughput increase** vs Java implementation +- **100x memory reduction**: Gigabytes → Megabytes +- **50% Kubernetes capacity freed**: Same workload, half the resources + +**Why This Matters**: This real-world production service demonstrates that the performance patterns in this skill (COW, value semantics, generic specialization, ARC) deliver measurable business impact at scale. + +**Source**: [Swift.org - Password Monitoring Case Study](https://www.swift.org/blog/password-monitoring/) + +--- + +## Resources + +**WWDC**: 2025-312, 2024-10217, 2024-10170, 2021-10216, 2016-416 + +**Docs**: /swift/inlinearray, /swift/span + +**Skills**: axiom-performance-profiling, axiom-swift-concurrency, axiom-swiftui-performance + +--- + +**Last Updated**: 2025-12-18 +**Swift Version**: 6.2+ (for InlineArray, Span, `@concurrent`) +**Status**: Production-ready + +**Remember**: Profile first, optimize later. Readability > micro-optimizations. diff --git a/skill-index/skills/axiom-swift-testing/SKILL.md b/skill-index/skills/axiom-swift-testing/SKILL.md new file mode 100644 index 0000000..7399856 --- /dev/null +++ b/skill-index/skills/axiom-swift-testing/SKILL.md @@ -0,0 +1,725 @@ +--- +name: axiom-swift-testing +description: Use when writing unit tests, adopting Swift Testing framework, making tests run faster without simulator, architecting code for testability, testing async code reliably, or migrating from XCTest - covers @Test/@Suite macros, #expect/#require, parameterized tests, traits, tags, parallel execution, host-less testing +skill_type: discipline +version: 1.0.0 +last_updated: WWDC 2024 (Swift Testing framework) +--- + +# Swift Testing + +## Overview + +Swift Testing is Apple's modern testing framework introduced at WWDC 2024. It uses Swift macros (`@Test`, `#expect`) instead of naming conventions, runs tests in parallel by default, and integrates seamlessly with Swift concurrency. + +**Core principle**: Tests should be fast, reliable, and expressive. The fastest tests run without launching your app or simulator. + +## The Speed Hierarchy + +Tests run at dramatically different speeds depending on how they're configured: + +| Configuration | Typical Time | Use Case | +|---------------|--------------|----------| +| `swift test` (Package) | ~0.1s | Pure logic, models, algorithms | +| Host Application: None | ~3s | Framework code, no UI dependencies | +| Bypass app launch | ~6s | App target but skip initialization | +| Full app launch | 20-60s | UI tests, integration tests | + +**Key insight**: Move testable logic into Swift Packages or frameworks, then test with `swift test` or "None" host application. + +--- + +## Building Blocks + +### @Test Functions + +```swift +import Testing + +@Test func videoHasCorrectMetadata() { + let video = Video(named: "example.mp4") + #expect(video.duration == 120) +} +``` + +**Key differences from XCTest**: +- No `test` prefix required — `@Test` attribute is explicit +- Can be global functions, not just methods in a class +- Supports `async`, `throws`, and actor isolation +- Each test runs on a fresh instance of its containing suite + +### #expect and #require + +```swift +// Basic expectation — test continues on failure +#expect(result == expected) +#expect(array.isEmpty) +#expect(numbers.contains(42)) + +// Required expectation — test stops on failure +let user = try #require(await fetchUser(id: 123)) +#expect(user.name == "Alice") + +// Unwrap optionals safely +let first = try #require(items.first) +#expect(first.isValid) +``` + +**Why #expect is better than XCTAssert**: +- Captures source code and sub-values automatically +- Single macro handles all operators (==, >, contains, etc.) +- No need for specialized assertions (XCTAssertEqual, XCTAssertNil, etc.) + +### Error Testing + +```swift +// Expect any error +#expect(throws: (any Error).self) { + try dangerousOperation() +} + +// Expect specific error type +#expect(throws: NetworkError.self) { + try fetchData() +} + +// Expect specific error value +#expect(throws: ValidationError.invalidEmail) { + try validate(email: "not-an-email") +} + +// Custom validation +#expect { + try process(data) +} throws: { error in + guard let networkError = error as? NetworkError else { return false } + return networkError.statusCode == 404 +} +``` + +### @Suite Types + +```swift +@Suite("Video Processing Tests") +struct VideoTests { + let video = Video(named: "sample.mp4") // Fresh instance per test + + @Test func hasCorrectDuration() { + #expect(video.duration == 120) + } + + @Test func hasCorrectResolution() { + #expect(video.resolution == CGSize(width: 1920, height: 1080)) + } +} +``` + +**Key behaviors**: +- Structs preferred (value semantics, no accidental state sharing) +- Each `@Test` gets its own suite instance +- Use `init` for setup, `deinit` for teardown (actors/classes only) +- Nested suites supported for organization + +--- + +## Traits + +Traits customize test behavior: + +```swift +// Display name +@Test("User can log in with valid credentials") +func loginWithValidCredentials() { } + +// Disable with reason +@Test(.disabled("Waiting for backend fix")) +func brokenFeature() { } + +// Conditional execution +@Test(.enabled(if: FeatureFlags.newUIEnabled)) +func newUITest() { } + +// Time limit +@Test(.timeLimit(.minutes(1))) +func longRunningTest() async { } + +// Bug reference +@Test(.bug("https://github.com/org/repo/issues/123", "Flaky on CI")) +func sometimesFailingTest() { } + +// OS version requirement +@available(iOS 18, *) +@Test func iOS18OnlyFeature() { } +``` + +### Tags for Organization + +```swift +// Define tags +extension Tag { + @Tag static var networking: Self + @Tag static var performance: Self + @Tag static var slow: Self +} + +// Apply to tests +@Test(.tags(.networking, .slow)) +func networkIntegrationTest() async { } + +// Apply to entire suite +@Suite(.tags(.performance)) +struct PerformanceTests { + @Test func benchmarkSort() { } // Inherits .performance tag +} +``` + +**Use tags to**: +- Run subsets of tests (filter by tag in Test Navigator) +- Exclude slow tests from quick feedback loops +- Group related tests across different files/suites + +--- + +## Parameterized Testing + +Transform repetitive tests into a single parameterized test: + +```swift +// ❌ Before: Repetitive +@Test func vanillaHasNoNuts() { + #expect(!IceCream.vanilla.containsNuts) +} +@Test func chocolateHasNoNuts() { + #expect(!IceCream.chocolate.containsNuts) +} +@Test func almondHasNuts() { + #expect(IceCream.almond.containsNuts) +} + +// ✅ After: Parameterized +@Test(arguments: [IceCream.vanilla, .chocolate, .strawberry]) +func flavorWithoutNuts(_ flavor: IceCream) { + #expect(!flavor.containsNuts) +} + +@Test(arguments: [IceCream.almond, .pistachio]) +func flavorWithNuts(_ flavor: IceCream) { + #expect(flavor.containsNuts) +} +``` + +### Two-Collection Parameterization + +```swift +// Test all combinations (4 × 3 = 12 test cases) +@Test(arguments: [1, 2, 3, 4], ["a", "b", "c"]) +func allCombinations(number: Int, letter: String) { + // Tests: (1,"a"), (1,"b"), (1,"c"), (2,"a"), ... +} + +// Test paired values only (3 test cases) +@Test(arguments: zip([1, 2, 3], ["one", "two", "three"])) +func pairedValues(number: Int, name: String) { + // Tests: (1,"one"), (2,"two"), (3,"three") +} +``` + +### Benefits Over For-Loops + +| For-Loop | Parameterized | +|----------|---------------| +| Stops on first failure | All arguments run | +| Unclear which value failed | Each argument shown separately | +| Sequential execution | Parallel execution | +| Can't re-run single case | Re-run individual arguments | + +--- + +## Fast Tests: Architecture for Testability + +### Strategy 1: Swift Package for Logic (Fastest) + +Move pure logic into a Swift Package: + +``` +MyApp/ +├── MyApp/ # App target (UI, app lifecycle) +├── MyAppCore/ # Swift Package (testable logic) +│ ├── Package.swift +│ └── Sources/ +│ └── MyAppCore/ +│ ├── Models/ +│ ├── Services/ +│ └── Utilities/ +└── MyAppCoreTests/ # Package tests +``` + +Run with `swift test` — no simulator, no app launch: + +```bash +cd MyAppCore +swift test # Runs in ~0.1 seconds +``` + +### Strategy 2: Framework with No Host Application + +For code that must stay in the app project: + +1. **Create a framework target** (File → New → Target → Framework) +2. **Move model code** into the framework +3. **Make types public** that need external access +4. **Add imports** in files using the framework +5. **Set Host Application to "None"** in test target settings + +``` +Project Settings → Test Target → Testing + Host Application: None ← Key setting + ☐ Allow testing Host Application APIs +``` + +Build+test time: ~3 seconds vs 20-60 seconds with app launch. + +### Strategy 3: Bypass SwiftUI App Launch + +If you can't use a framework, bypass the app launch: + +```swift +// Simple solution (no custom startup code) +@main +struct ProductionApp: App { + var body: some Scene { + WindowGroup { + if !isRunningTests { + ContentView() + } + } + } + + private var isRunningTests: Bool { + NSClassFromString("XCTestCase") != nil + } +} +``` + +```swift +// Thorough solution (custom startup code) +@main +struct MainEntryPoint { + static func main() { + if NSClassFromString("XCTestCase") != nil { + TestApp.main() // Empty app for tests + } else { + ProductionApp.main() + } + } +} + +struct TestApp: App { + var body: some Scene { + WindowGroup { } // Empty + } +} +``` + +--- + +## Async Testing + +### Basic Async Tests + +```swift +@Test func fetchUserReturnsData() async throws { + let user = try await userService.fetch(id: 123) + #expect(user.name == "Alice") +} +``` + +### Testing Callbacks with Continuations + +```swift +// Convert completion handler to async +@Test func legacyAPIWorks() async throws { + let result = try await withCheckedThrowingContinuation { continuation in + legacyService.fetchData { result in + continuation.resume(with: result) + } + } + #expect(result.count > 0) +} +``` + +### Confirmations for Multiple Events + +```swift +@Test func cookiesAreEaten() async { + await confirmation("cookie eaten", expectedCount: 10) { confirm in + let jar = CookieJar(count: 10) + jar.onCookieEaten = { confirm() } + await jar.eatAll() + } +} + +// Confirm something never happens +await confirmation(expectedCount: 0) { confirm in + let cache = Cache() + cache.onEviction = { confirm() } + cache.store("small-item") // Should not trigger eviction +} +``` + +### Reliable Async Testing with Concurrency Extras + +**Problem**: Async tests can be flaky due to scheduling unpredictability. + +```swift +// ❌ Flaky: Task scheduling is unpredictable +@Test func loadingStateChanges() async { + let model = ViewModel() + let task = Task { await model.loadData() } + #expect(model.isLoading == true) // Often fails! + await task.value +} +``` + +**Solution**: Use Point-Free's `swift-concurrency-extras`: + +```swift +import ConcurrencyExtras + +@Test func loadingStateChanges() async { + await withMainSerialExecutor { + let model = ViewModel() + let task = Task { await model.loadData() } + await Task.yield() + #expect(model.isLoading == true) // Deterministic! + await task.value + #expect(model.isLoading == false) + } +} +``` + +**Why it works**: Serializes async work to main thread, making suspension points deterministic. + +### Deterministic Time with TestClock + +Use Point-Free's `swift-clocks` to control time in tests: + +```swift +import Clocks + +@MainActor +class FeatureModel: ObservableObject { + @Published var count = 0 + let clock: any Clock + var timerTask: Task? + + init(clock: any Clock) { + self.clock = clock + } + + func startTimer() { + timerTask = Task { + while true { + try await clock.sleep(for: .seconds(1)) + count += 1 + } + } + } +} + +// Test with controlled time +@Test func timerIncrements() async { + let clock = TestClock() + let model = FeatureModel(clock: clock) + + model.startTimer() + + await clock.advance(by: .seconds(1)) + #expect(model.count == 1) + + await clock.advance(by: .seconds(4)) + #expect(model.count == 5) + + model.timerTask?.cancel() +} +``` + +**Clock types**: +- `TestClock` — Advance time manually, deterministic +- `ImmediateClock` — All sleeps return instantly (great for previews) +- `UnimplementedClock` — Fails if used (catch unexpected time dependencies) + +--- + +## Parallel Testing + +Swift Testing runs tests in parallel by default. + +### When to Serialize + +```swift +// Serialize tests in a suite that share external state +@Suite(.serialized) +struct DatabaseTests { + @Test func createUser() { } + @Test func deleteUser() { } // Runs after createUser +} + +// Serialize parameterized test cases +@Test(.serialized, arguments: [1, 2, 3]) +func sequentialProcessing(value: Int) { } +``` + +### Hidden Dependencies + +```swift +// ❌ Bug: Tests depend on execution order +@Suite struct CookieTests { + static var cookie: Cookie? + + @Test func bakeCookie() { + Self.cookie = Cookie() // Sets shared state + } + + @Test func eatCookie() { + #expect(Self.cookie != nil) // Fails if runs first! + } +} + +// ✅ Fixed: Each test is independent +@Suite struct CookieTests { + @Test func bakeCookie() { + let cookie = Cookie() + #expect(cookie.isBaked) + } + + @Test func eatCookie() { + let cookie = Cookie() + cookie.eat() + #expect(cookie.isEaten) + } +} +``` + +**Random order** helps expose these bugs — fix them rather than serialize. + +--- + +## Known Issues + +Handle expected failures without noise: + +```swift +@Test func featureUnderDevelopment() { + withKnownIssue("Backend not ready yet") { + try callUnfinishedAPI() + } +} + +// Conditional known issue +@Test func platformSpecificBug() { + withKnownIssue("Fails on iOS 17.0") { + try reproduceEdgeCaseBug() + } when: { + ProcessInfo().operatingSystemVersion.majorVersion == 17 + } +} +``` + +**Better than .disabled because**: +- Test still compiles (catches syntax errors) +- You're notified when the issue is fixed +- Results show "expected failure" not "skipped" + +--- + +## Migration from XCTest + +### Comparison Table + +| XCTest | Swift Testing | +|--------|---------------| +| `func testFoo()` | `@Test func foo()` | +| `XCTAssertEqual(a, b)` | `#expect(a == b)` | +| `XCTAssertNil(x)` | `#expect(x == nil)` | +| `XCTAssertThrowsError` | `#expect(throws:)` | +| `XCTUnwrap(x)` | `try #require(x)` | +| `class FooTests: XCTestCase` | `@Suite struct FooTests` | +| `setUp()` / `tearDown()` | `init` / `deinit` | +| `continueAfterFailure = false` | `#require` (per-expectation) | +| `addTeardownBlock` | `deinit` or defer | + +### Keep Using XCTest For + +- **UI tests** (XCUIApplication) +- **Performance tests** (XCTMetric) +- **Objective-C tests** + +### Migration Tips + +1. Both frameworks can coexist in the same target +2. Migrate incrementally, one test file at a time +3. Consolidate similar XCTests into parameterized Swift tests +4. Single-test XCTestCase → global `@Test` function + +--- + +## Common Mistakes + +### ❌ Mixing Assertions + +```swift +// Don't mix XCTest and Swift Testing +@Test func badExample() { + XCTAssertEqual(1, 1) // ❌ Wrong framework + #expect(1 == 1) // ✅ Use this +} +``` + +### ❌ Using Classes for Suites + +```swift +// ❌ Avoid: Reference semantics can cause shared state bugs +@Suite class VideoTests { } + +// ✅ Prefer: Value semantics isolate each test +@Suite struct VideoTests { } +``` + +### ❌ Forgetting @MainActor + +```swift +// ❌ May fail with Swift 6 strict concurrency +@Test func updateUI() async { + viewModel.updateTitle("New") // Data race warning +} + +// ✅ Isolate to main actor +@Test @MainActor func updateUI() async { + viewModel.updateTitle("New") +} +``` + +### ❌ Over-Serializing + +```swift +// ❌ Don't serialize just because tests use async +@Suite(.serialized) struct APITests { } // Defeats parallelism + +// ✅ Only serialize when tests truly share mutable state +``` + +### ❌ XCTestCase with Swift 6.2 MainActor Default + +Swift 6.2's `default-actor-isolation = MainActor` breaks XCTestCase: + +```swift +// ❌ Error: Main actor-isolated initializer 'init()' has different +// actor isolation from nonisolated overridden declaration +final class PlaygroundTests: XCTestCase { + override func setUp() async throws { + try await super.setUp() + } +} +``` + +**Solution**: Mark XCTestCase subclass as `nonisolated`: + +```swift +// ✅ Works with MainActor default isolation +nonisolated final class PlaygroundTests: XCTestCase { + @MainActor + override func setUp() async throws { + try await super.setUp() + } + + @Test @MainActor + func testSomething() async { + // Individual tests can be @MainActor + } +} +``` + +**Why**: XCTestCase is Objective-C, not annotated for Swift concurrency. Its initializers are `nonisolated`, causing conflicts with MainActor-isolated subclasses. + +**Better solution**: Migrate to Swift Testing (`@Suite struct`) which handles isolation properly. + +--- + +## Xcode Optimization for Fast Feedback + +### Turn Off Parallel XCTest Execution + +Swift Testing runs in parallel by default; XCTest parallelization adds overhead: + +``` +Test Plan → Options → Parallelization → "Swift Testing Only" +``` + +### Turn Off Test Debugger + +Attaching the debugger costs ~1 second per run: + +``` +Scheme → Edit Scheme → Test → Info → ☐ Debugger +``` + +### Delete UI Test Templates + +Xcode's default UI tests slow everything down. Remove them: +1. Delete UI test target (Project Settings → select target → -) +2. Delete UI test source folder + +### Disable dSYM for Debug Builds + +``` +Build Settings → Debug Information Format + Debug: DWARF + Release: DWARF with dSYM File +``` + +### Check Build Scripts + +Run Script phases without defined inputs/outputs cause full rebuilds. Always specify: +- Input Files / Input File Lists +- Output Files / Output File Lists + +--- + +## Checklist + +### Before Writing Tests +- [ ] Identify what can move to a Swift Package (pure logic) +- [ ] Set up framework target if package isn't viable +- [ ] Configure Host Application: None for unit tests + +### Writing Tests +- [ ] Use `@Test` with clear display names +- [ ] Use `#expect` for all assertions +- [ ] Use `#require` to fail fast on preconditions +- [ ] Use parameterization for similar test cases +- [ ] Add `.tags()` for organization + +### Async Tests +- [ ] Mark test functions `async` and use `await` +- [ ] Use `confirmation()` for callback-based code +- [ ] Consider `withMainSerialExecutor` for flaky tests + +### Parallel Safety +- [ ] Avoid shared mutable state between tests +- [ ] Use fresh instances in each test +- [ ] Only use `.serialized` when absolutely necessary + +--- + +## Resources + +**WWDC**: 2024-10179, 2024-10195 + +**Docs**: /testing, /testing/migratingfromxctest, /testing/testing-asynchronous-code, /testing/parallelization + +**GitHub**: pointfreeco/swift-concurrency-extras, pointfreeco/swift-clocks + +--- + +**History:** See git log for changes diff --git a/skill-index/skills/axiom-swiftdata/SKILL.md b/skill-index/skills/axiom-swiftdata/SKILL.md new file mode 100644 index 0000000..88701e9 --- /dev/null +++ b/skill-index/skills/axiom-swiftdata/SKILL.md @@ -0,0 +1,1489 @@ +--- +name: axiom-swiftdata +description: Use when working with SwiftData - @Model definitions, @Query in SwiftUI, @Relationship macros, ModelContext patterns, CloudKit integration, iOS 26+ features, and Swift 6 concurrency with @MainActor — Apple's native persistence framework +skill_type: discipline +version: 1.0.0 +--- + +# SwiftData + +## Overview + +Apple's native persistence framework using `@Model` classes and declarative queries. Built on Core Data, designed for SwiftUI. + +**Core principle** Reference types (`class`) + `@Model` macro + declarative `@Query` for reactive SwiftUI integration. + +**Requires** iOS 17+, Swift 5.9+ +**Target** iOS 26+ (this skill focuses on latest features) +**License** Proprietary (Apple) + +## When to Use SwiftData + +#### Choose SwiftData when you need +- ✅ Native Apple integration with SwiftUI +- ✅ Simple CRUD operations +- ✅ Automatic UI updates with `@Query` +- ✅ CloudKit sync (iOS 17+) +- ✅ Reference types (classes) with relationships + +#### Use SQLiteData instead when +- Need value types (structs) +- CloudKit record sharing (not just sync) +- Large datasets (50k+ records) with specific performance needs + +#### Use GRDB when +- Complex raw SQL required +- Fine-grained migration control needed + +**For migrations** See the `axiom-swiftdata-migration` skill for custom schema migrations with VersionedSchema and SchemaMigrationPlan. For migration debugging, see `axiom-swiftdata-migration-diag`. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### Basic Operations + +#### 1. "I have a notes app with folders. I need to filter notes by folder and sort by last modified. How do I set up the @Query?" +→ The skill shows how to use `@Query` with predicates, sorting, and automatic view updates + +#### 2. "When a user deletes a task list, all tasks should auto-delete too. How do I set up the relationship?" +→ The skill explains `@Relationship` with `deleteRule: .cascade` and inverse relationships + +#### 3. "I have a relationship between User → Messages → Attachments. How do I prevent orphaned data when deleting?" +→ The skill shows cascading deletes, inverse relationships, and safe deletion patterns + +#### CloudKit & Sync + +#### 4. "My chat app syncs messages to other devices via CloudKit. Sometimes messages conflict. How do I handle sync conflicts?" +→ The skill covers CloudKit integration, conflict resolution strategies (last-write-wins, custom resolution), and sync patterns + +#### 5. "I'm adding CloudKit sync to my app, but I get 'Property must have a default value' error. What's wrong?" +→ The skill explains CloudKit constraints: all properties must be optional or have defaults, explains why (network timing), and shows fixes + +#### 6. "I want to show users when their data is syncing to iCloud and what happens when they're offline." +→ The skill shows monitoring sync status with notifications, detecting network connectivity, and offline-aware UI patterns + +#### 7. "I need to share a playlist with other users. How do I implement CloudKit record sharing?" +→ The skill covers CloudKit record sharing patterns (iOS 26+) with owner/permission tracking and sharing metadata + +#### Performance & Optimization + +#### 8. "I need to query 50,000 messages but only display 20 at a time. How do I paginate efficiently?" +→ The skill covers performance patterns, batch fetching, limiting queries, and preventing memory bloat with chunked imports + +#### 9. "My app loads 100 tasks with relationships, and displaying them is slow. I think it's N+1 queries." +→ The skill shows how to identify N+1 problems without prefetching, provides prefetching pattern, and shows 100x performance improvement + +#### 10. "I'm importing 1 million records from an API. What's the best way to batch them without running out of memory?" +→ The skill shows chunk-based importing with periodic saves, memory cleanup patterns, and batch operation optimization + +#### 11. "Which properties should I add indexes to? I'm worried about over-indexing slowing down writes." +→ The skill explains index optimization patterns: when to index (frequently filtered/sorted properties), when to avoid (rarely used, frequently changing), maintenance costs + +#### Migration from Legacy Frameworks + +#### 12. "We're migrating from Realm to SwiftData. What are the biggest differences in how we write code?" +→ The skill shows Realm → SwiftData pattern equivalents: @Persisted → @Attribute, threading model differences, relationship handling + +#### 13. "We have Core Data in production. What's the safest way to migrate to SwiftData while keeping both running?" +→ The skill covers dual-stack migration: reading Core Data, writing to SwiftData, marking migrated records, gradual cutover, validation + +#### 14. "Our Realm app uses background threads for all database operations. How do I convert to SwiftData's async/await model?" +→ The skill explains thread-confinement migration: actor-based safety, removing manual DispatchQueue, proper async context patterns, Swift 6 concurrency + +#### 15. "I need to migrate our CloudKit sync from Realm Sync (deprecated) to SwiftData CloudKit integration." +→ The skill shows Realm Sync → SwiftData CloudKit migration, addressing sync feature gaps, testing new sync implementation + +--- + +## @Model Definitions + +### Basic Model + +```swift +import SwiftData + +@Model +final class Track { + @Attribute(.unique) var id: String + var title: String + var artist: String + var duration: TimeInterval + var genre: String? + + init(id: String, title: String, artist: String, duration: TimeInterval, genre: String? = nil) { + self.id = id + self.title = title + self.artist = artist + self.duration = duration + self.genre = genre + } +} +``` + +#### Key patterns +- Use `final class`, not `struct` +- Use `@Attribute(.unique)` for primary key-like behavior +- Provide explicit `init` (SwiftData doesn't synthesize) +- Optional properties (`String?`) are nullable + +### Relationships + +```swift +@Model +final class Track { + @Attribute(.unique) var id: String + var title: String + + @Relationship(deleteRule: .cascade, inverse: \Album.tracks) + var album: Album? + + init(id: String, title: String, album: Album? = nil) { + self.id = id + self.title = title + self.album = album + } +} + +@Model +final class Album { + @Attribute(.unique) var id: String + var title: String + + @Relationship(deleteRule: .cascade) + var tracks: [Track] = [] + + init(id: String, title: String) { + self.id = id + self.title = title + } +} +``` + +### Many-to-Many Self-Referential Relationships + +```swift +@MainActor // Required for Swift 6 strict concurrency +@Model +final class User { + @Attribute(.unique) var id: String + var name: String + + // Users following this user (inverse relationship) + @Relationship(deleteRule: .nullify, inverse: \User.following) + var followers: [User] = [] + + // Users this user is following + @Relationship(deleteRule: .nullify) + var following: [User] = [] + + init(id: String, name: String) { + self.id = id + self.name = name + } +} +``` + +#### CRITICAL: SwiftData automatically manages BOTH sides when you modify ONE side. + +✅ **Correct — Only modify ONE side** +```swift +// user1 follows user2 (modifying ONE side) +user1.following.append(user2) +try modelContext.save() + +// SwiftData AUTOMATICALLY updates user2.followers +// Don't manually append to both sides - causes duplicates! +``` + +❌ **Wrong — Don't manually update both sides** +```swift +user1.following.append(user2) +user2.followers.append(user1) // Redundant! Creates duplicates in CloudKit sync +``` + +#### Unfollowing (remove from ONE side only) +```swift +user1.following.removeAll { $0.id == user2.id } +try modelContext.save() +// user2.followers automatically updated +``` + +#### Verifying relationship integrity (for debugging) +```swift +// Check if relationship is truly bidirectional +let user1FollowsUser2 = user1.following.contains { $0.id == user2.id } +let user2FollowedByUser1 = user2.followers.contains { $0.id == user1.id } + +// These MUST always match after save() +assert(user1FollowsUser2 == user2FollowedByUser1, "Relationship corrupted!") +``` + +#### CloudKit Sync Recovery (if relationships become corrupted) +```swift +// If CloudKit sync creates duplicate/orphaned relationships: + +// 1. Backup current state +let backup = user.following.map { $0.id } + +// 2. Clear relationships +user.following.removeAll() +user.followers.removeAll() +try modelContext.save() + +// 3. Rebuild from source of truth (e.g., API) +for followingId in backup { + if let followingUser = fetchUser(id: followingId) { + user.following.append(followingUser) + } +} +try modelContext.save() + +// 4. Force CloudKit resync (in ModelConfiguration) +// Re-create ModelContainer to force full sync after corruption recovery +``` + +#### Delete rules +- `.cascade` - Delete related objects +- `.nullify` - Set relationship to nil +- `.deny` - Prevent deletion if relationship exists +- `.noAction` - Leave relationship as-is (careful!) + +## ModelContainer Setup + +### SwiftUI App + +```swift +import SwiftUI +import SwiftData + +@main +struct MusicApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .modelContainer(for: [Track.self, Album.self]) + } +} +``` + +### Custom Configuration + +```swift +let schema = Schema([Track.self, Album.self]) + +let config = ModelConfiguration( + schema: schema, + url: URL(fileURLWithPath: "/path/to/database.sqlite"), + cloudKitDatabase: .private("iCloud.com.example.app") +) + +let container = try ModelContainer( + for: schema, + configurations: config +) +``` + +### In-Memory (Tests) + +```swift +let config = ModelConfiguration(isStoredInMemoryOnly: true) +let container = try ModelContainer( + for: schema, + configurations: config +) +``` + +## Queries in SwiftUI + +### Basic @Query + +```swift +import SwiftUI +import SwiftData + +struct TracksView: View { + @Query var tracks: [Track] + + var body: some View { + List(tracks) { track in + Text(track.title) + } + } +} +``` + +**Automatic updates** View refreshes when data changes. + +### Filtered Query + +```swift +struct RockTracksView: View { + @Query(filter: #Predicate { track in + track.genre == "Rock" + }) var rockTracks: [Track] + + var body: some View { + List(rockTracks) { track in + Text(track.title) + } + } +} +``` + +### Sorted Query + +```swift +@Query(sort: \.title, order: .forward) var tracks: [Track] + +// Multiple sort descriptors +@Query(sort: [ + SortDescriptor(\.artist), + SortDescriptor(\.title) +]) var tracks: [Track] +``` + +### Combined Filter + Sort + +```swift +@Query( + filter: #Predicate { $0.duration > 180 }, + sort: \.title +) var longTracks: [Track] +``` + +## ModelContext Operations + +### Accessing ModelContext + +```swift +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + + func addTrack() { + let track = Track( + id: UUID().uuidString, + title: "New Song", + artist: "Artist", + duration: 240 + ) + modelContext.insert(track) + } +} +``` + +### Insert + +```swift +let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240) +modelContext.insert(track) + +// Save immediately (optional - auto-saves on view disappear) +try modelContext.save() +``` + +### Fetch + +```swift +let descriptor = FetchDescriptor( + predicate: #Predicate { $0.genre == "Rock" }, + sortBy: [SortDescriptor(\.title)] +) + +let rockTracks = try modelContext.fetch(descriptor) +``` + +### Update + +```swift +// Just modify properties — SwiftData tracks changes +track.title = "Updated Title" + +// Save if needed immediately +try modelContext.save() +``` + +### Delete + +```swift +modelContext.delete(track) +try modelContext.save() +``` + +### Batch Delete + +```swift +try modelContext.delete(model: Track.self, where: #Predicate { track in + track.genre == "Classical" +}) +``` + +## Predicates + +### Basic Comparisons + +```swift +#Predicate { $0.duration > 180 } +#Predicate { $0.artist == "Artist Name" } +#Predicate { $0.genre != nil } +``` + +### Compound Predicates + +```swift +#Predicate { track in + track.genre == "Rock" && track.duration > 180 +} + +#Predicate { track in + track.artist == "Artist" || track.artist == "Other Artist" +} +``` + +### String Matching + +```swift +// Contains +#Predicate { track in + track.title.contains("Love") +} + +// Case-insensitive contains +#Predicate { track in + track.title.localizedStandardContains("love") +} + +// Starts with +#Predicate { track in + track.artist.hasPrefix("The ") +} +``` + +### Relationship Predicates + +```swift +#Predicate { track in + track.album?.title == "Album Name" +} + +#Predicate { album in + album.tracks.count > 10 +} +``` + +## Swift 6 Concurrency + +### @MainActor Isolation + +```swift +import SwiftData + +@MainActor +@Model +final class Track { + var id: String + var title: String + + init(id: String, title: String) { + self.id = id + self.title = title + } +} +``` + +**Why** SwiftData models are not `Sendable`. Use `@MainActor` to ensure safe access from SwiftUI. + +### Background Context + +```swift +import SwiftData + +actor DataImporter { + let modelContainer: ModelContainer + + init(container: ModelContainer) { + self.modelContainer = container + } + + func importTracks(_ tracks: [TrackData]) async throws { + // Create background context + let context = ModelContext(modelContainer) + + for track in tracks { + let model = Track( + id: track.id, + title: track.title, + artist: track.artist, + duration: track.duration + ) + context.insert(model) + } + + try context.save() + } +} +``` + +**Pattern** Use `ModelContext(modelContainer)` for background operations, not `@Environment(\.modelContext)` which is main-actor bound. + +## CloudKit Integration + +### Enable CloudKit Sync + +```swift +let schema = Schema([Track.self]) + +let config = ModelConfiguration( + schema: schema, + cloudKitDatabase: .private("iCloud.com.example.MusicApp") +) + +let container = try ModelContainer( + for: schema, + configurations: config +) +``` + +### Capabilities Required + +1. Enable iCloud in Xcode (Signing & Capabilities) +2. Select CloudKit +3. Add iCloud container: `iCloud.com.example.MusicApp` + +**Note** SwiftData CloudKit sync is automatic - no manual conflict resolution needed. + +### CloudKit Constraints (CRITICAL) + +#### When using CloudKit sync, ALL properties must be optional or have default values + +```swift +@Model +final class Track { + @Attribute(.unique) var id: String = UUID().uuidString // ✅ Has default + var title: String = "" // ✅ Has default + var duration: TimeInterval = 0 // ✅ Has default + var genre: String? = nil // ✅ Optional + + // ❌ These don't work with CloudKit: + // var requiredField: String // No default, not optional +} +``` + +**Why** CloudKit only syncs to private zones, and network delays mean new records may not have all fields populated yet. + +**Relationship Constraint** All relationships must be optional +```swift +@Model +final class Track { + @Relationship(deleteRule: .cascade, inverse: \Album.tracks) + var album: Album? // ✅ Must be optional for CloudKit +} +``` + +### Monitoring Sync Status (iOS 26+) + +```swift +struct ContentView: View { + @Environment(\.modelContext) private var modelContext + @State private var isSyncing = false + + var body: some View { + VStack { + if isSyncing { + Label("Syncing with iCloud...", systemImage: "icloud.and.arrow.up.fill") + .foregroundColor(.blue) + } + + List { + // Your content + } + } + .task { + // Monitor sync notifications + for await notification in NotificationCenter.default + .notifications(named: NSNotification.Name("CloudKitSyncDidComplete")) { + isSyncing = false + } + } + } +} +``` + +### Handling CloudKit Sync Conflicts + +SwiftData uses **last-write-wins** by default. If you need custom resolution: + +```swift +@MainActor +@Model +final class Track { + @Attribute(.unique) var id: String = UUID().uuidString + var title: String = "" + var lastModified: Date = Date() // Track modification time + var deviceID: String = "" // Track which device modified + + init(id: String = UUID().uuidString, title: String = "", deviceID: String) { + self.id = id + self.title = title + self.deviceID = deviceID + self.lastModified = Date() + } +} + +// Conflict resolution pattern: Keep newest version +actor ConflictResolver { + let modelContext: ModelContext + + init(context: ModelContext) { + self.modelContext = context + } + + func resolveTrackConflict(_ local: Track, _ remote: Track) { + // Remote is newer + if remote.lastModified > local.lastModified { + local.title = remote.title + local.lastModified = remote.lastModified + local.deviceID = remote.deviceID + } + // Local is newer - keep local (do nothing) + } +} +``` + +### Offline Handling & Network Status + +```swift +import Network + +@MainActor +class NetworkMonitor: ObservableObject { + @Published var isConnected = false + private let monitor = NWPathMonitor() + + init() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + } + } + monitor.start(queue: DispatchQueue.global()) + } +} + +struct OfflineAwareView: View { + @StateObject private var networkMonitor = NetworkMonitor() + @Query var tracks: [Track] + + var body: some View { + VStack { + if !networkMonitor.isConnected { + Label("You're offline. Changes will sync when online.", systemImage: "wifi.slash") + .font(.caption) + .foregroundColor(.orange) + } + + List(tracks) { track in + Text(track.title) + } + } + } +} +``` + +### CloudKit Record Sharing (iOS 26+) + +```swift +@MainActor +@Model +final class SharedPlaylist { + @Attribute(.unique) var id: String = UUID().uuidString + var name: String = "" + var ownerID: String = "" // CloudKit User ID of owner + + @Relationship(deleteRule: .cascade, inverse: \Track.playlist) + var tracks: [Track] = [] + + // Share metadata + var sharedWith: [String] = [] // Array of shared user IDs + var sharePermission: SharePermission = .readOnly + + init(name: String, ownerID: String) { + self.name = name + self.ownerID = ownerID + } +} + +enum SharePermission: String, Codable { + case readOnly + case readWrite +} + +// Share a playlist with another user +actor PlaylistSharing { + let modelContainer: ModelContainer + + func sharePlaylist(_ playlist: SharedPlaylist, with userID: String) async throws { + let context = ModelContext(modelContainer) + + // Add user to shared list + if !playlist.sharedWith.contains(userID) { + playlist.sharedWith.append(userID) + try context.save() + } + + // Note: Actual CloudKit share URL generation requires CKShare + // This is handled by system frameworks + } +} +``` + +### Resolving "Property must be optional or have default value" Error + +**Problem** You get this error when trying to use CloudKit sync: +``` +Property 'title' must be optional or have a default value for CloudKit synchronization +``` + +#### Solution +```swift +// ❌ Wrong - required property +@Model +final class Track { + var title: String +} + +// ✅ Correct - has default +@Model +final class Track { + var title: String = "" +} + +// ✅ Also correct - optional +@Model +final class Track { + var title: String? +} +``` + +### Testing CloudKit Sync (Without iCloud) + +```swift +let schema = Schema([Track.self]) + +// Test configuration (no CloudKit sync) +let testConfig = ModelConfiguration(isStoredInMemoryOnly: true) + +let container = try ModelContainer(for: schema, configurations: testConfig) +``` + +#### For real CloudKit testing +1. Sign in to iCloud on test device +2. Enable CloudKit in Capabilities +3. Use real device (simulator CloudKit is unreliable) +4. Check iCloud status in Settings → [Your Name] → iCloud + +## iOS 26+ Features + +### Enhanced Relationship Handling + +```swift +@Model +final class Track { + @Relationship( + deleteRule: .cascade, + inverse: \Album.tracks, + minimum: 0, + maximum: 1 // Track belongs to at most one album + ) var album: Album? +} +``` + +### Transient Properties + +```swift +@Model +final class Track { + var id: String + var duration: TimeInterval + + @Transient + var formattedDuration: String { + let minutes = Int(duration) / 60 + let seconds = Int(duration) % 60 + return String(format: "%d:%02d", minutes, seconds) + } +} +``` + +**Transient** Computed property, not persisted. + +### History Tracking + +```swift +// Enable history tracking +let config = ModelConfiguration( + schema: schema, + cloudKitDatabase: .private("iCloud.com.example.app"), + allowsSave: true, + isHistoryEnabled: true // iOS 26+ +) +``` + +## Performance Patterns + +### Batch Fetching + +```swift +let descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.title)] +) +descriptor.fetchLimit = 100 // Paginate results + +let tracks = try modelContext.fetch(descriptor) +``` + +### Prefetch Relationships (Prevent N+1 Queries) + +```swift +let descriptor = FetchDescriptor() +descriptor.relationshipKeyPathsForPrefetching = [\.album] // Eager load album + +let tracks = try modelContext.fetch(descriptor) +// No N+1 queries - albums already loaded +``` + +**CRITICAL** Without prefetching, accessing `track.album.title` in a loop triggers individual queries for EACH track: + +```swift +// ❌ SLOW: N+1 queries (1 fetch tracks + 100 fetch albums) +let tracks = try modelContext.fetch(FetchDescriptor()) +for track in tracks { + print(track.album?.title) // 100 separate queries! +} + +// ✅ FAST: 2 queries total (1 fetch tracks + 1 fetch all albums) +let descriptor = FetchDescriptor() +descriptor.relationshipKeyPathsForPrefetching = [\.album] +let tracks = try modelContext.fetch(descriptor) +for track in tracks { + print(track.album?.title) // Already loaded +} +``` + +### Faulting (Lazy Loading) + +SwiftData uses faulting (lazy loading) by default: + +```swift +let track = tracks.first +// Album is a fault - not loaded yet + +let albumTitle = track.album?.title +// Album loaded on access (separate query) +``` + +#### Use faulting strategically +- ✅ Good when you access relationships in only 10-20% of cases +- ✅ Good for large relationship graphs you partially use +- ❌ Bad when you access relationships in loops → use prefetching instead + +### Batch Operations (Performance for Large Datasets) + +```swift +// ❌ SLOW: 1000 individual saves +for track in largeDataset { + track.genre = "Updated" + try modelContext.save() // Expensive - 1000 times +} + +// ✅ FAST: Single save operation +for track in largeDataset { + track.genre = "Updated" +} +try modelContext.save() // Once for entire batch +``` + +### Index Optimization (iOS 26+) + +Create indexes on frequently queried properties: + +```swift +@Model +final class Track { + @Attribute(.unique) var id: String = UUID().uuidString + + @Attribute(.indexed) // ✅ Add index + var genre: String = "" + + @Attribute(.indexed) + var releaseDate: Date = Date() + + var title: String = "" + var duration: TimeInterval = 0 +} + +// Now these queries are faster: +@Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] +@Query(filter: #Predicate { $0.releaseDate > Date() }) var upcomingTracks: [Track] +``` + +#### When to add indexes +- ✅ Properties used in `@Query` filters frequently +- ✅ Properties used in sort operations +- ✅ Properties used in relationships +- ❌ NOT properties that are rarely filtered +- ❌ NOT properties that change frequently (maintenance cost) + +### Memory Optimization: Fetch Chunks + +For very large datasets (100k+ records), fetch in chunks: + +```swift +actor DataImporter { + let modelContainer: ModelContainer + + func importLargeDataset(_ items: [Item]) async throws { + let chunkSize = 1000 + let context = ModelContext(modelContainer) + + for chunk in items.chunked(into: chunkSize) { + for item in chunk { + let track = Track( + id: item.id, + title: item.title, + artist: item.artist, + duration: item.duration + ) + context.insert(track) + } + + try context.save() // Save after each chunk + + // Prevent memory bloat + context.delete(model: Track.self, where: #Predicate { _ in true }) + } + } +} + +extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0.. [Track] { + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor() + return try! context.fetch(descriptor) + } +} + +// Usage (no manual threading needed) +@MainActor +class ViewController: UIViewController { + @State private var tracks: [Track] = [] + + func loadTracks() async { + tracks = await dataManager.fetchTracks() + } +} +``` + +#### Relationship Migration (Realm → SwiftData) + +```swift +// REALM: Explicit linking +class RealmAlbum: Object { + @Persisted(primaryKey: true) var id: String + @Persisted var title: String + @Persisted var tracks: RealmSwiftCollection // Explicit collection +} + +// SWIFTDATA: Inverse relationships automatic +@Model +final class Album { + @Attribute(.unique) var id: String = "" + var title: String = "" + + @Relationship(deleteRule: .cascade, inverse: \Track.album) + var tracks: [Track] = [] +} + +@Model +final class Track { + @Attribute(.unique) var id: String = "" + var title: String = "" + var album: Album? // Inverse automatically maintained +} +``` + +#### Migration Scenario: Small App (< 10,000 records) + +```swift +actor RealmToSwiftDataMigration { + let modelContainer: ModelContainer + + func migrateFromRealm(_ realmPath: String) async throws { + // 1. Read from Realm database file + let realmConfig = Realm.Configuration(fileURL: URL(fileURLWithPath: realmPath)) + let realm = try await Realm(configuration: realmConfig) + + // 2. Create SwiftData models + let context = ModelContext(modelContainer) + + try realm.objects(RealmTrack.self).forEach { realmTrack in + let track = Track( + id: realmTrack.id, + title: realmTrack.title, + artist: realmTrack.artist, + duration: realmTrack.duration + ) + context.insert(track) + } + + // 3. Save to SwiftData + try context.save() + + // 4. Verify migration + let descriptor = FetchDescriptor() + let tracks = try context.fetch(descriptor) + print("Migrated \(tracks.count) tracks") + } +} +``` + +### Migrating from Core Data + +#### Core Data Pattern → SwiftData Equivalent + +```swift +// CORE DATA +@NSManaged class CDTrack: NSManagedObject { + @NSManaged var id: String + @NSManaged var title: String + @NSManaged var duration: TimeInterval + @NSManaged var album: CDAlbum? +} + +// SWIFTDATA +@Model +final class Track { + @Attribute(.unique) var id: String = "" + var title: String = "" + var duration: TimeInterval = 0 + var album: Album? +} +``` + +#### Thread Confinement Migration (Core Data → SwiftData) + +```swift +// CORE DATA: Manual thread handling +class CoreDataManager { + var persistentContainer: NSPersistentContainer + + func fetchTracks(completion: @escaping ([CDTrack]) -> Void) { + let context = persistentContainer.newBackgroundContext() + context.perform { + let request = NSFetchRequest(entityName: "Track") + let results = try! context.fetch(request) + + DispatchQueue.main.async { + completion(results) // ❌ Can't cross thread boundary with NSManagedObject + } + } + } +} + +// SWIFTDATA: Safe async/await +class SwiftDataManager { + let modelContainer: ModelContainer + + func fetchTracks() async -> [Track] { + let context = ModelContext(modelContainer) + let descriptor = FetchDescriptor() + return (try? context.fetch(descriptor)) ?? [] + } +} +``` + +#### Batch Operations Migration (Core Data → SwiftData) + +```swift +// CORE DATA: Complex batch delete +class CoreDataBatchDelete { + var persistentContainer: NSPersistentContainer + + func deleteOldTracks(olderThan date: Date) { + let context = persistentContainer.newBackgroundContext() + let request = NSFetchRequest(entityName: "Track") + request.predicate = NSPredicate(format: "createdAt < %@", date as NSDate) + + let deleteRequest = NSBatchDeleteRequest(fetchRequest: request) + deleteRequest.resultType = .resultTypeCount + + do { + let result = try context.execute(deleteRequest) as? NSBatchDeleteResult + print("Deleted \(result?.result ?? 0) tracks") + } catch { + print("Delete failed: \(error)") + } + } +} + +// SWIFTDATA: Simple and safe +actor SwiftDataBatchDelete { + let modelContainer: ModelContainer + + func deleteOldTracks(olderThan date: Date) async throws { + let context = ModelContext(modelContainer) + try context.delete(model: Track.self, where: #Predicate { track in + track.createdAt < date + }) + } +} +``` + +#### Migration Scenario: Enterprise App (Gradual Migration) + +```swift +// Phase 1: Parallel persistence (Core Data + SwiftData) +class DualStackDataManager { + let coreDataStack: CoreDataStack + let swiftDataContainer: ModelContainer + + func migrateRecord(coreDataTrack: CDTrack) async throws { + // 1. Read from Core Data + let id = coreDataTrack.id + let title = coreDataTrack.title + let artist = coreDataTrack.artist + let duration = coreDataTrack.duration + + // 2. Write to SwiftData + let context = ModelContext(swiftDataContainer) + let track = Track( + id: id, + title: title, + artist: artist, + duration: duration + ) + context.insert(track) + try context.save() + + // 3. Mark as migrated in Core Data + coreDataTrack.isMigratedToSwiftData = true + } + + // Phase 2: Cutover (mark Core Data as deprecated) + func completeMigration() { + print("Migration complete — Core Data can be removed") + } +} +``` + +### CloudKit Sync Migration (Realm → SwiftData) + +```swift +// Realm uses Realm Sync (now deprecated) +// SwiftData uses CloudKit directly + +@Model +final class SyncedTrack { + @Attribute(.unique) var id: String = UUID().uuidString + var title: String = "" + var syncedAt: Date = Date() + + init(id: String = UUID().uuidString, title: String) { + self.id = id + self.title = title + } +} + +// Enable CloudKit sync in ModelConfiguration +let schema = Schema([SyncedTrack.self]) +let config = ModelConfiguration( + schema: schema, + cloudKitDatabase: .private("iCloud.com.example.MusicApp") +) + +let container = try ModelContainer(for: schema, configurations: config) +``` + +## Testing + +### Test Setup + +```swift +import XCTest +import SwiftData +@testable import MusicApp + +final class TrackTests: XCTestCase { + var modelContext: ModelContext! + + override func setUp() async throws { + let schema = Schema([Track.self]) + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: config) + modelContext = ModelContext(container) + } + + func testInsertTrack() throws { + let track = Track(id: "1", title: "Test", artist: "Artist", duration: 240) + modelContext.insert(track) + + let descriptor = FetchDescriptor() + let tracks = try modelContext.fetch(descriptor) + + XCTAssertEqual(tracks.count, 1) + XCTAssertEqual(tracks.first?.title, "Test") + } +} +``` + +## Comparison: SwiftData vs SQLiteData + +| Feature | SwiftData | SQLiteData | +|---------|-----------|------------| +| **Type** | Reference (class) | Value (struct) | +| **Macro** | `@Model` | `@Table` | +| **Queries** | `@Query` in SwiftUI | `@FetchAll` / `@FetchOne` | +| **Relationships** | `@Relationship` macro | Explicit foreign keys | +| **CloudKit** | Automatic sync | Manual SyncEngine + sharing | +| **Backend** | Core Data | GRDB + SQLite | +| **Learning Curve** | Easy (native) | Moderate | +| **Performance** | Good | Excellent (raw SQL) | + +## Quick Reference + +### Common Operations + +```swift +// Insert +let track = Track(id: "1", title: "Song", artist: "Artist", duration: 240) +modelContext.insert(track) + +// Fetch all +@Query var tracks: [Track] + +// Fetch filtered +@Query(filter: #Predicate { $0.genre == "Rock" }) var rockTracks: [Track] + +// Fetch sorted +@Query(sort: \.title) var sortedTracks: [Track] + +// Update +track.title = "Updated" + +// Delete +modelContext.delete(track) + +// Save +try modelContext.save() +``` + +## Resources + +**Docs**: /swiftdata + +**Skills**: axiom-swiftdata-migration, axiom-swiftdata-migration-diag, axiom-database-migration, axiom-sqlitedata, axiom-grdb, axiom-swift-concurrency + +## Common Mistakes + +### ❌ Forgetting explicit init +```swift +@Model +final class Track { + var id: String + var title: String + // No init - won't compile +} +``` +**Fix** Always provide `init` for `@Model` classes + +### ❌ Using structs +```swift +@Model +struct Track { } // Won't work - must be class +``` +**Fix** Use `final class` not `struct` + +### ❌ Background operations on main context +```swift +@Environment(\.modelContext) var context // Main actor only + +Task { + // ❌ Crash - crossing actor boundaries + context.insert(track) +} +``` +**Fix** Use `ModelContext(modelContainer)` for background work + +### ❌ Not saving when needed +```swift +modelContext.insert(track) +// Might not persist immediately +``` +**Fix** Call `try modelContext.save()` for immediate persistence + +--- + +**Created** 2025-11-28 +**Targets** iOS 17+ (focus on iOS 26+ features) +**Framework** SwiftData (Apple) +**Swift** 5.9+ (Swift 6 concurrency patterns) diff --git a/skill-index/skills/axiom-swiftui-architecture/SKILL.md b/skill-index/skills/axiom-swiftui-architecture/SKILL.md new file mode 100644 index 0000000..6919dcb --- /dev/null +++ b/skill-index/skills/axiom-swiftui-architecture/SKILL.md @@ -0,0 +1,1515 @@ +--- +name: axiom-swiftui-architecture +description: Use when separating logic from SwiftUI views, choosing architecture patterns, refactoring view files, or asking 'where should this code go', 'how do I organize my SwiftUI app', 'MVVM vs TCA vs vanilla SwiftUI', 'how do I make SwiftUI testable' - comprehensive architecture patterns with refactoring workflows for iOS 26+ +skill_type: discipline +version: 1.0 +apple_platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ +xcode_version: Xcode 26+ +--- + +# SwiftUI Architecture + +## When to Use This Skill + +Use this skill when: +- You have logic in your SwiftUI view files and want to extract it +- Choosing between MVVM, TCA, vanilla SwiftUI patterns, or Coordinator +- Refactoring views to separate concerns +- Making SwiftUI code testable +- Asking "where should this code go?" +- Deciding which property wrapper to use (@State, @Environment, @Bindable) +- Organizing a SwiftUI codebase for team development + +## Example Prompts + +| What You Might Ask | Why This Skill Helps | +|--------------------|----------------------| +| "There's quite a bit of code in my model view files about logic things. How do I extract it?" | Provides refactoring workflow with decision trees for where logic belongs | +| "Should I use MVVM, TCA, or Apple's vanilla patterns?" | Decision criteria based on app complexity, team size, testability needs | +| "How do I make my SwiftUI code testable?" | Shows separation patterns that enable testing without SwiftUI imports | +| "Where should formatters and calculations go?" | Anti-patterns section prevents logic in view bodies | +| "Which property wrapper do I use?" | Decision tree for @State, @Environment, @Bindable, or plain properties | + +## Quick Architecture Decision Tree + +``` +What's driving your architecture choice? +│ +├─ Starting fresh, small/medium app, want Apple's patterns? +│ └─ Use Apple's Native Patterns (Part 1) +│ - @Observable models for business logic +│ - State-as-Bridge for async boundaries +│ - Property wrapper decision tree +│ +├─ Familiar with MVVM from UIKit? +│ └─ Use MVVM Pattern (Part 2) +│ - ViewModels as presentation adapters +│ - Clear View/ViewModel/Model separation +│ - Works well with @Observable +│ +├─ Complex app, need rigorous testability, team consistency? +│ └─ Consider TCA (Part 3) +│ - State/Action/Reducer/Store architecture +│ - Excellent testing story +│ - Learning curve + boilerplate trade-off +│ +└─ Complex navigation, deep linking, multiple entry points? + └─ Add Coordinator Pattern (Part 4) + - Can combine with any of the above + - Extracts navigation logic from views + - NavigationPath + Coordinator objects +``` + +--- + +# Part 1: Apple's Native Patterns (iOS 26+) + +## Core Principle + +> "A data model provides separation between the data and the views that interact with the data. This separation promotes modularity, improves testability, and helps make it easier to reason about how the app works." +> — Apple Developer Documentation + +Apple's modern SwiftUI patterns (WWDC 2023-2025) center on: +1. **@Observable** for data models (replaces ObservableObject) +2. **State-as-Bridge** for async boundaries (WWDC 2025) +3. **Three property wrappers**: @State, @Environment, @Bindable +4. **Synchronous UI updates** for animations + +## The State-as-Bridge Pattern + +### Problem + +Async functions create suspension points that can break animations: + +```swift +// ❌ Problematic: Animation might miss frame deadline +struct ColorExtractorView: View { + @State private var isLoading = false + + var body: some View { + Button("Extract Colors") { + Task { + isLoading = true // Synchronous ✅ + await extractColors() // ⚠️ Suspension point! + isLoading = false // ❌ Might happen too late + } + } + .scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ Animation timing uncertain + } +} +``` + +### Solution: Use State as a Bridge + +"Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic." + +```swift +// ✅ Correct: State bridges UI and async code +@Observable +class ColorExtractor { + var isLoading = false + var colors: [Color] = [] + + func extract(from image: UIImage) async { + // This method is async and can live in the model + let extracted = await heavyComputation(image) + // Synchronous mutation for UI update + self.colors = extracted + } +} + +struct ColorExtractorView: View { + let extractor: ColorExtractor + + var body: some View { + Button("Extract Colors") { + // Synchronous state change for animation + withAnimation { + extractor.isLoading = true + } + + // Launch async work + Task { + await extractor.extract(from: currentImage) + + // Synchronous state change for animation + withAnimation { + extractor.isLoading = false + } + } + } + .scaleEffect(extractor.isLoading ? 1.5 : 1.0) + } +} +``` + +**Benefits**: +- UI logic stays synchronous (animations work correctly) +- Async code lives in the model (testable without SwiftUI) +- Clear boundary between time-sensitive UI and long-running work + +## Property Wrapper Decision Tree + +There are only **3 questions** to answer: + +``` +Which property wrapper should I use? +│ +├─ Does this model need to be STATE OF THE VIEW ITSELF? +│ └─ YES → Use @State +│ Examples: Form inputs, local toggles, sheet presentations +│ Lifetime: Managed by the view's lifetime +│ +├─ Does this model need to be part of the GLOBAL ENVIRONMENT? +│ └─ YES → Use @Environment +│ Examples: User account, app settings, dependency injection +│ Lifetime: Lives at app/scene level +│ +├─ Does this model JUST NEED BINDINGS? +│ └─ YES → Use @Bindable +│ Examples: Editing a model passed from parent +│ Lightweight: Only enables $ syntax for bindings +│ +└─ NONE OF THE ABOVE? + └─ Use as plain property + Examples: Immutable data, parent-owned models + No wrapper needed: @Observable handles observation +``` + +### Examples + +```swift +// ✅ @State — View owns the model +struct DonutEditor: View { + @State private var donutToAdd = Donut() // View's own state + + var body: some View { + TextField("Name", text: $donutToAdd.name) + } +} + +// ✅ @Environment — App-wide model +struct MenuView: View { + @Environment(Account.self) private var account // Global + + var body: some View { + Text("Welcome, \(account.userName)") + } +} + +// ✅ @Bindable — Need bindings to parent-owned model +struct DonutRow: View { + @Bindable var donut: Donut // Parent owns it + + var body: some View { + TextField("Name", text: $donut.name) // Need binding + } +} + +// ✅ Plain property — Just reading +struct DonutRow: View { + let donut: Donut // Parent owns, no binding needed + + var body: some View { + Text(donut.name) // Just reading + } +} +``` + +## @Observable Model Pattern + +Use `@Observable` for business logic that needs to trigger UI updates: + +```swift +// ✅ Domain model with business logic +@Observable +class FoodTruckModel { + var orders: [Order] = [] + var donuts = Donut.all + + var orderCount: Int { + orders.count // Computed properties work automatically + } + + func addDonut() { + donuts.append(Donut()) + } +} + +// ✅ View automatically tracks accessed properties +struct DonutMenu: View { + let model: FoodTruckModel // No wrapper needed! + + var body: some View { + List { + Section("Donuts") { + ForEach(model.donuts) { donut in + Text(donut.name) // Tracks model.donuts + } + Button("Add") { + model.addDonut() + } + } + Section("Orders") { + Text("Count: \(model.orderCount)") // Tracks model.orders + } + } + } +} +``` + +**How it works** (WWDC 2023/10149): +- SwiftUI tracks which properties are accessed during `body` execution +- Only those properties trigger view updates when changed +- Granular dependency tracking = better performance + +## ViewModel Adapter Pattern + +Use ViewModels as **presentation adapters** when you need filtering, sorting, or view-specific logic: + +```swift +// ✅ ViewModel as presentation adapter +@Observable +class PetStoreViewModel { + let petStore: PetStore // Domain model + var searchText: String = "" + + // View-specific computed property + var filteredPets: [Pet] { + guard !searchText.isEmpty else { return petStore.myPets } + return petStore.myPets.filter { $0.name.contains(searchText) } + } +} + +struct PetListView: View { + @Bindable var viewModel: PetStoreViewModel + + var body: some View { + List { + ForEach(viewModel.filteredPets) { pet in + PetRowView(pet: pet) + } + } + .searchable(text: $viewModel.searchText) + } +} +``` + +**When to use a ViewModel adapter**: +- Filtering, sorting, grouping for display +- Formatting for presentation (but NOT heavy computation) +- View-specific state that doesn't belong in domain model +- Bridging between domain model and SwiftUI conventions + +**When NOT to use a ViewModel**: +- Simple views that just display model data +- Logic that belongs in the domain model +- Over-extraction just for "pattern purity" + +--- + +# Part 2: MVVM Pattern + +## When to Use MVVM + +MVVM (Model-View-ViewModel) is appropriate when: + +✅ **You're familiar with it from UIKit** — Easier onboarding for team +✅ **You want explicit View/ViewModel separation** — Clear contracts +✅ **You have complex presentation logic** — Multiple filtering/sorting operations +✅ **You're migrating from UIKit** — Familiar mental model + +❌ **Avoid MVVM when**: +- Views are simple (just displaying data) +- You're starting fresh with SwiftUI (Apple's patterns are simpler) +- You're creating unnecessary abstraction layers + +## MVVM Structure for SwiftUI + +```swift +// Model — Domain data and business logic +struct Pet: Identifiable { + let id: UUID + var name: String + var kind: Kind + var trick: String + var hasAward: Bool = false + + mutating func giveAward() { + hasAward = true + } +} + +// ViewModel — Presentation logic +@Observable +class PetListViewModel { + private let petStore: PetStore + + var pets: [Pet] { petStore.myPets } + var searchText: String = "" + var selectedSort: SortOption = .name + + var filteredSortedPets: [Pet] { + let filtered = pets.filter { pet in + searchText.isEmpty || pet.name.contains(searchText) + } + return filtered.sorted { lhs, rhs in + switch selectedSort { + case .name: lhs.name < rhs.name + case .kind: lhs.kind.rawValue < rhs.kind.rawValue + } + } + } + + init(petStore: PetStore) { + self.petStore = petStore + } + + func awardPet(_ pet: Pet) { + petStore.awardPet(pet.id) + } +} + +// View — UI only +struct PetListView: View { + @Bindable var viewModel: PetListViewModel + + var body: some View { + List { + ForEach(viewModel.filteredSortedPets) { pet in + PetRow(pet: pet) { + viewModel.awardPet(pet) + } + } + } + .searchable(text: $viewModel.searchText) + } +} +``` + +## Common MVVM Mistakes in SwiftUI + +### ❌ Mistake 1: Duplicating @Observable in View and ViewModel + +```swift +// ❌ Don't do this +@Observable +class MyViewModel { + var data: String = "" +} + +struct MyView: View { + @State private var viewModel = MyViewModel() // ❌ Redundant + // ... +} +``` + +```swift +// ✅ Correct: Just use @Observable +@Observable +class MyViewModel { + var data: String = "" +} + +struct MyView: View { + let viewModel: MyViewModel // ✅ Or @State if view owns it + // ... +} +``` + +### ❌ Mistake 2: God ViewModel + +```swift +// ❌ Don't do this +@Observable +class AppViewModel { + // Settings + var isDarkMode = false + var notificationsEnabled = true + + // User + var userName = "" + var userEmail = "" + + // Content + var posts: [Post] = [] + var comments: [Comment] = [] + + // ... 50 more properties +} +``` + +```swift +// ✅ Correct: Separate concerns +@Observable +class SettingsViewModel { + var isDarkMode = false + var notificationsEnabled = true +} + +@Observable +class UserProfileViewModel { + var user: User +} + +@Observable +class FeedViewModel { + var posts: [Post] = [] +} +``` + +### ❌ Mistake 3: Business Logic in ViewModel + +```swift +// ❌ Business logic shouldn't be in ViewModel +@Observable +class OrderViewModel { + func calculateDiscount(for order: Order) -> Double { + // Complex business rules... + return discount + } +} +``` + +```swift +// ✅ Business logic in Model +struct Order { + func calculateDiscount() -> Double { + // Complex business rules... + return discount + } +} + +@Observable +class OrderViewModel { + let order: Order + + var displayDiscount: String { + "$\(order.calculateDiscount(), specifier: "%.2f")" // Just formatting + } +} +``` + +--- + +# Part 3: TCA (Composable Architecture) + +## When to Consider TCA + +TCA is a third-party architecture from Point-Free. Consider it when: + +✅ **Rigorous testability is critical** — TestStore makes testing deterministic +✅ **Large team needs consistency** — Strict patterns reduce variation +✅ **Complex state management** — Side effects, dependencies, composition +✅ **You value Redux-like patterns** — Unidirectional data flow + +❌ **Avoid TCA when**: +- Small app or prototype (too much overhead) +- Team unfamiliar with functional programming +- You need rapid iteration (boilerplate slows development) +- You want minimal dependencies + +## TCA Core Concepts + +### State + +Data your feature needs to perform logic and render UI: + +```swift +@ObservableState +struct CounterFeature { + var count = 0 + var fact: String? + var isLoading = false +} +``` + +### Action + +All possible events in your feature: + +```swift +enum Action { + case incrementButtonTapped + case decrementButtonTapped + case factButtonTapped + case factResponse(String) +} +``` + +### Reducer + +Describes how state evolves in response to actions: + +```swift +struct CounterFeature: Reducer { + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .incrementButtonTapped: + state.count += 1 + return .none + + case .decrementButtonTapped: + state.count -= 1 + return .none + + case .factButtonTapped: + state.isLoading = true + return .run { [count = state.count] send in + let fact = try await numberFact(count) + await send(.factResponse(fact)) + } + + case let .factResponse(fact): + state.isLoading = false + state.fact = fact + return .none + } + } + } +} +``` + +### Store + +Runtime engine that receives actions, executes reducer, handles effects: + +```swift +struct CounterView: View { + let store: StoreOf + + var body: some View { + VStack { + Text("\(store.count)") + Button("Increment") { + store.send(.incrementButtonTapped) + } + } + } +} +``` + +## TCA Trade-offs + +### ✅ Benefits + +| Benefit | Description | +|---------|-------------| +| **Testability** | TestStore makes testing deterministic and exhaustive | +| **Consistency** | One pattern for all features reduces cognitive load | +| **Composition** | Small reducers combine into larger features | +| **Side effects** | Structured effect management (networking, timers, etc.) | + +### ❌ Costs + +| Cost | Description | +|------|-------------| +| **Boilerplate** | State/Action/Reducer for every feature | +| **Learning curve** | Concepts from functional programming (effects, dependencies) | +| **Dependency** | Third-party library, not Apple-supported | +| **Iteration speed** | More code to write for simple features | + +## When to Choose TCA Over Apple Patterns + +| Scenario | Recommendation | +|----------|----------------| +| Small app (< 10 screens) | Apple patterns (simpler) | +| Medium app, experienced team | TCA if testability is priority | +| Large app, multiple teams | TCA for consistency | +| Rapid prototyping | Apple patterns (faster) | +| Mission-critical (banking, health) | TCA for rigorous testing | + +--- + +# Part 4: Coordinator Pattern + +## When to Use Coordinators + +Coordinators extract navigation logic from views. Use when: + +✅ **Complex navigation** — Multiple paths, conditional flows +✅ **Deep linking** — URL-driven navigation to any screen +✅ **Multiple entry points** — Same screen from different contexts +✅ **Testable navigation** — Isolate navigation from UI + +## SwiftUI Coordinator Implementation + +```swift +// Navigation destinations +enum Route: Hashable { + case detail(Pet) + case settings + case profile(User) +} + +// Coordinator manages navigation state +@Observable +class AppCoordinator { + var path: [Route] = [] + + func showDetail(for pet: Pet) { + path.append(.detail(pet)) + } + + func showSettings() { + path.append(.settings) + } + + func popToRoot() { + path.removeAll() + } + + func handleDeepLink(_ url: URL) { + // Parse URL and build path + if url.path == "/pets/123" { + let pet = loadPet(id: "123") + path = [.detail(pet)] + } + } +} + +// Root view with NavigationStack +struct AppView: View { + @State private var coordinator = AppCoordinator() + + var body: some View { + NavigationStack(path: $coordinator.path) { + PetListView(coordinator: coordinator) + .navigationDestination(for: Route.self) { route in + switch route { + case .detail(let pet): + PetDetailView(pet: pet, coordinator: coordinator) + case .settings: + SettingsView(coordinator: coordinator) + case .profile(let user): + ProfileView(user: user, coordinator: coordinator) + } + } + } + .onOpenURL { url in + coordinator.handleDeepLink(url) + } + } +} + +// Views use coordinator instead of NavigationLink +struct PetListView: View { + let coordinator: AppCoordinator + let pets: [Pet] + + var body: some View { + List(pets) { pet in + Button(pet.name) { + coordinator.showDetail(for: pet) + } + } + } +} +``` + +## Coordinator + Architecture Combinations + +You can combine Coordinators with any architecture: + +| Pattern | Coordinator Role | +|---------|------------------| +| **Apple Native** | Coordinator manages path, @Observable models for data | +| **MVVM** | Coordinator manages path, ViewModels for presentation | +| **TCA** | Coordinator manages path, Reducers for features | + +--- + +# Part 5: Refactoring Workflow + +## Step 1: Identify Logic in Views + +Run this checklist on your views: + +**View body contains:** +- DateFormatter, NumberFormatter creation +- Calculations or data transformations +- API calls or async operations +- Business rules (discounts, validation, etc.) +- Data filtering or sorting +- Heavy string manipulation +- Task { } with complex logic inside + +If ANY of these are present, that logic should likely move out. + +## Step 2: Extract to Appropriate Layer + +Use this decision tree: + +``` +Where does this logic belong? +│ +├─ Pure domain logic (discounts, validation, business rules)? +│ └─ Extract to Model +│ Example: Order.calculateDiscount() +│ +├─ Presentation logic (filtering, sorting, formatting)? +│ └─ Extract to ViewModel or computed property +│ Example: filteredItems, displayPrice +│ +├─ External side effects (API, database, file system)? +│ └─ Extract to Service +│ Example: APIClient, DatabaseManager +│ +└─ Just expensive computation? + └─ Cache with @State or create once + Example: let formatter = DateFormatter() +``` + +### Example: Refactoring Logic from View + +```swift +// ❌ Before: Logic in view body +struct OrderListView: View { + let orders: [Order] + + var body: some View { + let formatter = NumberFormatter() // ❌ Created every render + formatter.numberStyle = .currency + + let discounted = orders.filter { order in // ❌ Computed every render + let discount = order.total * 0.1 // ❌ Business logic in view + return discount > 10.0 + } + + return List(discounted) { order in + Text(formatter.string(from: order.total)!) // ❌ Force unwrap + } + } +} +``` + +```swift +// ✅ After: Logic extracted + +// Model — Business logic +struct Order { + let id: UUID + let total: Decimal + + var discount: Decimal { + total * 0.1 + } + + var qualifiesForDiscount: Bool { + discount > 10.0 + } +} + +// ViewModel — Presentation logic +@Observable +class OrderListViewModel { + let orders: [Order] + private let formatter: NumberFormatter // ✅ Created once + + var discountedOrders: [Order] { // ✅ Computed property + orders.filter { $0.qualifiesForDiscount } + } + + init(orders: [Order]) { + self.orders = orders + self.formatter = NumberFormatter() + formatter.numberStyle = .currency + } + + func formattedTotal(_ order: Order) -> String { + formatter.string(from: order.total as NSNumber) ?? "$0.00" + } +} + +// View — UI only +struct OrderListView: View { + let viewModel: OrderListViewModel + + var body: some View { + List(viewModel.discountedOrders) { order in + Text(viewModel.formattedTotal(order)) + } + } +} +``` + +## Step 3: Verify Testability + +Your refactoring succeeded if: + +```swift +// ✅ Can test without importing SwiftUI +import XCTest + +final class OrderTests: XCTestCase { + func testDiscountCalculation() { + let order = Order(id: UUID(), total: 100) + XCTAssertEqual(order.discount, 10) + } + + func testQualifiesForDiscount() { + let order = Order(id: UUID(), total: 100) + XCTAssertTrue(order.qualifiesForDiscount) + } +} + +final class OrderViewModelTests: XCTestCase { + func testFilteredOrders() { + let orders = [ + Order(id: UUID(), total: 50), // Discount: 5 ❌ + Order(id: UUID(), total: 200), // Discount: 20 ✅ + ] + let viewModel = OrderListViewModel(orders: orders) + + XCTAssertEqual(viewModel.discountedOrders.count, 1) + } +} +``` + +## Step 4: Update View Bindings + +After extraction, update property wrappers: + +```swift +// Before refactoring +struct OrderListView: View { + @State private var orders: [Order] = [] // View owned + // ... logic in body +} + +// After refactoring +struct OrderListView: View { + @State private var viewModel: OrderListViewModel // View owns ViewModel + + init(orders: [Order]) { + _viewModel = State(initialValue: OrderListViewModel(orders: orders)) + } +} + +// Or if parent owns it +struct OrderListView: View { + let viewModel: OrderListViewModel // Parent owns, just reading +} + +// Or if need bindings +struct OrderListView: View { + @Bindable var viewModel: OrderListViewModel // Parent owns, need $ +} +``` + +--- + +# Anti-Patterns (DO NOT DO THIS) + +## ❌ Anti-Pattern 1: Logic in View Body + +```swift +// ❌ Don't do this +struct ProductListView: View { + let products: [Product] + + var body: some View { + let formatter = NumberFormatter() // ❌ Created every render! + formatter.numberStyle = .currency + + let sorted = products.sorted { $0.price > $1.price } // ❌ Sorted every render! + + return List(sorted) { product in + Text("\(product.name): \(formatter.string(from: product.price)!)") + } + } +} +``` + +**Why it's wrong**: +- `formatter` created on every render (performance) +- `sorted` computed on every render (performance) +- Business logic (`sorted`) lives in view (not testable) +- Force unwrap (`!`) can crash + +```swift +// ✅ Correct +@Observable +class ProductListViewModel { + let products: [Product] + private let formatter = NumberFormatter() + + var sortedProducts: [Product] { + products.sorted { $0.price > $1.price } + } + + init(products: [Product]) { + self.products = products + formatter.numberStyle = .currency + } + + func formattedPrice(_ product: Product) -> String { + formatter.string(from: product.price as NSNumber) ?? "$0.00" + } +} + +struct ProductListView: View { + let viewModel: ProductListViewModel + + var body: some View { + List(viewModel.sortedProducts) { product in + Text("\(product.name): \(viewModel.formattedPrice(product))") + } + } +} +``` + +## ❌ Anti-Pattern 2: Async Code Without Boundaries + +"Synchronous updates are important for a good user experience." + +```swift +// ❌ Don't do this +struct ColorExtractorView: View { + @State private var colors: [Color] = [] + @State private var isLoading = false + + var body: some View { + Button("Extract") { + Task { + isLoading = true + await heavyExtraction() // ⚠️ Suspension point + isLoading = false // ❌ Animation might break + } + } + .scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ Timing issues + } +} +``` + +**Why it's wrong**: +- `await` creates suspension point +- `isLoading = false` might happen after frame deadline +- Animation timing is unpredictable + +```swift +// ✅ Correct: State-as-Bridge pattern +@Observable +class ColorExtractor { + var isLoading = false + var colors: [Color] = [] + + func extract(from image: UIImage) async { + let extracted = await heavyComputation(image) + self.colors = extracted // Synchronous mutation + } +} + +struct ColorExtractorView: View { + let extractor: ColorExtractor + + var body: some View { + Button("Extract") { + withAnimation { + extractor.isLoading = true // ✅ Synchronous + } + + Task { + await extractor.extract(from: currentImage) + + withAnimation { + extractor.isLoading = false // ✅ Synchronous + } + } + } + .scaleEffect(extractor.isLoading ? 1.5 : 1.0) + } +} +``` + +## ❌ Anti-Pattern 3: Wrong Property Wrapper + +```swift +// ❌ Don't use @State for passed-in models +struct DetailView: View { + @State var item: Item // ❌ Creates a copy, loses parent changes +} + +// ✅ Correct: No wrapper for passed-in models +struct DetailView: View { + let item: Item // ✅ Or @Bindable if you need $item +} +``` + +```swift +// ❌ Don't use @Environment for view-local state +struct FormView: View { + @Environment(FormData.self) var formData // ❌ Overkill for local form +} + +// ✅ Correct: @State for view-local +struct FormView: View { + @State private var formData = FormData() // ✅ View owns it +} +``` + +## ❌ Anti-Pattern 4: God ViewModel + +```swift +// ❌ Don't create massive ViewModels +@Observable +class AppViewModel { + // User stuff + var userName: String + var userEmail: String + + // Settings stuff + var isDarkMode: Bool + var notificationsEnabled: Bool + + // Content stuff + var posts: [Post] + var comments: [Comment] + + // ... 50 more properties +} +``` + +**Why it's wrong**: +- Violates Single Responsibility Principle +- Hard to test +- Poor performance (changes anywhere update all views) +- Difficult to reason about + +```swift +// ✅ Correct: Separate ViewModels by concern +@Observable class UserViewModel { } +@Observable class SettingsViewModel { } +@Observable class FeedViewModel { } +``` + +--- + +# Code Review Checklist + +Before merging SwiftUI code, verify: + +### Views +- View bodies contain ONLY UI code (Text, Button, List, etc.) +- No formatters created in view body +- No calculations or transformations in view body +- No API calls or database queries in view body +- No business rules in view body + +### Logic Separation +- Business logic is in models (testable without SwiftUI) +- Presentation logic is in ViewModels or computed properties +- Side effects are in services or model methods +- Heavy computations are cached or computed once + +### Property Wrappers +- @State for view-owned models +- @Environment for app-wide models +- @Bindable when bindings are needed +- No wrapper when just reading + +### Animations & Async +- State changes for animations are synchronous +- Async boundaries use State-as-Bridge pattern +- No `await` between `withAnimation { }` blocks + +### Testability +- Can test business logic without importing SwiftUI +- Can test ViewModels without rendering views +- Navigation logic is isolated (if using Coordinators) + +--- + +# Pressure Scenarios + +## Scenario 1: "Just put it in the view for now" + +### The Pressure + +**Manager**: "We need this feature by Friday. Just put the logic in the view for now, we'll refactor later." + +### Red Flags + +If you hear: +- ❌ "We'll refactor later" (tech debt that never gets paid) +- ❌ "It's just one view" (views multiply) +- ❌ "We don't have time for architecture" (costs more later) + +### Time Cost Comparison + +**Option A: Put logic in view** +- Write feature in view: 2 hours +- Realize it's untestable: 1 hour +- Try to test it anyway: 2 hours +- Give up, ship with manual testing: 0 hours +- **Total: 5 hours, 0 tests** + +**Option B: Extract logic properly** +- Create model/ViewModel: 30 min +- Write feature with separation: 2 hours +- Write tests: 1 hour +- **Total: 3.5 hours, full test coverage** + +### How to Push Back Professionally + +**Step 1**: Acknowledge the deadline +> "I understand Friday is the deadline. Let me show you why proper separation is actually faster." + +**Step 2**: Show the time comparison +> "Putting logic in views takes 5 hours with no tests. Extracting it properly takes 3.5 hours with full tests. We save 1.5 hours AND get tests." + +**Step 3**: Offer the compromise +> "If we're truly out of time, I can extract 80% now and mark the remaining 20% as tech debt with a ticket. But let's not skip extraction entirely." + +**Step 4**: Document if pressured to proceed +```swift +// TODO: TECH DEBT - Extract business logic to ViewModel +// Ticket: PROJ-123 +// Added: 2025-12-14 +// Reason: Deadline pressure from manager +// Estimated refactor time: 2 hours +``` + +### When to Accept + +Only skip extraction if: +1. This is a throwaway prototype (deleted next week) +2. You have explicit time budget for refactoring (scheduled ticket) +3. The view will never grow beyond 20 lines + +## Scenario 2: "TCA is overkill, just use vanilla SwiftUI" + +### The Pressure + +**Tech Lead**: "TCA is too complex for this project. Just use vanilla SwiftUI with @Observable." + +### Decision Criteria + +Ask these questions: + +| Question | TCA | Vanilla | +|----------|-----|---------| +| Is testability critical (medical, financial)? | ✅ | ❌ | +| Do you have < 5 screens? | ❌ | ✅ | +| Is team experienced with functional programming? | ✅ | ❌ | +| Do you need rapid prototyping? | ❌ | ✅ | +| Is consistency across large team critical? | ✅ | ❌ | +| Do you have complex side effects (sockets, timers)? | ✅ | ~ | + +**Recommendation matrix**: +- 4+ checks for TCA → Use TCA +- 4+ checks for Vanilla → Use Vanilla +- Tie → Start with Vanilla, migrate to TCA if needed + +### How to Push Back + +**If arguing FOR TCA**: +> "I understand TCA feels heavy. But we're building a banking app. The TestStore gives us exhaustive testing that catches bugs before production. The 2-week learning curve is worth it for 2 years of maintenance." + +**If arguing AGAINST TCA**: +> "I agree TCA is powerful, but we're prototyping features weekly. The boilerplate will slow us down. Let's use @Observable now and migrate to TCA if we prove the features are worth building." + +## Scenario 3: "Refactoring will take too long" + +### The Pressure + +**PM**: "We have 3 features to ship this month. We can't spend 2 weeks refactoring existing views." + +### Incremental Extraction Strategy + +You don't have to refactor everything at once: + +**Week 1**: Extract 1 view +- Pick the most painful view (lots of logic) +- Extract to ViewModel +- Write tests +- **Time**: 4 hours + +**Week 2**: Extract 2 views +- Now you have a pattern to follow +- Faster than week 1 +- **Time**: 6 hours + +**Week 3**: New features use proper architecture +- Don't refactor old code yet +- All NEW code follows the pattern +- **Time**: 0 hours (same as before) + +**Month 2**: Gradually refactor as you touch files +- Refactor when fixing bugs in old views +- Refactor when adding features to old views +- **Time**: Amortized over feature work + +### How to Push Back + +> "I'm not proposing we stop feature work for 2 weeks. I'm proposing: +> 1. Week 1: Extract our worst view (the OrdersView with 500 lines) +> 2. Week 2: Extract 2 more problematic views +> 3. Going forward: All NEW features use proper architecture +> 4. We refactor old views when we touch them anyway +> +> This costs 10 hours upfront and saves us 2+ hours per feature going forward." + +--- + +# Real-World Impact + +## Before: Logic in View + +```swift +// 😰 200 lines of pain +struct OrderListView: View { + @State private var orders: [Order] = [] + @State private var searchText = "" + @State private var selectedFilter: FilterType = .all + + var body: some View { + // ❌ Formatters created every render + let currencyFormatter = NumberFormatter() + currencyFormatter.numberStyle = .currency + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + + // ❌ Business logic in view + let filtered = orders.filter { order in + if !searchText.isEmpty && !order.customerName.contains(searchText) { + return false + } + + switch selectedFilter { + case .all: return true + case .pending: return !order.isCompleted + case .completed: return order.isCompleted + case .highValue: return order.total > 1000 + } + } + + // ❌ More business logic + let sorted = filtered.sorted { lhs, rhs in + if selectedFilter == .highValue { + return lhs.total > rhs.total + } else { + return lhs.date > rhs.date + } + } + + return List(sorted) { order in + VStack(alignment: .leading) { + Text(order.customerName) + Text(currencyFormatter.string(from: order.total as NSNumber)!) + Text(dateFormatter.string(from: order.date)) + + if order.isCompleted { + Image(systemName: "checkmark.circle.fill") + } else { + Button("Complete") { + // ❌ Async logic in view + Task { + do { + try await completeOrder(order) + await loadOrders() + } catch { + print(error) // ❌ No error handling + } + } + } + } + } + } + .searchable(text: $searchText) + .task { + await loadOrders() + } + } + + func loadOrders() async { + // ❌ API call in view + // ... 50 more lines + } + + func completeOrder(_ order: Order) async throws { + // ❌ API call in view + // ... 30 more lines + } +} +``` + +**Problems**: +- 200+ lines in one file +- Formatters created every render (performance) +- Business logic untestable +- No error handling +- Hard to reason about + +## After: Proper Architecture + +```swift +// Model — 30 lines +struct Order { + let id: UUID + let customerName: String + let total: Decimal + let date: Date + var isCompleted: Bool + + var isHighValue: Bool { + total > 1000 + } +} + +// ViewModel — 60 lines +@Observable +class OrderListViewModel { + private let orderService: OrderService + private let currencyFormatter = NumberFormatter() + private let dateFormatter = DateFormatter() + + var orders: [Order] = [] + var searchText = "" + var selectedFilter: FilterType = .all + var error: Error? + + var filteredOrders: [Order] { + orders + .filter(matchesSearch) + .filter(matchesFilter) + .sorted(by: sortComparator) + } + + init(orderService: OrderService) { + self.orderService = orderService + currencyFormatter.numberStyle = .currency + dateFormatter.dateStyle = .medium + } + + func loadOrders() async { + do { + orders = try await orderService.fetchOrders() + } catch { + self.error = error + } + } + + func completeOrder(_ order: Order) async { + do { + try await orderService.complete(order.id) + await loadOrders() + } catch { + self.error = error + } + } + + func formattedTotal(_ order: Order) -> String { + currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00" + } + + func formattedDate(_ order: Order) -> String { + dateFormatter.string(from: order.date) + } + + private func matchesSearch(_ order: Order) -> Bool { + searchText.isEmpty || order.customerName.contains(searchText) + } + + private func matchesFilter(_ order: Order) -> Bool { + switch selectedFilter { + case .all: true + case .pending: !order.isCompleted + case .completed: order.isCompleted + case .highValue: order.isHighValue + } + } + + private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool { + selectedFilter == .highValue + ? lhs.total > rhs.total + : lhs.date > rhs.date + } +} + +// View — 40 lines +struct OrderListView: View { + @Bindable var viewModel: OrderListViewModel + + var body: some View { + List(viewModel.filteredOrders) { order in + OrderRow(order: order, viewModel: viewModel) + } + .searchable(text: $viewModel.searchText) + .task { + await viewModel.loadOrders() + } + .alert("Error", error: $viewModel.error) { } + } +} + +struct OrderRow: View { + let order: Order + let viewModel: OrderListViewModel + + var body: some View { + VStack(alignment: .leading) { + Text(order.customerName) + Text(viewModel.formattedTotal(order)) + Text(viewModel.formattedDate(order)) + + if order.isCompleted { + Image(systemName: "checkmark.circle.fill") + } else { + Button("Complete") { + Task { + await viewModel.completeOrder(order) + } + } + } + } + } +} + +// Tests — 100 lines +final class OrderViewModelTests: XCTestCase { + func testFilterBySearch() async { + let viewModel = OrderListViewModel(orderService: MockOrderService()) + await viewModel.loadOrders() + + viewModel.searchText = "John" + XCTAssertEqual(viewModel.filteredOrders.count, 1) + } + + func testFilterByHighValue() async { + let viewModel = OrderListViewModel(orderService: MockOrderService()) + await viewModel.loadOrders() + + viewModel.selectedFilter = .highValue + XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue }) + } + + // ... 10 more tests +} +``` + +**Benefits**: +- View: 40 lines (was 200) +- ViewModel: Fully testable without SwiftUI +- Model: Pure business logic +- Formatters: Created once, not every render +- Error handling: Proper with alerts +- Tests: 10+ tests covering all logic + +--- + +## Resources + +**WWDC**: 2025-266, 2024-10150, 2023-10149, 2023-10160 + +**Docs**: /swiftui/managing-model-data-in-your-app + +**External**: github.com/pointfreeco/swift-composable-architecture + +--- + +**Platforms**: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, axiom-visionOS 26+ +**Xcode**: 26+ +**Status**: Production-ready (v1.0) diff --git a/skill-index/skills/axiom-swiftui-debugging/SKILL.md b/skill-index/skills/axiom-swiftui-debugging/SKILL.md new file mode 100644 index 0000000..3ef0e22 --- /dev/null +++ b/skill-index/skills/axiom-swiftui-debugging/SKILL.md @@ -0,0 +1,1289 @@ +--- +name: axiom-swiftui-debugging +description: Use when debugging SwiftUI view updates, preview crashes, or layout issues - diagnostic decision trees to identify root causes quickly and avoid misdiagnosis under pressure +skill_type: discipline +version: 1.3.0 +last_updated: Added Self._printChanges() debugging, @Observable patterns (iOS 17+), @Bindable, view identity section, and cross-references to swiftui-performance +--- + +# SwiftUI Debugging + +## Overview + +SwiftUI debugging falls into three categories, each with a different diagnostic approach: + +1. **View Not Updating** – You changed something but the view didn't redraw. Decision tree to identify whether it's struct mutation, lost binding identity, accidental view recreation, or missing observer pattern. +2. **Preview Crashes** – Your preview won't compile or crashes immediately. Decision tree to distinguish between missing dependencies, state initialization failures, and Xcode cache corruption. +3. **Layout Issues** – Views appearing in wrong positions, wrong sizes, overlapping unexpectedly. Quick reference patterns for common scenarios. + +**Core principle**: Start with observable symptoms, test systematically, eliminate causes one by one. Don't guess. + +**Requires**: Xcode 26+, iOS 17+ (iOS 14-16 patterns still valid, see notes) +**Related skills**: `axiom-xcode-debugging` (cache corruption diagnosis), `axiom-swift-concurrency` (observer patterns), `axiom-swiftui-performance` (profiling with Instruments), `axiom-swiftui-layout` (adaptive layout patterns) + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My list item doesn't update when I tap the favorite button, even though the data changed" +→ The skill walks through the decision tree to identify struct mutation vs lost binding vs missing observer + +#### 2. "Preview crashes with 'Cannot find AppModel in scope' but it compiles fine" +→ The skill shows how to provide missing dependencies with `.environment()` or `.environmentObject()` + +#### 3. "My counter resets to 0 every time I toggle a boolean, why?" +→ The skill identifies accidental view recreation from conditionals and shows `.opacity()` fix + +#### 4. "I'm using @Observable but the view still doesn't update when I change the property" +→ The skill explains when to use @State vs plain properties with @Observable objects + +#### 5. "Text field loses focus when I start typing, very frustrating" +→ The skill identifies ForEach identity issues and shows how to use stable IDs + +## When to Use SwiftUI Debugging + +#### Use this skill when +- ✅ A view isn't updating when you expect it to +- ✅ Preview crashes or won't load +- ✅ Layout looks wrong on specific devices +- ✅ You're tempted to bandaid with @ObservedObject everywhere + +#### Use `axiom-xcode-debugging` instead when +- App crashes at runtime (not preview) +- Build fails completely +- You need environment diagnostics + +#### Use `axiom-swift-concurrency` instead when +- Questions about async/await or MainActor +- Data race warnings + +## Debugging Tools + +### Self._printChanges() + +SwiftUI provides a debug-only method to understand why a view's body was called. + +**Usage in LLDB**: +```swift +// Set breakpoint in view's body +// In LLDB console: +(lldb) expression Self._printChanges() +``` + +**Temporary in code** (remove before shipping): +```swift +var body: some View { + let _ = Self._printChanges() // Debug only + + Text("Hello") +} +``` + +**Output interpretation**: +``` +MyView: @self changed + - Means the view value itself changed (parameters passed to view) + +MyView: count changed + - Means @State property "count" triggered the update + +MyView: (no output) + - Body not being called; view not updating at all +``` + +**⚠️ Important**: +- Prefixed with underscore → May be removed in future releases +- **NEVER submit to App Store** with _printChanges calls +- Performance impact → Use only during debugging + +**When to use**: +- Need to understand exact trigger for view update +- Investigating unexpected updates +- Verifying dependencies after refactoring + +**Cross-reference**: For complex update patterns, use SwiftUI Instrument → see `axiom-swiftui-performance` skill + +--- + +## View Not Updating Decision Tree + +The most common frustration: you changed @State but the view didn't redraw. The root cause is always one of four things. + +### Step 1: Can You Reproduce in a Minimal Preview? + +```swift +#Preview { + YourView() +} +``` + +**YES** → The problem is in your code. Continue to Step 2. + +**NO** → It's likely Xcode state or cache corruption. Skip to Preview Crashes section. + +### Step 2: Diagnose the Root Cause + +#### Root Cause 1: Struct Mutation + +**Symptom**: You modify a @State value directly, but the view doesn't update. + +**Why it happens**: SwiftUI doesn't see direct mutations on structs. You need to reassign the entire value. + +```swift +// ❌ WRONG: Direct mutation doesn't trigger update +@State var items: [String] = [] + +func addItem(_ item: String) { + items.append(item) // SwiftUI doesn't see this change +} + +// ✅ RIGHT: Reassignment triggers update +@State var items: [String] = [] + +func addItem(_ item: String) { + var newItems = items + newItems.append(item) + self.items = newItems // Full reassignment +} + +// ✅ ALSO RIGHT: Use a binding +@State var items: [String] = [] + +var itemsBinding: Binding<[String]> { + Binding( + get: { items }, + set: { items = $0 } + ) +} +``` + +**Fix it**: Always reassign the entire struct value, not pieces of it. + +--- + +#### Root Cause 2: Lost Binding Identity + +**Symptom**: You pass a binding to a child view, but changes in the child don't update the parent. + +**Why it happens**: You're passing `.constant()` or creating a new binding each time, breaking the two-way connection. + +```swift +// ❌ WRONG: Constant binding is read-only +@State var isOn = false + +ToggleChild(value: .constant(isOn)) // Changes ignored + +// ❌ WRONG: New binding created each render +@State var name = "" + +TextField("Name", text: Binding( + get: { name }, + set: { name = $0 } +)) // New binding object each time parent renders + +// ✅ RIGHT: Pass the actual binding +@State var isOn = false + +ToggleChild(value: $isOn) + +// ✅ RIGHT (iOS 17+): Use @Bindable for @Observable objects +@Observable class Book { + var title = "Sample" + var isAvailable = true +} + +struct EditView: View { + @Bindable var book: Book // Enables $book.title syntax + + var body: some View { + TextField("Title", text: $book.title) + Toggle("Available", isOn: $book.isAvailable) + } +} + +// ✅ ALSO RIGHT (iOS 17+): @Bindable as local variable +struct ListView: View { + @State private var books = [Book(), Book()] + + var body: some View { + List(books) { book in + @Bindable var book = book // Inline binding + TextField("Title", text: $book.title) + } + } +} + +// ✅ RIGHT (pre-iOS 17): Create binding once, not in body +@State var name = "" +@State var nameBinding: Binding? + +var body: some View { + if nameBinding == nil { + nameBinding = Binding( + get: { name }, + set: { name = $0 } + ) + } + return TextField("Name", text: nameBinding!) +} +``` + +**Fix it**: Pass `$state` directly when possible. For @Observable objects (iOS 17+), use `@Bindable`. If creating custom bindings (pre-iOS 17), create them in `init` or cache them, not in `body`. + +--- + +#### Root Cause 3: Accidental View Recreation + +**Symptom**: The view updates, but @State values reset to initial state. You see brief flashes of initial values. + +**Why it happens**: The view got a new identity (removed from a conditional, moved in a container, or the container itself was recreated), causing SwiftUI to treat it as a new view. + +```swift +// ❌ WRONG: View identity changes when condition flips +@State var count = 0 + +var body: some View { + VStack { + if showCounter { + Counter() // Gets new identity each time showCounter changes + } + Button("Toggle") { + showCounter.toggle() + } + } +} + +// Counter gets recreated, @State count resets to 0 + +// ✅ RIGHT: Preserve identity with opacity or hidden +@State var count = 0 + +var body: some View { + VStack { + Counter() + .opacity(showCounter ? 1 : 0) + Button("Toggle") { + showCounter.toggle() + } + } +} + +// ✅ ALSO RIGHT: Use id() if you must conditionally show +@State var count = 0 + +var body: some View { + VStack { + if showCounter { + Counter() + .id("counter") // Stable identity + } + Button("Toggle") { + showCounter.toggle() + } + } +} +``` + +**Fix it**: Preserve view identity by using `.opacity()` instead of conditionals, or apply `.id()` with a stable identifier. + +--- + +#### Root Cause 4: Missing Observer Pattern + +**Symptom**: An object changed, but views observing it didn't update. + +**Why it happens**: SwiftUI doesn't know to watch for changes in the object. + +```swift +// ❌ WRONG: Property changes don't trigger update +class Model { + var count = 0 // Not observable +} + +struct ContentView: View { + let model = Model() // New instance each render, not observable + + var body: some View { + Text("\(model.count)") + Button("Increment") { + model.count += 1 // View doesn't update + } + } +} + +// ✅ RIGHT (iOS 17+): Use @Observable with @State +@Observable class Model { + var count = 0 // No @Published needed +} + +struct ContentView: View { + @State private var model = Model() // @State, not @StateObject + + var body: some View { + Text("\(model.count)") + Button("Increment") { + model.count += 1 // View updates + } + } +} + +// ✅ RIGHT (iOS 17+): Injected @Observable objects +struct ContentView: View { + var model: Model // Just a plain property + + var body: some View { + Text("\(model.count)") // View updates when count changes + } +} + +// ✅ RIGHT (iOS 17+): @Observable with environment +@Observable class AppModel { + var count = 0 +} + +@main +struct MyApp: App { + @State private var model = AppModel() + + var body: some Scene { + WindowGroup { + ContentView() + .environment(model) // Add to environment + } + } +} + +struct ContentView: View { + @Environment(AppModel.self) private var model // Read from environment + + var body: some View { + Text("\(model.count)") + } +} + +// ✅ RIGHT (pre-iOS 17): Use @StateObject/ObservableObject +class Model: ObservableObject { + @Published var count = 0 +} + +struct ContentView: View { + @StateObject var model = Model() // For owned instances + + var body: some View { + Text("\(model.count)") + Button("Increment") { + model.count += 1 // View updates + } + } +} + +// ✅ RIGHT (pre-iOS 17): Use @ObservedObject for injected instances +struct ContentView: View { + @ObservedObject var model: Model // Passed in from parent + + var body: some View { + Text("\(model.count)") + } +} +``` + +**Fix it (iOS 17+)**: Use `@Observable` macro on your class, then `@State` to store it. Views automatically track dependencies on properties they read. + +**Fix it (pre-iOS 17)**: Use `@StateObject` if you own the object, `@ObservedObject` if it's injected, or `@EnvironmentObject` if it's shared across the tree. + +**Why @Observable is better** (iOS 17+): +- Automatic dependency tracking (only reads trigger updates) +- No `@Published` wrapper needed +- Works with `@State` instead of `@StateObject` +- Can pass as plain property instead of `@ObservedObject` + +**See also**: [Managing model data in your app](https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app) + +--- + +### Decision Tree Summary + +``` +View not updating? +├─ Can reproduce in preview? +│ ├─ YES: Problem is in code +│ │ ├─ Modified struct directly? → Struct Mutation +│ │ ├─ Passed binding to child? → Lost Binding Identity +│ │ ├─ View inside conditional? → Accidental Recreation +│ │ └─ Object changed but view didn't? → Missing Observer +│ └─ NO: Likely cache/Xcode state → See Preview Crashes +``` + +## Preview Crashes Decision Tree + +When your preview won't load or crashes immediately, the three root causes are distinct. + +### Step 1: What's the Error? + +#### Error Type 1: "Cannot find in scope" or "No such module" + +**Root cause**: Preview missing a required dependency (@EnvironmentObject, @Environment, imported module). + +```swift +// ❌ WRONG: ContentView needs a model, preview doesn't provide it +struct ContentView: View { + @EnvironmentObject var model: AppModel + + var body: some View { + Text(model.title) + } +} + +#Preview { + ContentView() // Crashes: model not found +} + +// ✅ RIGHT: Provide the dependency +#Preview { + ContentView() + .environmentObject(AppModel()) +} + +// ✅ ALSO RIGHT: Check for missing imports +// If using custom types, make sure they're imported in preview file + +#Preview { + MyCustomView() // Make sure MyCustomView is defined or imported +} +``` + +**Fix it**: Trace the error, find what's missing, provide it to the preview. + +--- + +#### Error Type 2: Fatal error or Silent crash (no error message) + +**Root cause**: State initialization failed at runtime. The view tried to access data that doesn't exist. + +```swift +// ❌ WRONG: Index out of bounds at runtime +struct ListView: View { + @State var selectedIndex = 10 + let items = ["a", "b", "c"] + + var body: some View { + Text(items[selectedIndex]) // Crashes: index 10 doesn't exist + } +} + +// ❌ WRONG: Optional forced unwrap fails +struct DetailView: View { + @State var data: Data? + + var body: some View { + Text(data!.title) // Crashes if data is nil + } +} + +// ✅ RIGHT: Safe defaults +struct ListView: View { + @State var selectedIndex = 0 // Valid index + let items = ["a", "b", "c"] + + var body: some View { + if selectedIndex < items.count { + Text(items[selectedIndex]) + } + } +} + +// ✅ RIGHT: Handle optionals +struct DetailView: View { + @State var data: Data? + + var body: some View { + if let data = data { + Text(data.title) + } else { + Text("No data") + } + } +} +``` + +**Fix it**: Review your @State initializers. Check array bounds, optional unwraps, and default values. + +--- + +#### Error Type 3: Works fine locally but preview won't load + +**Root cause**: Xcode cache corruption. The preview process has stale information about your code. + +**Diagnostic checklist**: +- Preview worked yesterday, code hasn't changed → Likely cache +- Restarting Xcode fixes it temporarily but returns → Definitely cache +- Same code builds in simulator fine but preview fails → Cache +- Multiple unrelated previews fail at once → Cache + +**Fix it** (in order): +1. Restart Preview Canvas: `Cmd+Option+P` +2. Restart Xcode completely (File → Close Window, then reopen project) +3. Nuke derived data: `rm -rf ~/Library/Developer/Xcode/DerivedData` +4. Rebuild: `Cmd+B` + +If still broken after all four steps: It's not cache, see Error Types 1 or 2. + +--- + +### Decision Tree Summary + +``` +Preview crashes? +├─ Error message visible? +│ ├─ "Cannot find in scope" → Missing Dependency +│ ├─ "Fatal error" or silent crash → State Init Failure +│ └─ No error → Likely Cache Corruption +└─ Try: Restart Preview → Restart Xcode → Nuke DerivedData +``` + +## Layout Issues Quick Reference + +Layout problems are usually visually obvious. Match your symptom to the pattern. + +### Pattern 1: Views Overlapping in ZStack + +**Symptom**: Views stacked on top of each other, some invisible. + +**Root cause**: Z-order is wrong or you're not controlling visibility. + +```swift +// ❌ WRONG: Can't see the blue view +ZStack { + Rectangle().fill(.blue) + Rectangle().fill(.red) +} + +// ✅ RIGHT: Use zIndex to control layer order +ZStack { + Rectangle().fill(.blue).zIndex(0) + Rectangle().fill(.red).zIndex(1) +} + +// ✅ ALSO RIGHT: Hide instead of removing from hierarchy +ZStack { + Rectangle().fill(.blue) + Rectangle().fill(.red).opacity(0.5) +} +``` + +--- + +### Pattern 2: GeometryReader Sizing Weirdness + +**Symptom**: View is tiny or taking up the entire screen unexpectedly. + +**Root cause**: GeometryReader sizes itself to available space; parent doesn't constrain it. + +```swift +// ❌ WRONG: GeometryReader expands to fill all available space +VStack { + GeometryReader { geo in + Text("Size: \(geo.size)") + } + Button("Next") { } +} +// Text takes entire remaining space + +// ✅ RIGHT: Constrain the geometry reader +VStack { + GeometryReader { geo in + Text("Size: \(geo.size)") + } + .frame(height: 100) + + Button("Next") { } +} +``` + +--- + +### Pattern 3: SafeArea Complications + +**Symptom**: Content hidden behind notch, or not using full screen space. + +**Root cause**: `.ignoresSafeArea()` applied to wrong view. + +```swift +// ❌ WRONG: Only the background ignores safe area +ZStack { + Color.blue.ignoresSafeArea() + VStack { + Text("Still respects safe area") + } +} + +// ✅ RIGHT: Container ignores, children position themselves +ZStack { + Color.blue + VStack { + Text("Can now use full space") + } +} +.ignoresSafeArea() + +// ✅ ALSO RIGHT: Be selective about which edges +ZStack { + Color.blue + VStack { ... } +} +.ignoresSafeArea(edges: .horizontal) // Only horizontal +``` + +--- + +### Pattern 4: frame() vs fixedSize() Confusion + +**Symptom**: Text truncated, buttons larger than text, sizing behavior unpredictable. + +**Root cause**: Mixing `frame()` (constrains) with `fixedSize()` (expands to content). + +```swift +// ❌ WRONG: fixedSize() overrides frame() +Text("Long text here") + .frame(width: 100) + .fixedSize() // Overrides the frame constraint + +// ✅ RIGHT: Use frame() to constrain +Text("Long text here") + .frame(width: 100, alignment: .leading) + .lineLimit(1) + +// ✅ RIGHT: Use fixedSize() only for natural sizing +VStack(spacing: 0) { + Text("Small") + .fixedSize() // Sizes to text + Text("Large") + .fixedSize() +} +``` + +--- + +### Pattern 5: Modifier Order Matters + +**Symptom**: Padding, corners, or shadows appearing in wrong place. + +**Root cause**: Applying modifiers in wrong order. SwiftUI applies bottom-to-top. + +```swift +// ❌ WRONG: Corners applied after padding +Text("Hello") + .padding() + .cornerRadius(8) // Corners are too large + +// ✅ RIGHT: Corners first, then padding +Text("Hello") + .cornerRadius(8) + .padding() + +// ❌ WRONG: Shadow after frame +Text("Hello") + .frame(width: 100) + .shadow(radius: 4) // Shadow only on frame bounds + +// ✅ RIGHT: Shadow includes all content +Text("Hello") + .shadow(radius: 4) + .frame(width: 100) +``` + +## View Identity + +### Understanding View Identity + +SwiftUI uses view identity to track views over time, preserve state, and animate transitions. Understanding identity is critical for debugging state preservation and animation issues. + +### Two Types of Identity + +#### 1. Structural Identity (Implicit) +Position in view hierarchy determines identity: + +```swift +VStack { + Text("First") // Identity: VStack.child[0] + Text("Second") // Identity: VStack.child[1] +} +``` + +**When structural identity changes**: +```swift +if showDetails { + DetailView() // Identity changes when condition changes + SummaryView() +} else { + SummaryView() // Same type, different position = different identity +} +``` + +**Problem**: `SummaryView` gets recreated each time, losing @State values. + +#### 2. Explicit Identity +You control identity with `.id()` modifier: + +```swift +DetailView() + .id(item.id) // Explicit identity tied to item + +// When item.id changes → SwiftUI treats as different view +// → @State resets +// → Animates transition +``` + +### Common Identity Issues + +#### Issue 1: State Resets Unexpectedly +**Symptom**: @State values reset to initial values when you don't expect. + +**Cause**: View identity changed (position in hierarchy or .id() value changed). + +```swift +// ❌ PROBLEM: Identity changes when showDetails toggles +@State private var count = 0 + +var body: some View { + VStack { + if showDetails { + CounterView(count: $count) // Position changes + } + Button("Toggle") { + showDetails.toggle() + } + } +} + +// ✅ FIX: Stable identity with .opacity() +var body: some View { + VStack { + CounterView(count: $count) + .opacity(showDetails ? 1 : 0) // Same identity always + Button("Toggle") { + showDetails.toggle() + } + } +} + +// ✅ ALSO FIX: Explicit stable ID +var body: some View { + VStack { + if showDetails { + CounterView(count: $count) + .id("counter") // Stable ID + } + Button("Toggle") { + showDetails.toggle() + } + } +} +``` + +#### Issue 2: Animations Don't Work +**Symptom**: View changes but doesn't animate. + +**Cause**: Identity changed, SwiftUI treats as remove + add instead of update. + +```swift +// ❌ PROBLEM: Identity changes with selection +ForEach(items) { item in + ItemView(item: item) + .id(item.id + "-\(selectedID)") // ID changes when selection changes +} + +// ✅ FIX: Stable identity +ForEach(items) { item in + ItemView(item: item, isSelected: item.id == selectedID) + .id(item.id) // Stable ID +} +``` + +#### Issue 3: ForEach with Changing Data +**Symptom**: List items jump around or animate incorrectly. + +**Cause**: Non-unique or changing identifiers. + +```swift +// ❌ WRONG: Index-based ID changes when array changes +ForEach(Array(items.enumerated()), id: \.offset) { index, item in + Text(item.name) +} + +// ❌ WRONG: Non-unique IDs +ForEach(items, id: \.category) { item in // Multiple items per category + Text(item.name) +} + +// ✅ RIGHT: Stable, unique IDs +ForEach(items, id: \.id) { item in + Text(item.name) +} + +// ✅ RIGHT: Make type Identifiable +struct Item: Identifiable { + let id = UUID() + var name: String +} + +ForEach(items) { item in // id: \.id implicit + Text(item.name) +} +``` + +### When to Use .id() + +**Use .id() to**: +- Force view recreation when data changes fundamentally +- Animate transitions between distinct states +- Reset @State when external dependency changes + +**Example: Force recreation on data change**: +```swift +DetailView(item: item) + .id(item.id) // New item → new view → @State resets +``` + +**Don't use .id() when**: +- You just need to update view content (use bindings instead) +- Trying to fix update issues (investigate root cause instead) +- Identity is already stable + +### Debugging Identity Issues + +#### 1. Self._printChanges() +```swift +var body: some View { + let _ = Self._printChanges() + // Check if "@self changed" appears when you don't expect +} +``` + +#### 2. Check .id() modifiers +Search codebase for `.id()` - are IDs changing unexpectedly? + +#### 3. Check conditionals +Views in `if/else` change position → different identity. + +**Fix**: Use `.opacity()` or stable `.id()` instead. + +### Identity Quick Reference + +| Symptom | Likely Cause | Fix | +|---------|--------------|-----| +| State resets | Identity change | Use `.opacity()` instead of `if` | +| No animation | Identity change | Remove `.id()` or use stable ID | +| ForEach jumps | Non-unique ID | Use unique, stable IDs | +| Unexpected recreation | Conditional position | Add explicit `.id()` | + +**See also**: [WWDC21: Demystify SwiftUI](https://developer.apple.com/videos/play/wwdc2021/10022/) + +--- + +## Pressure Scenarios and Real-World Constraints + +When you're under deadline pressure, you'll be tempted to shortcuts that hide problems instead of fixing them. + +### Scenario 1: "Preview keeps crashing, we ship tomorrow" + +#### Red flags you might hear +- "Just rebuild everything" +- "Delete derived data and don't worry about it" +- "Ship without validating in preview" +- "It works on my machine, good enough" + +**The danger**: You skip diagnosis, cache issue recurs after 2 weeks in production, you're debugging while users hit crashes. + +**What to do instead** (5-minute protocol, total): +1. Restart Preview Canvas: `Cmd+Option+P` (30 seconds) +2. Restart Xcode (2 minutes) +3. Nuke derived data: `rm -rf ~/Library/Developer/Xcode/DerivedData` (30 seconds) +4. Rebuild: `Cmd+B` (2 minutes) +5. Still broken? Use the dependency or initialization decision trees above + +**Time cost**: 5 minutes diagnosis + 2 minutes fix = **7 minutes total** + +**Cost of skipping**: 30 min shipping + 24 hours debug cycle = **24+ hours total** + +--- + +### Scenario 2: "View won't update, let me just wrap it in @ObservedObject" + +#### Red flags you might think +- "Adding @ObservedObject everywhere will fix it" +- "Use ObservableObject as a band-aid" +- "Add @Published to random properties" +- "It's probably a binding issue, I'll just create a custom binding" + +**The danger**: You're treating symptoms, not diagnosing. Same view won't update in other contexts. You've just hidden the bug. + +**What to do instead** (2-minute diagnosis): +1. Can you reproduce in a minimal preview? If NO → cache corruption (see Scenario 1) +2. If YES: Test each root cause in order: + - Does the view have @State that you're modifying directly? → Struct Mutation + - Did the view move into a conditional recently? → View Recreation + - Are you passing bindings to children that have changed? → Lost Binding Identity + - Only if none of above: Missing Observer +3. Fix the actual root cause, not with @ObservedObject band-aid + +**Decision principle**: If you can't name the specific root cause, you haven't diagnosed yet. Don't code until you can answer "the problem is struct mutation because...". + +--- + +### Scenario 2b: "Intermittent updates - it works sometimes, not always" + +#### Red flags you might think +- "It must be a threading issue, let me add @MainActor everywhere" +- "Let me try @ObservedObject, @State, and custom Binding until something works" +- "Delete DerivedData and hope cache corruption fixes it" +- "This is unfixable, let me ship without this feature" + +**The danger**: You're exhausted after 2 hours of guessing. You're 17 hours from App Store submission. You're panicking. Every minute feels urgent, so you stop diagnosing and start flailing. + +Intermittent bugs are the MOST important to diagnose correctly. One wrong guess now creates a new bug. You ship with a broken view AND a new bug. App Store rejects you. You miss launch. + +**What to do instead** (60-minute systematic diagnosis): + +**Step 1: Reproduce in preview** (15 min) +- Create minimal preview of just the broken view +- Tap/interact 20 times +- Does it fail intermittently, consistently, or never? + - **Fails in preview**: Real bug in your code, use decision tree above + - **Works in preview but fails in app**: Cache or environment issue, use Preview Crashes decision tree + - **Can't reproduce at all**: Intermittent race condition, investigate further + +**Step 2: Isolate the variable** (15 min) +- If it's intermittent in preview: Likely view recreation + - Did the view recently move into a conditional? Remove it and test + - Did you add `if` logic that might recreate the parent? Remove it and test +- If it works in preview but fails in app: Likely environment/cache issue + - Try on different device/simulator + - Try after clearing DerivedData + +**Step 3: Apply the specific fix** (30 min) +- Once you've identified view recreation: Use `.opacity()` instead of conditionals +- Once you've identified struct mutation: Use full reassignment +- Once you've verified it's cache: Nuke DerivedData properly + +**Step 4: Verify 100% reliability** (until submission) +- Run the same interaction 30+ times +- Test on multiple devices/simulators +- Get QA to verify +- Only ship when it's 100% reproducible (not the bug, the FIX) + +**Time cost**: 60 minutes diagnosis + 30 minutes fix + confidence = **submit at 9am** + +**Cost of guessing**: 2 hours already + 3 more hours guessing + new bug introduced + crash reports post-launch + emergency patch + reputation damage = **miss launch + post-launch chaos** + +**The decision principle**: Intermittent bugs require SYSTEMATIC diagnosis. The slower you go in diagnosis, the faster you get to the fix. Guessing is the fastest way to disaster. + +#### Professional script for co-leads who suggest guessing + +> "I appreciate the suggestion. Adding @ObservedObject everywhere is treating the symptom, not the root cause. The skill says intermittent bugs create NEW bugs when we guess. I need 60 minutes for systematic diagnosis. If I can't find the root cause by then, we'll disable the feature and ship a clean v1.1. The math shows we have time—I can complete diagnosis, fix, AND verification before the deadline." + +--- + +### Scenario 3: "Layout looks wrong on iPad, we're out of time" + +#### Red flags you might think +- "Add some padding and magic numbers" +- "It's probably a safe area thing, let me just ignore it" +- "Let's lock this to iPhone only" +- "GeometryReader will solve this" + +**The danger**: Magic numbers break on other sizes. SafeArea ignoring is often wrong. Locking to iPhone means you ship a broken iPad experience. + +**What to do instead** (3-minute diagnosis): +1. Run in simulator or device +2. Use Debug View Hierarchy: Debug menu → View Hierarchy (takes 30 seconds to load) +3. Check: Is the problem SafeArea, ZStack ordering, or GeometryReader sizing? +4. Use the correct pattern from the Quick Reference above + +**Time cost**: 3 minutes diagnosis + 5 minutes fix = **8 minutes total** + +**Cost of magic numbers**: Ship wrong, report 2 weeks later, debug 4 hours, patch in update = **2+ weeks delay** + +--- + +## Quick Reference + +### Common View Update Fixes + +```swift +// Fix 1: Reassign the full struct +@State var items: [String] = [] +var newItems = items +newItems.append("new") +self.items = newItems + +// Fix 2: Pass binding correctly +@State var value = "" +ChildView(text: $value) // Pass binding, not value + +// Fix 3: Preserve view identity +View().opacity(isVisible ? 1 : 0) // Not: if isVisible { View() } + +// Fix 4: Observe the object +@StateObject var model = MyModel() +@ObservedObject var model: MyModel +``` + +### Common Preview Fixes + +```swift +// Fix 1: Provide dependencies +#Preview { + ContentView() + .environmentObject(AppModel()) +} + +// Fix 2: Safe defaults +@State var index = 0 // Not 10, if array has 3 items + +// Fix 3: Nuke cache +// Terminal: rm -rf ~/Library/Developer/Xcode/DerivedData +``` + +### Common Layout Fixes + +```swift +// Fix 1: Z-order +Rectangle().zIndex(1) + +// Fix 2: Constrain GeometryReader +GeometryReader { geo in ... }.frame(height: 100) + +// Fix 3: SafeArea +ZStack { ... }.ignoresSafeArea() + +// Fix 4: Modifier order +Text().cornerRadius(8).padding() // Corners first +``` + +## Real-World Examples + +### Example 1: List Item Doesn't Update When Tapped + +**Scenario**: You have a list of tasks. When you tap a task to mark it complete, the checkmark should appear, but it doesn't. + +**Code**: +```swift +struct TaskListView: View { + @State var tasks: [Task] = [...] + + var body: some View { + List { + ForEach(tasks, id: \.id) { task in + HStack { + Image(systemName: task.isComplete ? "checkmark.circle.fill" : "circle") + Text(task.title) + Spacer() + Button("Done") { + // ❌ WRONG: Direct mutation + task.isComplete.toggle() + } + } + } + } + } +} +``` + +**Diagnosis using the skill**: +1. Can you reproduce in preview? YES +2. Are you modifying the struct directly? YES → **Struct Mutation** (Root Cause 1) + +**Fix**: +```swift +Button("Done") { + // ✅ RIGHT: Full reassignment + if let index = tasks.firstIndex(where: { $0.id == task.id }) { + tasks[index].isComplete.toggle() + } +} +``` + +**Why this works**: SwiftUI detects the array reassignment, triggering a redraw. The task in the List updates. + +--- + +### Example 2: Preview Crashes with "No Such Module" + +**Scenario**: You created a custom data model. It works fine in the app, but the preview crashes with "Cannot find 'CustomModel' in scope". + +**Code**: +```swift +import SwiftUI + +// ❌ WRONG: Preview missing the dependency +#Preview { + TaskDetailView(task: Task(...)) +} + +struct TaskDetailView: View { + @Environment(\.modelContext) var modelContext + let task: Task // Custom model + + var body: some View { + Text(task.title) + } +} +``` + +**Diagnosis using the skill**: +1. What's the error? "Cannot find in scope" → **Missing Dependency** (Error Type 1) +2. What does TaskDetailView need? The Task model and modelContext + +**Fix**: +```swift +#Preview { + let config = ModelConfiguration(isStoredInMemoryOnly: true) + let container = try! ModelContainer(for: Task.self, configurations: config) + + return TaskDetailView(task: Task(title: "Sample")) + .modelContainer(container) +} +``` + +**Why this works**: Providing the environment object and model container satisfies the view's dependencies. Preview loads successfully. + +--- + +### Example 3: Text Field Value Changes Don't Appear + +**Scenario**: You have a search field. You type characters, but the text doesn't appear in the UI. However, the search results DO update. + +**Code**: +```swift +struct SearchView: View { + @State var searchText = "" + + var body: some View { + VStack { + // ❌ WRONG: Passing constant binding + TextField("Search", text: .constant(searchText)) + + Text("Results for: \(searchText)") // This updates + List { + ForEach(results(for: searchText), id: \.self) { result in + Text(result) + } + } + } + } + + func results(for text: String) -> [String] { + // Returns filtered results + } +} +``` + +**Diagnosis using the skill**: +1. Can you reproduce in preview? YES +2. Are you passing a binding to a child view? YES (TextField) +3. Is it a constant binding? YES → **Lost Binding Identity** (Root Cause 2) + +**Fix**: +```swift +// ✅ RIGHT: Pass the actual binding +TextField("Search", text: $searchText) +``` + +**Why this works**: `$searchText` passes a two-way binding. TextField writes changes back to @State, triggering a redraw. Text field now shows typed characters. + +--- + +## Simulator Verification + +After fixing SwiftUI issues, verify with visual confirmation in the simulator. + +### Why Simulator Verification Matters + +SwiftUI previews don't always match simulator behavior: +- **Different rendering** — Some visual effects only work on device/simulator +- **Different timing** — Animations may behave differently +- **Different state** — Full app lifecycle vs isolated preview + +**Use simulator verification for**: +- Layout fixes (spacing, alignment, sizing) +- View update fixes (state changes, bindings) +- Animation and gesture issues +- Before/after visual comparison + +### Quick Verification Workflow + +```bash +# 1. Take "before" screenshot +/axiom:screenshot + +# 2. Apply your fix + +# 3. Rebuild and relaunch +xcodebuild build -scheme YourScheme + +# 4. Take "after" screenshot +/axiom:screenshot + +# 5. Compare screenshots to verify fix +``` + +### Navigating to Problem Screens + +If the bug is deep in your app, use debug deep links to navigate directly: + +```bash +# 1. Add debug deep links (see deep-link-debugging skill) +# Example: debug://settings, debug://recipe-detail?id=123 + +# 2. Navigate and capture +xcrun simctl openurl booted "debug://problem-screen" +sleep 1 +/axiom:screenshot +``` + +### Full Simulator Testing + +For complex scenarios (state setup, multiple steps, log analysis): + +```bash +/axiom:test-simulator +``` + +Then describe what you want to test: +- "Navigate to the recipe editor and verify the layout fix" +- "Test the profile screen with empty state" +- "Verify the animation doesn't stutter anymore" + +### Before/After Example + +**Before fix** (view not updating): +```bash +# 1. Reproduce bug +xcrun simctl openurl booted "debug://recipe-list" +sleep 1 +xcrun simctl io booted screenshot /tmp/before-fix.png +# Screenshot shows: Tapping star doesn't update UI +``` + +**After fix** (added @State binding): +```bash +# 2. Test fix +xcrun simctl openurl booted "debug://recipe-list" +sleep 1 +xcrun simctl io booted screenshot /tmp/after-fix.png +# Screenshot shows: Star updates immediately when tapped +``` + +**Time saved**: 60%+ faster iteration with visual verification vs manual navigation + +--- + +## Resources + +**WWDC**: 2025-256, 2025-306, 2023-10160, 2023-10149, 2021-10022 + +**Docs**: /swiftui/managing-model-data-in-your-app, /swiftui, /swiftui/state-and-data-flow, /xcode/previews, /observation + +**Skills**: axiom-swiftui-performance, axiom-swiftui-debugging-diag, axiom-xcode-debugging, axiom-swift-concurrency + diff --git a/skill-index/skills/axiom-swiftui-performance/SKILL.md b/skill-index/skills/axiom-swiftui-performance/SKILL.md new file mode 100644 index 0000000..dbad0c8 --- /dev/null +++ b/skill-index/skills/axiom-swiftui-performance/SKILL.md @@ -0,0 +1,1137 @@ +--- +name: axiom-swiftui-performance +description: Use when UI is slow, scrolling lags, animations stutter, or when asking 'why is my SwiftUI view slow', 'how do I optimize List performance', 'my app drops frames', 'view body is called too often', 'List is laggy' - SwiftUI performance optimization with Instruments 26 and WWDC 2025 patterns +skill_type: discipline +version: 1.1.0 +last_updated: TDD-tested with production performance crisis scenarios +apple_platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, axiom-visionOS 3+ +xcode_version: Xcode 26+ +--- + +# SwiftUI Performance Optimization + +## When to Use This Skill + +Use when: +- App feels less responsive (hitches, hangs, delayed scrolling) +- Animations pause or jump during execution +- Scrolling performance is poor +- Profiling reveals SwiftUI is the bottleneck +- View bodies are taking too long to run +- Views are updating more frequently than necessary +- Need to understand cause-and-effect of SwiftUI updates + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My app has janky scrolling and animations are stuttering. How do I figure out if SwiftUI is the cause?" +→ The skill shows how to use the new SwiftUI Instrument in Instruments 26 to identify if SwiftUI is the bottleneck vs other layers + +#### 2. "I'm using the new SwiftUI Instrument and I see orange/red bars showing long updates. How do I know what's causing them?" +→ The skill covers the Cause & Effect Graph patterns that show data flow through your app and which state changes trigger expensive updates + +#### 3. "Some views are updating way too often even though their data hasn't changed. How do I find which views are the problem?" +→ The skill demonstrates unnecessary update detection and Identity troubleshooting with the visual timeline + +#### 4. "I have large data structures and complex view hierarchies. How do I optimize them for SwiftUI performance?" +→ The skill covers performance patterns: breaking down view hierarchies, minimizing body complexity, and using the @Sendable optimization checklist + +#### 5. "We have a performance deadline and I need to understand what's slow in SwiftUI. What are the critical metrics?" +→ The skill provides the decision tree for prioritizing optimizations and understands pressure scenarios with professional guidance for trade-offs + +--- + +## Overview + +**Core Principle**: Ensure your view bodies update quickly and only when needed to achieve great SwiftUI performance. + +**NEW in WWDC 2025**: Next-generation SwiftUI instrument in Instruments 26 provides comprehensive performance analysis with: +- Visual timeline of long updates (color-coded orange/red by severity) +- Cause & Effect Graph showing data flow through your app +- Integration with Time Profiler for CPU analysis +- Hangs and Hitches tracking + +**Key Performance Problems**: +1. **Long View Body Updates** — View bodies taking too long to run +2. **Unnecessary View Updates** — Views updating when data hasn't actually changed + +--- + +## iOS 26 Framework Performance Improvements + +**"Performance improvements to the framework benefit apps across all of Apple's platforms, from our app to yours."** — WWDC 2025-256 + +SwiftUI in iOS 26 includes major performance wins that benefit all apps automatically. These improvements work alongside the new profiling tools to make SwiftUI faster out of the box. + +### List Performance (macOS Focus) + +#### Massive gains for large lists + +- **6x faster loading** for lists of 100,000+ items on macOS +- **16x faster updates** for large lists +- **Even bigger gains** for larger lists +- Improvements benefit **all platforms** (iOS, iPadOS, watchOS, not just macOS) + +```swift +List(trips) { trip in // 100k+ items + TripRow(trip: trip) +} +// iOS 26: Loads 6x faster, updates 16x faster on macOS +// All platforms benefit from performance improvements +``` + +#### Impact on your app +- Large datasets (10k+ items) see noticeable improvements +- Filtering and sorting operations complete faster +- Real-time updates to lists are more responsive +- Benefits apps like file browsers, contact lists, data tables + +### Scrolling Performance + +#### Reduced dropped frames during high-speed scrolling + +SwiftUI has improved scheduling of user interface updates on iOS and macOS. This improves responsiveness and lets SwiftUI do even more work to prepare for upcoming frames. All in all, it reduces the chance of your app dropping a frame while scrolling quickly at high frame rates. + +#### Key improvements +1. **Better frame scheduling** — SwiftUI gets more time to prepare for upcoming frames +2. **Improved responsiveness** — UI updates scheduled more efficiently +3. **Fewer dropped frames** — Especially during quick scrolling at 120Hz (ProMotion) + +#### When you'll notice +- Scrolling through image-heavy content +- High frame rate devices (iPhone Pro, iPad Pro with ProMotion) +- Complex list rows with multiple views + +### Nested ScrollViews with Lazy Stacks + +#### Photo carousels and multi-axis scrolling now properly optimize + +```swift +ScrollView(.horizontal) { + LazyHStack { + ForEach(photoSets) { photoSet in + ScrollView(.vertical) { + LazyVStack { + ForEach(photoSet.photos) { photo in + PhotoView(photo: photo) + } + } + } + } + } +} +// iOS 26: Nested scrollviews now properly delay loading with lazy stacks +// Great for photo carousels, Netflix-style layouts, multi-axis content +``` + +**Before iOS 26** Nested ScrollViews didn't properly delay loading lazy stack content, causing all nested content to load immediately. + +**After iOS 26** Lazy stacks inside nested ScrollViews now delay loading until content is about to appear, matching the behavior of single-level ScrollViews. + +#### Use cases +- Photo galleries with horizontal/vertical scrolling +- Netflix-style category rows +- Multi-dimensional data browsers +- Image carousels with vertical detail scrolling + +### SwiftUI Performance Instrument Enhancements + +#### New lanes in Instruments 26 + +The SwiftUI instrument now includes dedicated lanes for: + +1. **Long View Body Updates** — Identify expensive body computations +2. **Platform View Updates** — Track UIKit/AppKit bridging performance (Long Representable Updates) +3. **Other Long Updates** — All other types of long SwiftUI work + +These lanes are covered in detail in the next section. + +### Performance Improvement Summary + +#### Automatic wins (recompile only) +- ✅ 6x faster list loading (100k+ items, macOS) +- ✅ 16x faster list updates (macOS) +- ✅ Reduced dropped frames during scrolling +- ✅ Improved frame scheduling on iOS/macOS +- ✅ Nested ScrollView lazy loading optimization + +**No code changes required** — rebuild with iOS 26 SDK to get these improvements. + +**Cross-reference** SwiftUI 26 Features (swiftui-26-ref skill) — Comprehensive guide to all iOS 26 SwiftUI changes + +--- + +## The SwiftUI Instrument (Instruments 26) + +### Getting Started + +**Requirements**: +- Install Xcode 26 +- Update devices to latest OS releases (support for recording SwiftUI traces) +- Build app in Release mode for accurate profiling + +**Launch**: +1. Open project in Xcode +2. Press **Command-I** to profile +3. Choose **SwiftUI template** from template chooser +4. Click Record button + +### Template Contents + +The SwiftUI template includes three instruments: + +1. **SwiftUI Instrument** (NEW) — Identifies performance issues in SwiftUI code +2. **Time Profiler** — Shows CPU work samples over time +3. **Hangs and Hitches** — Tracks app responsiveness + +### SwiftUI Instrument Track Lanes + +#### Lane 1: Update Groups +- Shows when SwiftUI is actively doing work +- **Empty during CPU spikes?** → Problem likely outside SwiftUI + +#### Lane 2: Long View Body Updates +- Highlights when `body` property takes too long +- **Most common performance issue** — start here + +#### Lane 3: Long Representable Updates +- Identifies slow UIViewRepresentable/NSViewRepresentable updates +- UIKit/AppKit integration performance + +#### Lane 4: Other Long Updates +- All other types of long SwiftUI work + +### Color-Coding System + +Updates shown in **orange** and **red** based on likelihood to cause hitches: + +- **Red** — Very likely to contribute to hitch/hang (investigate first) +- **Orange** — Moderately likely to cause issues +- **Gray** — Normal updates, not concerning + +**Note**: Whether updates actually result in hitches depends on device conditions, but red updates are the highest priority. + +--- + +## Understanding the Render Loop + +### Normal Frame Rendering + +``` +Frame 1: +├─ Handle events (touches, key presses) +├─ Update UI (run view bodies) +│ └─ Complete before frame deadline ✅ +├─ Hand off to system +└─ System renders → Visible on screen + +Frame 2: +├─ Handle events +├─ Update UI +│ └─ Complete before frame deadline ✅ +├─ Hand off to system +└─ System renders → Visible on screen +``` + +**Result**: Smooth, fluid animations + +### Frame with Hitch (Long View Body) + +``` +Frame 1: +├─ Handle events +├─ Update UI +│ └─ ONE VIEW BODY TOO SLOW +│ └─ Runs past frame deadline ❌ +├─ Miss deadline +└─ Previous frame stays visible (HITCH) + +Frame 2: (Delayed) +├─ Handle events (delayed by 1 frame) +├─ Update UI +├─ Hand off to system +└─ System renders → Finally visible + +Result: Previous frame visible for 2+ frames = animation stutter +``` + +### Frame with Hitch (Too Many Updates) + +``` +Frame 1: +├─ Handle events +├─ Update UI +│ ├─ Update 1 (fast) +│ ├─ Update 2 (fast) +│ ├─ Update 3 (fast) +│ ├─ ... (100 more fast updates) +│ └─ Total time exceeds deadline ❌ +├─ Miss deadline +└─ Previous frame stays visible (HITCH) +``` + +**Result**: Many small updates add up to miss deadline + +**Key Insight**: View body runtime matters because missing frame deadlines causes hitches, making animations less fluid. + +**Reference**: +- [Understanding hitches in your app](https://developer.apple.com/documentation/xcode/understanding-hitches-in-your-app) +- Tech Talk on render loop and fixing hitches + +--- + +## Problem 1: Long View Body Updates + +### Identifying Long Updates + +1. **Record trace** in Instruments with SwiftUI template +2. **Look at Long View Body Updates lane** — any orange/red bars? +3. **Expand SwiftUI track** to see subtracks +4. **Select View Body Updates subtrack** +5. **Filter to long updates**: + - Detail pane → Dropdown → Choose "Long View Body Updates summary" + +### Analyzing with Time Profiler + +**Workflow**: +1. Find long update in Long View Body Updates summary +2. Hover over view name → Click arrow → "Show Updates" +3. Right-click on long update → "Set Inspection Range and Zoom" +4. **Switch to Time Profiler instrument track** + +**What you see**: +- Call stacks for samples recorded during view body execution +- Time spent in each frame (leftmost column) +- Your view body nested in deep SwiftUI call stack + +**Finding the bottleneck**: +1. Option-click to expand main thread call stack +2. Command-F to search for your view name (e.g., "LandmarkListItemView") +3. Identify expensive operations in time column + +### Common Expensive Operations + +#### Formatter Creation (Very Expensive) + +**❌ WRONG - Creating formatters in view body**: +```swift +struct LandmarkListItemView: View { + let landmark: Landmark + @State private var userLocation: CLLocation + + var distance: String { + // ❌ Creating formatters every time body runs + let numberFormatter = NumberFormatter() + numberFormatter.maximumFractionDigits = 1 + + let measurementFormatter = MeasurementFormatter() + measurementFormatter.numberFormatter = numberFormatter + + let meters = userLocation.distance(from: landmark.location) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + return measurementFormatter.string(from: measurement) + } + + var body: some View { + HStack { + Text(landmark.name) + Text(distance) // Calls expensive distance property + } + } +} +``` + +**Why it's slow**: +- Formatters are expensive to create (milliseconds each) +- Created every time view body runs +- Runs on main thread → app waits before continuing UI updates +- Multiple views → time adds up quickly + +**✅ CORRECT - Cache formatters centrally**: +```swift +@Observable +class LocationFinder { + private let formatter: MeasurementFormatter + private let landmarks: [Landmark] + private var distanceCache: [Landmark.ID: String] = [:] + + init(landmarks: [Landmark]) { + self.landmarks = landmarks + + // Create formatters ONCE during initialization + let numberFormatter = NumberFormatter() + numberFormatter.maximumFractionDigits = 1 + + self.formatter = MeasurementFormatter() + self.formatter.numberFormatter = numberFormatter + + updateDistances() + } + + func didUpdateLocations(_ locations: [CLLocation]) { + guard let location = locations.last else { return } + updateDistances(from: location) + } + + private func updateDistances(from location: CLLocation? = nil) { + guard let location else { return } + + for landmark in landmarks { + let meters = location.distance(from: landmark.location) + let measurement = Measurement(value: meters, unit: UnitLength.meters) + distanceCache[landmark.id] = formatter.string(from: measurement) + } + } + + func distanceString(for landmarkID: Landmark.ID) -> String { + distanceCache[landmarkID] ?? "Unknown" + } +} + +struct LandmarkListItemView: View { + let landmark: Landmark + @Environment(LocationFinder.self) private var locationFinder + + var body: some View { + HStack { + Text(landmark.name) + Text(locationFinder.distanceString(for: landmark.id)) // ✅ Fast lookup + } + } +} +``` + +**Benefits**: +- Formatters created once, reused for all landmarks +- Strings pre-calculated when location changes +- View body just reads cached value (instant) +- Long view body updates eliminated + +#### Other Expensive Operations + +**Complex Calculations**: +```swift +// ❌ Don't calculate in view body +var body: some View { + let result = expensiveAlgorithm(data) // Complex math, sorting, etc. + Text("\(result)") +} + +// ✅ Calculate in model, cache result +@Observable +class ViewModel { + private(set) var result: Int = 0 + + func updateData(_ data: [Int]) { + result = expensiveAlgorithm(data) // Calculate once + } +} +``` + +**Network/File I/O**: +```swift +// ❌ NEVER do I/O in view body +var body: some View { + let data = try? Data(contentsOf: fileURL) // ❌ Synchronous I/O + // ... +} + +// ✅ Load asynchronously, store in state +@State private var data: Data? + +var body: some View { + // Just read state +} +.task { + data = try? await loadData() // Async loading +} +``` + +**Image Processing**: +```swift +// ❌ Don't process images in view body +var body: some View { + let thumbnail = image.resized(to: CGSize(width: 100, height: 100)) + Image(uiImage: thumbnail) +} + +// ✅ Process images in background, cache +.task { + await processThumbnails() +} +``` + +### Verifying the Fix + +After implementing fix: + +1. Record new trace in Instruments +2. Check Long View Body Updates summary +3. **Verify your view is gone from the list** (or significantly reduced) + +**Note**: Updates at app launch may still be long (building initial view hierarchy) — this is normal and won't cause hitches during scrolling. + +--- + +## Problem 2: Unnecessary View Updates + +### Why Unnecessary Updates Matter + +Even if individual updates are fast, **too many updates add up**: + +``` +100 fast updates × 2ms each = 200ms total +→ Misses 16.67ms frame deadline +→ Hitch +``` + +### Identifying Unnecessary Updates + +**Scenario**: Tapping a favorite button on one item updates ALL items in a list. + +**Expected**: Only the tapped item updates. +**Actual**: All visible items update. + +**How to find**: +1. Record trace with user interaction in mind +2. Highlight relevant portion of timeline +3. Expand hierarchy in detail pane +4. **Count updates** — more than expected? + +### Understanding SwiftUI's Data Model + +SwiftUI uses **AttributeGraph** to define dependencies and avoid re-running views unnecessarily. + +#### Attributes & Dependencies + +```swift +struct OnOffView: View { + @State private var isOn: Bool = false + + var body: some View { + Text(isOn ? "On" : "Off") + } +} +``` + +**What SwiftUI creates**: +1. **View attribute** — Stores view struct (recreated frequently) +2. **State storage** — Keeps `isOn` value (persists entire view lifetime) +3. **Signal attribute** — Tracks when state changes +4. **View body attribute** — Depends on state signal +5. **Text attributes** — Depend on view body + +**When state changes**: +1. Create transaction (scheduled change for next frame) +2. Mark signal attribute as outdated +3. Walk dependency chain, marking dependent attributes as outdated (just set flag - fast) +4. Before rendering, update all outdated attributes +5. View body runs again, producing new Text struct +6. Continue updates until all needed attributes updated +7. Render frame + +### The Cause & Effect Graph + +**Purpose**: Visualize **what marked your view body as outdated**. + +**Example graph**: +``` +[Gesture] → [State Change] → [View Body Update] + ↓ + [Other View Bodies] +``` + +**Node types**: +- **Blue nodes** — Your code or actions (gestures, state changes, view bodies) +- **System nodes** — SwiftUI/system work +- **Arrows labeled "update"** — Caused update +- **Arrows labeled "creation"** — Caused view to appear + +**Selecting nodes**: +- Click **State change node** → See backtrace of where value was updated +- Click **View body node** → See which views updated and why + +**Accessing graph**: +1. Detail pane → Expand hierarchy to find view +2. Hover over view name → Click arrow +3. Choose **"Show Cause & Effect Graph"** + +### Example: Favorites List Problem + +**Problem**: +```swift +@Observable +class ModelData { + var favoritesCollection: Collection // Contains array of favorites + + func isFavorite(_ landmark: Landmark) -> Bool { + favoritesCollection.landmarks.contains(landmark) // ❌ Depends on whole array + } +} + +struct LandmarkListItemView: View { + let landmark: Landmark + @Environment(ModelData.self) private var modelData + + var body: some View { + HStack { + Text(landmark.name) + Button { + modelData.toggleFavorite(landmark) // Modifies array + } label: { + Image(systemName: modelData.isFavorite(landmark) ? "heart.fill" : "heart") + } + } + } +} +``` + +**What happens**: +1. Each view calls `isFavorite()`, accessing `favoritesCollection.landmarks` array +2. `@Observable` creates dependency: **Each view depends on entire array** +3. Tapping button calls `toggleFavorite()`, modifying array +4. **All views** marked as outdated (array changed) +5. **All view bodies run** (even though only one changed) + +**Cause & Effect Graph shows**: +``` +[Gesture] → [favoritesCollection.landmarks array change] → [All LandmarkListItemViews update] +``` + +**✅ Solution — Granular Dependencies**: +```swift +@Observable +class LandmarkViewModel { + var isFavorite: Bool = false + + func toggleFavorite() { + isFavorite.toggle() + } +} + +@Observable +class ModelData { + private(set) var viewModels: [Landmark.ID: LandmarkViewModel] = [:] + + init(landmarks: [Landmark]) { + for landmark in landmarks { + viewModels[landmark.id] = LandmarkViewModel() + } + } + + func viewModel(for landmarkID: Landmark.ID) -> LandmarkViewModel? { + viewModels[landmarkID] + } +} + +struct LandmarkListItemView: View { + let landmark: Landmark + @Environment(ModelData.self) private var modelData + + var body: some View { + if let viewModel = modelData.viewModel(for: landmark.id) { + HStack { + Text(landmark.name) + Button { + viewModel.toggleFavorite() // ✅ Only modifies this view model + } label: { + Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart") + } + } + } + } +} +``` + +**Result**: +- Each view depends **only on its own view model** +- Tapping button updates **only that view model** +- **Only one view body runs** + +**Cause & Effect Graph shows**: +``` +[Gesture] → [Single LandmarkViewModel change] → [Single LandmarkListItemView update] +``` + +--- + +## Environment Updates + +### How Environment Works + +```swift +struct EnvironmentValues { + // Dictionary-like value type + var colorScheme: ColorScheme + var locale: Locale + // ... many more values +} +``` + +**Each view has dependency on entire EnvironmentValues struct** via `@Environment` property wrapper. + +### What Happens on Environment Change + +1. **Any environment value changes** (e.g., dark mode enabled) +2. **All views with `@Environment` dependency notified** +3. **Each view checks** if the specific value it reads changed +4. **If value changed** → View body runs +5. **If value didn't change** → SwiftUI skips running view body (already up-to-date) + +**Cost**: Even when body doesn't run, there's still cost of checking for updates. + +### Environment Update Nodes in Graph + +Two types: + +1. **External Environment** — App-level changes from outside SwiftUI (color scheme, accessibility settings) +2. **EnvironmentWriter** — Changes inside SwiftUI via `.environment()` modifier + +**Example**: +``` +View1 reads colorScheme: +[External Environment] → [View1 body runs] ✅ + +View2 reads locale (doesn't read colorScheme): +[External Environment] → [View2 body check] (body doesn't run - dimmed icon) +``` + +**Same update shows as multiple nodes**: Hover/click any node for same update → all highlight together. + +### Environment Performance Warning + +⚠️ **AVOID storing frequently-changing values in environment**: + +```swift +// ❌ DON'T DO THIS +struct ContentView: View { + @State private var scrollOffset: CGFloat = 0 + + var body: some View { + ScrollView { + // Content + } + .environment(\.scrollOffset, scrollOffset) // ❌ Updates on every scroll frame + .onPreferenceChange(ScrollOffsetKey.self) { offset in + scrollOffset = offset + } + } +} +``` + +**Why it's bad**: +- Environment change triggers checks in **all child views** +- Scrolling = 60+ updates/second +- Massive performance hit + +**✅ Better approach**: +```swift +// Pass via parameter or @Observable model +struct ContentView: View { + @State private var scrollViewModel = ScrollViewModel() + + var body: some View { + ScrollView { + ChildView(scrollViewModel: scrollViewModel) // Direct parameter + } + } +} +``` + +**Environment is great for**: +- Color scheme +- Locale +- Accessibility settings +- Other relatively stable values + +--- + +## Performance Optimization Checklist + +### Before Profiling +- [ ] Build in Release mode (Debug mode has overhead) +- [ ] Test on real devices (Simulator performance ≠ real device) +- [ ] Update device to latest OS (SwiftUI trace support) +- [ ] Identify specific slow interactions to profile + +### During Profiling +- [ ] Use SwiftUI template in Instruments 26 +- [ ] Focus on Long View Body Updates lane first +- [ ] Check Update Groups lane (empty = problem outside SwiftUI) +- [ ] Record realistic user workflows (not artificial scenarios) +- [ ] Keep profiling sessions short (easier to analyze) + +### Analyzing Long View Body Updates +- [ ] Filter detail pane to "Long View Body Updates" +- [ ] Start with red updates, then orange +- [ ] Use Time Profiler to find expensive operations +- [ ] Look for formatter creation, calculations, I/O +- [ ] Check if work can be moved to model layer + +### Analyzing Unnecessary Updates +- [ ] Count view body updates - more than expected? +- [ ] Use Cause & Effect Graph to trace data flow +- [ ] Check for whole array/collection dependencies +- [ ] Verify each view depends only on relevant data +- [ ] Avoid frequently-changing environment values + +### After Optimization +- [ ] Record new trace to verify improvements +- [ ] Compare before/after Long View Body Updates counts +- [ ] Test on slowest supported device +- [ ] Monitor in real-world usage +- [ ] Profile regularly during development + +--- + +## Production Pressure: When Performance Issues Hit Live + +### The Problem + +When performance issues appear in production, you face competing pressures: +- **Engineering manager**: "Fix it ASAP" +- **VP of Product**: "Users have been complaining for hours" +- **Deployment window**: 6 hours before next App Store review window +- **Temptation**: Quick fix (add `.compositingGroup()`, disable animation, simplify view) + +**The issue**: Quick fixes based on guesses fail 80% of the time and waste your deployment window. + +### Red Flags — Resist These Pressure Tactics + +If you hear ANY of these under deadline pressure, **STOP and use SwiftUI Instrument**: + +- ❌ **"Just add .compositingGroup()"** – Without profiling, you don't know if this helps +- ❌ **"We can roll back if it doesn't work"** – App Store review takes 24 hours; rollback isn't fast +- ❌ **"Other apps use this pattern"** – Doesn't mean it solves YOUR specific problem +- ❌ **"Users will accept degradation for now"** – Once shipped, you're committed for 24 hours +- ❌ **"We don't have time to profile"** – You have less time if you guess wrong + +### One SwiftUI Instrument Recording (30-Minute Protocol) + +Under production pressure, one good diagnostic recording beats random fixes: + +**Time Budget**: +- Build in Release mode: 5 min +- Launch and interact to trigger sluggishness: 3 min +- Record SwiftUI Instrument trace: 5 min +- Review Long View Body Updates lane: 5 min +- Check Cause & Effect Graph: 5 min +- Identify specific expensive view: 2 min + +**Total**: 25 minutes to know EXACTLY what's slow + +**Then**: +- Apply targeted fix (15-30 min) +- Test in Instruments again (5 min) +- Ship with confidence + +**Total time**: 1 hour 15 minutes for diagnosis + fix, leaving 4+ hours for edge case testing. + +### Comparing Time Costs + +#### Option A: Guess and Pray +- Time to implement: 30 min +- Time to deploy: 20 min +- Time to learn it failed: 24 hours (next App Store review) +- Total delay: 24 hours minimum +- User suffering: Continues through deployment window + +#### Option B: One SwiftUI Instrument Recording +- Time to diagnose: 25 min +- Time to apply targeted fix: 20 min +- Time to verify: 5 min +- Time to deploy: 20 min +- Total time: 1.5 hours +- User suffering: Stopped after 2 hours instead of 26+ hours + +**Time cost of being wrong**: +- A: 24-hour delay + reputational damage + users suffering +- B: 1.5 hours + you know the actual problem + confidence in the fix + +### Real-World Example: Tab Transition Sluggishness + +**Pressure scenario**: +- iOS 26 build shipped +- Users report "sluggish tab transitions" +- VP asking for updates every hour +- 6 hours until deployment window closes + +**Bad approach** (Option A): +``` +Junior suggests: "Add .compositingGroup() to TabView" +You: "Sure, let's try it" +Result: Ships without profiling +Outcome: Doesn't fix issue (compositing wasn't the problem) +Next: 24 hours until next deploy window +VP update: "Users still complaining" +``` + +**Good approach** (Option B): +``` +"Running one SwiftUI Instrument recording of tab transition" +[25 minutes later] +"SwiftUI Instrument shows Long View Body Updates in ProductGridView during transition. +Cause & Effect Graph shows ProductList rebuilding entire grid unnecessarily. +Applying view identity fix (`.id()`) to prevent unnecessary updates" +[30 minutes to implement and test] +"Deployed at 1.5 hours. Verified with Instruments. Tab transitions now smooth." +``` + +### When to Accept the Pressure (And Still be Right) + +Sometimes managers are right to push for speed. Accept the pressure IF: + +- [ ] You've run ONE SwiftUI Instrument recording (25 minutes) +- [ ] You know what specific view/operation is expensive +- [ ] You have a targeted fix, not a guess +- [ ] You've verified the fix in Instruments before shipping +- [ ] You're shipping WITH profiling data, not hoping it works + +**Document your decision**: +``` +Slack to VP + team: + +"Completed diagnostic: ProductGridView rebuilding unnecessarily during +tab transitions (confirmed in SwiftUI Instrument, Long View Body Updates). +Applied view identity fix. Verified in Instruments - transitions now 16.67ms. +Deploying now." +``` + +This shows: +- You diagnosed (not guessed) +- You solved the right problem +- You verified the fix +- You're shipping with confidence + +### If You Still Get It Wrong After Profiling + +**Honest admission**: +``` +"SwiftUI Instrument showed ProductGridView was the bottleneck. +Applied view identity fix, but performance didn't improve as expected. +Root cause is deeper than expected. Requiring architectural change. +Shipping animation disable (.animation(nil) on TabView) as mitigation. +Proper fix queued for next release cycle." +``` + +This is different from guessing: +- You have **evidence** of the root cause +- You **understand** why the quick fix didn't work +- You're **buying time** with a known mitigation +- You're **committed** to proper fix next cycle + +### Decision Framework Under Pressure + +#### Before shipping ANY fix + +| Question | Answer Yes? | Action | +|----------|-------------|--------| +| Have you run SwiftUI Instrument? | No | STOP - 25 min diagnostic | +| Do you know which view is expensive? | No | STOP - review Cause & Effect Graph | +| Can you explain in one sentence why the fix helps? | No | STOP - you're guessing | +| Have you verified the fix in Instruments? | No | STOP - test before shipping | +| Did you consider simpler explanations? | No | STOP - check documentation first | + +**Answer YES to all five** → Ship with confidence + +--- + +## Common Patterns & Solutions + +### Pattern 1: List Item Dependencies + +**Problem**: Updating one item updates entire list + +**Solution**: Per-item view models with granular dependencies + +```swift +// ❌ Shared dependency +@Observable +class ListViewModel { + var items: [Item] // All views depend on whole array +} + +// ✅ Granular dependencies +@Observable +class ListViewModel { + private(set) var itemViewModels: [Item.ID: ItemViewModel] +} + +@Observable +class ItemViewModel { + var item: Item // Each view depends only on its item +} +``` + +### Pattern 2: Computed Properties in View Bodies + +**Problem**: Expensive computation runs every render + +**Solution**: Move to model, cache result + +```swift +// ❌ Compute in view +struct MyView: View { + let data: [Int] + + var body: some View { + Text("\(data.sorted().last ?? 0)") // Sorts every render + } +} + +// ✅ Compute in model +@Observable +class ViewModel { + var data: [Int] { + didSet { + maxValue = data.max() ?? 0 // Compute once when data changes + } + } + private(set) var maxValue: Int = 0 +} + +struct MyView: View { + @Environment(ViewModel.self) private var viewModel + + var body: some View { + Text("\(viewModel.maxValue)") // Just read cached value + } +} +``` + +### Pattern 3: Formatter Reuse + +**Problem**: Creating formatters repeatedly + +**Solution**: Create once, reuse + +```swift +// ❌ Create every time +var body: some View { + let formatter = DateFormatter() + formatter.dateStyle = .short + Text(formatter.string(from: date)) +} + +// ✅ Reuse formatter +class Formatters { + static let shortDate: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .short + return f + }() +} + +var body: some View { + Text(Formatters.shortDate.string(from: date)) +} +``` + +### Pattern 4: Environment for Stable Values Only + +**Problem**: Rapidly-changing environment values + +**Solution**: Use direct parameters or models + +```swift +// ❌ Frequently changing in environment +.environment(\.scrollPosition, scrollPosition) // 60+ updates/second + +// ✅ Direct parameter or model +ChildView(scrollPosition: scrollPosition) +``` + +--- + +## iOS 26 Performance Improvements + +**Automatic improvements** when building with Xcode 26 (no code changes needed): + +### Lists +- Update up to **16× faster** +- Large lists on macOS load **6× faster** + +### SwiftUI Instrument +- Next-generation performance analysis +- Captures detailed cause-and-effect information +- Makes it easier than ever to understand when and why views update + +--- + +## Debugging Performance Issues + +### Step-by-Step Process + +1. **Reproduce issue** — Identify specific slow interaction +2. **Profile with Instruments** — SwiftUI template +3. **Check Update Groups lane** — SwiftUI doing work when slow? +4. **Identify problem type**: + - Long View Body Updates? → Section on Long Updates + - Too many updates? → Section on Unnecessary Updates +5. **Use Time Profiler** for long updates (find expensive operation) +6. **Use Cause & Effect Graph** for unnecessary updates (find dependency issue) +7. **Implement fix** +8. **Verify with new trace** + +### When SwiftUI Isn't the Problem + +#### Update Groups lane empty during performance issue? + +Problem likely elsewhere: +- Network requests +- Background processing +- Image loading +- Database queries +- Third-party frameworks + +**Next steps**: +- [Analyze hangs with Instruments](https://developer.apple.com/documentation/xcode/analyzing-hangs-in-your-app) +- [Optimize CPU performance with Instruments](https://developer.apple.com/documentation/xcode/optimizing-your-app-s-performance) + +--- + +## Real-World Impact + +#### Example: Landmarks App (from WWDC 2025) + +**Before optimization**: +- Every favorite button tap updated ALL visible landmark views +- Each view recreated formatters for distance calculation +- Scrolling felt janky + +**After optimization**: +- Only tapped view updates (granular view models) +- Formatters created once, strings cached +- Smooth 60fps scrolling + +**Improvements**: +- 100+ unnecessary view updates → 1 update per action +- Milliseconds saved per view × dozens of views = significant improvement +- Eliminated long view body updates entirely + +--- + +## Resources + +**WWDC**: 2025-306 + +**Docs**: /xcode/understanding-hitches-in-your-app, /xcode/analyzing-hangs-in-your-app, /xcode/optimizing-your-app-s-performance + +**Skills**: axiom-swiftui-debugging-diag, axiom-swiftui-debugging, axiom-memory-debugging, axiom-xcode-debugging + +--- + +## Key Takeaways + +1. **Fast view bodies** — Keep them quick so SwiftUI has time to get UI on screen without delay +2. **Update only when needed** — Design data flow to update views only when necessary +3. **Careful with environment** — Don't store frequently-changing values +4. **Profile early and often** — Use Instruments during development, not just when problems arise +5. **Greatest takeaway**: **Ensure your view bodies update quickly and only when needed to achieve great SwiftUI performance** + +--- + +**Xcode:** 26+ +**Platforms:** iOS 26+, iPadOS 26+, macOS Tahoe+, axiom-visionOS 3+ +**History:** See git log for changes diff --git a/skill-index/skills/axiom-ui-testing/SKILL.md b/skill-index/skills/axiom-ui-testing/SKILL.md new file mode 100644 index 0000000..83a8d78 --- /dev/null +++ b/skill-index/skills/axiom-ui-testing/SKILL.md @@ -0,0 +1,1162 @@ +--- +name: axiom-ui-testing +description: Use when writing UI tests, recording interactions, tests have race conditions, timing dependencies, inconsistent pass/fail behavior, or XCTest UI tests are flaky - covers Recording UI Automation (WWDC 2025), condition-based waiting, network conditioning, multi-factor testing, crash debugging, and accessibility-first testing patterns +skill_type: discipline +version: 2.1.0 +last_updated: WWDC 2025 (Updated with production debugging patterns) +--- + +# UI Testing + +## Overview + +Wait for conditions, not arbitrary timeouts. **Core principle** Flaky tests come from guessing how long operations take. Condition-based waiting eliminates race conditions. + +**NEW in WWDC 2025**: Recording UI Automation allows you to record interactions, replay across devices/languages, and review video recordings of test runs. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My UI tests pass locally on my Mac but fail in CI. How do I make them more reliable?" +→ The skill shows condition-based waiting patterns that work across devices/speeds, eliminating CI timing differences + +#### 2. "My tests use sleep(2) and sleep(5) but they're still flaky. How do I replace arbitrary timeouts with real conditions?" +→ The skill demonstrates waitForExistence, XCTestExpectation, and polling patterns for data loads, network requests, and animations + +#### 3. "I just recorded a test using Xcode 26's Recording UI Automation. How do I review the video and debug failures?" +→ The skill covers Video Debugging workflows to analyze recordings and find the exact step where tests fail + +#### 4. "My test is failing on iPad but passing on iPhone. How do I write tests that work across all device sizes?" +→ The skill explains multi-factor testing strategies and device-independent predicates for robust cross-device testing + +#### 5. "I want to write tests that are not flaky. What are the critical patterns I need to know?" +→ The skill provides condition-based waiting templates, accessibility-first patterns, and the decision tree for reliable test architecture + +--- + +## Red Flags — Test Reliability Issues + +If you see ANY of these, suspect timing issues: +- Tests pass locally, fail in CI (timing differences) +- Tests sometimes pass, sometimes fail (race conditions) +- Tests use `sleep()` or `Thread.sleep()` (arbitrary delays) +- Tests fail with "UI element not found" then pass on retry +- Long test runs (waiting for worst-case scenarios) + +## Quick Decision Tree + +``` +Test failing? +├─ Element not found? +│ └─ Use waitForExistence(timeout:) not sleep() +├─ Passes locally, fails CI? +│ └─ Replace sleep() with condition polling +├─ Animation causing issues? +│ └─ Wait for animation completion, don't disable +└─ Network request timing? + └─ Use XCTestExpectation or waitForExistence +``` + +## Core Pattern: Condition-Based Waiting + +**❌ WRONG (Arbitrary Timeout)**: +```swift +func testButtonAppears() { + app.buttons["Login"].tap() + sleep(2) // ❌ Guessing it takes 2 seconds + XCTAssertTrue(app.buttons["Dashboard"].exists) +} +``` + +**✅ CORRECT (Wait for Condition)**: +```swift +func testButtonAppears() { + app.buttons["Login"].tap() + let dashboard = app.buttons["Dashboard"] + XCTAssertTrue(dashboard.waitForExistence(timeout: 5)) +} +``` + +## Common UI Testing Patterns + +### Pattern 1: Waiting for Elements + +```swift +// Wait for element to appear +func waitForElement(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool { + return element.waitForExistence(timeout: timeout) +} + +// Usage +XCTAssertTrue(waitForElement(app.buttons["Submit"])) +``` + +### Pattern 2: Waiting for Element to Disappear + +```swift +func waitForElementToDisappear(_ element: XCUIElement, timeout: TimeInterval = 5) -> Bool { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter().wait(for: [expectation], timeout: timeout) + return result == .completed +} + +// Usage +XCTAssertTrue(waitForElementToDisappear(app.activityIndicators["Loading"])) +``` + +### Pattern 3: Waiting for Specific State + +```swift +func waitForButton(_ button: XCUIElement, toBeEnabled enabled: Bool, timeout: TimeInterval = 5) -> Bool { + let predicate = NSPredicate(format: "isEnabled == %@", NSNumber(value: enabled)) + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: button) + let result = XCTWaiter().wait(for: [expectation], timeout: timeout) + return result == .completed +} + +// Usage +let submitButton = app.buttons["Submit"] +XCTAssertTrue(waitForButton(submitButton, toBeEnabled: true)) +submitButton.tap() +``` + +### Pattern 4: Accessibility Identifiers + +**Set in app**: +```swift +Button("Submit") { + // action +} +.accessibilityIdentifier("submitButton") +``` + +**Use in tests**: +```swift +func testSubmitButton() { + let submitButton = app.buttons["submitButton"] // Uses identifier, not label + XCTAssertTrue(submitButton.waitForExistence(timeout: 5)) + submitButton.tap() +} +``` + +**Why**: Accessibility identifiers don't change with localization, remain stable across UI updates. + +### Pattern 5: Network Request Delays + +```swift +func testDataLoads() { + app.buttons["Refresh"].tap() + + // Wait for loading indicator to disappear + let loadingIndicator = app.activityIndicators["Loading"] + XCTAssertTrue(waitForElementToDisappear(loadingIndicator, timeout: 10)) + + // Now verify data loaded + XCTAssertTrue(app.cells.count > 0) +} +``` + +### Pattern 6: Animation Handling + +```swift +func testAnimatedTransition() { + app.buttons["Next"].tap() + + // Wait for destination view to appear + let destinationView = app.otherElements["DestinationView"] + XCTAssertTrue(destinationView.waitForExistence(timeout: 2)) + + // Optional: Wait a bit more for animation to settle + // Only if absolutely necessary + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.3)) +} +``` + +## Testing Checklist + +### Before Writing Tests +- [ ] Use accessibility identifiers for all interactive elements +- [ ] Avoid hardcoded labels (use identifiers instead) +- [ ] Plan for network delays and animations +- [ ] Choose appropriate timeouts (2s UI, 10s network) + +### When Writing Tests +- [ ] Use `waitForExistence()` not `sleep()` +- [ ] Use predicates for complex conditions +- [ ] Test both success and failure paths +- [ ] Make tests independent (can run in any order) + +### After Writing Tests +- [ ] Run tests 10 times locally (catch flakiness) +- [ ] Run tests on slowest supported device +- [ ] Run tests in CI environment +- [ ] Check test duration (if >30s per test, optimize) + +## Xcode UI Testing Tips + +### Launch Arguments for Testing + +```swift +func testExample() { + let app = XCUIApplication() + app.launchArguments = ["UI-Testing"] + app.launch() +} +``` + +In app code: +```swift +if ProcessInfo.processInfo.arguments.contains("UI-Testing") { + // Use mock data, skip onboarding, etc. +} +``` + +### Faster Test Execution + +```swift +override func setUpWithError() throws { + continueAfterFailure = false // Stop on first failure +} +``` + +### Debugging Failing Tests + +```swift +func testExample() { + // Take screenshot on failure + addUIInterruptionMonitor(withDescription: "Alert") { alert in + alert.buttons["OK"].tap() + return true + } + + // Print element hierarchy + print(app.debugDescription) +} +``` + +## Common Mistakes + +### ❌ Using sleep() for Everything +```swift +sleep(5) // ❌ Wastes time if operation completes in 1s +``` + +### ❌ Not Handling Animations +```swift +app.buttons["Next"].tap() +XCTAssertTrue(app.buttons["Back"].exists) // ❌ May fail during animation +``` + +### ❌ Hardcoded Text Labels +```swift +app.buttons["Submit"].tap() // ❌ Breaks with localization +``` + +### ❌ Tests Depend on Each Other +```swift +// ❌ Test 2 assumes Test 1 ran first +func test1_Login() { /* ... */ } +func test2_ViewDashboard() { /* assumes logged in */ } +``` + +### ❌ No Timeout Strategy +```swift +element.waitForExistence(timeout: 100) // ❌ Too long +element.waitForExistence(timeout: 0.1) // ❌ Too short +``` + +**Use appropriate timeouts**: +- UI animations: 2-3 seconds +- Network requests: 10 seconds +- Complex operations: 30 seconds max + +## Real-World Impact + +**Before** (using sleep()): +- Test suite: 15 minutes (waiting for worst-case) +- Flaky tests: 20% failure rate +- CI failures: 50% require retry + +**After** (condition-based waiting): +- Test suite: 5 minutes (waits only as needed) +- Flaky tests: <2% failure rate +- CI failures: <5% require retry + +**Key insight** Tests finish faster AND are more reliable when waiting for actual conditions instead of guessing times. + +--- + +## Recording UI Automation + +### Overview + +**NEW in Xcode 26**: Record, replay, and review UI automation tests with video recordings. + +**Three Phases**: +1. **Record** — Capture interactions (taps, swipes, hardware button presses) as Swift code +2. **Replay** — Run across multiple devices, languages, regions, orientations +3. **Review** — Watch video recordings, analyze failures, view UI element overlays + +**Supported Platforms**: iOS, iPadOS, macOS, watchOS, tvOS, axiom-visionOS (Designed for iPad) + +### How UI Automation Works + +**Key Principles**: +- UI automation interacts with your app **as a person does** using gestures and hardware events +- Runs **completely independently** from your app (app models/data not directly accessible) +- Uses **accessibility framework** as underlying technology +- Tells OS which gestures to perform, then waits for completion **synchronously** one at a time + +**Actions include**: +- Launching your app +- Interacting with buttons and navigation +- Setting system state (Dark Mode, axiom-localization, etc.) +- Setting simulated location + +### Accessibility is the Foundation + +**Critical Understanding**: Accessibility provides information directly to UI automation. + +What accessibility sees: +- Element types (button, text, image, etc.) +- Labels (visible text) +- Values (current state for checkboxes, etc.) +- Frames (element positions) +- **Identifiers** (accessibility identifiers — NOT localized) + +**Best Practice**: Great accessibility experience = great UI automation experience. + +### Preparing Your App for Recording + +#### Step 1: Add Accessibility Identifiers + +**SwiftUI**: +```swift +Button("Submit") { + // action +} +.accessibilityIdentifier("submitButton") + +// Make identifiers specific to instance +List(landmarks) { landmark in + LandmarkRow(landmark) + .accessibilityIdentifier("landmark-\(landmark.id)") +} +``` + +**UIKit**: +```swift +let button = UIButton() +button.accessibilityIdentifier = "submitButton" + +// Use index for table cells +cell.accessibilityIdentifier = "cell-\(indexPath.row)" +``` + +**Good identifiers are**: +- ✅ Unique within entire app +- ✅ Descriptive of element contents +- ✅ Static (don't react to content changes) +- ✅ Not localized (same across languages) + +**Why identifiers matter**: +- Titles/descriptions may change, identifiers remain stable +- Work across localized strings +- Uniquely identify elements with dynamic content + +**Pro Tip**: Use Xcode coding assistant to add identifiers: +``` +Prompt: "Add accessibility identifiers to the relevant parts of this view" +``` + +#### Step 2: Review Accessibility with Accessibility Inspector + +**Launch Accessibility Inspector**: +- Xcode menu → Open Developer Tool → Accessibility Inspector +- Or: Launch from Spotlight + +**Features**: +1. **Element Inspector** — List accessibility values for any view +2. **Property details** — Click property name for documentation +3. **Platform support** — Works on all Apple platforms + +**What to check**: +- Elements have labels +- Interactive elements have types (button, not just text) +- Values set for stateful elements (checkboxes, toggles) +- Identifiers set for elements with dynamic/localized content + +**Sample Code Reference**: [Delivering an exceptional accessibility experience](https://developer.apple.com/documentation/accessibility/delivering_an_exceptional_accessibility_experience) + +#### Step 3: Add UI Testing Target + +1. Open project settings in Xcode +2. Click "+" below targets list +3. Select **UI Testing Bundle** +4. Click Finish + +**Result**: New UI test folder with template tests added to project. + +### Recording Interactions + +#### Starting a Recording (Xcode 26) + +1. Open UI test source file +2. **Popover appears** explaining how to start recording (first time only) +3. Click **"Start Recording"** button in editor gutter +4. Xcode builds and launches app in Simulator/device + +**During Recording**: +- Interact with app normally (taps, swipes, text entry, etc.) +- Code representing interactions appears in source editor in real-time +- Recording updates as you type (e.g., text field entries) + +**Stopping Recording**: +- Click **"Stop Run"** button in Xcode + +#### Example Recording Session + +```swift +func testCreateAustralianCollection() { + let app = XCUIApplication() + app.launch() + + // Tap "Collections" tab (recorded automatically) + app.tabBars.buttons["Collections"].tap() + + // Tap "+" to add new collection + app.navigationBars.buttons["Add"].tap() + + // Tap "Edit" button + app.buttons["Edit"].tap() + + // Type collection name + app.textFields.firstMatch.tap() + app.textFields.firstMatch.typeText("Max's Australian Adventure") + + // Tap "Edit Landmarks" + app.buttons["Edit Landmarks"].tap() + + // Add landmarks + app.tables.cells.containing(.staticText, identifier:"Great Barrier Reef").buttons["Add"].tap() + app.tables.cells.containing(.staticText, identifier:"Uluru").buttons["Add"].tap() + + // Tap checkmark to save + app.navigationBars.buttons["Done"].tap() +} +``` + +#### Reviewing Recorded Code + +After recording, **review and adjust queries**: + +**Multiple Options**: Each line has dropdown showing alternative ways to address element. + +**Selection Recommendations**: +1. **For localized strings** (text, button labels): Choose accessibility identifier if available +2. **For deeply nested views**: Choose shortest query (stays resilient as app changes) +3. **For dynamic content** (timestamps, temperature): Use generic query or identifier + +**Example**: +```swift +// Recorded options for text field: +app.textFields["Collection Name"] // ❌ Breaks if label localizes +app.textFields["collectionNameField"] // ✅ Uses identifier +app.textFields.element(boundBy: 0) // ✅ Position-based +app.textFields.firstMatch // ✅ Generic, shortest +``` + +**Choose shortest, most stable query** for your needs. + +### Adding Validations + +After recording, **add assertions** to verify expected behavior: + +#### Wait for Existence + +```swift +// Validate collection created +let collection = app.buttons["Max's Australian Adventure"] +XCTAssertTrue(collection.waitForExistence(timeout: 5)) +``` + +#### Wait for Property Changes + +```swift +// Wait for button to become enabled +let submitButton = app.buttons["Submit"] +XCTAssertTrue(submitButton.wait(for: .enabled, toEqual: true, timeout: 5)) +``` + +#### Combine with XCTAssert + +```swift +// Fail test if element doesn't appear +let landmark = app.staticTexts["Great Barrier Reef"] +XCTAssertTrue(landmark.waitForExistence(timeout: 5), "Landmark should appear in collection") +``` + +### Advanced Automation APIs + +#### Setup Device State + +```swift +override func setUpWithError() throws { + let app = XCUIApplication() + + // Set device orientation + XCUIDevice.shared.orientation = .landscapeLeft + + // Set appearance mode + app.launchArguments += ["-UIUserInterfaceStyle", "dark"] + + // Simulate location + let location = XCUILocation(location: CLLocation(latitude: 37.7749, longitude: -122.4194)) + app.launchArguments += ["-SimulatedLocation", location.description] + + app.launch() +} +``` + +#### Launch Arguments & Environment + +```swift +func testWithMockData() { + let app = XCUIApplication() + + // Pass arguments to app + app.launchArguments = ["-UI-Testing", "-UseMockData"] + + // Set environment variables + app.launchEnvironment = ["API_URL": "https://mock.api.com"] + + app.launch() +} +``` + +In app code: +```swift +if ProcessInfo.processInfo.arguments.contains("-UI-Testing") { + // Use mock data, skip onboarding +} +``` + +#### Custom URL Schemes + +```swift +// Open app to specific URL +let app = XCUIApplication() +app.open(URL(string: "myapp://landmark/123")!) + +// Open URL with system default app (global version) +XCUIApplication.open(URL(string: "https://example.com")!) +``` + +#### Accessibility Audits in Tests + +```swift +func testAccessibility() throws { + let app = XCUIApplication() + app.launch() + + // Perform accessibility audit + try app.performAccessibilityAudit() +} +``` + +**Reference**: [Perform accessibility audits for your app — WWDC23](https://developer.apple.com/videos/play/wwdc2023/10035/) + +### Test Plans for Multiple Configurations + +**Test Plans** let you: +- Include/exclude individual tests +- Set system settings (language, region, appearance) +- Configure test properties (timeouts, repetitions, parallelization) +- Associate with schemes for specific build settings + +#### Creating Test Plan + +1. Create new or use existing test plan +2. Add/remove tests on first screen +3. Switch to **Configurations** tab + +#### Adding Multiple Languages + +``` +Configurations: +├─ English +├─ German (longer strings) +├─ Arabic (right-to-left) +└─ Hebrew (right-to-left) +``` + +**Each locale** = separate configuration in test plan. + +**Settings**: +- Focused for specific locale +- Shared across all configurations + +#### Video & Screenshot Capture + +**In Configurations tab**: +- **Capture screenshots**: On/Off +- **Capture video**: On/Off +- **Keep media**: "Only failures" or "On, and keep all" + +**Defaults**: Videos/screenshots kept only for failing runs (for review). + +**"On, and keep all" use cases**: +- Documentation +- Tutorials +- Marketing materials + +**Reference**: [Author fast and reliable tests for Xcode Cloud — WWDC22](https://developer.apple.com/videos/play/wwdc2022/110371/) + +### Replaying Tests in Xcode Cloud + +**Xcode Cloud** = built-in service for: +- Building app +- Running tests +- Uploading to App Store +- All in cloud without using team devices + +**Workflow configuration**: +- Same test plan used locally +- Runs on multiple devices and configurations +- Videos/results available in App Store Connect + +**Viewing Results**: +- Xcode: Xcode Cloud section +- App Store Connect: Xcode Cloud section +- See build info, logs, failure descriptions, video recordings + +**Team Access**: Entire team can see run history and download results/videos. + +**Reference**: [Create practical workflows in Xcode Cloud — WWDC23](https://developer.apple.com/videos/play/wwdc2023/10269/) + +### Reviewing Test Results with Videos + +#### Accessing Test Report + +1. Click **Test** button in Xcode +2. Double-click failing run to see video + description + +**Features**: +- **Runs dropdown** — Switch between video recordings of different configurations (languages, devices) +- **Save video** — Secondary click → Save +- **Play/pause** — Video playback with UI interaction overlays +- **Timeline dots** — UI interactions shown as dots on timeline +- **Jump to failure** — Click failure diamond on timeline + +#### UI Element Overlay at Failure + +**At moment of failure**: +- Click timeline failure point +- **Overlay shows all UI elements** present on screen +- Click any element to see code recommendations for addressing it +- **Show All** — See alternative examples + +**Workflow**: +1. Identify what was actually present (vs what test expected) +2. Click element to get query code +3. Secondary click → Copy code +4. **View Source** → Go directly to test +5. Paste corrected code + +**Example**: +```swift +// Test expected: +let button = app.buttons["Max's Australian Adventure"] + +// But overlay shows it's actually text, not button: +let text = app.staticTexts["Max's Australian Adventure"] // ✅ Correct +``` + +#### Running Test in Different Language + +Click test diamond → Select configuration (e.g., Arabic) → Watch automation run in right-to-left layout. + +**Validates**: Same automation works across languages/layouts. + +**Reference**: [Fix failures faster with Xcode test reports — WWDC23](https://developer.apple.com/videos/play/wwdc2023/10175/) + +### Recording UI Automation Checklist + +#### Before Recording +- [ ] Add accessibility identifiers to interactive elements +- [ ] Review app with Accessibility Inspector +- [ ] Add UI Testing Bundle target to project +- [ ] Plan workflow to record (user journey) + +#### During Recording +- [ ] Interact naturally with app +- [ ] Record complete user journeys (not individual taps) +- [ ] Check code generates as you interact +- [ ] Stop recording when workflow complete + +#### After Recording +- [ ] Review recorded code options (dropdown on each line) +- [ ] Choose stable queries (identifiers > labels) +- [ ] Add validations (waitForExistence, XCTAssert) +- [ ] Add setup code (device state, launch arguments) +- [ ] Run test to verify it passes + +#### Test Plan Configuration +- [ ] Create/update test plan +- [ ] Add multiple language configurations +- [ ] Include right-to-left languages (Arabic, Hebrew) +- [ ] Configure video/screenshot capture settings +- [ ] Set appropriate timeouts for network tests + +#### Running & Reviewing +- [ ] Run test locally across configurations +- [ ] Review video recordings for failures +- [ ] Use UI element overlay to debug failures +- [ ] Run in Xcode Cloud for team visibility +- [ ] Download and share videos if needed + +## Network Conditioning in Tests + +### Overview + +UI tests can pass on fast networks but fail on 3G/LTE. **Network Link Conditioner** simulates real-world network conditions to catch timing-sensitive crashes. + +**Critical scenarios**: +- ❌ iPad Pro over Wi-Fi (fast) → pass +- ❌ iPad Pro over 3G (slow) → crash +- ✅ Test both to catch device-specific failures + +### Setup Network Link Conditioner + +**Install Network Link Conditioner**: +1. Download from [Apple's Additional Tools for Xcode](https://developer.apple.com/download/all/) +2. Search: "Network Link Conditioner" +3. Install: `sudo open Network\ Link\ Conditioner.pkg` + +**Verify Installation**: +```bash +# Check if installed +ls ~/Library/Application\ Support/Network\ Link\ Conditioner/ +``` + +**Enable in Tests**: +```swift +override func setUpWithError() throws { + let app = XCUIApplication() + + // Launch with network conditioning argument + app.launchArguments = ["-com.apple.CoreSimulator.CoreSimulatorService", "-networkShaping"] + app.launch() +} +``` + +### Common Network Profiles + +**3G Profile** (most failures occur here): +```swift +override func setUpWithError() throws { + let app = XCUIApplication() + + // Simulate 3G (type in launch arguments) + app.launchEnvironment = [ + "SIMULATOR_UDID": ProcessInfo.processInfo.environment["SIMULATOR_UDID"] ?? "", + "NETWORK_PROFILE": "3G" + ] + app.launch() +} +``` + +**Manual Network Conditioning** (macOS System Preferences): +1. Open System Preferences → Network +2. Click "Network Link Conditioner" (installed above) +3. Select profile: 3G, LTE, WiFi +4. Click "Start" +5. Run tests (they'll use throttled network) + +### Real-World Example: Photo Upload with Network Throttling + +**❌ Without Network Conditioning**: +```swift +func testPhotoUpload() { + app.buttons["Upload Photo"].tap() + + // Passes locally (fast network) + XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 5)) +} +// ✅ Passes locally, ❌ FAILS on 3G with timeout +``` + +**✅ With Network Conditioning**: +```swift +func testPhotoUploadOn3G() { + let app = XCUIApplication() + // Network Link Conditioner running (3G profile) + app.launch() + + app.buttons["Upload Photo"].tap() + + // Increase timeout for 3G + XCTAssertTrue(app.staticTexts["Upload complete"].waitForExistence(timeout: 30)) + + // Verify no crash occurred + XCTAssertFalse(app.alerts.element.exists, "App should not crash on 3G") +} +``` + +**Key differences**: +- Longer timeout (30s instead of 5s) +- Check for crashes +- Run on slowest expected network + +--- + +## Multi-Factor Testing: Device Size + Network Speed + +### The Problem + +Tests can pass on device A but fail on device B due to layout differences + network delays. **Multi-factor testing** catches these combinations. + +**Common failure patterns**: +- ✅ iPhone 14 Pro (compact, fast network) +- ❌ iPad Pro 12.9 (large, 3G network) → crashes +- ✅ iPhone 15 (compact, LTE) +- ❌ iPhone 12 (older GPU, 3G) → timeout + +### Test Plan Configuration for Multiple Devices + +**Create Test Plan in Xcode**: +1. File → New → Test Plan +2. Select tests to include +3. Click "Configurations" tab +4. Add configurations for each device/network combo + +**Example Configuration Matrix**: +``` +Configurations: +├─ iPhone 14 Pro + LTE +├─ iPhone 14 Pro + 3G +├─ iPad Pro 12.9 + LTE +├─ iPad Pro 12.9 + 3G (⚠️ Most failures here) +└─ iPhone 12 + 3G (⚠️ Older device) +``` + +**In Test Plan UI**: +- Device: iPhone 14 Pro / iPad Pro 12.9 +- OS Version: Latest +- Locale: English +- Network Profile: LTE / 3G + +### Programmatic Device-Specific Testing + +```swift +import XCTest + +final class MultiFactorUITests: XCTestCase { + var deviceModel: String { UIDevice.current.model } + + override func setUpWithError() throws { + let app = XCUIApplication() + app.launch() + + // Adjust timeouts based on device + switch deviceModel { + case "iPad" where UIScreen.main.bounds.width > 1000: + // iPad Pro - larger layout, slower rendering + app.launchEnvironment["TEST_TIMEOUT"] = "30" + case "iPhone": + // iPhone - compact, standard timeout + app.launchEnvironment["TEST_TIMEOUT"] = "10" + default: + app.launchEnvironment["TEST_TIMEOUT"] = "15" + } + } + + func testListLoadingAcrossDevices() { + let app = XCUIApplication() + let timeout = Double(app.launchEnvironment["TEST_TIMEOUT"] ?? "10") ?? 10 + + app.buttons["Refresh"].tap() + + // Wait for list to load (timeout varies by device) + XCTAssertTrue( + app.tables.cells.count > 0, + "List should load on \(deviceModel)" + ) + + // Verify no crashes + XCTAssertFalse(app.alerts.element.exists) + } +} +``` + +### Real-World Example: iPad Pro + 3G Crash + +**Scenario**: App works on iPhone 14, crashes on iPad Pro over 3G. + +**Why it crashes**: +1. iPad Pro has larger layout (landscape) +2. 3G network is slow (latency 100ms+) +3. Images don't load in time, layout engine crashes +4. Single-device testing misses this combo + +**Test that catches it**: +```swift +func testLargeLayoutOn3G() { + let app = XCUIApplication() + // Running with Network Link Conditioner on 3G profile + app.launch() + + // iPad Pro: Large grid of images + app.buttons["Browse"].tap() + + // Wait longer for images on slow network + let firstImage = app.images["photoGrid-0"] + XCTAssertTrue( + firstImage.waitForExistence(timeout: 20), + "First image must load on slow network" + ) + + // Verify grid loaded without crash + let loadedCount = app.images.matching(identifier: NSPredicate(format: "identifier BEGINSWITH 'photoGrid'")).count + XCTAssertGreater(loadedCount, 5, "Multiple images should load on 3G") + + // No alerts (no crashes) + XCTAssertFalse(app.alerts.element.exists, "App should not crash on large device + slow network") +} +``` + +### Running Multi-Factor Tests in CI + +**In GitHub Actions or Xcode Cloud**: +```yaml +- name: Run tests across devices + run: | + xcodebuild -scheme MyApp \ + -testPlan MultiDeviceTestPlan \ + test +``` + +**Test Plan runs on**: +- iPhone 14 Pro + LTE +- iPhone 14 Pro + 3G +- iPad Pro + LTE +- iPad Pro + 3G + +**Result**: Catch device-specific crashes before App Store submission. + +--- + +## Debugging Crashes Revealed by UI Tests + +### Overview + +UI tests sometimes reveal crashes that don't happen in manual testing. **Key insight** Automated tests run faster, interact with app differently, and can expose concurrency/timing bugs. + +**When crashes happen**: +- ❌ Manual testing: Can't reproduce (works when you run it) +- ✅ UI Test: Crashes every time (automated repetition finds race condition) + +### Recognizing Test-Revealed Crashes + +**Signs in test output**: +``` +Failing test: testPhotoUpload +Error: The app crashed while responding to a UI event +App died from an uncaught exception +Stack trace: [EXC_BAD_ACCESS in PhotoViewController] +``` + +**Video shows**: App visibly crashes (black screen, immediate termination). + +### Systematic Debugging Approach + +#### Step 1: Capture Crash Details + +**Enable detailed logging**: +```swift +override func setUpWithError() throws { + let app = XCUIApplication() + + // Enable all logging + app.launchEnvironment = [ + "OS_ACTIVITY_MODE": "debug", + "DYLD_PRINT_STATISTICS": "1" + ] + + // Enable test diagnostics + if #available(iOS 17, *) { + let options = XCUIApplicationLaunchOptions() + options.captureRawLogs = true + app.launch(options) + } else { + app.launch() + } +} +``` + +#### Step 2: Reproduce Locally + +```swift +func testReproduceCrash() { + let app = XCUIApplication() + app.launch() + + // Run exact sequence that causes crash + app.buttons["Browse"].tap() + app.buttons["Photo Album"].tap() + app.buttons["Select All"].tap() + app.buttons["Upload"].tap() + + // Should crash here + let uploadButton = app.buttons["Upload"] + XCTAssertFalse(uploadButton.exists, "App crashed (expected)") + + // Don't assert - just let it crash and read logs +} +``` + +**Run test with Console logs visible**: +- Xcode: View → Navigators → Show Console +- Watch for exception messages + +#### Step 3: Analyze Crash Logs + +**Locations**: +1. Xcode Console (real-time, less detail) +2. ~/Library/Logs/DiagnosticMessages/crash_*.log (full details) +3. Device Settings → Privacy → Analytics → Analytics Data + +**Look for**: +- Thread that crashed +- Exception type (EXC_BAD_ACCESS, EXC_CRASH, etc.) +- Stack trace showing which method crashed + +**Example crash log**: +``` +Exception Type: EXC_BAD_ACCESS (SIGSEGV) +Exception Codes: KERN_INVALID_ADDRESS at 0x0000000000000000 +Thread 0 Crashed: +0 MyApp 0x0001a234 -[PhotoViewController reloadPhotos:] + 234 +1 MyApp 0x0001a123 -[PhotoViewController viewDidLoad] + 180 +``` + +**This tells us**: +- Crash in `PhotoViewController.reloadPhotos(_:)` +- Likely null pointer dereference +- Called from `viewDidLoad` + +#### Step 4: Connection to Swift Concurrency Issues + +**Most UI test crashes are concurrency bugs** (not specific to UI testing). Reference related skills: + +```swift +// Common pattern: Race condition in async image loading +class PhotoViewController: UIViewController { + var photos: [Photo] = [] + + override func viewDidLoad() { + super.viewDidLoad() + + // ❌ WRONG: Accessing photos array from multiple threads + Task { + let newPhotos = await fetchPhotos() + self.photos = newPhotos // May crash if main thread access + reloadPhotos() // ❌ Crash here + } + } +} + +// ✅ CORRECT: Ensure main thread +class PhotoViewController: UIViewController { + @MainActor + var photos: [Photo] = [] + + override func viewDidLoad() { + super.viewDidLoad() + + Task { + let newPhotos = await fetchPhotos() + await MainActor.run { [weak self] in + self?.photos = newPhotos + self?.reloadPhotos() // ✅ Safe + } + } + } +} +``` + +**For deep crash analysis**: See `axiom-swift-concurrency` skill for @MainActor patterns and `axiom-memory-debugging` skill for thread-safety issues. + +#### Step 5: Add Crash-Prevention Tests + +**After fixing**: +```swift +func testPhotosLoadWithoutCrash() { + let app = XCUIApplication() + app.launch() + + // Rapid fire interactions that previously caused crash + app.buttons["Browse"].tap() + app.buttons["Photo Album"].tap() + + // Load should complete without crash + let photoGrid = app.otherElements["photoGrid"] + XCTAssertTrue(photoGrid.waitForExistence(timeout: 10)) + + // No alerts (no crash dialogs) + XCTAssertFalse(app.alerts.element.exists) +} +``` + +#### Step 6: Stress Test to Verify Fix + +```swift +func testPhotosLoadUnderStress() { + let app = XCUIApplication() + app.launch() + + // Repeat the crash-causing action multiple times + for iteration in 0..<10 { + app.buttons["Browse"].tap() + + // Wait for load + let grid = app.otherElements["photoGrid"] + XCTAssertTrue(grid.waitForExistence(timeout: 10), "Iteration \(iteration)") + + // Go back + app.navigationBars.buttons["Back"].tap() + app.buttons["Refresh"].tap() + } + + // Completed without crash + XCTAssertTrue(true, "Stress test passed") +} +``` + +### Prevention Checklist + +#### Before releasing +- [ ] Run UI tests on slowest network (3G) +- [ ] Run on largest device (iPad Pro) +- [ ] Run on oldest supported device (iPhone 12) +- [ ] Record video of test runs (saves debugging time) +- [ ] Check for crashes in logs +- [ ] Run stress tests (10x repeated actions) +- [ ] Verify @MainActor on UI properties +- [ ] Check for race conditions in async code + +--- + +## Resources + +**WWDC**: 2025-344, 2024-10179, 2023-10175, 2023-10035 + +**Docs**: /xctest, /xcuiautomation/recording-ui-automation-for-testing, /xctest/xctwaiter, /accessibility/delivering_an_exceptional_accessibility_experience, /accessibility/performing_accessibility_testing_for_your_app + +**Note**: This skill focuses on reliability patterns and Recording UI Automation. For TDD workflow, see superpowers:test-driven-development. + +--- + +**History:** See git log for changes diff --git a/skill-index/skills/axiom-xcode-debugging/SKILL.md b/skill-index/skills/axiom-xcode-debugging/SKILL.md new file mode 100644 index 0000000..58dc130 --- /dev/null +++ b/skill-index/skills/axiom-xcode-debugging/SKILL.md @@ -0,0 +1,255 @@ +--- +name: axiom-xcode-debugging +description: Use when encountering BUILD FAILED, test crashes, simulator hangs, stale builds, zombie xcodebuild processes, "Unable to boot simulator", "No such module" after SPM changes, or mysterious test failures despite no code changes - systematic environment-first diagnostics for iOS/macOS projects +skill_type: discipline +version: 1.0.0 +# MCP annotations (ignored by Claude Code) +mcp: + category: debugging + tags: [xcode, build, simulator, environment, diagnostics] + related: [build-debugging, axiom-swift-concurrency] +--- + +# Xcode Debugging + +## Overview + +Check build environment BEFORE debugging code. **Core principle** 80% of "mysterious" Xcode issues are environment problems (stale Derived Data, stuck simulators, zombie processes), not code bugs. + +## Example Prompts + +These are real questions developers ask that this skill is designed to answer: + +#### 1. "My build is failing with 'BUILD FAILED' but no error details. I haven't changed anything. What's going on?" +→ The skill shows environment-first diagnostics: check Derived Data, simulator states, and zombie processes before investigating code + +#### 2. "Tests passed yesterday with no code changes, but now they're failing. This is frustrating. How do I fix this?" +→ The skill explains stale Derived Data and intermittent failures, shows the 2-5 minute fix (clean Derived Data) + +#### 3. "My app builds fine but it's running the old code from before my changes. I restarted Xcode but it still happens." +→ The skill demonstrates that Derived Data caches old builds, shows how deletion forces a clean rebuild + +#### 4. "The simulator says 'Unable to boot simulator' and I can't run tests. How do I recover?" +→ The skill covers simulator state diagnosis with simctl and safe recovery patterns (erase/shutdown/reboot) + +#### 5. "I'm getting 'No such module: SomePackage' errors after updating SPM dependencies. How do I fix this?" +→ The skill explains SPM caching issues and the clean Derived Data workflow that resolves "phantom" module errors + +--- + +## Red Flags — Check Environment First + +If you see ANY of these, suspect environment not code: +- "It works on my machine but not CI" +- "Tests passed yesterday, failing today with no code changes" +- "Build succeeds but old code executes" +- "Build sometimes succeeds, sometimes fails" (intermittent failures) +- "Simulator stuck at splash screen" or "Unable to install app" +- Multiple xcodebuild processes (10+) older than 30 minutes + +## Mandatory First Steps + +**ALWAYS run these commands FIRST** (before reading code): + +```bash +# 1. Check processes (zombie xcodebuild?) +ps aux | grep -E "xcodebuild|Simulator" | grep -v grep + +# 2. Check Derived Data size (>10GB = stale) +du -sh ~/Library/Developer/Xcode/DerivedData + +# 3. Check simulator states (stuck Booting?) +xcrun simctl list devices | grep -E "Booted|Booting|Shutting Down" +``` + +#### What these tell you +- **0 processes + small Derived Data + no booted sims** → Environment clean, investigate code +- **10+ processes OR >10GB Derived Data OR simulators stuck** → Environment problem, clean first +- **Stale code executing OR intermittent failures** → Clean Derived Data regardless of size + +#### Why environment first +- Environment cleanup: 2-5 minutes → problem solved +- Code debugging for environment issues: 30-120 minutes → wasted time + +## Quick Fix Workflow + +### Finding Your Scheme Name + +If you don't know your scheme name: +```bash +# List available schemes +xcodebuild -list +``` + +### For Stale Builds / "No such module" Errors +```bash +# Clean everything +xcodebuild clean -scheme YourScheme +rm -rf ~/Library/Developer/Xcode/DerivedData/* +rm -rf .build/ build/ + +# Rebuild +xcodebuild build -scheme YourScheme \ + -destination 'platform=iOS Simulator,name=iPhone 16' +``` + +### For Simulator Issues +```bash +# Shutdown all simulators +xcrun simctl shutdown all + +# If simctl command fails, shutdown and retry +xcrun simctl shutdown all +xcrun simctl list devices + +# If still stuck, erase specific simulator +xcrun simctl erase + +# Nuclear option: force-quit Simulator.app +killall -9 Simulator +``` + +### For Zombie Processes +```bash +# Kill all xcodebuild (use cautiously) +killall -9 xcodebuild + +# Check they're gone +ps aux | grep xcodebuild | grep -v grep +``` + +### For Test Failures +```bash +# Isolate failing test +xcodebuild test -scheme YourScheme \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + -only-testing:YourTests/SpecificTestClass +``` + +## Simulator Verification (Optional) + +After applying fixes, verify in simulator with visual confirmation. + +### Quick Screenshot Verification + +```bash +# 1. Boot simulator (if not already) +xcrun simctl boot "iPhone 16 Pro" + +# 2. Build and install app +xcodebuild build -scheme YourScheme \ + -destination 'platform=iOS Simulator,name=iPhone 16 Pro' + +# 3. Launch app +xcrun simctl launch booted com.your.bundleid + +# 4. Wait for UI to stabilize +sleep 2 + +# 5. Capture screenshot +xcrun simctl io booted screenshot /tmp/verify-build-$(date +%s).png +``` + +### Using Axiom Tools + +**Quick screenshot**: +```bash +/axiom:screenshot +``` + +**Full simulator testing** (with navigation, state setup): +```bash +/axiom:test-simulator +``` + +### When to Use Simulator Verification + +Use when: +- **Visual fixes** — Layout changes, UI updates, styling tweaks +- **State-dependent bugs** — "Only happens in this specific screen" +- **Intermittent failures** — Need to reproduce specific conditions +- **Before shipping** — Final verification that fix actually works + +**Pro tip**: If you have debug deep links (see `axiom-deep-link-debugging` skill), you can navigate directly to the screen that was broken: +```bash +xcrun simctl openurl booted "debug://problem-screen" +sleep 1 +xcrun simctl io booted screenshot /tmp/fix-verification.png +``` + +## Decision Tree + +``` +Test/build failing? +├─ BUILD FAILED with no details? +│ └─ Clean Derived Data → rebuild +├─ Build intermittent (sometimes succeeds/fails)? +│ └─ Clean Derived Data → rebuild +├─ Build succeeds but old code executes? +│ └─ Delete Derived Data → rebuild (2-5 min fix) +├─ "Unable to boot simulator"? +│ └─ xcrun simctl shutdown all → erase simulator +├─ "No such module PackageName"? +│ └─ Clean + delete Derived Data → rebuild +├─ Tests hang indefinitely? +│ └─ Check simctl list → reboot simulator +├─ Tests crash? +│ └─ Check ~/Library/Logs/DiagnosticReports/*.crash +└─ Code logic bug? + └─ Use systematic-debugging skill instead +``` + +## Common Error Patterns + +| Error | Fix | +|-------|-----| +| `BUILD FAILED` (no details) | Delete Derived Data | +| `Unable to boot simulator` | `xcrun simctl erase ` | +| `No such module` | Clean + delete Derived Data | +| Tests hang | Check simctl list, reboot simulator | +| Stale code executing | Delete Derived Data | + +## Useful Flags + +```bash +# Show build settings +xcodebuild -showBuildSettings -scheme YourScheme + +# List schemes/targets +xcodebuild -list + +# Verbose output +xcodebuild -verbose build -scheme YourScheme + +# Build without testing (faster) +xcodebuild build-for-testing -scheme YourScheme +xcodebuild test-without-building -scheme YourScheme +``` + +## Crash Log Analysis + +```bash +# Recent crashes +ls -lt ~/Library/Logs/DiagnosticReports/*.crash | head -5 + +# Symbolicate address (if you have .dSYM) +atos -o YourApp.app.dSYM/Contents/Resources/DWARF/YourApp \ + -arch arm64 0x
+``` + +## Common Mistakes + +❌ **Debugging code before checking environment** — Always run mandatory steps first + +❌ **Ignoring simulator states** — "Booting" can hang 10+ minutes, shutdown/reboot immediately + +❌ **Assuming git changes caused the problem** — Derived Data caches old builds despite code changes + +❌ **Running full test suite when one test fails** — Use `-only-testing` to isolate + +## Real-World Impact + +**Before** 30+ min debugging "why is old code running" +**After** 2 min environment check → clean Derived Data → problem solved + +**Key insight** Check environment first, debug code second.